查看原文
其他

Java内存模型之可见性(填坑之路)

专注于编程、互联网动态。最终将总结的技术、心得、经验(数据结构与算法、源码分析等)享给大家,这里不只限于技术!还有职场心得、生活感悟、以及面经点击上方 "程序员小乐" ,选择“置顶公众号”,第一时间送达!

每日英文

Dream is like the stars in the sky, maybe you can never touch, but if you follow them, they will lead you to find the way of life.

梦想就像天上星星,也许你永远无法触碰,但如果你跟随它们,它们将引领你找到人生路。


乐乐有话说 

一个人,可能走得会慢一点,但尽量避免跌倒,也不要横冲直撞,一步一步脚踏实地,学着不去担忧得太多,会照顾好自己,也不会再贪恋。


来自:徐志毅

链接:https://www.jianshu.com/p/6abcddd04f4e

封面来自网络

00 前言  

前几天路过一个经常负责面试的同事附近,看到几个人在讨论volatile的可见性问题,当时第一感觉是 :“可见性还不简单吗?volatile修饰一个变量时,那么在一个线程都对这个变量的更改,其他线程都立即可见。”

后面听到这样一句话:“实际运行结果能刷新你的三观,网上的例子很多都是有问题的”,让我瞬间产生了兴趣。凑近一看,果然跟我的很多认知都产生了偏差。

为了解决其中的疑惑,查阅的不少文章,拨开了一些迷雾,现将结果整理出来,与大家一同探讨。

基础Java环境:

java version "1.8.0_172" Java(TM) SE Runtime Environment (build 1.8.0_172-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.172-b11, mixed mode)

01 基本概念  

Java内存模型

首先先复习一下内存模型的概念:

Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范 定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

JVM程序运行的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:

volatile关键字

volatile是老生常谈的一个关键字,大家在编程中其实用得都很少,面试中比较常见,也正是这个原因,让大家对这一块的理解与实际结果产生了偏差。

volatile是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用。
1)保证被volatile修饰的共享变量对所有线程 总是可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
2)禁止指令重排序优化。

可见性

关于内存模型和volatile的概念本篇不做详细赘述,不熟悉的看官建议先百度一下。JMM是围绕 原子性、有序性、可见性 展开的,本文主要围绕内存模型的可见性出发,通过实际例子来探究其运行原理。

先思考一个问题:volatile保证的“立即可见”的反义是什么?

这是大家最容易想到的答案,应该是“不可见”,且有实实在在的例子让我们觉得“不可见”深根不移。

示例1:

package com.youzan;

/**
 * Date: 2018/8/12
 * @author xuzhiyi
 */

public class Test1 {

    private static boolean flag = true;

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (flag) {
                i++;
            }
            System.out.printf("**********test1 跳出成功, i=%d **********\n", i);
        });
        thread.start();
        Thread.sleep(100);
        flag = false;
        System.out.printf("**********test1 main thread 结束, i=%d **********\n", i);
    }
}

示例2:

package com.youzan;

/**
 * Date: 2018/8/12
 * @author xuzhiyi
 */

public class Test2 {

    private static boolean flag = true;

    private static volatile int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (flag) {
                i++;
            }
            System.out.printf("**********test2 跳出成功, i=%d **********\n", i);
        });
        thread.start();
        Thread.sleep(100);
        flag = false;
        System.out.printf("**********test2 main thread 结束, i=%d **********\n", i);
    }
}

示例1和示例2的唯一区别在于,示例2的flag有volatile修饰。上述示例的运行结果大家都“知道”,示例1会一直死循环,示例2会立即跳出循环。大家可能都运行过这两段(或者相似的)代码,大部分人对结果很满意,因为符合预期,没有加volatile关键字的成员变量多线程之间不可见。

回到刚刚那个问题,“立即可见”的反义是什么?

通过上述实践我们可以“肯定”的回答:“立即可见”的反义是“不可见”!!!而且是“一直不可见”

说到这里,可能有部分人有疑问了,“立即可见”的反义应该是“不立即可见”,说人话就是“可能过一段时间后可见,不一定是马上可见”。可是即使我们运行一万遍示例1的代码,都是一直不可见。怎么办?继续往下看。

02 实战  

让没有volatile也能跳出循环

方式一

示例3:

package com.youzan;

