面试必备——说说你对volatile关键字的理解

在这里插入图片描述

1 问题背景

复习完面试必备——Java内存模型Java Memory Model(JMM),仍处于一种懂原理但是不懂如何组织语言回答给面试官听。今天再次整理一下回答的思路。

本文仅供自己总结参考使用,如有不正确之处请指正。如对底层原理有兴趣,详情见面试必备——Java内存模型Java Memory Model(JMM)

2 说说你对volatile关键字的理解

要谈volatile关键字,首先得说说Java内存模型。而Java内存模型是在硬件效率的问题下引入的。

2.1 硬件效率

2.1.1 引入高速缓存

CPU计算的速度要远高于内存的速度,因此引入一个高速缓存来弥补这个差异。具体实现是,CPU从主内存中读取运算所需的数据到高速缓存中,运算结束后,再将结果同步到主内存。

在这里插入图片描述

2.1.2 处理器内部优化——乱序执行

处理器执行代码的顺序不一定与输入代码的顺序一致

2.2 带来缓存一致性的问题

引入了高速缓存,却带来了缓存一致性的问题。多个CPU的运算都涉及到同一个共享变量,将运算结果从各自的工作内存同步到主内存时,到底取哪一个值呢?

2.3 Java内存模型JMM

为了解决缓存一致性的问题,设计了一个协议——Java内存模型。线程做读写操作时,需要按照协议定下的规则执行。

Java内存模型如下所示:

在这里插入图片描述

按照上面的图分别从主内存工作内存线程之间的影响来介绍Java内存模型:

  1. 主内存是虚拟机内存中的一部分。所有的变量都处于主内存中。
  2. 每个线程都有自己的工作内存,保存了线程需要用到的变量的主内存副本拷贝。线程不能直接读写主内存的变量,只能操作各自工作内存的变量副本。
  3. 线程之间无法访问对方的工作内存中的变量。主内存充当中介角色完成线程之间的值传递。

2.4 8个原子性操作

介绍完Java内存模型,现在详细讲述该模型定义的8个原子操作,这些操作也就是从主内存拷贝到工作内存、从工作内存同步回主内存的实现细节。

结合下面的代码以及图讲解,代码参考自诸葛老师的Java高并发编程精髓Java内存模型JMM详解全集

有一个变量initFlag = false。有两个线程,线程1一直循坏执行空操作,直到initFlag被改为true。线程2执行操作,将initFlag修改为true。运行结果是线程1一直在循环里面空转,无法结束。代码如下:

public class VarVisibility {
    private static boolean initFlag = false;

    public static void change() {
        initFlag = true;
    }

    public static void main(String[] args)  throws InterruptedException {
        // 线程1
        new Thread(new Runable() {
            @Override
            public void run() {
                System.out.println("initializing data...");
                while (!initFlag) {
                }
                System.out.println("Success...");
            }
        }).start();

        Thread.sleep(2000);

        // 线程2
        new Thread(new Runable() {
            @Override
            public void run() {
                change();
            }
        }).start();
    }
}

用下图来描述上面代码发生的过程:

在这里插入图片描述

如上图,主内存中有initFlag变量,初始值是false。线程1执行read操作,将false值从主内存中读取到线程1的工作内存,执行load操作将false值放入initFlag变量副本中,执行use 操作从工作内存读取initFlag = false到执行引擎进行计算。线程2执行read操作,将false值从主内存中读取到线程2的工作内存,执行load操作将false值放入initFlag变量副本,执行use操作从工作内存读取initFlag = false到执行引擎进行计算,在执行引擎中修改initFlag的值为true,执行assign操作将执行引擎中的true重新赋值给工作内存的initFlag变量副本,使得工作内存中initFlag = true,执行store操作将initFlag = true传递到主内存,执行write操作将true存入到主内存中的initFlag变量。

