前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JUC并发编程之Volatile关键字详解

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

作者头像
黎明大大
发布2021-04-16 16:26:53
3080
发布2021-04-16 16:26:53
举报
文章被收录于专栏:java相关资料java相关资料

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和没加的区别。

代码语言:javascript
复制
@Slf4j
public 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

代码语言:javascript
复制
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在执行你的代码的时候,会认为你写的代码从上往下执行的速度还没有达到最优,它会在底层帮你优化一下代码的执行顺序,它是在不更改源结果的前提下进行优化的。

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

这里我来放上一段代码,来证明一下它是否会进行指令重排

代码语言:javascript
复制
@Slf4j
public 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演示

代码语言:javascript
复制
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演示

代码语言:javascript
复制
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演示

代码语言:javascript
复制
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()方法

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

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

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

本文分享自 黎明大大 微信公众号,前往查看

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档