8. volatile
8.0 为什么存在内存可见性问题 -> 缓存一致性问题
可见性是指某线程修改了一个变量的值,新值对于其他线程是立即可见的。
为了提高代码执行速度,CPU 先把资源从内存加载到 CPU 缓存中(L1, L2等),但操作完并并不一定实时写回内存。此外,每条线程有自己的工作内存 Working Memory,用于保存被该线程所使用的变量的主内存的副本,线程对变量的所有操作都在工作内存中进行,不能直接读写主内存中的数据。
volatile
关键字能解决这个问题。
8.1 volatile 功能
- 保证变量的内存可见性
- 当某个线程对
volatile
变量进行写操作时,JMM 会立即把该线程对应的本地内存刷新到主内存中。 - 当某个线程对
volatile
变量进行读操作时,JMM 会立即把该线程的本地内存置为无效,并从主内存中重新读取。
- 当某个线程对
- 禁止
volatile
变量与 普通变量 的指令重排- 禁止指令重排是为了实现一种类似锁的线程间通信机制,但比锁更轻量级
虽然
volatile
保证了内存可见,但 java 中的运算操作符不能保证原子性,同样会导致并发的不安全。需要额外使用锁来保证原子性。
8.1.1 如何限制指令重排
通过内存屏障来限制指令重排:
- 内存屏障分为 读屏障 Load Barrier 和 写屏障 Store Barrier;
- 阻止屏障两侧的指令重排序;
- 写后强制把写缓冲区/高速缓存中的脏数据写回主内存,读时先让缓存中数据失效
JMM 内存屏障:
- 在每个 volatile 写操作前插入一个 StoreStore 屏障;
- 在每个 volatile 写操作后插入一个 StoreLoad 屏障;
- 在每个 volatile 读操作后插入一个 LoadLoad 屏障;
- 在每个 volatile 读操作后再插入一个 LoadStore 屏障。
具体:
- LoadLoad 屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore 屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,这个屏障会吧Store1强制刷新到内存,保证Store1的写入操作对其它处理器可见。
- LoadStore 屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad 屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
8.1.2 volatile 与 锁 的区别
volatile
和 锁 都能保证内存可见性,因此 volatile
可以作为一个轻量级的锁。
- 但
volatile
仅能保证单个变量的读写具有原子性,锁可以保证整个临界区代码的原子性 - 锁的功能更强大,但
volatile
的性能更有优势
8.2 volatile 实现原理
从字节码的角度来看,对 volatile
变量进行写操作时,在赋值后,JVM 会多执行一行带有 lock
前缀的指令,该指令会把 CPU 缓存中的新值写回内存。
// lock + 空操作 来执行缓存回写
0x01a3de24: lock addl $0x0, (%esp)
同时,缓存一致性协议保证了各个处理器的缓存是一致且最新的。
lock
前缀的指令会引起处理器缓存写回内存;- 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
- 当处理器发现自己的本地缓存失效后,就会从主内存中重读最新值。