volatile与JMM的那些恩怨情仇

8 篇文章 1 订阅
6 篇文章 0 订阅

源于蚂蚁课堂的学习,点击这里查看(老余很给力)    

一、前言

谈起volatile,想必大家最多的影响无非其三大特性:线程可见性、防止指令重排序以及不能保证原子性。当然,初学者对于这些特性多数只能做到死记硬背,应付面试。工作中对于其真正的原理及实现方式,可能甚至还达不到一知半解。而若要对volatile的原理娓娓道来,需要先引入它的老冤家JMM了。

 

二、JMM

JMM(Java Memory Model),即Java内存模型。其主要是按照CPU的工作方式,将内存空间模型化。如下图:

                                                  

这里我们要明确几个角色: 

1、主内存

用于存放全局共享变量。直白一点,就是在主线程中定义了一些变量,但其他子线程想去使用。那么主线程这些变量就存放在主内存中。注意误区:主内存和主线程无关。 

2、工作内存

用于线程处理各自内部逻辑所占用的内存空间,也会拷贝主内存中全局共享变量的副本数据方便使用。

3、多级缓存

这是CPU的多级缓存机制。主要为了提升CPU运行效率。缓存是我们比较熟悉的名词。其机制也大致相仿。 

理论知识有些抽象,代码也许会有助于你的理解。

/**
 * @Description todo
 * @CSDN https://blog.csdn.net/yxh13521338301
 * @Author: yanxh<br>
 * @Date 2020-08-12 09:42<br>
 * @Version 1.0<br>
 */
public class Demo extends Thread {

    private static String task = "";

    public static void main(String[] args) {
        new Thread(() -> {
            while (!"主线程修改task".equals(task)) {

            }
            System.out.println(Thread.currentThread().getName() + "执行完毕");
        }).start();
        try {
            Thread.sleep(1000);
        } catch (Exception e) {
            e.printStackTrace();
        }

        task = "主线程修改task";
        System.out.println(Thread.currentThread().getName() + "执行完毕");
    }
}

这是一个典型的案例,主线程修改了全局变量的值,妄想着停止子线程的运行。但事与愿违,子线程视若无睹,依旧沉醉自己的死循环中。

                                    

这并非是BUG,而是设计如此。毕竟线程之间要尽可能的做到隔离解耦。非要彼此可见的话,就需要引入volatile。 

三、Volatile

 Java的关键字,用于标识修饰的变量在线程之间可见。

                                         

通俗易懂地讲,volatile修饰的变量变化时,会主动将变更的信息同步至主内存,同时也会通知其他工作内存中,此变量值无效,其它工作内存需要重新去主内存拉取新的共享变量值。

至于同步机制,基于CPU的总线锁/缓存一致性协议了。

1、CPU的总线

解决多核CPU在多个工作内存高速缓存一致性问题,可捕获监听修改各工作内存中共享变量副本数据的状态。 

2、CPU的总线锁

对于全局共享变量的修改,会在总线上加锁,确保只有一个工作内存的副本数据变更成功,但这本质上将多核CPU变成了串行,故效率低下,趋于淘汰。 

3、缓存一致性协议(MESI)

将全局共享变量的副本数据约定状态。

M(Modified):修改,即当前工作内存的共享变量副本数据发生了变化。

E(Exclusive):独占,即只有当前工作内存的数据和主内存一致。(单核CPU)

S(Shared):共享,即所有工作内存的共享数据均与主内存一致。

I(Invalid):无效,此工作内存的共享数据无效,需重新去主内存获取共享数据。

                                 

大致流程:主内存中定义了volatile修饰的全局共享数据,工作内存进行拷贝,多个工作内存中副本数据的状态都是S(共享);当某个工作内存的此共享数据发生变动后,此工作内存的副本数据状态为M(修改);总线嗅探机制捕获到M状态的工作内存副本数据后,会将此副本数据的变更同步至主内存,同时通知其它工作内存将副本状态从S变为I(无效);其它工作内存会重新到主内存拷贝最新的副本数据,最终所有的副本数据再次变为S。

以上是对volatile的可见性进行了底层原理的分析。但也是基于这个理论,我们可以推论出volatile不能保证原子性。

假设主线程在修改共享数据时,子线程也修改共享数据,按照MESI的协议,我们可以得出总线嗅探会使得子线程的副本数据置为无效,子线程重新从主内存拉取,覆盖其自身的修改,所以这也是volatile使用上的一个注意事项。

 4、CPU的伪共享

经常会遇到这样的面试问题:new的Java对象到底占用多少个字节?我们先了解一下Java对象的布局吧。

对象头:用于存放hashCode、GC分代信息、偏向锁信息等

实例数据:对象内部定义的成员变量或方法

填充数据:需要补充行的字节

你会有疑问,为什么有填充数据一说?这取决于CPU读取数据方式,即按行读取。因为一个字节慢慢读取的效率远逊色于按行读取,故高性能的处理方式都是以块的方式读取。

操作系统64位的CPU读取每次读64个字节。所以当我们创建的对象不足64字节时,CPU在共享数据时会读取此对象之后的其它对象,从而出现伪共享的问题。那么,Java非常机智地提供了对象的填充数据,我们可以通过注解或者手动的方式,补全对象,防止伪共享。

5、内存屏障

指令重排序是指,在代码指令互补影响的前提下,CPU可能会调整其指令顺序。

比如 int i=1; int j=2; 彼此重排序对程序无影响。

但有些隐式的情形则不然,指令重拍新的话,就会操作程序的错误数据。volatile可以很好地防止这一点。这要得益于它底层的实现,CPU的内存屏障。

即如果改为volatile int i=1; int j=2;底层实现会在i变量后插入一道屏障。

我个人理解,屏障,就是必须所有数据都到底这一状态,才会继续向下执行。也就是屏障上面的代码必须执行完才会执行下面代码。

好了,这对冤家的缠绵史就告一段落了,希望能够帮助大家! 

欢迎大家和帝都的雁积极互动,头脑交流会比个人埋头苦学更有效!共勉!

公众号:帝都的雁

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值