由于线程2更新完initFlag变量的值后,线程1没拿到最新的值,那么线程1一直循坏执行空操作。

从上面的程序运行结果可以看到,这样会存在工作内存一致性问题。由于多CPU并发执行操作时可能会存在各自的工作内存不一致,早期的解决方案是对总线加锁。如下:

在这里插入图片描述
如上图所示,线程读取、写入数据到主内存时,需要通过CPU总线作为媒介。当有线程要修改主内存的变量时,对总线加锁,以此来达到线程独占主内存的目的。这样就是其他线程能拿到最新的 值。但是总线被加锁的期间,其他线程无法从主内存读取数据活向主内存写入数据。多CPU并发执行任务变成顺序串型执行任务,效率大大降低。

对CPU总线加锁的锁定范围太大,可以适当减小锁定范围的粒度。因此又提出了一种解决方案,常见的有MESI协议,如下所示:

在这里插入图片描述
如上图所示,线程2执行store操作将initFlag变量副本被修改后的true值传回到主内存并执行write操作将true值写回给主内存的initFlag变量。线程2要将新值写回主内存,会向总线发消息,线程1会通过总线嗅探机制监听到自己工作内存中的initFlag变量被修改了,线程1会立即将自己工作内存的initFlag = false失效,当线程1的执行引擎需要用到initFlag变量时,线程1要重新执行readload操作,那么线程1就能拿到最新值了。(不必纠结于底层到底是怎么做到失效的,有兴趣可了解MESI协议的详情)

2.5 volatile型变量的特性

volatile关键字修饰的变量有2个特性:对所有线程的可见性禁止指令重排序

2.5.1 可见性

一个线程修改了被volatile修饰的变量,新值对其他线程来说是可以立即得知的。

还是采用前面的代码例子,用volatile修饰共享变量initFlag。代码如下:

public class VarVisibility {
    private volatile static boolean initFlag = false;

    public static void change() {
        initFlag = true;
    }

    public static void main(String[] args)  throws InterruptedException {
        // 线程1
        new Thread(new Runable() {
            @Override
            public void run() {
                System.out.println("initializing data...");
                while (!initFlag) {
                }
                System.out.println("Success...");
            }
        }).start();

        Thread.sleep(2000);

        // 线程2
        new Thread(new Runable() {
            @Override
            public void run() {
                change();
            }
        }).start();
    }
}

