JUC并发编程之Volatile关键字详解

黎明大大

共 16605字,需浏览 34分钟

 ·

2021-04-12 20:50

1
前言

volatile关键字可以说是Java虚拟机提供的最轻量级的同步机制,但是它并不容易完全被正确、完整地理解,以至于许多程序员都习惯不去使用它,遇到需要处理多线程数据竞争问题的时候一律使用synchronized来进行同步。了解volatile变量的语义对了解多线程操作的其他特性很有意义,在本文中我们将介绍volatile的语义到底是什么。由于volatile关键字与Java内存模型(Java Memory Model,JMM)有较多的关联,因此对于JMM内存模型还不是很了解的,可以看我这篇文章 JUC并发编程之JMM内存模型详解


1
浅谈volatile关键字


2
volatile内存语义


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

  • 禁止指令重排序优化。


2
volatile特性分解


本文围绕着并发的三大特性(原子性、可见性、有序性)来聊一聊,volatile在并发中它能够解决哪些问题


3
volatile可见性


先上一段demo代码,来看看加了volatile和没加的区别。
@Slf4jpublic class Test01 {    private static boolean initFlag = false;    public static void refresh() {        log.info("refresh data.......");        initFlag = true;        log.info("refresh data success.......");    }    public static void main(String[] args) {        Thread threadA = new Thread(() -> {            while (!initFlag) {            }            log.info("线程:" + Thread.currentThread().getName()                    + "当前线程嗅探到initFlag的状态的改变");        }, "threadA");        threadA.start();        try {            Thread.sleep(500);        } catch (InterruptedException e) {            e.printStackTrace();        }        Thread threadB = new Thread(() -> {            refresh();        }, "threadB");        threadB.start();    }}


运行效果图


从动图我们看到,A线程内部判断 "initFlag" 变量,如果变量的值为"false"则一直进行循环,在代码中B线程内部调用refresh()方法将变量 "initFlag" 的值修改为"true",而此时A线程内部的循环感应到 "initFlag" 变量的值为"true"了应该退出来才对,而为什么演示图中A线程内部的循环并没有退出来?


带着这个疑惑,将代码稍微改动一下,往 "initFlag" 变量加上"volatile",然后再来看看它的效果是如何?


经过两轮的测试,从动图中,我们可以很明显的看到它们之间的区别,加了 "voaltile" 之后,A线程内部循环的 "initFlag" 变量能够感知到值发生的变化,然后跳出了循环,这是为什么呢?


先来看看这种图,或许会更加的好理解一点


对于上面的疑惑,我先做一个结论性的回答,然后再来分析它的过程,对于程序来说,其实我们无论是否加了 "volatile" A线程内部的循环最终都会退出来,只不过加了"volatile"后,A线程能够立马感知到值发生的变化。


分析结论:先看到我红色标记的一段话,A线程内部的循环最终都会跳出来,只不过是时间长短的问题而已。


结合上图分析,initFlag作为成员变量,程序会将它存放在主内存中,当线程A和B启动后,如果线程需要用到主内存的initFlag,线程会从主内存中将变量复制一份到自己内部的工作内存中,然后再对变量进行操作。而不是直接在线程内部对主内存中的变量进行操作。那么这就会有一个问题,当线程B对工作内存中的initFlag值进行改变后,然后将initFlag值从工作内存中推回到主内存,这时候线程A可能不会立即知道主内存的值已经发生了改变,因为A线程中的空循环它的优先级是非常高的,它会一直占用CPU来执行这串代码,这就导致JVM无法让CPU分点时间去主内存中拉取最新的值。而加了volatile后,它会通知其他有用到initFlag变量的线程,强制它去拉取主内存中最新变量的值,然后重新刷回到内部的工作内存中。简单来说,加了volatile关键字会强制保证线程的可见性;而不加的话,JVM也会尽力的保证线程的可见性(也就是CPU空闲的时候),这也就是我前面为什么会说无论是否加了 "volatile" A线程内部的循环最终都会退出来原因。


看到这相信对volatile的可见性有了一定的了解,接着再继续来看看volatile它是否能够解决并发中的原子性呢?


3
volatile原子性


老套路,先放上一段代码demo
public class Test02 {    private static int counter = 0;    public static void main(String[] args) {        for (int i = 0; i < 100; i++) {            Thread thread = new Thread(()->{                for (int j = 0; j < 1000; j++) {                        counter++;                }            });            thread.start();        }        try {            Thread.sleep(3000);        } catch (InterruptedException e) {            e.printStackTrace();        }        System.out.println(counter);    }}


如下图,是它的执行结果


