1. 可见性问题
现代计算机的cpu都是多核cpu,鉴于内存(RAM)的速度对于cpu来说还不够快。因此多核cpu涉及出了缓存的概念进一步加速cpu的速度。在cpu一侧引入缓存会带来一致性的问题,而我们java同时也受到了这个的影响。
考虑一下下面代码的执行结果有是什么?
1 | public class TaskRunner { |
正常预期来说结果会是42
,但是也有可能结果输出为0
。这是怎么回事呢?
假设我们cpu
有两个核心,核心A运行主线程,核心B运行Reader
线程。核心A更新了number之后,其实只是在cpu核心A缓存中进行修改,并且未同步到主内存(RAM)和cpu核心B缓存。因此当核心B运行Reader
线程读取number
时,只是读的核心B缓存中的值,并非Number
的最新值。而最新值cpu
会选择合适的时机把缓存中的值统一刷新到主存(RAM
)中去。
因此这意味着主线程更新了number
之后,reader
线程对number
的最新值能否看到是有不确定性的。
2. 指令重排
更糟糕的是,我们的代码可能不会按照我们预期的顺序执行,这样也会导致我们看到的结果是0,而不是42。
1 | number = 42; |
这里有可能先给ready
赋值,再给number
赋值。出现这样指令重排的原因是为了提高性能而做的优化。因为在大多数情况下,改变赋值的顺序,并不影响最终结果。但是多线程竞争下就可能出现问题。具体来说不同的组件都可能使用指令重排的优化,比如:
- 处理器可能以任何可能的顺序从cpu核心缓存刷新数据到主存中。
- 处理器可能使用乱序执行的优化技术。
- jit编译器也有可能重排指令。
3. volatile
为了保证多线程状态下,变量的可见性能如预期的那样,我们可以使用volatile关键字修饰变量。这样cpu和运行时环境jvm都知道被volatile修饰的变量都不应该被重排、已经应该把所有修改及时刷新到主存中去。因此可以总结一下volatile:
- 保证可见性
- 禁止指令重排
- 不保证原子性(特殊情况:boolean和int赋值可以保证原子性)
有必要说下为什么volatile为什么不保证原子性。
原子性意味着不可分割,完整性,即某个操作中间不可以被加塞或者分隔,要么同时成功,要么同时失败。
我们想想一下这样一个方法。
1 | public static volatile int num =0; |
当多个线程同时运行plus
函数时,num的总数会小于调用plus
的次数。为什么会这样,因为在字节码层面,num++
会被编译器翻译成多行指令,因此在执行时会被打断,无法保持原子性。
4. Happens-Before
因为jvm会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。
Happens-Before的规则包括:
- 程序顺序规则
- 锁定规则
- volatile变量规则
- 线程启动规则
- 线程结束规则
- 中断规则
- 终结器规则
- 传递性规则
5. 内存屏障原理
内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样,java通过屏蔽这些差异,统一由jvm来生成内存屏障的指令。硬件层的内存屏障分为两种:
Load Barrier 读屏障
对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载新数据;
Store Barrier写屏障。
对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。
内存屏障有两个作用:
- 阻止屏障两侧的指令重排序;
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。