以上代码被转成汇编指令时,会在change()方法中的initFlag = true指令前加一个lock前缀指令。该lock指令锁定initFlag变量副本所在的缓存行的数据,并 立即 写回到系统内存。这个写回操作会引起在其他CPU里缓存了该内存地址的数据 无效。(MESI协议详情可了解CPU中的缓存、缓存一致性、伪共享和缓存行填充

此处有3个重要点,一是 立即写 回内存;二是写的时候(即store、write操作)会 在store前先 lock,write完之后再 unlock;三是使其他CPU的缓存的数据 失效 。 如下图所示:

在这里插入图片描述

为什么要加锁?如果不加锁会有什么后果?

有可能读取到旧数据。前面lock前缀汇编指令提到,有一个点是写回内存时会引起在其他CPU里缓存了该内存地址的数据 无效。而当其他CPU的执行引擎此时要用到那个数据,发现数据无效,就会执行readload操作重新读取数据。没加锁的情况下,立即就能读取数据,并且该数据还是旧的。如果加锁,要等待锁释放才能读取数据,并且是读到新数据。

前面提到的总线加锁与此处的加锁有什么区别?

此处加锁的粒度比总线加锁更小,前者的加锁只有在回写主内存时候才无法运算,效率比总线加锁更高。

2.5.2 禁止指令重排序

在虚拟机看来,在 同一个线程中,如果调整某些指令的顺序对最终结果没有影响,那么虚拟机有可能会对这些指令进行 重排序,来达到更好的性能表现(这其实就是as-if-serial原则,不管怎么重排序,单线程程序的执行结果不能被改变)。但是在多线程并发的场景下,重排序可能会存在问题,最经典的例子就是双重检查锁定单例(Double Check Lock Singleton)。代码如下:

使用jclasslib的idea插件查看下面代码中synchronied同步代码块的字节码,注意下面代码中的注释:

public class DoubleCheckLockSingleton {

    private static DoubleCheckLockSingleton instance = null;

    public DoubleCheckLockSingleton() {

    }


    public static DoubleCheckLockSingleton getInstance() {
        if (instance == null) { // 2. 此时来了一个线程2,因为instance字段目前已经是非null,所以直接return了出去一个未完全初始化的instance对象。
            /**
             * 字节码
             * 10 monitorenter
             * 11 getstatic #2 <com/ganzalang/gmall/concurrentart/jvm/DoubleCheckLockSingleton.instance : Lcom/ganzalang/gmall/concurrentart/jvm/DoubleCheckLockSingleton;>
             * 14 ifnonnull 27 (+13)
             * 17 new #3 <com/ganzalang/gmall/concurrentart/jvm/DoubleCheckLockSingleton>
             * 20 dup
             * 21 invokespecial #4 <com/ganzalang/gmall/concurrentart/jvm/DoubleCheckLockSingleton.<init> : ()V>
             * 24 putstatic #2 <com/ganzalang/gmall/concurrentart/jvm/DoubleCheckLockSingleton.instance : Lcom/ganzalang/gmall/concurrentart/jvm/DoubleCheckLockSingleton;>
             * 27 aload_0
             * 28 monitorexit
             */
            synchronized (DoubleCheckLockSingleton.class) {
                if (instance == null) {
                    /**
                     * 1. 线程1执行new DoubleCheckLockSingleton,而该代码是有若干条指令组成的。
                     * 1)第一条指令是上面字节码中的序号17的new指令,表示new一个对象,划分一个内存区域给一个对象,但是该对象的值还是0值,还没有完全初始化
                     * 2)第二条指令是上面字节码中的序号20的dup指令,表示复制栈顶的内容,此处不需要关注
                     * 3)第三条指令是上面字节码中的序号21的invokespecial指令,表示对一个对象执行init方法,即初始化对象的值
                     * 4)第四条指令是上面字节码中的序号24的putstatic指令,表示将new指令出来的对象赋值给DoubleCheckLockSingleton类的instance字段
                     * 在同一个线程内,调整putstatic和invokespecial指令对最终结果没有影响,因此虚拟机有可能会对这两个指令重排序。
                     * 当先执行putstatic指令再执行invokespecial指令,即将一个未完全初始化的对象复制给了instance字段,再执行初始化。
                     * 那么当执行到第一个if的时候,见第一个if的注释
                     */
                    instance = new DoubleCheckLockSingleton();
                }
            }
        }
        return instance;
    }
}

解释:注释中提到未完全初始化的对象是指,一个对象要完成初始化,必须要经过划分内存区域、初始化0值、设置对象头、执行init方法,才能算是一个完整的对象,没有经过这四个过程的对象都是未完全初始化的对象。上面出现的问题就是一个对象未经过init方法就被返回出去了。

如何解决上面指令重排序的问题?方法是对共享对象加volatile,禁止指令重排序。底层实现是内存屏障

什么是内存屏障?看下面伪代码:

int x = 0;
volatile int y = 0;
int z = 0;

handle() {
    x = x + 1;
    // ------读屏障--------
    y = y + 1;
    // ------写屏障--------
    z = z + 1;
    
}

解释:根据as-if-serial 原则,不管怎么排序,单线程内程序执行的结果不能被改变。又因为x、y、z三个变量之间的运算不存在依赖关系,因此该代码块有可能被重排序。但是y变量是被volatile修饰,因此会在对y操作的代码前后加上屏障。所谓的屏障,即x = x + 1的代码不能调到读屏障之后,z = z + 1的代码不能调到写屏障之前。这样就实现了禁止对y变量的操作进行重排序。

下面用代码例子给出jvm规范定义的内存屏障:(Load表示读取,Store表示写。)

StoreStore屏障:

volatile int a = 0;

a = 1; // 写操作,将1写给a
StoreStore屏障
a = 2; // 写操作,将2写给a

如上,因为a是volatile变量,因此只需看对a变量是做写操作还是读操作即可。在两个写操作之间,要禁止这两个指令重排序,则会在他们之间加上StoreStore屏障。

LoadLoad屏障:

volatile int a = 0;
int b = 1;
int c = 1;

b = a; // 读操作,读取a的值
LoadLoad屏障
c = a; // 读操作,读取a的值

如上,因为a是volatile变量,只需关注对a做写还是做读。因为是两个读操作,所以要禁止他们重排序,则会在两个读操作之间加上LoadLoad屏障。

以此类推,还有LoadStore屏障、StoreLoad屏障。

jvm规范规定对volatile变量要实现内存屏障,如下:

volatile int a = 0;

a = 2; // volatile写
StoreStore屏障
a = 1; //volatile写
StoreLoad屏障
b = a; // volatile读
LoadLoad屏障
LoadStore屏障

为什么加了屏障就能实现禁止重排序?底层是什么?如下:

volatile int a = 0;

a = 1;
StoreLoad屏障
b = a;

解释:hotspot底层的c++代码运行StoreLoad屏障,StoreLoad屏障其实是一个c++写的方法。里面会最终在a = 1;后加一个lock指令,而cpu 识别到lock指令,则会禁止lock指令前后的操作进行重排序。

2.6 volatile不保证原子性

现有10个线程,每个线程执行1000次对num加1操作,最终的num能否保证等于10 * 1000。代码如下:

public class NotAtomic {

    public static volatile int num = 0;

    public static void increarse(){
        num++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 1000; j++) {
                        increarse();
                    }
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }
        
        System.out.println("num = " + num);
    }
}

