面试打怪升升级-被问烂的volatile关键字,这次我要搞懂它(深入到操作系统层面理解,超多图片示意图)

一、volatile简介

Java语言规范第3版中对volatile的定义如下:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。

二、多线程下的安全问题

1. visibility(可见性引起的问题)

(1)、代码

在多线程并发执行下,多个线程修改共享的成员变量,会出现一个线程修改了共享变量值后,两一个线程不能立即看到该线程修改后的变量的新值

public class VisibilityDemo extends Thread {
    private static boolean isRunning = true;

    public static void main(String[] args) throws InterruptedException {
    
        new VisibilityDemo().start();
        Thread.sleep(100);
        isRunning = false;
        System.out.println("我已经修改了标志变量,isRunning = " + isRunning);

    }

    @Override
    public void run() {
        while (isRunning) ;
        System.out.println("线程被停止了");
    }
}

这个例子很简单,在子线程中判断flag的值,如果为true一直运行;如果为false则停止运行。我们在main线程中启动了子线程,在sleep(100)后设置flag值为false(保证子线程先运行),发现线程无法停止。

(2)、测试结果

在这里插入图片描述

2. order(有序性引起的问题)

(1)、代码

定义两组变量x,y和a,b。然后再分别在两个线程t1,t2中修改变量值。

public class OrderDemo {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;


    public static void main(String[] args) throws InterruptedException {
        int count = 0;
        while (count++ >= 0) {
            //A
            A t1 = new Thread(() -> {
                x = 1;// A1
                a = y;// A2
            });

            //B
            B t2 = new Thread(() -> {
                y = 1;// B1
                b = x;// B2
            });


            t1.start();
            t2.start();

            //等待t1,t2运行完毕,查看变量值
            t1.join();
            t2.join();


            System.out.println("第"+count+"次,a = "+a+",b = "+b);
            //这种情况概率比较少,所以在这里判断它作为中止条件
            if(a==0 && b==0){
                break;
            }

            //initialize
            x = y = a = b = 0;
        }
    }
}

(2)、测试结果

在这里插入图片描述
指令1、2、3、4的混序执行导致出现了不一样的运行结果。其中a,b分别为四组值:(1,0)、(0,1)、(1,1)、(0,0)

三、volatile的作用与原理

1. 可见性测试代码解释,如何保证可见性

(1)、现象解释

在测试用例1中,可以看到我们在main线程中修改了共享变量值,但是在子线程中却没有读取到改变后的值,导致程序无法正常停止。说到原因我们要先从cpu说起,现代cpu的处理速度远远高于硬盘的io读取速度,因此在这种情况下就出现了缓存策略,缓存是在内存中分配的空间,大大提高了io速度。并由最开始的一级缓存进化到现在3级缓存
在这里插入图片描述
缓存的意义:

  • 时间局部性:如果某个数据被访问,name在不久的将来他可能能再次被访问。
  • 空间局部性:如果某个数据被访问,那么他与相邻的数据很快也可能被访问。
    在这里插入图片描述
    为了方便理解我们可以得出一个更为简洁的等效模型
    在这里插入图片描述
    由上图可以看出线程在运行时将主内存中的变量缓存到本地工作空间中(复制一份副本)。cpu的处理策略一般是会==修改缓存中变量值,而不是直接修改主内存中的值。==在合适的时候(调用os同步指令,或累计够了一定值)写入主内存中。在我们的测试中就是因为main线程修改的是它的工作空间的变量值,而子线程读因为一直在执行while没有空闲去从主内存中同步变量isRunning,子线程中isRunning值一直是缓存的false,所以不会停止运行。

(2)、使用volatile保证可见性

使用volatile修饰isRunning变量,发现线程可以正常停止,达到我们的预期效果。
在这里插入图片描述
这是因为什么呢,我们查看代码的汇编指令,下载hsdis-amd64插件(lbbc),将他解压到jre/bin目录下。然后在idea中设置启动参数(eclipse中同理)如下:
在这里插入图片描述

VM options:-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VisibilityDemo.main(最后替换为类名.方法名即可)

我们仔细观察添加volatile后的代码和没有添加之前的区别,如图:
在这里插入图片描述
可以看到在赋值的时候多了一个lock指令,这个lock指令是什么呢,我们查看Intel® 64 and IA-32 Architectures Software Developer’s Manual文档可以发现这个指令
在这里插入图片描述

对于Intel486和Pentium处理器,在lock操作时,总是在总线上声言LOCK#信号。(这会给总线枷锁,效率较低)
但是对于P6和更新的处理器系列,访问的内存区域如果已经缓存在处理器内部,则不会声言LOCK#信号。相反他会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被成 “缓存锁定“缓存一致性机制会防止缓存了相同内存区域的两个或多个进程同时修改该区域中的数据。在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充一个处理器的缓存回写到内存会导致其他处理器的缓存无效。

