参考资料: 《深入理解java虚拟机》(周志明)
1. JAVA为什么要有一个volatile修饰符?
要说清楚volatile
就是要说清楚下面3个问题:
-
- 没有
volatile
的程序会出现什么问题?
- 没有
-
volatile
解决了什么问题?
-
- 为什么
volatile
能解决这些问题?
- 为什么
2. 代码不符合预期的问题
2.1 工作内存带来的可见性
问题
2.1.1 java内存模型
预备知识: java内存模型JSR-133
https://jcp.org/en/jsr/detail?id=133
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中 (物理上对应L3缓存或内存或硬盘(虚拟内存))
每条线程还有自己的工作内存 (物理上可能是L1,L2高速缓存或者寄存器)
线程的工作内存中保存了被该线程使用的变量的主内存副本
线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。
不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成
- 实际上JVM在操作变量的时候经常会有如下步骤
-
- 将变量的值从
主内存
加载到工作内存
- 将变量的值从
-
- 再对
工作内存
中的变量进行操作,比如加减乘除
- 再对
-
- 当我们操作结束之后,某个时刻写回到
主内存
中
- 当我们操作结束之后,某个时刻写回到
-
- 也就是我们的赋值操作被拆分成了多条字节码指令,基本无法保证原子性和可见性
-
- 修改的结果,其他线程可能不能立刻观测到
2.1.2 主内存和工作内存的区分带来了什么问题?
- 每个线程有自己的工作内存
- 导致了一个问题: 如果多个线程之间,是依赖于某个变量作为状态位来影响其他线程,那么状态位的更新不能保证立刻被其他线程观测到
直接代码举例:
- 问题例子1:
private boolean isOpening = true;//成员变量
//线程1:
isOpening = false;
closeResources();//释放资源操作
//线程2:
if(isOpening){
readResources();//读取资源操作
}
- 此段代码,预期 在释放资源之前,用
isOpening
作为状态位来控制其他线程,禁止其他线程访问此资源readResources()
,然后安全的释放资源closeResources()
- 但是实际上程序可能是按照以下步骤进行的:
-
- 线程1 将
isOpening
变量拷贝到 线程1的工作线程中
- 线程1 将
-
- 线程1 将 工作线程中的
isOpening
修改为false
- 线程1 将 工作线程中的
-
- 线程1 执行释放资源操作
closeResources()
- 线程1 执行释放资源操作
-
- 线程2 从主内存中读取
isOpening
变量,此时线程1的工作内存还没有写回到主内存中
- 线程2 从主内存中读取
-
- 线程2 判断
isOpening == true
,则开始执行readResources()
- 线程2 判断
-
- 最终: 由于资源已经被释放,所以线程2读取失败报错,程序出现bug
-
- 由于变量可见性导致的问题会非常的隐蔽,所以我们需要重视这个问题
2.2 各种优化策略带来的指令重排
问题
2.2.1 什么是指令重排序
- 为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化
- 计算之后,将乱序执行的结果重组,保证该结果与顺序执行结果是一致的
- 但不保证程序中各个语句计算的先后顺序与代码的顺序一致
- Java虚拟机的即时编译器中也有指令重排序(Instruction Recorder)优化
下面举个例子:
private int a = 0;//成员变量a
private int b = 0;//成员变量b
//线程1
a = 1;
b = 2;
- 在这里是a先被赋值还是b先被赋值,我们是不知道的,因为CPU会对指令进行重排序,以提高代码的执行效率
2.2.2 指令重排序带来了什么问题?
在单个线程中看似好像没有问题,但是在多线程环境下,就会出现问题了
举个例子:
- 问题例子2:
private int a;//成员变量a
private int b;//成员变量b
//线程1
//假设这里的逻辑是先赋值a,再赋值b,当b赋值完成也就表示赋值操作完成
a = 1;
b = 2;
//线程2
if(b==2){
//假设这里有个业务,认为赋值操作完成之后,要计算一个分数=a*10
score = a * 10;
}
- 这里出现了一个问题
- 线程2,认为 给
a,b
的赋值操作是有顺序的,b=2
会在最后完成 - 但是事实上不一定,我们不能保证,a和b的赋值顺序
- 线程2预期的是,
b=2
的时候a=1
,score=10
- 由于指令被重排序了,线程2依赖
b==2
做判断可能得不到正确的结果 - 实际可能执行的过程是这样的:
-
- 线程1指令重排了,先执行了
b=2
- 线程1指令重排了,先执行了
-
- 这时候线程1让出CPU时间片,改由线程2执行
-
- 线程2检测到
b==2
, 取出a=0
乘以10
,得到score=0
- 线程2检测到
-
- 线程2让出CPU时间片,线程1执行
-
- 线程1,给a赋值
a=1
- 线程1,给a赋值
-
- 最终:
a=1
,b=2
,score=0
- 最终:
3. 怎么解决上面的问题?
3.1 解决可见性的问题
- 解决2.1.2的问题:
private volatile boolean isOpening = true;//成员变量,是volatile变量
//线程1:
isOpening = false;
closeResources();//释放资源操作
//线程2:
if(isOpening){
readResources();//读取资源操作
}
- 代码和 2.1.2 中的例子几乎一模一样,只是
isOpening
被volatile
修饰了 - JVM保证了,在写
volatile
修饰的变量的时候,会立刻同步到主内存中,并且将其他工作线程中的副本作废 - 具体硬件上的实现,可以查一查
MESI
协议等缓存一致性协议 - 由于
isOpening
被设置为volatile
,状态位保证了线程之间的可见性; - 我们的代码可以保证在线程1执行到
closeResources()
的时候, 线程2是绝对不可能执行readResources()
的
3.2 解决指令重排序带来的问题
- 解决 2.2.2 的问题:
private int a;//成员变量a
private volatile int b;//成员变量b,被设置成了volatile
//线程1
//假设这里的逻辑是先赋值a,再赋值b,当b赋值完成也就表示赋值操作完成
a = 1;
b = 2;//指令重排序屏障
//线程2
if(b==2){
//假设这里有个业务,认为赋值操作完成之后,要计算一个分数=a*10
score = a * 10;
}
- 这里
b
被设置为了,volatile
变量 volatile
在这里存在的意义是:volatile
在b = 2
;这步操作的位置设置了一个指令重排序的屏障,他的意义是:b = 2;
这行代码之前的代码,随便重排序,但是一定不会影响到b = 2;
这行代码及其之后的代码b = 2;
这行代码之后的代码,随便重排序,但是一定不会影响到b = 2;
这行代码及其之前的代码- 这样就保证了
b = 2;
这条语句一定发生在a = 1;
之后 - 也就解决了
score
的值可能算不准的问题
4. 为什么volatile
能解决 可见性和指令重排序造成的问题
- java内存模型为
volatile
专门定义了一些特殊的访问规则 - 当一个变量被定义成
volatile
之后,它将具备两项特性: -
- 保证此变量对所有线程的可见性,当一条线程修改了变量的值,新值对于其他线程来说是可以立即得知的
-
- 对
volatile
变量的操作,禁止指令重排序
- 对
4.1 详解volatile
原理
4.1.1 实现可见性的原理:
- 在对
votalite
修饰的变量进行赋值之后会立即执行一条指令lock addl $0x0, (%esp)
lock addl $0x0, (%esp)
: 把ESP计算器的值加0; 是一个空操作- 这里的关键在于
lock
前缀 - 它的作用是将本地处理器的缓存写入内存,该动作会引起别的处理器或者别的内核的无效化其缓存(硬件上CPU可能使用了MESI之类的协议来实现)
4.1.2 禁止指令重排序的原理:
先聊一下,硬件层面上的指令重排序是什么:
以下引用《深入理解java虚拟机》(周志明)
指令重排序是 指处理器采用了允许 将多条指令 不按程序规定的顺序 分开发送给各个相应的电路单元 进行处理
但并不是说指令任意重排,处理器必须能正确处理
指令依赖
情况,保障程序能得出正确的执行结果如:
指令1
把地址A
中的值加10,指令2
把地址A
中的值乘以2,指令3
把地址B
中的值减去3这时
指令1和指令2是有依赖的
,他们之间的顺序不能重排
(A+10)*2
与A*2+10
肯定不相等但
指令3
可以重排到指令1,2
之前或者中间,因为指令3
操作的是地址B
,与地址A
上的计算无关
只要保证处理器执行到后面,其他操作依赖到A、B值的操作时 能获取正确的A、B的值即可所以在同一个处理器中,重排序过的代码,看起来依然是有序的
之前分析可见性的时候,我们知道了:
- 在对
votalite
修饰的变量进行赋值之后会立即执行一条指令lock addl $0x0, (%esp)
关键来了:
lock addl $0x0, (%esp)
指令把修改同步到内存时,意味着所有之前的操作已经完成了!为了达到这个效果,CPU不会去将
lock addl $0x0, (%esp)
之前的指令重排到lock addl $0x0, (%esp)
之后这样便形成了
指令重排序无法越过的内存屏障
效果
总结:
- 这里其实CPU要分析的是各个指令的互相依赖关系
- 由于需要将工作内存的计算值,写入到主内存
- CPU为了保证写入到主内存的值是正确的
- 那么意味着
lock addl $0x0, (%esp)
执行的时候,之前的指令必须执行完毕 - 自然的就会形成一个
指令重排序的屏障
- 防止了屏障前的指令,重排到屏障之后
- 也防止了屏障后的指令,重排到屏障之前