首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >请谈谈你对线程可见性及volatile关键字的理解?

请谈谈你对线程可见性及volatile关键字的理解?

作者头像
敲得码黛
发布2021-02-22 11:07:56
4130
发布2021-02-22 11:07:56
举报
文章被收录于专栏:敲得码黛敲得码黛

目录

引言可见性问题基本数据类型的可见性问题引用数据类型可见性问题引用可见性问题成员变量可见性问题可见性问题总结Java内存模型CPU与内存之间的爱恨情仇Java内存模型主存与工作内存间的交互规则Volatile变量特殊规则先行发生原则对先行发生原则的理解volatile的使用保证变量可见性防止指令重排案例解决

引言

工作一段时间的老铁们对这个问题应该都不陌生吧。回想起刚毕业那会儿,我信心满满的拿着简历去面试,面试官问我“请谈谈你对线程可见性及volatile关键字的理解?” 我暗自欣喜,这个问题我可是已经背过好几遍了,于是自信的答道:“可见性是指一个线程所做的修改可以被其他线程观察到,volatile可以保证可见性,还可以防止指令重排序”。面试官可能被我流畅的回答惊呆了,于是愣了几秒钟后说让我回去等通知。回家的路上我回想了一下,觉得这次面试肯定稳了。但是不知道为什么至今那位面试官还没给我打电话。。。。

可见性问题

基本数据类型的可见性问题
public class Test {

    public static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        // 启动一个线程,通过flag变量状态进行循环
        new Thread(() -> {
            while (Test.flag) {
                // do something
            }
        }).start();

        // 主线程休眠1秒后将flag变量设置为false
        Thread.sleep(1000);
        flag = false;
        System.out.println("主线程运行完毕");
    }
}

这段程序包含两个线程(一个是main方法所在的主线程、另一个暂时称之为子线程),我们大致能猜出来这段代码的意图是让子线程监测flag状态做点什么事情,然后再通过主线程将flag状态改变,从而停止子线程的工作。然而理想很现实。。。这段程序将会导致子线程进入死循环

引用数据类型可见性问题
引用可见性问题
public class Test {

    public static Son son ;

    public static void main(String[] args) throws InterruptedException {
        // 启动一个线程,监测son变量
        new Thread(() -> {
            while(son==null){
                // do something
            }
        }).start();

        // 主线程休眠1秒后将对son变量进行初始化操作
        Thread.sleep(1000);
        Test.son = new Son("张三");
        System.out.println("主线程运行完毕");
    }

    @AllArgsConstructor
    static class Son{
        public String name;
    }

}

程序执行结果与基本数据类型的栗子一毛一样。。。子线程死循环

成员变量可见性问题
public class Test {

    public static Son son = new Son("张三");
    public static void main(String[] args) throws InterruptedException {
        // 启动一个线程,监测son的name值
        new Thread(() -> {
            while("张三".equals(son.name)){
                // do something
            }
        }).start();

        // 主线程休眠1秒后改变son的name值
        Thread.sleep(1000);
        Test.son.name="李四";

        // 主线程休眠2秒后改变son的引用
        Thread.sleep(2000);
        Test.son=new Son("李四");
        System.out.println("主线程运行完毕");
    }

    @AllArgsConstructor
    static class Son{
        public String name;
    }
}

这个栗子监测Son的name值,当主线程第一次只改变name值时,子线程无法观察到此变化。然后主线程休眠两秒后直接改变son的引用。最后终于。。。还是死循环了。

可见性问题总结

由此可见多线程环境下常常会出现一些我们意想不到的问题,我们一般会统称为线程安全性问题(这个说法其实并不严谨)。可见性问题是线程安全性问题的其中一种,出现可见性问题的主要原因是线程对共享变量的修改不能够及时的被其他线程观察到。

Java内存模型

虽然我们发现了多线程环境下的,但是我们先别忙着找解决方案,而是先来分析一下为什么线程对共享变量的修改不能被其他线程观察到呢?这个问题就解释起来就比较复杂了。。所以我选择了抄书。 tip:如果对原理不敢兴趣的同学可以直接跳到末尾部分volatile的使用

CPU与内存之间的爱恨情仇

以下部分是我摘抄自《深入理解Java虚拟机》这本书上的原话,读起来可能比较繁琐。这段话的主要目的是为了引入CPU高速缓存这个概念,如果你已经对CPU高速缓存比较了解了,那么你也可以选择跳过这段。 “ 多任务处理在现代计算机操作系统中几乎已是一项必备的功能了。在许多情况下,让计算机同事去做几件事情,不仅是因为计算机的运算能力强大了,还有一个很重要的原因是计算机的运算速度与它的存储和通信子系统速度的差距太大,大量的时间都花费在磁盘I/O、网络IO通信或数据库访问上。如果不希望处理器在大部分时间里都处于等待其他资源的状态,就必须使用一些手段去把处理器的运算能力“压榨”出来,否则就会造成很大的浪费,而让计算机同时处理几项任务是最容易想到、也被证明是非常有效的压榨手段。” “让计算机并发执行若干个运算任务”与“更充分地利用计算机处理器的效能”之间的因果关系,看起来顺理成章,实际上他们之间的关系并没有想象中的那么简单,其中一个重要的复杂性来源是对大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个I/O操作是很难消除的(无法仅靠寄存器来完成所有运算任务)。由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。 基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主内存(Main Memory),如下图所示。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议。这些协议被称为缓存一致性协议(例如MESI等、、);