答案:num最终结果只会小于等于10 * 1000

为什么会出现小于的情况?因为num++操作是由以下4条字节码指令组成的:

0 getstatic #2 <com/ganzalang/gmall/concurrentart/jvm/NotAtomic.num : I>
3 iconst_1
4 iadd
5 putstatic #2 <com/ganzalang/gmall/concurrentart/jvm/NotAtomic.num : I>

解释:getstatic是从主内存获取num值,iconst_l是将num值放入线程的栈(栈是线程私有的,由栈帧组成,每个栈帧其实就是每一个方法,栈帧里面有局部变量表、操作数栈等。此处从JMM角度可以将线程的栈理解为线程的工作内存),iadd是对num做加法操作,putstatic是将num值写回到主内存。(如果对这些概念不理解,可以看看黑马出品的JVM教程,推荐从第一集开始看,如果有基础可直接看P112)

结合字节码指令解释:

当getstatic指令把num的值取到操作栈顶时,volatile保证num的值在此时是正确的,但是在执行iconst_1、iadd这些指令时,其他线程可能已经把num的值加大了,而在操作栈顶的值变成了过期的数据,所以putstatic指令执行后就把较小的num值同步回主内存。

结合字节码指令并结合图来讲解:

在这里插入图片描述

如上图所示,先看蓝色线的数据流向,再看紫色线的数据流向。线程1线程2都将num = 0加载到各自的工作内存。线程2的执行引擎 use num的值到执行引擎中,此时线程1已经将num加1并同步回主内存。线程2工作内存中的num = 0失效,执行引擎中的num值其实也是过期的数据了。线程2将num加1后把1值assign给工作内存中的num变量,然后线程2把num = 1同步回主内存,此时主内存也是num = 1,因此实际上num没有增加过。所以最终的num值会小于等于10 * 1000

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值