Java内存模型
-
主内存 (Main Memory)
主内存可以简单理解为计算机当中的内存, 但又不完全等同. 主内存被所有的线程共享, 对于一个共享变量来说, 主内存当中存储了它的 “本尊”.
-
工作内存 (Working Memory)
工作内存可以理解为CPU中的高速缓存, 每一个线程拥有自己的工作内存, 对于一个共享变量来说, 工作内存中存储了它的 “副本”.
线程对共享变量的所有操作必须在工作内存上进行, 不能直接读写主内存的变量, 不同线程之间也无法访问彼此的工作内存, 变量值的传递只能通过主内存来进行.
所以就产生了主内存和工作内存之间可能会有很小的一段时间不同步的问题.
不同步的例子
对于一个静态变量: static int s = 0;
线程A: s = 3;
则JMM的工作流程如下所示:
- 第一步: 先在主内存中创建共享变量s
-
第二步: 线程A读进工作内存中, 进行修改
-
第三步: 再将修改后的共享变量的值赋给主内存
从单线程的角度来讲, 这一系列过程没有任何问题.
这时候我们引入线程B, 执行如下代码:
System.out.println("s=" + s);
这个时候, 在多线程环境下, 输出的结果可能是 0 或 3.
-
上图中的第三步完成时读入
-
上图中的第二步之后, 第三步赋值之前读入 (主内存中还是0)
volatile引入
特性 - 保证可见性
Volatile关键字最重要的特性就是保证了用volatile修饰的变量对所有线程的可见性.
当一个线程修改了变量的值, 新的值会立刻同步到主内存, 而其他线程读取这个变量的时候, 会从主内存中拉取最新的变量值, 也就是说: 用volatile修饰的变量, 写操作必定先于读操作.
缺点 - 不保证原子性
volatile关键字只可以保障变量对所有线程的可见性, 但是无法保证对变量操作的原子性.
count++; // i的自增, 不是原子操作
// 字节码指令:
getstatic // 读取静态变量count
iconst_1 // 定义常量 1
iadd // count增加1
putstatic // 把count结果同步到主内存
每一次读操作 (getstatic字节码指令)的时候, 虽然获取的都是主内存中的最新变量值, 但进行iadd的时候, 由于不是原子性操作, 其他线程在此过程中可能让count自增了很多次, 这样本线程计算更新的是一个陈旧的count值, 自然无法做到线程安全.
- 线程A读取最新值 0
- 在此之后, 有一个线程B, 将主内存的值更新为了8
- 线程A在自己的工作内存中进行自增
- 线程A将结果1同步到主内存, 而事实上主内存正确结果应该是9
总结 - 什么时候适合用volatile
- 运行结果不依赖变量的当前值, 或者能够确保只有单一线程修改变量值
- 变量不需要与其他的状态变量共同参与不变约束
第二条的解释:
volatile static int start = 3;
volatile static int end = 6;
线程A执行如下代码:
while (start < end){
//do something
}
线程B执行如下代码:
start+=3;
end+=3;
这种情况下,一旦在线程A的循环中执行了线程B,start有可能先更新成6,造成了一瞬间 start == end,从而跳出while循环的可能性。
指令重排序
介绍
指令重排序是指JVM在编译Java代码的时候, 或者CPU在执行JVM字节码的时候, 对现有的指令顺序进行重新排序的现象.
指令重排序的目的是为了在不改变程序执行结果的前提下, 优化程序的运行效率.
指令重排序虽然优化了程序的运行效率, 但是在某些情况下, 会影响到多线程的执行结果.
影响多线程执行结果的例子
指令重排序前
boolean contextReady = false;
// 在线程A中执行:
context = loadContext();
contextReady = true;
// 在线程B中执行:
while( ! contextReady ){
sleep(200);
}
doAfterContextReady (context);
指令重排序后
boolean contextReady = false;
// 在线程A中执行:
// 这里发生指令重排序, boolean赋值先执行
contextReady = true;
context = loadContext();
// 在线程B中执行:
while( ! contextReady ){
sleep(200);
}
doAfterContextReady (context);
内存屏障
内存屏障, 也称为内存栅栏, 或栅栏指令, 是一种屏障指令. 它使CPU或编译器对屏障指令之前和之后发出的内存操作执行一个排序约束
LoadLoad屏障
场景: Load1; LoadLoad; Load2
Load1和Load2代表两条读取指令, 在Load2要读取的数据被访问前, 保证Load1要读取的数据被读取完毕.
StoreStore屏障
场景: Store1; StoreStore; Store2
Store1和Store2代表两条读写指令, 在Store2写入执行前, 保证Store1的写入操作对其他处理器可见.
LoadStore屏障
场景: Load1; LoadStore; Store2
在Store2被写入前, 保证Load1要读取的数据被读取完毕
StoreLoad屏障
场景: Store1; StoreLoad; Load2
在Load2读取操作执行前, 保证Store1的写入对所有处理器可见, StoreLoad屏障的开销是四种内存屏障中最大的.
Volatile和内存屏障
在一个变量被volatile修饰后, JVM会为我们做两件事:
- 在每一个volatile写操作前插入StoreStore屏障, 在写操作后插入StoreLoad屏障.
- 在每一个volatile读操作前插入LoadLoad屏障, 在读操作后插入LoadStore屏障.
例子
boolean contextReady = false;
在线程A中执行:
// 不加volatile, 这里会发生指令重排序
context = loadContext();
contextReady = true;
加入volatile修饰之后: 在写操作前StoreStore, 写操作后StoreLoad屏障
由于加入了StoreStore屏障, 屏障上方的普通写入语句 context = loadContext()和屏障下方的volatile写入语句contextReady=true 无法交换顺序, 从而成功阻止了指令重排序.
总结
volatile特性一: 可见性
保证变量在线程之间的可见性, 可见性的保证基于CPU的内存屏障指令, 被称为 happens-before原则.
volatile特性二: 阻止指令重排序
阻止编译和运行时的指令重排序, 编译时JVM编译器遵循内存屏障的约束, 运行时依靠CPU屏障指令来组织指令重排序.