Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该通过排他锁单独获取这个变量
当你将一个共享变量定义为volatitle时,对它进行写操作时,汇编代码会多一条lock前缀的指令,该指令有如下作用(集合Java内存模型思考)
1)修改了当前线程缓存行的该变量,会强制将修改后的值立即写入主内存
2)这个写入内存的操作会使在其他线程里缓存的该变量无效
不知道大家还记不记得JMM的可见性。
当一个线程对从主内存中拷贝的某一共享变量进行修改时,对于其他缓存了该共享变量的线程来说是可见的
毫无疑问,volatitle完美的实现了可见性。当该线程修改之后,其他线程中的该共享变量就是无效的,读取操作时就只能再次从主内存中读取,保证了共享变量准确和一致的更新。
具体的如何使用lock前缀的指令去实现这些作用的,有兴趣的读者可以自行搜索查阅,方便的话还可以留言传授给我它的底层原理。
说到这里,大家是不是感觉这个volatitle的实现功能有些熟悉的味道。
是不是想起了缓存一致性协议?这就相当于缓存一致性协议将某一共享状态的缓存块,设置为了Modified,然后其他CPU中的该缓存块状态修改为Invalid(失效状态),在其他CPU需要再次读取该缓存块时,Modified状态的缓存块会提前将修改后的内容写入主内存中,以保证了缓存块的数据一致性。
参考我的上一篇博文
多线程并发艺术(二)volatitle修饰符之缓存一致性协议
当我在学习到二者之后,很长一段时间都在思考,
后来我在长时间的网上翻阅后,总结出了几下几点观念
JMM主要用于屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
如果扩展开来说,它也可以称为内存一致性模型。
JMM屏蔽了计算机的硬件问题,屏蔽了底层的差异,对上层提供了一个统一的模型。所以通过volatitle再次实现了这个一致性。同时也是为了防止优化到寄存器,虽然有一致性协议但是编译器可能会把全局变量优化到寄存器,而各个CPU的寄存器则是完全独立的。
这是一种回答。
Java多线程中,每个线程都有自己的工作内存,需要和主内存进行交互。
这里的工作内存和计算机硬件的缓存根本并不是一回事,只是可以相互类比。
所以,并发编程中的可见性问题,是由于哥哥线程之间的本地内容数据不一致导致的,和计算机缓存并无关系
这也涉及到了后续对于进程,线程,CPU核数之间的讨论。暂且不提。
这是另外一种回答。
如果有读者对上述问题有解答的,欢迎留言回复。
内存屏障:又称内存栅栏(Memory Fence),是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题
1)LoadLoad 屏障:语句 Load1,LoadLoad, Load2
在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
2)StoreStore 屏障:语句 Store1, StoreStore, Store2
在Store2及后续写入操作执行前,保证Store1的写入操作对其他处理器可见
3)LoadStore 屏障:语句 Load1,LoadStore,Store2
在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕
4)StoreLoad 屏障:语句 Store1,StoreLoad,Load2
在Load2及后续读取操作执行前,保证Store1的写入对其他处理器可见。
它的开销是四种屏障中最大的,在大多数处理器的实现中,这个屏障是万能屏障,兼具其他三种内存屏障的功能
再介绍一份非常经典的volatitle重排序规则表
是否能重排序 | 第二个操作 | 第二个操作 | 第二个操作 |
---|---|---|---|
第一个操作 | 普通读/写 | volatile读 | volatile写 |
普通读/写 | NO | ||
volatile读 | NO | NO | NO |
volatile写 | NO | NO |
我们解析以下这张表
如何实现上述的volatile重排序表呢。
volatile的基于保守策略的内存屏障插入策略非常严格保存,非常悲观而且毫无安全感的心态
(悲观又让我想起了乐观锁和悲观锁的概念,后续也会发布相应的博客)
在每个volatile写操作前插入 StoreStore屏障,在写操作后插入 StoreLoad屏障
在每个volatile读操作后插入 LoadLoad屏障, 在读操作后插入 LoadStore屏障
保守策略下,volatile写插入内存屏障后生成的指令序列示意图
如图:
上图的StoreStore屏障可以保证在volatile写之前,其前面的所有写操作已经对任意处理器可见了。
因为StoreStore屏障会保证上面所有写操作在volatile写之前刷新到主内存
保守策略下,volatile读插入内存屏障后生成的指令序列示意图
其实我本人对于详细volatile的内存屏障也是一知半解。如果有详细研究的读者还请不吝赐教。
有兴趣的读者还可以去研究以下Java的 happen-before原则。
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happen-before关系
其中它的第三条原则便是
volatile变量原则:对一个变量的写操作先行发生于后面对这个变量的读操作
已经介绍完了 volatile保证了可见性和有序性。
那么它为什么不能保证原子性呢,下面有一个经典的案例来说明:
package com.keyring.weixin.controller;
/**
* @Project: proxy
* @Author: Mr_yao
* @Date: 2019/4/20 8:27 PM
* @Desc:
*/
public class Test {
private volatile int count = 0;
public void increase() {
count++;
}
public static void main(String[] args) {
final Test test = new Test();
for (int i = 0; i < 10; i++) {
new Thread( () -> {
for (int j = 0; j < 1000; j++) {
test.increase();
try {
Thread.sleep( 100 );
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println( test.count );
}
} ).start();
while (Thread.activeCount() > 1) {
Thread.yield();
}
}
}
网上的其他案例怎么测试结果都是正确的,后来我就添加了sleep(100),最终输出的结果果然永远是小于10000的数。
这是为什么呢。网上的解释我翻译过来都是这样的:
自增分为三步,
先读取count的值到自己的工作内存,再将count值进行自增操作,最后将修改后的值写入工作内存。
假如某个时刻变量count的值为10。
现在线程1对变量进行自增操作。
执行了第一步,将count值在自己的工作内存中缓存了,缓存值为10.然后堵塞了。
这个时候线程2进来了,开始自增操作。
由于线程1只是读取了count值,线程2还是直接从主内存中读取了count值,count=10.
第二步,线程2将count值进行自增,count+1,count修改值为11。
第三步,线程2将count值写入工作内存,由于是volatile修饰,直接写入了主内存中。
这个时候最大的争议点到了,线程1重新开始运行,此时它的工作内存的值还是count=10,它直接开始执行自增操作的第二步,count+1,count的值修改为11.
最后线程1执行第三步,将count=11的值写入主内存中。
所以最终我们获取的count值并不是两次线程自增后的12,而是count=11.
这里最大的争议点就是,当线程2将count值写入主内存后,其他的线程中缓存count的值难道不应该无效么。线程1中的count应该无效,它必须再次从主内存中获取count值,然后count=11重新读取出来,再次+1,最终答案应该是正确的12才对。
如果volatile的底层是和我前面述说的缓存一致性协议底层一样,其他的线程中缓存值是通过监听形式,监听到该变量有修改,则变成无效的话,我认为,确实应该是12才对,但是我并不知道它的底层lock究竟是如何判断的。
它是如何让该变量在其他线程中是呈现无效状态的。
是通过与主内存中该变量的对比还是直接给该变量添个状态标识符。
无效状态会不会影响后续线程对它的调用。
当线程2对volatile变量进行修改后,线程1(已经读取该变量值)再开始运行。可能有如下情况:
如果读者有自己的意见的,欢迎留言讨论。
后续我们将会讨论synchronized。并将它与volatile进行比较。它可是可以实现原子性的。