缓存一致性协议:一致性缓存:所有缓存副本中的值都相同,多个CPU处理器共享缓存并且更改共享数据时,更改必须同步到所有缓存副本。在处理器中,嗅探是一致性缓存的常见的机制,各个核能够时刻监控自己和其他核的状态,从而统一管理协调。窥探的思想是:CPU的各个缓存是独立的,但是内存却是共享的,所有缓存的数据最终都通过总线写入同一个内存,因此CPU各个核都能“看见”总线,即各个缓存不仅在进行内存数据交换的时候访问总线,还可以时刻“窥探”总线,监控其他缓存在干什么。因此当一个缓存在往内存中写数据时,其他缓存也都能“窥探”到,从而按照一致性协议保证缓存间的同步。关于更多的细节可以查看这篇博客:《大话处理器》Cache一致性协议之MESI

状态 说明
失效(Invalid)缓存段 要么已经不在缓存中,要么它的内容已经过时。为了达到缓存的目的,这种状态的段将会被忽略。一旦缓存段被标记为失效,那效果就等同于它从来没被加载到缓存中。
共享(Shared)缓存段 它是和主内存内容保持一致的一份拷贝,在这种状态下的缓存段只能被读取,不能被写入。多组缓存可以同时拥有针对同一内存地址的共享缓存段,这就是名称的由来。
独占(Exclusive)缓存段 和 S 状态一样,也是和主内存内容保持一致的一份拷贝。区别在于,如果一个处理器持有了某个 E 状态的缓存段,那其他处理器就不能同时持有它,所以叫“独占”。这意味着,如果其他处理器原本也持有同一缓存段,那么它会马上变成“失效”状态。
已修改(Modified)缓存段,属于脏段 它们已经被所属的处理器修改了。如果一个段处于已修改状态,那么它在其他处理器缓存中的拷贝马上会变成失效状态,这个规律和 E 状态一样。此外,已修改缓存段如果被丢弃或标记为失效,那么先要把它的内容回写到内存中——这和回写模式下常规的脏段处理方式一样。

只有当缓存段处于 E 或 M 状态时,处理器才能去写它,也就是说只有这两种状态下,处理器是独占这个缓存段的。当处理器想写某个缓存段时,如果它没有独占权,它必须先发送一条“我要独占权”的请求给总线,这会通知其他处理器,把它们拥有的同一缓存段的拷贝失效(如果它们有的话)。只有在获得独占权后,处理器才能开始修改数据——并且此时,这个处理器知道,这个缓存段只有一份拷贝,在我自己的缓存里,所以不会有任何冲突。反之,如果有其他处理器想读取这个缓存段(我们马上能知道,因为我们一直在窥探总线),独占或已修改的缓存段必须先回到“共享”状态。如果是已修改的缓存段,那么还要先把内容回写到内存中
总的来说就是在添加lock操作后,缓存一致协议会保证我们对volatile修饰变量的写操作是原子的,并且会同步到主内存中,对volatile读会保证它永远是最新的值。那么这样就解决了我们之前用例中的可见性问题。改变后的通信模型如下:
在这里插入图片描述
在这种情况下,在main线程修改isRunning变量后,会写入到主内存中,而子线程在读取isRunning变量时也会是最新的值。

2.有序性测试代码解释, 如何保证有序性

(1)、现象解释

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句
    的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:
在这里插入图片描述

指令1、2、3、4的混序执行导致出现了不一样的运行结果。其中a,b分别为三组值:(1,0)、(0,1)、(1,1)、(0,0)。前两种可以画处示意图如下
在这里插入图片描述
(1,1)同理,可能是A1->B1->B2->A2等。(0,0)这种情况稍微复杂一点,可能是A1和A2指令重排序后变为先执行A2(因为A2不依赖于A1的运行结果),那么有可能出现A2->B1->B2->A2,也有可能因为内存系统重排序导致这种情况,如下图所示:
在这里插入图片描述
这里处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3, B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。

(2)、使用volatile保证有序性

上面的例子中如果将变量都用volatile修饰就可以保证不会有(0,0)的结果出现,即A1和A2,B1和B2无法指令重排序,但是他们之间仍然可以交替执行,要解决这一问题,只能添加synchronized关键字修饰方法,来解决原子性问题。下面解释volatile关键字的内存语言。

JSR133规范

