Java内存模型可见性解决方案以及原理分析

上篇我们提到java内存模型可见性的问题,这篇我们就来讲讲 如何解决这个问题,以及他的原理分析。
在分析之前,我们先来了解一下计算机底层的内存模型,因为 我们java程序毕竟还是运行在计算机底层的架构上,如果底层不会出现这种情况,我认为java内存模型也没必要搞个可见性的问题出来。
在X86架构 (目前所有的PC都是X86架构)里,底层内存模型(TSO内存模型)可认为如下图:
TSO内存模型
这幅图肯定很多人会有疑问, 首先为什么要有cache呢,而目前计算机一般都有三级缓存了,为什么不直接从主内存里面取数据,这是为了进一步加快cpu读取数据的速度,因为cpu直接从主存读取速度太慢了,直接从缓存中读取速度会大大加快,但是因为成本,所以cache一般也不会很大,cache是以缓存行为单位的,而storeBuffer则是当遇到写的操作直接先将数据先写入storeBuffer,等到合适的机会再将数据写回cache(例如缓冲区满了的情况),也是为了进一步提高cpu的效率,从这幅图中,我们可以发现两个cpu的他们都有自己的cache,这就会出现缓存一致性问题了,就比如两个cpu都在缓存中对主内存中的一块内存区域读写时,当同步回主内存的时候要以哪个为准呢。
既然提出了问题,那么肯定是有解决方案的,根据这个问题提出了很多的协议,目前比较常用的就是 MESI协议
于是,我们就来了解一下这个协议
这边我就大致的解释一下这个协议,如果要更加仔细的了解,读者可以去网上查找资料细细的了解.
这个协议将 缓存行 分为 4 种状态:
M: 被修改(Modified)
E: 独享的(Exclusive)
S: 共享的(Shared)
I: 无效的(Invalid)
当这个缓存行只存在这个cpu的缓存中,且没有被修改过他就是 独享状态
当有其他cpu也读取了这个缓存行,那么他就为共享状态
当这个cpu修改了这个缓存行的数据,那么他就为被修改的状态,而其他有这个缓存行的的状态就会被改成 无效的状态
一个处于被修改的状态的缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S状态之前被延迟执行。
上面这句话的意思就是:因为 cpu去读取缓存中的数据发现 数据是无效的状态就会 从主存中读取,而上面那句话就保证了 每次发现数据是无效的状态去主存里读取的就是最新的数据
基于这个协议,我们就可以解决缓存一致性的问题
但是还是没有解决掉可见性的问题,因为storeBuffer没有立刻把修改的数据写回缓存当CPU0在修改一个数据没有写回缓存时,CPU1读到的还是旧数据,但这毕竟是底层硬件的特性,我们只有利用提供的指令在编码层面来解决这个问题。
说完了底层内存模型,我们来看看基于java内存模型给我们提供关键字来解决可见性的问题
第一个方案:利用 synchronized 关键字
synchronized 在被这个关键字包围里的共享变量里面的操作会对下一个进入这段代码块的线程可见, 所以天然就解决了 可见性的问题,因为jvm里面定义一些先行发生原则,就是一些天然保证了可见性的原则,这个我们放在后面讲,这边就只放结论了。

第二个方案: 利用 volatile关键字
这个关键字算是一把非常轻量级的锁吧,volatile修饰的变量可以保证每次读到的值都是新的值,也可以保证每次修改都会同步到主内存,还防止指令重排序(关于指令的重排序,读者可以在网上查阅资料)。但是具体如何解决的呢
volatile修饰的变量会加上内存屏障
那先说一下内存屏障
java的内存屏障通常所谓如下的四种LoadLoad,StoreStore,LoadStore,StoreLoad
下面是基于保守策略的JMM内存屏障插入策略:
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的前面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。
每个屏障的功能如下:
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

为了进一步探究Java内存屏障在X86平台的实现细节
后面基于HSDIS插件 获得java代码生成的汇编代码可以对比一下 加了volatile 关键字前后的差别
测试代码

public class Test {

    volatile int a;

    public void testVolatile() {
        a = 5;
    }

    public static void main(String[] args) {
        new Test().testVolatile();
    }
}

上面是对一个加了volatile关键字的变量的写的操作我们来查看一下汇编代码
汇编代码图片
我们发现 就是在赋值之后主要多了lock addl $0x0,(%esp) 这条语句也就是加了一个内存屏障
通过这个我在书本上有看过对他的说明
书本上是 说 将缓存的数据写入内存,并且可以使其他核心的相应的缓存数据失效,并且可以保证前面的之前的指令不会重排序到这个内存屏障之后。
但是个人的理解:在X86 TSO内存模型下,就是将 stroreBuffer写入缓存,而其他的可见性 有MESI协议保证这也是为什么有了MESI协议还需要 volatile关键字的原因之一,另外一个原因也是为了防止重排序对我们程序的影响
这边有一个关键点: volatile 修饰的变量如果修改了,不仅是他自己这个变量对其他核心可见,并且在他上面的被修改的共享变量也会可见
而对 volatile 变量读 并没有多加任何汇编代码
个人理解上面的情况 : 内存屏障的插入的形式 会根据不同平台,采用不同形式的实现
个人理解: java内存模型只是一个抽象的定义,会根据不同平台的底层,然后底层会有不同的调整,我们只需要根据规则来写代码即可

说了这么多,可能读者会对这个可见性感觉很麻烦的样子,感觉一不小心就会产生可见性的问题,我们上面提到了一个 规则,这个规则也就是java虚拟机为我们定义的,我们只要遵循即可。

这个规则也就是 先行发生原则(happens-before)
比如 A操作先行发生于B ,也就是 在B操作还没发生的时候,A操作的行为会被B操作所观察到,比如A操作修改了共享变量,B操作发生的时候可以知道
一共有八个规则:

  1. 程序次序规则:同一个线程内,按照代码出现的顺序,前面的代码先行于后面的代码,准确的说是控制流顺序,因为要考虑到分支和循环结构。
  2. 管程锁定规则:一个unlock操作先行发生于后面(时间上)对同一个锁的lock操作。
  3. volatile变量规则:对一个volatile变量的写操作先行发生于后面(时间上)对这个变量的读操作。
  4. 线程启动规则:Thread的start( )方法先行发生于这个线程的每一个操作。
  5. 线程终止规则:线程的所有操作都先行于此线程的终止检测。可以通过
    Thread.join( )方法结束、Thread.isAlive( )的返回值等手段检测线程的终止。
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupt()方法检测线程是否中断
  7. 对象终结规则:一个对象的初始化完成先行于发生它的finalize()方法的开始。
  8. 传递性:如果操作A先行于操作B,操作B先行于操作C,那么操作A先行于操作C。

只要遵循这八大规则就可以保证可见性了

以上就是我这篇文章所要写的所以内容,如果有不对的地方 欢迎指正

参考资料: 深入理解java虚拟机第三版:周志明著
以及其他网上一些资料

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值