Java 之 volatile 关键字
作用
volatile 能够保证可见性、有序性,不保证复合操作的原子性。在单线程中,为了提高程序执行效率,编译器和处理器可能对指令进行重排序。单线程环境下,这种优化是没有问题的,但是多线程环境下,如果两个线程之间存在数据依赖,就可能导致程序出错,volatile 可以用来协调不同线程间的变量共享。
volatile 的写-读具有和锁的释放-获取相同的内存语义,所以我们有时候也会说 volatile 是轻量级的 synchronized 。具体来说,当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存。当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。
如何理解锁的释放与获取的内存语义?
- 释放锁之前的指令和之后的指令不能被重排序
- 锁的释放会导致线程本地内存中的共享变量写入主存
- 获得锁之后需要强制从主存读取共享变量
底层实现
Java 语言规范规定了 volatile 关键字的重排序规则:
- volatile 写之前的任何操作都不能重排序到 volatile 写之后,volatile 读之后的任何操作都不能重排序到 volatile 读之前。
- 如果第一个操作是 volatile 写,第二个操作是 volatile 读,也不能重排序。
具体实现时,编译器会在 volatile 写之前插入 LoadStore 屏障,在 volatile 写之后插入 StoreLoad 屏障,在 volatile 读之后插入 LoadLoad 和 LoadStore 屏障。再具体到处理器层面来说,使用的是 LOCK 前缀、MESI 缓存一致性协议和总线嗅探技术。
LOCK 前缀有三个作用:
- 禁止当前指令与它前后的指令重排序,也就是说当前指令之前的指令执行结果对当前指令以及之后的指令全部可见
- 会锁定总线或者缓存,强制把工作内存中的共享变量刷新到主存
- 写主存的动作会导致其他处理器的缓存失效
失效缓存使用总线嗅探技术实现,如果当前处理器通过嗅探总线发现其他处理器正在写一个共享内存地址,正在嗅探的处理器会将它的缓存行置为无效,下一次强制从主存读取。
JSR-133 为什么要增强 volatile 的内存语义?
在旧的内存模型中,volatile 的写-读没有锁的释放-获取所具有的内存语义。为了提供一种比锁更轻量级的线程间通信机制,JSR-133 专家组决定增强 volatile 的内存语义:严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写-读和锁的释放-获取具有相同的内存语义。JSR-133 对 volatile 的增强主要是通过使用内存屏障实现的,限制了处理器和编译器对 volatile 变量和普通变量之间的重排序。
伪共享问题
不同线程的不同字段缓存在同一个缓存行内,多个处理器同时操作同一个缓存行,互相导致对方的缓存失效,会大大降低系统性能。一般来说我们不太需要考虑这个问题,很多时候语言层面的工具会帮我们解决这个问题。如果确实碰到了这个问题,可以尝试采用缓存行填充来解决。