这段代码发起了100个线程,每个线程对counter变量进行1000次自增操作,如果这段代码能够正确并发的话,最后输出的结果应该是100000。运行完这段代码之后,并不会获得期望的结果,而且会发现每次运行程序,输出的结果都不一样,都是一个小于100000的数字,这是为什么?根据上面所说的,可能是成员变量没有加volatile,然后导致每个线程内的工作内存的counter值没有及时的得到更新,所以才导致的结果不对,那不妨我们再将volatile加到成员变量counter上,再来看看结果如何。


下图是在成员变量上加了volatile的效果图


嗯哼?好家伙,上图打印的结果居然不是100000??看到是不是会感觉到有点不可思议?按照我文章中所说的volatile可见性的一个特性,当我某一个线程修改了主内存的值后,会立即通知其他的线程主动的去主内存中拉取最新的值,这里应该正常的输出才对,难道我前面所说的结论不对吗?其实并非不对,且看我细细道来。


因为volatile它并不能够解决并发中的原子性问题,看到这是不是又懵逼了?代码中的counter++就一行代码,为什么不是原子操作呢??其实这里是有一个坑的,其实counter++并非是一步操作,它在底层是被拆分为三个步骤进行执行的,且看,counter++操作是counter = counter + 1的简写操作对吧,那么我们可以简单的思考一下,counter的值是怎么来的呢?


根据这个思考,再来拆分一下它三个细致的步骤:

第一步:线程从主内存中复制一份变量到内部的工作内存中(读操作)
第二步:对counter变量进行+1计算(计算操作)
第三步:将计算后的值赋值给工作内存的counter变量,然后推回到主内存中(写操作)


我们都知道,线程是基于时间片进行执行的,在多线程下,假如线程内部刚好执行完第一步或者第二步操作,这个时候CPU发生中断操作,它并没有去执行该线程内的第三步操作(意思是暂停执行第三步操作,等到时间片轮询到该线程再回来继续执行接下来的操作),转而去执行另外一个线程的一个自增操作,这个时候就会出现问题,第一个线程执行完第二步操作后发生暂停,转而执行第二个线程自增操作,回看前面所说的volatile可见性特性, 因为加了volatile的原因,第二个线程改变完值后,会通知第一个线程现有的counter变量已经过期,需要重新去拉取主内存最新的值,这个时候就会造成,我两个线程都发生了自增操作,但是只有一个线程自增成功了,那么结果自然就不对,这也就造成了线程安全的问题。


从上面例子我们可以确定volatile是不能保证原子性的,要保证运算的原子性可以使用java.util.concurrent.atomic包下的一些原子操作类,或者使用synchronized同步块和Lock锁来解决该问题。


3
volatile有序性


关于有序性,在程序中我们都知道,我们写的代码都是从上往下进行执行的,那么在底层它是如何知道程序是从上往下的的呢?有没有可能代码会乱序执行的呢?


我前面有提到过线程是基于时间片执行的,从时间的维度上来讲,在线程内,上一行代码总会比下一行代码优先执行,但是在CPU里面它又不同了,它可能会将下一行的代码放到上一行先去执行,看到这估计有小伙伴有点懵了?啥玩意儿?这不是逗我玩吗?代码上中是从上往下执行,结果到你CPU又给我乱序执行?说着这,就不得不说到指令重排的概念了。


什么是指令重排

java语言规定JVM线程内部维持顺序语义,只要程序最终执行结果与它顺序化结果相等(一致的情况下),那么指令的执行顺序可以与代码顺序不一致,此过程叫指令重排。


为什么要指令重排

JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
上面这段话有点官方,我白话文来再来说一下,CPU在执行你的代码的时候,会认为你写的代码从上往下执行的速度还没有达到最优,它会在底层帮你优化一下代码的执行顺序,它是在不更改源结果的前提下进行优化的。


下图为从源码到最终执行的指令序列示意图:


这里我来放上一段代码,来证明一下它是否会进行指令重排
@Slf4jpublic class Test03 {    private static int x = 0, y = 0;    private static int a = 0, b = 0;    public static void main(String[] args) throws InterruptedException {        int i = 0;        for (; ; ) {            i++;            x = 0;            y = 0;            a = 0;            b = 0;            Thread t1 = new Thread(()->{                shortWait(10000);                a = 1;                x = b;            });            Thread t2 = new Thread(()->{                b = 1;                y = a;            });            t1.start();            t2.start();            t1.join();            t2.join();            String result = "第" + i + "次 (" + x + "," + y + ")";            if (x == 0 && y == 0) {                System.out.println(result);                break;            } else {                log.info(result);            }        }    }    /**     * 等待一段时间,时间单位纳秒     *     * @param interval     */    public static void shortWait(long interval) {        long start = System.nanoTime();        long end;        do {            end = System.nanoTime();        } while (start + interval >= end);    }}


在运行代码之前,先对这段代码的变量"x""y"的打印的结果,做一个简单的分析。看一下上述代码中会出现多少种不同的结果。在下方我将通过时序图来展现过程。


