并发编程的特性
- 原子性
一个或一组操作在执行过程中不会被其他操作插入或中断 - 可见性
一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改 - 有序性
程序执行的顺序按照代码的先后顺序执行
先行发生原则(Happens-Before)
- 先行发生原则,是对 可见性 和 有序性 的约束
- 若 java 内存中,操作 A 先行发生于 操作 B ,则 操作 B 可以观测到 操作 A 产生的影响
- 操作 A 与 操作 B具有先行发生关系,并不意味两操作一定按先行发生顺序执行
若两操作指令重拍后,执行结果与不重排一致,则这种指令重排合法 - 用于判断数据是否存在线程竞争或是否线程安全,对先行发生的场景的指令重排是有限制的
java 语法天然支持下面 8 个先行发生场景
下面8个场景中,操作 A 先行发生于操作 B 时,操作 B 可以观测到 操作 A 的结果
- 程序次序规则(Program Order Rule)
同一个线程内,流程控制上在前的操作先行发生于流程控制中在后的操作时 - 管程锁定规则(Monitor Lock Rule)
同一个锁的 unlock 操作 先行发生于 此锁的 lock 操作时 - volatile 变量规则(Volatile Variable Rule)
同一个 volatile 变量的写操作 先行发生于对于 这个变量的读操作之前时 - 线程启动规则(Thread Start Rule)
同一个线程 start()方法 先行发生 此线程其他方法时 - 线程终止规则(Thread Termination Rule)
同一个线程的 所有其他操作 先行发生 此线程的终止检测(join() 和 isAlive())时 - 线程中断规则(Thread Interruption Rule)
同一个线程的 interrupt() 方法的调用先行发生 此线程的终止检测(interrupt())时 - 对象终结规则(Finalizer Rule)
同一个对象的 构造方法执行结束 先行发生于 此对象 finalize() 方法开始时 - 传递性(Transitivity)
若 操作A 先行发生于 操作B
且 操作B 先行发生与 操作C
则 操作A 先行发生于 操作C
场景举例
private int value = 0;
public int geValue(){
return value;
}
public int setValue(){
return ++value;
}
分析
- 首先,不涉及线程的启动、中断、终止,以及对象的终结
- 其次,因为是多线程调用,所以不涉及 程序次序
程序次序的前提是在同一个线程中 - 再次,两个方法没有使用锁,不满足 管程锁定
- 又次,涉及变量不被
volatile
修饰,所以不满足 == volatile 变量规则== - 最后,一共只有两个操作(取值和赋值),因此无所谓 传递性
- 综上,不能推定上面两操作具有先行发生关系
只能认为调用这两个方法的线程根据调用顺序具有一定的优先
volatile 关键字
volatile 关键字是 java 虚拟机提供的轻量级(线程间)同步机制,这意味着 volatile
- 保证可见性
写操作时,会立刻将结果同步到主内存
读操作时,会强迫本地内存与主内存同步
相当于直接往主内存写,从主内存读 - 不保证原子性
volatile 可能导致写操作丢失
因为其他线程获取了同一个值并在此线程之前提交成功 - 禁用指令重排
指令重排
为提高性能,编译器和处理器会对指令进行重排
包括编译器优化重排、指令并行重排、内存重排
- 指令在执行的过程中,若交换顺序不影响执行结果,则允许指令重排
即,指令重排不能破坏数据原本的依赖关系 - 编译器优化重排:编译器可能导致指令重排
JMM 可以根据重排规则,禁止特定编译器的重排 - 指令并行重排:处理器可能导致指令重排
编译器可以通过在指令序列中插入 内存屏障 禁用 - 内存重排:内存系统可能导致指令重排
由处理器使用缓存或读写缓冲区导致
单线程时,指令重排前后保持一致
多线程时,可能出现问题
指令重排会出问题的场景
- 写后读
若重排,会导致读到旧值i = 1;
j = i;
- 写后写
若重排,会导致实际最后执行的语句生效i = 1;
i = 10;
- 读后读
若重排,会导致读取到新值i = j;
j = 10;
多线程下指令重排问题示例
说明
假设只有一个 AAA 对象
有两个线程,线程 1 调用 m1,线程 2 调用 m2
此测试程序编译并执行多次,
每次都是 m1 的第一句执行后,m2 开始执行
则
m1 先执行 a=1 时,输出 6
m1 先执行 f=true 时,输出 5
class AAA{
int a = 0;
boolean f = false;
public void m1(){
a = 1;
f = true;
}
public void m2(){
if (f){
a = a+5;
System.out.println(a);
}
}
}
内存屏障(Memory Barrier)
内存屏障是一个 CPU 指令,作用如下
- 内存屏障前后的指令不能与之调换顺序,因此前后指令也不会调换顺序
- 内存屏障可以强制刷新 CPU 的缓存数据
详见 内存屏障的划分
当一个变量被声明为 volatile
时,字节码会添加 ACC_VOLATITL
标记
JVM 遇到此标记时,会按下面 JVM 指令重排的规定 的顺序添加内存屏障
内存屏障的划分
写屏障(store fence): 使内存屏障之前的写操作回写主内存
读屏障(load fence): 使内存屏障之后的所有读操作都能获得屏障前写操作的值
全屏障(full fence): 上面两者合并
实际使用时,会更细化分为 4 个屏障
- LoadLoad,保证两个读操作有序
对应汇编语句 movq 0(%%rsp) ,%0
将 rsp (寄存器)中的值存入 thread.sp (线程的共享内存,sp 是共享内存 sharded memory 的一部分)中,完成rsp的存储 - StoreStore,保证两个写操作有序
- LoadStore,保证先读后写有序
- StoreLoad,保证先写后读有序
JVM 指令重排的规定
是否允许指令重排 | 后 普通读 | 后 普通写 | 后 volatile 读 | 后 volatile 写 |
---|
先 普通读 | √ | √ | √ | × |
先 普通写 | √ | √ | √ | × |
先 volatile 读 | ×(但应该可以? ) | × | × | × |
先 volatile 写 | √ | √ | × | × |
概括的讲,下面场景禁用指令重排
- 先 volatile 读时
防止后面的操作重排到 volatile 读之前 - 后 volatile 写时
防止前面的操作重排到 volatile 写之后
比如先读后写重排为先写后读,会导致读到的值不一致 - 先 volatile 写后读时
防止重排为先读后写,导致读的值不一致
JMM 内存屏障策略
JMM 在 volatile 读写前后添加屏障如下面两组
volatile 写组
- 写写屏障(StoreStore)
- volatile 写
- 写读屏障(StoreLoad)
volatile 读组
- volatile 读
- 读读屏障(LoadLoad)
- 读写屏障(LoadStore)
上面两组直接与普通读写自由前后组合,如先 volatile 读,后 volatile 写可以等效为
- volatile 读
- 读读屏障(LoadLoad)
- 读写屏障(LoadStore)
- 写写屏障(StoreStore)
- volatile 写
- 写读屏障(StoreLoad)
是否允许指令重排 | 后 普通读 | 后 普通写 | 后 volatile 读 | 后 volatile 写 |
---|
先 普通读 | - | - | - | 写写屏障(StoreStore) |
先 普通写 | - | - | - | 写写屏障(StoreStore) |
先 volatile 读 | 读读屏障(LoadLoad) | 读写屏障(LoadStore) | 读读屏障(LoadLoad) | 写写屏障(StoreStore |
先 volatile 写 | - | - | 写读屏障(StoreLoad) | 写读屏障(StoreLoad) |
volatile 的适用场景
总结
- volatile 不保证 原子性,因此不适用于基于当前值的运算
比如在当前值基础上 + 1 等操作 - volatile 可以保证 可见性 和 有序性,因此适用于 无所谓原值而直接复制 的操作
比如用于标识某些状态的 boolean 和 int
单例
双重检验懒汉式(DCL)理论上有很小概率出现问题
语句 instance = new Singleton() 不是原子的
由 3 步组成 - 在堆中分配内存空间
- 初始化对象
- 变量 instance 指向内存空间
若 2 、3 步发生指令重排,且出现并发问题
则因为引用了内存空间,内外两层校验检查时 != null
但此时返回的是一个未初始化完成的对象
可以使用 volatile 禁用指令重排
读写锁缓存
读通过 volatile 保证可见性和顺序性来代替锁,因为可以认为简单的读天然带有原子性
写操作依然通过锁保证
JUC 包中