————摘抄自《深入理解JAVA虚拟机》第二版

Java内存模型

Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。定义Java内存模型并非一件容易的事情,这个模型必须定义得足够严谨,才能让java的并发内存访问操作不会产生歧义;但是,也必须定义得足够宽松,使得虚拟机的实现有足够的只有空间去利用硬件的各种特性(寄存器、高速缓存和指令集中某些特有的指令)来获取更好的执行速度。 Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时的主内存名字一样,两者也可以互相类比,但此处仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory,可与前面讲的高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。线程、主内存、工作内存三者的交互关系如下图所示

————摘抄自《深入理解JAVA虚拟机》第二版

主存与工作内存间的交互规则

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型定义了以下8种操作来完成。虚拟机实现时必须保证下面的每一种操作都是原子性操作

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read(读取):作用域主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
  • store(存储):作用于工作内存的变量,它把工作内存中的一个变量的值传送到主内存中,以便随后的write操作使用
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中

如果要把一个变量从主内存复制到工作内存,那就要顺序的执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。注意,java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起了回写但主内存不接受的情况出现
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行过了assign和load操作
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁住的变量
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)

这8种内存访问操作及上述规则限定,再加上稍后介绍的对volatile的一些特殊规定,就已经完全确定了Java程序中哪些内存访问操作在并发下是安全的。由于这种定义相当严谨又十分烦琐,所以末尾会介绍这种定义的一个等效判断原则——先行发生原则,用来确定一个访问在并发环境下是否安全 ————摘抄自《深入理解JAVA虚拟机》第二版

Volatile变量特殊规则

基于之前的理论知识我们了解到,使用主存中中的变量时通常是需要顺序的执行read->load->use操作的。而将工作内存的值写回主存时则是要顺序的执行assign->store->write操作的。而volatile变量在基于此规则的基础上又扩展了几条规则

  • 在工作内存中每次使用volatile变量前都必须要先从主内存刷新最新的值(保证read->load->use的连续性)
  • 在工作内存中每次修改volatile变量的值后都必须立即同步回主内存(保证assign->store->write的连续性)
  • volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同
先行发生原则

定义:Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。 本质:前面一个操作的结果对后续操作是可见的

  • 程序次序规则:在一个线程内,按照程序顺序,前面的操作先行发生于后续的任何操作 tip:同一个线程内,后面的程序可以看到前面程序产生的变更(后面指程序顺序的后面)
  • volatile变量规则:指对一个volatile变量的写操作,先行发生于后续对这个变量的读操作 tip:volatile变量变更后,后面的线程都可以看到此次变更(后面指时间上的后面)
  • 管程锁定规则:同一个变量的unlock操作先行发生于这个变量的lock操作 tip:对一个变量执行的unlock操作可以被后面的线程看到(后面指时间上的后面)
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作 tip:主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程B前的操作。
  • 线程终止规则:线程中所有的操作都先行发生于对此线程的终止检查 tip:主线程A启动子线程B后,main方法中调用线程B的join方法串行执行。等线程B的join返回后主线程可以看到线程B的所有操作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被终端线程的代码监测到终端时间的发生,可以通过Thread.interrupted()方法监测到是否有中断发生。 tip:线程的interrupted()方法调用后,可以被后面的任意线程看到中断状态。
  • 对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始。 tip:????
  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那么就可以得出操作A先行发生于操作C
对先行发生原则的理解
public class Test {

    public static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {

        flag = false;
        // 启动一个线程,通过flag变量状态进行循环
        new Thread(() -> {
            while (Test.flag) {
                // do something
            }
        }).start();
        System.out.println("主线程运行完毕");
    }
    // 根据先行发生原则中的程序次序规则得出main方法内部的赋值操作先行发生于子线程的start操作
    // 又根据线程启动规则得出子线程的start操作先行发生于子线程内部的任何操作。
    // 然后再根据先行发生规则的传递性得出:main方法的赋值操作先行发生于子线程内部的任何操作。
    // 即main方法中的赋值操作可以被子线程观察到。
}

本来只想着抄一点的。。。没想到越抄多,觉得这有用、这也有用、这也不能忽略。。。。不过最后发现抄书其实也挺香的。有一些技术点也是在写这篇文章的时候才想明白。

volatile的使用

保证变量可见性

volatile是一种非常轻量级的同步机制,读的性能与普通变量几乎没什么差别。写的操作因为要插入内存屏障防止指令重排所以可能略有一点点影响。当程序中出现线程安全性问题时要先判断是什么原因导致的线程安全问题。如果是可见性问题则应该优先考虑volatile,由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(sync或lock)来保证原子性。

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变约束
防止指令重排

场景:单例模式的实现——双重校验锁就是通过volatile关键字保证了客户端不会获取到未初始化完成的对象 这个我目前了解的还不是很透彻,就不卖弄了。。

案例解决

文章开头提到的三个例子,都可以通过volatile解决。至于怎么解决就不用我说了吧(这点动手能力还是要有的)。。。。。 最后赶快交出你们的三连(点赞、收藏、转发)

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-09-25,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 敲得码黛 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 目录
  • 引言
    • 可见性问题
      • 基本数据类型的可见性问题
      • 引用数据类型可见性问题
      • 可见性问题总结
    • Java内存模型
      • CPU与内存之间的爱恨情仇
      • Java内存模型
    • volatile的使用
      • 保证变量可见性
      • 防止指令重排
      • 案例解决
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档