第一种结果:先排除指令重排,当这段代码以我们视觉效果的从上往下执行,结果就是x=0,y=1(因为t1线程已经执行完了,t2线程才来执行)


第二种结果:先排除指令重排,当T2线程先执行,然后在执行T1线程,它的结果就是x=1,y=0


第三种结果:先排除指令重排,当T1线程先执行,刚执行第一步,发生CPU中断操作,转而执行T2线程的第一步,结果又发生CPU中断操作,CPU又回到T1线程继续执行第二步,最后又来执行T2的第二步骤。所以最终的结果是x=1,y=1


第四种结果:就是与第三种顺序进行相反,不过并没有意义,因为结果都是一样的,这次我们不排除指令重排那么结果可能为:x=0,y=0


看了上面四种分析,也不知道结果对错与否,接下来贴出一张我测试的动态图,来验证指令重排的效果


从动态图中,是不是已经可以验证指令重排的存在了呢?那出现这种情况,有没有办法能够禁止指令重排呢?当然是可以的,volatile关键字完全可以解决这个问题


如上图中,我分别往x、y、a、b这变量加上了volatile,它就不会指令重排了,我动图的效果比较时间比较短,不相信的话,大家伙可以自己测试一下。


volatile禁止重排优化

volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化前面已详细分析过,这里主要简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。


内存屏障其实简单理解的话,假如代码中有两行代码,这两行代码在底层可能会发生指令重排,那么我不想让他发生重排怎么办呢?内存屏障的作用就体现出来啦,我们可以将内存屏障插在两行代码中间,告诉编译器或者CPU等,不让它进行重排,当然内存屏障是关于硬件层面的一些知识了,其实JVM也帮我们基于硬件层面的内存屏障封装好了软件层面的内存屏障,先来看看硬件层的内存屏障有哪些?


硬件层的内存屏障

Intel硬件提供了一系列的内存屏障,主要有: 
1.lfence,是一种Load Barrier 读屏障 
2.sfence, 是一种Store Barrier 写屏障 
3.mfence, 是一种全能型的屏障,具备ifence和sfence的能力 
4.Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。


jvm层的内存屏障
不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。JVM中提供了四类内存屏障指令:
屏障类型
指令示例
说明
LoadLoad
Load1; LoadLoad; Load2
保证load1的读取操作在load2及后续读取操作之前执行
StoreStore
Store1; StoreStore; Store2
在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存
LoadStore
Load1; LoadStore; Store2
在stroe2及其后的写操作执行前,保证load1的读操作已读取结束
StoreLoad
Store1; StoreLoad; Load2
保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行


volatile内存语义的实现
下图是JMM针对编译器制定的volatile重排序规则表。
第一个操作
第二个操作:普通读写
第二个操作:volatile读
第二个操作:volatile写
普通读写
可以重排
可以重排
不可以重排
volatile读
不可以重排
不可以重排
不可以重排
volatile写
可以重排
不可以重排
不可以重排


看到这,针对上面的几个表格看的是不是还有点懵圈,没关系,我接下来会对上面的表格的内容做一个简单总结,以及通过代码演示。相信大家伙应该会收获很多。


总结:普通读写和volatile读写的概念
1.普通读的概念:读取的变量可以是局部变量或是成员变量,成员变量不能被volatile所修饰
2.普通写的概念:赋值的变量可以是局部变量或是成员变量,成员变量不能被volatile所修饰
3.volatile读概念:读取的变量必须是被volatile所修饰的变量
4.volatile写概念:赋值的变量必须是被volatile所修饰的变量