在解释volatile保证有序性之前先说一下,JSR133规范中给出的解决模型(就好像iOS网络7层协议,只是给出了指导规范,具体实践中采用的其实是四层模型)。

  • happens-before

    从JDK 5开始,Java使用新的JSR-133内存模型JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一 个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关 系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。具体规定的规则如下:

    规则 解释
    程序顺序规则 一个线程中的每个操作,happens-before于该线程中的任意后续操作
    监视器锁规则 对一个锁的解锁,happens-before于随后对这个锁的加锁
    volatile变量规则 对一个volatile域的写,happens-before于任意后续对这个volatile域的读
    传递性 如果A happens-before B,且B happens-before C,那么A happens-before C
    start()规则 如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作
    join()规则 如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回
  • as-if-serial
    as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被 编译器和处理器重排序。如下示例:

double pi = 3.14; // A
double r = 1.0; // B 
double area = pi * r * r;// C

上面的程序依赖关系如下:
在这里插入图片描述
那么只要保证A、B在C之前执行即可,这中不改变单线程的执行结果的重排序,JMM实现时是允许的。
在这里插入图片描述

虚拟机具体实现

java内存模型中定义了8种操作来完成,虚拟机保证了每种操作都是原子的。

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

如果要把一个变量从主存复制到工作内存:顺序执行 read 和 load 操作。
如果要把变量从工作内存同步会主存:顺序执行 store 和 write 操作。

注意:JMM 只是规定了必须顺序执行,而没有保证是连续执行,其间可以插入其他指令。

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

  • 处理器重排序规则如下
    在这里插入图片描述
    通过上表可以发现,常见的处理器都允许Store-Load重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类,如表所示。
在这里插入图片描述
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效
果。

前文提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。下表是JMM针对编译器制定的volatile重排序规则表。
在这里插入图片描述
从表中得出:

  • volatile写是第二个操作时,第一个操作不论是什么都不准重排序到volatile写后面
  • 当第一个是volatile读时,不管第二个操作是什么,都不能重排序
  • 当第一个是volatile写时不能排到volatile读后面

为了实现volatile的内存语义,编译器会在生成字节码时插入内存屏障,针对不同情况减少不必要的屏障。
在这里插入图片描述
这里我们总结一下
volatile变量在读和写时会添加lock指令保证缓存同步,volatile读会从内存中去同步所有的共享变量,并且保证不会有任何指令越过volatile读重排序到他的前面即volatile读后面的操作,都是使用的内存中最新的值。volatile写保证在写入时会将工作空间中的所有变量都同步到主内存中,而且所有指令都无法越过volatile写重排序到他的后面,因此他保证了,volatile写之前的所有操作最终都会同步到主内存中。volatile写happens before volatile读。

synchronized锁的获取和volatile读有同样的内存语义,锁的释放和volatile写有同样的语音。实现大致相同,除去原子性,排他性。

四、常见关于volatile的面试题

1. volatile可以保证原子性对吗?

public class Test {
    volatile static AtomicInteger x = new AtomicInteger(0);
    static int y = 0;
	
	//普通加
    static void add() {
        y++;
    }
	//原子加
    static void addAtomic() {
        x.getAndIncrement();
    }

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

        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    add();
                }
            }).start();

        }
        Thread.sleep(5000);
        System.out.println(y);
    }
}

测试结果
在这里插入图片描述
我们去执行addAtomic()方法,输出x的值永远都是1000000
在这里插入图片描述
究其原因,我们查看javap输出的信息
在这里插入图片描述
可以得到i++并非是一个原子操作,而是三,get,add,put。因此在多线程情况下可能出现多个线程同时get取到相同值,然后add,放入内存,这样最后就会总数小于1000000。

2. 单例模式的应用

public class Singleton {
    static Singleton instance;
 
    public Object o;
 
    public Singleton() {
        this.o = new Object();
    }
 
    static Singleton getInstance(){
        if (instance == null) {
            synchronized(Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

现在我们分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:

(1)分配内存空间。

(2)初始化对象。

(3)将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:

(1)分配内存空间。

(2)将内存空间的地址赋值给对应的引用。

(3)初始化对象

如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量,volatile可以禁止指令重排序,这样我们的单例模式就安全了。

参考
http://ifeve.com/wp-content/uploads/2014/03/JSR133中文版.pdf
Intel® 64 and IA-32 Architectures Software Developer’s Manual
深入理解java虚拟机
java并发编程的艺术
Java并发编程实战

写在最后,这篇文章写了挺久,知识点挺琐碎,复习了以前看过的一些书,想写这个内容不是很难,但是难在如何用最短的篇幅讲清楚更多的内容。昨天晚上在脑子里构思了很久,一有想法就会马上去验证,就这样睡着都半夜了吧。bb这么多,创作不易,还请多多支持。如果有问题和建议请在下方留言。

更多优质文章
Synchronized关键字深析(小白慎入,深入jvm源码,两万字长文)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章