文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

Java程序员面试:Volatile全方位解析

2024-12-11 16:52

关注

volatile是Java程序员必备的基础,也是面试官非常喜欢问的一个话题,本文跟大家一起开启vlatile学习之旅,如果有不正确的地方,也麻烦大家指出哈,一起相互学习~

「github 地址」

❝https://github.com/whx123/JavaHome❞

1.volatile的用法

volatile关键字是Java虚拟机提供的的「最轻量级的同步机制」,它作为一个修饰符出现,用来「修饰变量」,但是这里不包括局部变量哦。我们来看个demo吧,代码如下:

  1.  
  2. public class VolatileTest  { 
  3.  
  4.     public static void main(String[] args) throws InterruptedException { 
  5.         Task task = new Task(); 
  6.  
  7.         Thread t1 = new Thread(task, "线程t1"); 
  8.         Thread t2 = new Thread(new Runnable() { 
  9.             @Override 
  10.             public void run() { 
  11.                 try { 
  12.                     Thread.sleep(1000); 
  13.                     System.out.println("开始通知线程停止"); 
  14.                     task.stop = true; //修改stop变量值。 
  15.                 } catch (InterruptedException e) { 
  16.                     e.printStackTrace(); 
  17.                 } 
  18.  
  19.             } 
  20.         }, "线程t2"); 
  21.         t1.start();  //开启线程t1 
  22.         t2.start();  //开启线程t2 
  23.         Thread.sleep(1000); 
  24.     } 
  25.  
  26. class Task implements Runnable { 
  27.     boolean stop = false
  28.     int i = 0; 
  29.  
  30.     @Override 
  31.     public void run() { 
  32.         long s = System.currentTimeMillis(); 
  33.         while (!stop) { 
  34.             i++; 
  35.         } 
  36.         System.out.println("线程退出" + (System.currentTimeMillis() - s)); 
  37.     } 

「运行结果:」

可以发现线程t2,虽然把stop设置为true了,但是线程t1对t2的「stop变量视而不可见」,因此,它一直在死循环running中。如果给变量stop加上volatile修饰,线程t1是可以停下来的,运行结果如下:

  1. volatile boolean stop = false

 

2. vlatile修饰变量的作用

从以上例子,我们可以发现变量stop,加了vlatile修饰之后,线程t1对stop就可见了。其实,vlatile的作用就是:「保证变量对所有线程可见性」。当然,vlatile还有个作用就是,「禁止指令重排」,但是它「不保证原子性」。

 

所以当面试官问你「volatile的作用或者特性」,都可以这么回答:

保证变量对所有线程可见性;

禁止指令重排序

不保证原子性

3. 现代计算机的内存模型(计算机模型,MESI协议,嗅探技术,总线)

为了更好理解volatile,先回顾一下计算机的内存模型与JMM(Java内存模型)吧~

计算机模型计算机执行程序时,指令是由CPU处理器执行的,而打交道的数据是在主内存当中的。

由于计算机的存储设备与处理器的运算速度有几个数量级的差距,总不能每次CPU执行完指令,然后等主内存慢悠悠存取数据吧, 所以现代计算机系统加入一层读写速度接近处理器运算速度的高速缓存(Cache),以作为来作为内存与处理器之间的缓冲。

在多路处理器系统中,每个处理器都有自己的高速缓存,而它们共享同一主内存。「计算机抽象内存模型」如下:

 

随着科学技术的发展,为了效率,高速缓存又衍生出一级缓存(L1),二级缓存(L2),甚至三级缓存(L3);

 

当多个处理器的运算任务都涉及同一块主内存区域,可能导致「缓存数据不一致」问题。如何解决这个问题呢?有两种方案

❝1、通过在总线加LOCK#锁的方式。

通过缓存一致性协议(Cache Coherence Protocol)❞

总线

❝总线(Bus)是计算机各种功能部件之间传送信息的公共通信干线,它是由导线组成的传输线束, 按照计算机所传输的信息种类,计算机的总线可以划分为数据总线、地址总线和控制总线,分别用来传输数据、数据地址和控制信号。❞

CPU和其他功能部件是通过总线通信的,如果在总线加LOCK#锁,那么在锁住总线期间,其他CPU是无法访问内存,这样一来,「效率就比较低了」。

MESI协议为了解决一致性问题,还可以通过缓存一致性协议。即各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。比较著名的就是Intel的MESI(Modified Exclusive Shared Or Invalid)协议,它的核心思想是:

❝当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。❞

CPU中每个缓存行标记的4种状态(M、E、S、I),也了解一下吧:

缓存状态 描述
M,被修改(Modified) 该缓存行只被该CPU缓存,与主存的值不同,会在它被其他CPU读取之前写入内存,并设置为Shared
E,独享的(Exclusive) 该缓存行只被该CPU缓存,与主存的值相同,被其他CPU读取时置为Shared,被其他CPU写时置为Modified
S,共享的(Shared) 该缓存行可能被多个CPU缓存,各个缓存中的数据与主存数据相同
I,无效的(Invalid) 该缓存行数据是无效,需要时需重新从主存载入

MESI协议是如何实现的?如何保证当前处理器的内部缓存、主内存和其他处理器的缓存数据在总线上保持一致的?「多处理器总线嗅探」

嗅探技术

在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据库读到处理器缓存中。❞

4. Java内存模型(JMM)

 

举个例子吧,假设i的初始值是0,执行以下语句:

  1. i = i+1; 

首先,执行线程t1从主内存中读取到i=0,到工作内存。然后在工作内存中,赋值i+1,工作内存就得到i=1,最后把结果写回主内存。因此,如果是单线程的话,该语句执行是没问题的。但是呢,线程t2的本地工作内存还没过期,那么它读到的数据就是脏数据了。如图:

 

Java内存模型是围绕着如何在并发过程中如何处理「原子性、可见性和有序性」这3个特征来建立的,我们再来一起回顾一下~

5.并发编程的3个特性(原子性、可见性、有序性)

原子性

原子性,指操作是不可中断的,要么执行完成,要么不执行,基本数据类型的访问和读写都是具有原子性,当然(long和double的非原子性协定除外)。我们来看几个小例子:

  1. i =666; // 语句1 
  2. i = j;   // 语句2 
  3. i = i+1;  //语句 3 
  4. i++;   // 语句4 

可见性

有序性

Java虚拟机这样描述Java程序的有序性的:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中,观察另一个线程,所有的操作都是无序的。

后半句意思就是,在Java内存模型中,「允许编译器和处理器对指令进行重排序」,会影响到多线程并发执行的正确性;前半句意思就是「as-if-serial」的语义,即不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不会被改变。

比如以下程序代码:

  1. double pi  = 3.14;    //A 
  2. double r   = 1.0;     //B 
  3. double area = pi * r * r; //C 

步骤C依赖于步骤A和B,因为指令重排的存在,程序执行顺讯可能是A->B->C,也可能是B->A->C,但是C不能在A或者B前面执行,这将违反as-if-serial语义。

 

看段代码吧,假设程序先执行read方法,再执行add方法,结果一定是输出sum=2嘛?

  1. bool flag = false
  2. int b = 0; 
  3.  
  4. public void read() { 
  5.    b = 1;              //1 
  6.    flag = true;        //2 
  7.  
  8. public void add() { 
  9.    if (flag) {         //3 
  10.        int sum =b+b;   //4 
  11.        System.out.println("bb sum is"+sum);  
  12.    }  

如果是单线程,结果应该没问题,如果是多线程,线程t1对步骤1和2进行了「指令重排序」呢?结果sum就不是2了,而是0,如下图所示:

 

这是为啥呢?「指令重排序」了解一下,指令重排是指在程序执行过程中,「为了提高性能」, 「编译器和CPU可能会对指令进行重新排序」。CPU重排序包括指令并行重排序和内存系统重排序,重排序类型和重排序执行过程如下:

 

实际上,可以给flag加上volatile关键字,来保证有序性。当然,也可以通过synchronized和Lock来保证有序性。synchronized和Lock保证某一时刻是只有一个线程执行同步代码,相当于是让线程顺序执行程序代码了,自然就保证了有序性。

实际上Java内存模型的有序性并不是仅靠volatile、synchronized和Lock来保证有序性的。这是因为Java语言中,有一个先行发生原则(happens-before):

根据happens-before的八大规则,我们回到刚的例子,一起分析一下。给flag加上volatile关键字,look look它是如何保证有序性的,

  1. volatile bool flag = false
  2. int b = 0; 
  3.  
  4. public void read() { 
  5.    b = 1;              //1 
  6.    flag = true;        //2 
  7.  
  8. public void add() { 
  9.    if (flag) {         //3 
  10.        int sum =b+b;   //4 
  11.        System.out.println("bb sum is"+sum);  
  12.    }  

6.volatile底层原理

以上讨论学习,我们知道volatile的语义就是保证变量对所有线程可见性以及禁止指令重排优化。那么,它的底层是如何保证可见性和禁止指令重排的呢?

图解volatile是如何保证可见性的?

在这里,先看几个图吧,哈哈~

假设flag变量的初始值false,现在有两条线程t1和t2要访问它,就可以简化为以下图:

 

如果线程t1执行以下代码语句,并且flag没有volatile修饰的话;t1刚修改完flag的值,还没来得及刷新到主内存,t2又跑过来读取了,很容易就数据flag不一致了,如下:

  1. flag=true

 

如果flag变量是由volatile修饰的话,就不一样了,如果线程t1修改了flag值,volatile能保证修饰的flag变量后,可以「立即同步回主内存」。如图:

 

细心的朋友会发现,线程t2不还是flag旧的值吗,这不还有问题嘛?其实volatile还有一个保证,就是「每次使用前立即先从主内存刷新最新的值」,线程t1修改完后,线程t2的变量副本会过期了,如图:

 

显然,这里还不是底层,实际上volatile保证可见性和禁止指令重排都跟「内存屏障」有关,我们编译volatile相关代码看看~

DCL单例模式(volatile)&编译对比

DCL单例模式(Double Check Lock,双重检查锁)比较常用,它是需要volatile修饰的,所以就拿这段代码编译吧

  1. public class Singleton {   
  2.     private volatile static Singleton instance;   
  3.     private Singleton (){}   
  4.     public static Singleton getInstance() {   
  5.     if (instance == null) {   
  6.         synchronized (Singleton.class) {   
  7.         if (instance == null) {   
  8.             instance = new Singleton();   
  9.         }   
  10.         }   
  11.     }   
  12.     return instance;   
  13.     }   
  14. }   

编译这段代码后,观察有volatile关键字和没有volatile关键字时的instance所生成的汇编代码发现,有volatile关键字修饰时,会多出一个lock addl $0x0,(%esp),即多出一个lock前缀指令

  1. 0x01a3de0f: mov    $0x3375cdb0,%esi   ;...beb0cd75 33   
  2.                                         ;   {oop('Singleton')}   
  3. 0x01a3de14: mov    %eax,0x150(%esi)   ;...89865001 0000   
  4. 0x01a3de1a: shr    $0x9,%esi          ;...c1ee09   
  5. 0x01a3de1d: movb   $0x0,0x1104800(%esi)  ;...c6860048 100100   
  6. 0x01a3de24: lock addl $0x0,(%esp)     ;...f0830424 00   
  7.                                         ;*putstatic instance   
  8.                                         ; - Singleton::getInstance@24  

lock指令相当于一个「内存屏障」,它保证以下这几点:

显然,第2、3点不就是volatile保证可见性的体现嘛,第1点就是禁止指令重排列的体现。

内存屏障内存屏障四大分类:(Load 代表读取指令,Store代表写入指令)

内存屏障

内存屏障类型 抽象场景 描述
LoadLoad屏障 Load1; LoadLoad; Load2 在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障 Store1; StoreStore; Store2 在Store2写入执行前,保证Store1的写入操作对其它处理器可见
LoadStore屏障 Load1; LoadStore; Store2 在Store2被写入前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障 Store1; StoreLoad; Load2 在Load2读取操作执行前,保证Store1的写入对所有处理器可见。

为了实现volatile的内存语义,Java内存模型采取以下的保守策略

有些小伙伴,可能对这个还是有点疑惑,内存屏障这玩意太抽象了。我们照着代码看下吧:

内存屏障保证前面的指令先执行,所以这就保证了禁止了指令重排啦,同时内存屏障保证缓存写入内存和其他处理器缓存失效,这也就保证了可见性,哈哈~

 

7.volatile的典型场景

通常来说,使用volatile必须具备以下2个条件:

实际上,volatile场景一般就是「状态标志」,以及「DCL单例模式」。

7.1 状态标志

深入理解Java虚拟机,书中的例子:

  1. Map configOptions; 
  2. char[] configText; 
  3. // 此变量必须定义为 volatile 
  4. volatile boolean initialized = false
  5.  
  6. // 假设以下代码在线程 A 中运行 
  7. // 模拟读取配置信息, 当读取完成后将 initialized 设置为 true 以告知其他线程配置可用 
  8. configOptions = new HashMap(); 
  9. configText = readConfigFile(fileName); 
  10. processConfigOptions(configText, configOptions); 
  11. initialized = true
  12.        
  13. // 假设以下代码在线程 B 中运行 
  14. // 等待 initialized 为 true, 代表线程 A 已经把配置信息初始化完成 
  15. while(!initialized) { 
  16.    sleep(); 
  17. // 使用线程 A 中初始化好的配置信息 
  18. doSomethingWithConfig(); 

7.2 DCL单例模式

  1. class Singleton{ 
  2.     private volatile static Singleton instance = null
  3.       
  4.     private Singleton() {    
  5.     } 
  6.       
  7.     public static Singleton getInstance() { 
  8.         if(instance==null) { 
  9.             synchronized (Singleton.class) { 
  10.                 if(instance==null
  11.                     instance = new Singleton(); 
  12.             } 
  13.         } 
  14.         return instance; 
  15.     } 

8. volatile相关经典面试题

8.1 谈谈volatile的特性

 


 

 

8.2 volatile的内存语义

当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。

当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

8.3 说说并发编程的3大特性

8.4 什么是内存可见性,什么是指令重排序?

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。

8.5 volatile是如何解决java并发中可见性的问题

底层是通过内存屏障实现的哦,volatile能保证修饰的变量后,可以立即同步回主内存,每次使用前立即先从主内存刷新最新的值。

8.6 volatile如何防止指令重排

也是内存屏障哦,跟面试官讲下Java内存的保守策略:

再讲下volatile的语义哦,重排序时不能把内存屏障后面的指令重排序到内存屏障之前的位置

7 volatile可以解决原子性嘛?为什么?不可以,可以直接举i++那个例子,原子性需要synchronzied或者lock保证

  1. public class Test { 
  2.     public volatile int race = 0; 
  3.       
  4.     public void increase() { 
  5.         race++; 
  6.     } 
  7.       
  8.     public static void main(String[] args) { 
  9.         final Test test = new Test(); 
  10.         for(int i=0;i<10;i++){ 
  11.             new Thread(){ 
  12.                 public void run() { 
  13.                     for(int j=0;j<100;j++) 
  14.                         test.increase(); 
  15.                 }; 
  16.             }.start(); 
  17.         } 
  18.          
  19.         //等待所有累加线程结束 
  20.         while(Thread.activeCount()>1)   
  21.             Thread.yield(); 
  22.         System.out.println(test.race); 
  23.     } 

8.8 volatile底层的实现机制

可以看本文的第六小节,volatile底层原理哈,主要你要跟面试官讲述,volatile如何保证可见性和禁止指令重排,需要讲到内存屏障~

8.9 volatile和synchronized的区别?

本文转载自微信公众号「捡田螺的小男孩」,可以通过以下二维码关注。转载本文请联系捡田螺的小男孩公众号。

 

来源:捡田螺的小男孩内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