/**
 * Date: 2018/8/12
 * @author xuzhiyi
 */

public class Test3 {

    private static boolean flag = true;

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (flag) {
                i++;
            }
            System.out.printf("**********test3 跳出成功, i=%d **********\n", i);
        });
        thread.start();
        Thread.sleep(1);
        flag = false;
        System.out.printf("**********test3 main thread 结束, i=%d **********\n", i);
    }
}

在示例3中,我仅将示例1中的sleep时间改为1毫秒,while循环即可成功跳出,输出结果如下:

**********test3 main thread 结束, i=60167 **********
**********test3 跳出成功, i=60167 **********

ps:主线程可能由于停顿时间太短,导致while循环根本没进去。重试几次,当i的值不为0即代表已经进入循环。

对比示例1和示例3我们可以得出一个结论:

  • 当主线程停顿时间很极短(1~2ms)时,可以跳出循环;

  • 当主线程停顿时间较长时,无法跳出循环;

结论变种1:

  • 当子线程循环执行时间极短(1~2ms)时,可以跳出循环;

  • 当子线程循环执行时间较长时,无法跳出循环;

结论变种2:

  • 当子线程循环次数较少时,可以跳出循环;

  • 当子线程循环次数较多时,无法跳出循环;

看上去是不是有点意思?代码的执行结果居然跟执行时间、循环次数有关?推断到这里,有些看官可能已经想到了JIT即使编译优化。没错,正是JIT的优化对运行结果产生了影响。

关于JIT
当虚拟机发现某个方法或代码块运行特别频繁时,就会把这些代码认定为“Hot Spot Code”(热点代码),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,完成这项任务的正是 JIT 编译器。
运行过程中会被即时编译器编译的“热点代码”有两类:
1)被多次调用的方法。
2)被多次调用的循环体。

如何验证上述结论呢?

  • -Xint :强制使用解释执行的方式启动java虚拟机,此模式下,不会使用JIT优化,示例1和示例3的代码都会跳出循环。

  • -Xcomp:强制使用编译执行的方式启动java虚拟机,此模式下,代码会被优化并编译成机器码,示例1和示例3都无法填出循环。

总结一下:mac下默认为-Xmixed混合模式,使用java -version可以查看,混合模式下只有热点代码达到一定阈值才会发生JIT优化,因此导致了上述看到的运行时间长短对运行结果的影响。

方式二

不少热心的网友在自己运行示例1代码的时候,会不由自主的加上一行print,如下:

示例4:

package com.youzan;

/**
 * Date: 2018/8/12
 * @author xuzhiyi
 */

public class Test4 {

    private static boolean flag = true;

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (flag) {
                i++;
                System.out.println("i=" + i);
            }
            System.out.printf("**********test4 跳出成功, i=%d **********\n", i);
        });
        thread.start();
        Thread.sleep(100);
        flag = false;
        System.out.printf("**********test4 main thread 结束, i=%d **********\n", i);
    }
}

上述代码一运行后成功跳出,可能又惊倒了一批看官,为什么多一行print结果又不一样了。而且就算在-Xcomp模式优化后也可以跳出。有点神奇吧?
为了找出原因,我对print代码进行了几次不同的替换:

示例5:

package com.youzan;

import java.util.HashMap;

/**
 * Date: 2018/8/12
 * @author xuzhiyi
 */

public class Test5 {

    private static boolean flag = true;

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (flag) {
                doSomeThing1();
            }
            System.out.printf("**********test4 跳出成功, i=%d **********\n", i);
        });
        thread.start();
        Thread.sleep(10);
        flag = false;
        System.out.printf("**********test4 main thread 结束, i=%d **********\n", i);
    }

    private static void doSomeThing1() {
        System.out.println("doSomeThing1");
    }

    private static void doSomeThing2() {
        synchronized (Test5.class) {
            i++;
        }
    }

    private static void doSomeThing3() {
        i++;
        Thread.yield();
    }

    private static void doSomeThing4() {
        new HashMap<>();
    }
}

