volatile语义
volatile语义
目录
概述
声明共享变量为volatile后, 对这个变量的读/写将会很特别. 为了揭开volatile的神秘面纱, 下面将介绍volatile的内存语义及volatile内存语义的实现.
volatile的特性
理解volatile特性的一个好方法就是把对volatile变量的单个读/写, 看成是使用同一个锁对这些单个读/写操作做了同步.
意思就是
public class Demo {
volatile long val = 10L;
public void setVal(long val) {
this.val = val;
}
public long get() {
return val;
}
}
与
public class Demo {
volatile long val = 10L;
public synchronized void setVal(long val) {
this.val = val;
}
public synchronized long get() {
return val;
}
}
是一样的效果.
如上所示, 一个volatile变量的单个读/写操作, 与一个使用同一个锁来同步的普通变量的的读/写, 它们的执行效果是相同的.
锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性, 这意味着对一个volatile变量的读, 总是能看到(任意线程)对这个volatile变量最后的写入.
锁的内存语义决定了临界区代码的执行具有原子性. 这就意味着, 即使是64位的long类型和double类型变量, 只要它是volatile变量, 对该变量的读/写就具有原子性. 如果多个volatile操作或类似于volatile++这种复合操作, 这些操作整体上不具有原子性.
简言之, volatile变量自身具有以下特性.
- 可见性: 对一个volatile变量的读, 总是能看到(任意线程)对这个volatile变量最后的写入.
- 原子性: 对任意单个volatile变量的读写具有原子性, 单类似于volatile++这种复合操作不具有原子性.
volatile读/写的内存语义
volatile写的内存语义
当写一个volatile变量时, JMM会把线程对应的本地内存中的共享变量值刷新到主内存中.
volatile读的内存语义
当读一个volatile变量时, JMM会把该线程对应的本地内存置为无效, 线程接下来将从主内存中读取共享变量.
总结
对volatile写和volatile读的内存语义做个总结:
- 线程A写一个volatile变量, 实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息.
- 线程B读一个volatile变量, 实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息.
- 线程A写一个volatile变量, 随后线程B读这个volatile变量, 这个过程实质上是线程A通过主内存向线程B发送消息.
volatile内存语义的实现
为了实现volatile的内存语义, JMM会限制编译器和处理器的重排序. 下面是限制的重排序规则.
- 当第二个操作为volatile写时, 不管第一个操作是什么, 都不能重排序. 确保了volatile写之前的操作不会被编译器重排序到volatile写之后.
- 当第一个操作为volatile读时, 不管第二个操作是什么, 都不能重排序. 确保了volatile读之后的操作不会被编译器重排序到volatile读之前.
- 当第一个操作为volatile写时, 第二个操作是volatile读时, 不能被重排序.
为了实现volatile的内存语义, 编译器在生成字节码时, 会在指令序列中插入内存屏障来禁止特定类型的处理器重排序. JMM采取保守策略, 基于保守策略的JMM内存屏障插入策略如下:
- 在每个volatile写操作的前面插入一个StoreStore屏障.
- 在每个volatile写操作的后面插入一个StoreLoad屏障.
- 在每个volatile读操作的后面插入一个LoadLoad屏障.
- 在每个volatile读操作的后面插入一个LoadStore屏障.
上述内存屏障插入策略非常保守, 但它可以保证在任意处理器平台, 任意的程序中都能得到正确的volatile内存语义. 这也是为Java提供的跨平台打下的基础. 当然编译器可以根据具体的情况省略不必要的屏障.
处理器的重排序规则
处理器\规则 | LoadLoad | LoadStore | StoreStore | StoreLoad | 数据依赖 |
---|---|---|---|---|---|
SPARC-TSO | N | N | N | Y | N |
X86(X64 & AMD64) | N | N | N | Y | N |
IA64 | Y | Y | Y | Y | N |
PowerPC | Y | Y | Y | Y | N |
单元格中的”N”表示处理器不允许两个操作重排序, “Y”表示允许重排序.
可以发现常见的处理器都允许StoreLoad重排序; 常见的处理器都不允许对存在数据依赖的操作做重排序. SPARC-TSO和X86拥有相对较强的处理器内存模型, 它们仅允许对写-读操作做重排序(因为它们都使用了写缓冲区).
内存屏障类型表
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保Load1数据的装载先于Load2及所有后续装载指令的装载. |
StoreStore Barriers | Store1; StoreStore; Store2 | 确保Store1数据对其它处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储. |
LoadStore Barriers | Load1; LoadStore; Store2; | 确保Load1数据装载先于Store2及后续的存储指令刷新到内存. |
StoreLoad Barriers | Store1; StoreLoad; Load2; | 确保Store1数据对其他处理器变得可见(刷新到内存)先于Load2及所有后续装载指令的装载. StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后, 才执行该屏障之后的内存访问指令. |
StoreLoad Barriers是一个”全能型”的屏障, 它同时具有其他3个屏障的效果. 现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持). 执行该屏障开销会很昂贵, 因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush).
JSR-133为什么要增强volatile的内存语义
在JSR-133之前的旧Java内存模型中, 虽然不允许volatile变量之间重排序, 但旧的Java内存模型允许volatile变量与普通变量重排序.
在旧的内存模型中, volatile的写-读没有锁的释放-获所具有的内存语义. 为了提供一种比锁更轻量级的线程之间通信的机制, JSR-133专家组决定增强volatile的内存语义: 严格限制编译器和处理器对volatile变量与普通变量的重排序, 确保volatile的写-读和锁的释放-获取具有相同的内存语义. 从编译器重排序规则和处理器内存屏障插入策略来看, 只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语义, 这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止.
由于volatile仅仅保证对单个volatile变量的读/写具有原子性, 而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性. 在功能上, 锁比volatile更强大; 在可伸缩性和执行性能上, volatile更有优势.
参考<