总结:根据上方的表格的每一行操作,下方通过详细的代码Demo进行演示
第一行demo演示
public class ReadAndWrithe {    int a = 1;    int c = 0;    private static volatile int d = 5;    /**     * 第一个操作普通读写  第二个操作普通读写  可以重排     */    public void test1() {        //第一个操作:普通读写        //读取的a变量是成员变量但是没有被volatile所修饰,所以为普通读操作        //定义的b变量是局部变量,所以为普通写操作        int b = a + 1;        //第二个操作:普通读写        //读取的a变量和b变量都没有被volatile所修饰,所以为普通读操作        //定义的c变量是成员变量没有被volatile所修饰,所以为普通写操作        c = 2;        //该结论则是可以重排    }    /**     * 第一个操作普通读写  第二个操作volatile读  可以重排     */    public void test2() {        //第一个操作:普通读写        //读取的a变量是成员变量但是没有被volatile所修饰,所以为普通读操作        //定义的b变量是局部变量,所以为普通写操作        int b = a + 1;        //第二个操作:volatile读(准确来说:volatile读,普通写)        //读取的d变量是成员变量且是被volatile所修饰,所以为volatile读操作        //定义的c变量是成员变量没有被volatile所修饰,所以为普通写操作        c = d;        //该结论则是可以重排    }    /**     * 第一个操作普通读  第二个操作volatile写  不可以重排     */    public void test3() {        //第一个操作:普通读写        //读取的a变量是成员变量但是没有被volatile所修饰,所以为普通读操作        //定义的b变量是局部变量,所以为普通写操作        int b = a + 1;        //第二个操作:volatile写(准确来说:volatile写,普通读)        //读取的c变量是成员变量但是没有被volatile所修饰,所以为普通读操作        //赋值d变量是成员变量且是被volatile所修饰,所以为volatile写操作        d = c;        //该结论则是不可以重排    }}


第二行demo演示
public class ReadAndWrithe {    int a = 1;    int c = 0;    private static volatile int d = 5;    /**     * 第一个操作为volatile读,第二个操作为普通读写  不允许重排     */    public void test1() {        //第一个操作:volatile读(准确来说:volatile读,普通写)        //读取的d变量是成员变量是被volatile所修饰,所以为volatile读        //定义的j变量为成员变量,所以为普通写        int j = d;        //第二个操作:普通读写        a = 5;    }    /**     * 第一个操作为volatile读,第二个操作为volatile读  不允许重排     */    public void test2() {        //第一个操作:volatile读        //读取的d变量是成员变量是被volatile所修饰,所以为volatile读        //定义的j变量为成员变量,所以为普通写        int j = d;        //第二个操作:volatile读        //读取的d变量是成员变量是被volatile所修饰,所以为volatile读        //定义的f变量为成员变量,所以为普通写        int f = d;    }    /**     * 第一个操作为volatile读,第二个操作为volatile写  不允许重排     */    public void test3() {        //第一个操作:volatile读        //读取的d变量是成员变量是被volatile所修饰,所以为volatile读        //定义的j变量为成员变量,所以为普通写        int j = d;        //第二个操作:volatile写        //读取的a变量是成员变量但不是被volatile所修饰,普通读        //赋值的d变量为volatile所修饰,所以为volatile写        d = a;    }}


第三行demo演示
public class ReadAndWrithe {    int a = 1;    int c = 0;    private static volatile int d = 5;    private static volatile int d2 = 2;    /**     * 第一个操作为volatile写,第二个操作为普通读写  可以重排     */    public void test1() {        //第一个操作:volatile写(准确来说:volatile写,普通读)        //3:普通读        //赋值的d是volatile所修饰的,所以为volatile写        d = 3;            //第二个操作:普通读写        a = 5;    }    /**     * 第一个操作为volatile写,第二个操作为volatile读  不允许重排     */    public void test2() {        //第一个操作:volatile写        //3:普通读        //赋值的d是volatile所修饰的,所以为volatile写        d = 3;        //第二个操作:volatile读        //读取的d变量是成员变量是被volatile所修饰,所以为volatile读        //定义的f变量为成员变量,所以为普通写        int f = d;    }    /**     * 第一个操作为volatile写,第二个操作为volatile写  不允许重排     */    public void test3() {        //第一个操作:volatile写        //3:普通读        //赋值的d是volatile所修饰的,所以为volatile写        d = 3;        //第二个操作:volatile写        //读取的a变量是成员变量但不是被volatile所修饰,普通读        //赋值的d2变量为volatile所修饰,所以为volatile写        d2 = a;    }}


指令重排造成的问题
例如单例模式-双重检验锁创建实例,在多并发情况下则会出现问题,这个会在后面单独出一篇文章来剖析它,为什么会出现问题,本文先在这里埋上一个坑~


当然jvm虚拟机也不会随意将我们的代码进行指令重排,还需要遵守以下规则

as-if-serial语义
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。


happens-before 原则

只靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,从JDK 5开始,Java使用新的JSR-133内存模型,提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下

1.程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。

2.锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。

3.volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。

4.线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见

5.传递性 A先于B ,B先于C 那么A必然先于C

6.线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。

7.线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。

8.对象终结规则对象的构造函数执行,结束先于finalize()方法


我是黎明大大,我知道我没有惊世的才华,也没有超于凡人的能力,但毕竟我还有一个不屈服,敢于选择向命运冲锋的灵魂,和一个就是伤痕累累也要义无反顾走下去的心。


如果您觉得本文对您有帮助,还请关注点赞一波,后期将不间断更新更多技术文章


扫描二维码关注我
不定期更新技术文章哦



JUC并发编程之JMM内存模型详解

深入Hotspot源码与Linux内核理解NIO与Epoll

基于Python爬虫爬取有道翻译实现翻译功能

JAVA集合之ArrayList源码分析

Mysql几种join连接算法



发现“在看”和“赞”了吗,因为你的点赞,让我元气满满哦
浏览 32
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报