上述代码中,不论是在循环体内执行哪一个方法(doSomeThing1~ doSomeThing4),都可以正常跳出循环。为什么呢?究竟是什么影响了线程对成员变量的可见性呢?我的结论如下:
根据java的内存模型规范,一个线程对普通变量的修改并不需要立即写回到主存,且另一个线程读取也不需要每一次都从主存中去读取。至于什么时候与主内存同步,虚拟机只需保证方法出栈时将修改的值同步到主内存。因此这其中有比较宽松的优化空间。而上述几个方法,都存在一定的同步空间。虚拟机会在此时与主内存同步。
ps:以上结论纯属猜测,没有很好的论据,欢迎大家探讨!

volatile的传播范围

思考两个问题:

1.把volatile对象传递给另一个对象,新对象是否立即可见呢?
2.当volatile修饰对象时,如果对象的嵌套的层级较深,那该对象的内部是否立即可见呢?

示例6:

package com.youzan;

/**
 * Date: 2018/8/12
 * @author xuzhiyi
 */

public class Test6 {

    private static volatile ReferenceFlag referenceFlag = new ReferenceFlag();

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            BaseFlag baseFlag = referenceFlag.baseFlag;
            while (baseFlag.flag) {
                i++;
            }
            System.out.printf("**********test6 跳出成功, i=%d **********\n", i);
        });
        thread.start();
        Thread.sleep(100);
        referenceFlag.baseFlag.flag = false;
        System.out.printf("**********test6 main thread 结束, i=%d **********\n", i);
    }

    static class BaseFlag {
        boolean flag = true;
    }

    static class ReferenceFlag {
        volatile BaseFlag baseFlag = new BaseFlag();
    }
}

在示例6中,使用了引用嵌套的方式来验证volatile是否可以传递给一个局部变量,示例中的引用都是用来volatile关键字来修饰,运行结果是无法跳出。

结论一:当使用一个变量来接受一个volatile修饰的变量时,volatile的可见性并不会传递。即新的变量不再具有volatile特性。

示例2:

package com.youzan;

/**
 * Date: 2018/8/12
 * @author xuzhiyi
 */

public class Test7 {

    private static int i = 0;

    private static volatile DeapReferenceInnerFlag deapReferenceInnerFlag = new DeapReferenceInnerFlag();

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (deapReferenceInnerFlag.referenceInnerFlag.baseFlag.flag) {
                i++;
            }
            System.out.printf("**********test7 跳出成功, i=%d **********\n", i);
        });
        thread.start();
        Thread.sleep(100);
        deapReferenceInnerFlag.referenceInnerFlag.baseFlag.flag = false;
        System.out.printf("**********test7 main thread 结束, i=%d **********\n", i);
    }

    static class BaseFlag {
        boolean flag = true;
    }

    static class ReferenceInnerFlag {
        BaseFlag baseFlag = new BaseFlag();
    }

    static class DeapReferenceInnerFlag {
        ReferenceInnerFlag referenceInnerFlag = new ReferenceInnerFlag();
    }
}

示例7是一个多层嵌套的对象,只有最外层使用volatile修饰,当其内部的值改变后,使用链式调用的方式,则一直可以取到最新的值。

结论二:对于多层嵌套的对象,最外层使用volatile修饰,使用链式调用的方式,volatile的可见性可以传播。

ps:结论二没有很好的理论依据,仅从实践上看是如此。

03 总结  

本篇结合实际的几个例子,讲述了几个认识误区。仅通过运行结果说明了一些问题,但依然不够深入,不足之处,还望指出。想深入探究的看官,可以参考下面的几篇文章。

04 参考文章  


  • 全面理解Java内存模型(JMM)及volatile关键字

  • 并行编程之多线程共享非volatile变量,会不会可能导致线程while死循环

  • 深入浅出 JIT 编译器

  • 一个由JIT优化引发的问题

  • JVM执行篇:使用HSDIS插件分析JVM代码执行细节


如果您觉得不错,请别忘了转发、分享、点赞让更多的人去学习, 您的举手之劳,就是对小乐最好的支持,非常感谢!


如何您想进技术群交流,关注公众号在后台回复 “加群”,或者 “学习” 即可

著作权归作者所有,欢迎大家投稿


推荐阅读

阿里、腾讯、百度、华为、京东最新面试题汇集

6分钟演示15种排序算法,你能看懂几个?
读懂Java中的Socket编程
四步曲,解锁快速学习新技能套路


看完本文有收获?请转发分享给更多人
关注「程序员小乐」,提升技能


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存