Java并发篇:volatile关键字吐血整理「建议收藏」

Java并发篇:volatile关键字吐血整理「建议收藏」1.1主内存与工作内存在上一篇文章中我们已经知道线程是通过主内存去进行线程间的隐式通信的,而线程对共享变量的写操作在工作内存中完成,由JMM控制共享变量由工作内存写回到主内存的时机。JMM提供了一个保证内存可见性的原则:happens-before原则。这个原则可以保证线程对共享变量的写操作对其它线程可见。如果在多线程环境下需要满足happens-before原则,就…

大家好,又见面了,我是你们的朋友全栈君。

1.1 主内存与工作内存

在上一篇文章中我们已经知道线程是 通过主内存 去进行线程间的 隐式通信 的,而线程对共享变量的写操作在 工作内存 中完成,由JMM控制 共享变量由工作内存写回到主内存的时机 。

JMM提供了一个保证内存可见性的原则: happens-before原则 。这个原则可以保证线程对共享变量的写操作对其它线程可见。如果在多线程环境下需要满足happens-before原则,就必须对共享变量添加某种特定的读写规则,否则会导致多线程环境下对共享变量的操作无法对其它线程可见,造成 缓存不一致 的现象。

Java并发篇:volatile关键字吐血整理「建议收藏」

1.2 如何保证操作可见性

如下图所示,在JMM中定义了8种原子操作保证线程的操作具有内存可见性。

Java并发篇:volatile关键字吐血整理「建议收藏」

  1. lock – read – load – use 表示一个线程需要 读取并使用共享变量 的过程。

  2. assign – store – write – unlock 表示一个线程需要 写入共享变量 的过程。

这两个读-写操作都是内存可见的,如果破坏了这些顺序,那么就无法保证内存可见性。关于这8个原子操作,不展开细说,枯燥的概念会让人打瞌睡,我们可以这样理解:

  1. 线程读共享变量时必须获取变量的锁,从主内存中获取共享变量的值并加载到工作内存中使用;
  2. 线程写共享变量时必须事先获取共享变量的锁,更新工作内存中的共享变量值后立即写入到主内存,然后释放该变量的锁。

我们不需要记住这8种原子操作的顺序,只要满足happens-before原则,就必定满足上图的情况。 换言之,我们可以通过happens-before原则判断内存访问操作是否是线程间可见。

2. volatile的可见性

2.1 验证可见性

/**
 * @author Zeng
 * @date 2020/4/24 9:57
 */
public class Test {
    static class Child {
        static volatile int A = 0;
        public static void setA(int a) {
            A = a;
        }
    }
    public static void main(String[] args) {
        Child.setA(1);
        new Thread(() -> {
            try {
                // 保证先让主线程读到A的值后再写入
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            Child.setA(3);
            System.out.println("child-thread: read variable A - " + Child.A);
        }).start();
        System.out.println("main-thread: read variable A - " + Child.A);
        // 如果无法感知,主线程一直循环
        while (Child.A == 1) { }
        System.out.println("main-thread: read variable A - " + Child.A);
    }
}

复制代码

上面创建一个新线程对Child类的静态成员变量A写入值,观察主线程 能否接收 到子线程对变量A的写入操作。

Java并发篇:volatile关键字吐血整理「建议收藏」

结果是可以主线程接收到子线程对变量A的写入, 如果把volatile关键字去掉,那么主线程会陷入死循环。

Java并发篇:volatile关键字吐血整理「建议收藏」

2.2 volatile变量的特殊性

通过上面的例子,我们可以看到volatile能够让不同线程之间操作共享变量能够实现 内存可见性,还记得这幅图吗?

Java并发篇:volatile关键字吐血整理「建议收藏」

volatile的特殊性从两个角度分析:

  1. 读可见性:线程必须通过read – load – use来读取并使用共享变量,所以每次读取一个volatile的变量值必须先从主内存中读取到工作内存中,这就像是每次都是直接读取主内存中的共享变量一样。
  2. 写可见性:线程必须通过assign – store – write在工作内存写入一个共享变量并立即同步到主内存中,就像是每一次对共享变量的写入都是直接在主内存中完成的。

3. volatile不是线程安全的

3.1 volatile不保证原子性

3.1.1 验证非原子性

/**
 * @author Zeng
 * @date 2020/4/10 15:51
 */
public class VolatileTest {

    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) {
        while (true) {
            race = 0;
            Thread[] threads = new Thread[THREADS_COUNT];
            for (int i = 0; i < THREADS_COUNT; i++){
                threads[i] = new Thread(()->{
                    for (int j = 0; j < 10000; j++) {
                        increase();
                    }
                });
                threads[i].start();
            }
            while (Thread.activeCount() > 2){
                Thread.yield();
            }
            System.out.println(race);
        }
    }

}
复制代码

对race变量进行20万次的自增操作,理论上执行完以后race = 200000,是否真的是这样呢?

Java并发篇:volatile关键字吐血整理「建议收藏」

我们可以看到每一次的race都小于200000,即使已经用volatile修饰了,也不能保证线程安全的原子性这一特点。

3.1.2 分析非原子性

问题出在自增运算 race++ 中,还是这幅图,也许你看到这里会有疑问: 获取主内存的变量时已经lock了,怎么能被其它线程访问呢?

Java并发篇:volatile关键字吐血整理「建议收藏」

需要注意的是,类似于synchronized这样的关键字才会具有lock和unlock操作,而volatile是保证在读取和写入共享变量时都要在主内存中读取和写入, 简单来说,volatile并不会锁住一个volatile变量。volatile的读和写可以简化成下图

Java并发篇:volatile关键字吐血整理「建议收藏」

race++经历了三步操作:

  1. 获取race的值(从主内存中获取)
  2. race = race + 1(加法操作)
  3. 写回race的值(同步到主内存中)

所以在第一步就有可能出现错误了,两个线程同时读取r主内存的值race = 2 -> race = 2 + 1 -> 写回主内存race = 3。结果就是少增了一次1,当多个线程一起执行 race++ 时,重叠的次数将会大大增加。

3.2 保证volatile线程安全的两个条件

  1. 运算结果不依赖于当前值,或者能够保证只有一个线程更新变量的值

以 race++ 为例,等价于 race = race + 1 ,后一个状态依赖前一个状态的值,因此不能保证原子性。

  1. 变量不需要与其它的状态变量共同参与不变约束

第一条很好理解,如何理解第二个条件呢?来看看下面的代码:

/**
 * @author Zeng
 * @date 2020/4/24 9:57
 */
public class Test {

    volatile static int start = 3;
    volatile static int end = 6;

    public static void main(String[] args) {
        new Thread(()->{
            while (start < end){
                System.out.println("start < end : " + start + " " + end);
                //do something
            }
        }).start();
        new Thread(() -> {
            while (true) {
                start+=3;
                end+=3;
            }
        }).start();
    }

}
复制代码

有两个volatile变量 start 和 end ,不变性约束是 start < end ,思考一下如果执行代码,会发生什么现象?最好可以在IDE上跑一跑,这个现象很有趣呢~也许你会觉得一直打印 start < end : xxx xxx 。事实上它会在某个不确定的地方停下来。你可以多运行几遍看看效果。

Java并发篇:volatile关键字吐血整理「建议收藏」

那么为什么会出现这种情况呢?

线程B执行了如下操作

while (true) {
    start+=3;
    end+=3;
}
复制代码

而线程A执行了如下操作

while (start < end){
    System.out.println("start < end : " + start + " " + end);
    //do something
}
复制代码

原因在于 start < end 这个地方,如果线程B执行了 start += 3 ,还没来得及执行 end += 3 时,线程A判断 start < end 不成立而退出了循环, 所以volatile变量不能与其它状态共同参与不变性约束。

4. volatile的有序性

4.1 禁止指令重排序

  1. 不同阶段的重排序

Java并发篇:volatile关键字吐血整理「建议收藏」

重排序会经历三个阶段:编译器、机器指令、内存系统,重排序是为了让性能提升。 重排序前后必须保证指令序列的执行结果与顺序执行的一致。

禁止重排序会有四种内存屏障:

屏障类型 指令示例 说明
LoadLoad Barriers Load1 ; LoadLoad ; Load2 确保Load1数据的装载先于Load2数据及后续所有Load指令的装载;即 volatile变量的读操作必须先于任何其它volatile变量的读操作
StoreStore Barriers Store1 ; StoreStore ; Store2 确保Store1数据对其它线程可见(刷新回主内存)先于Store2及后续所有存储指令的主内存刷新;即 volatile变量的写操作必须先于任何其它volatile变量的写操作
Load Store Barriers Load1 ; LoadStore ; Store2 确保Load1数据的装载先于Store2及后续所有存储指令的回主内存刷新;即 volatile变量的读操作必须先于任何其它变量的写操作
StoreLoad Barriers Store1 ; StoreLoad ; Load2 确保Store1数据的存储刷新回主内存先发生于Load2及后续所有装载指令的装载;即 volatile变量的写操作必须先于任何其它volatile变量的读操作

内存屏障保证了位于内存屏障之前的所有操作先完成于内存屏障后面的所有操作。所以指令无法越过内存屏障,也就是无法重排序。

JMM针对编译器制定了一张volatile重排序规则表:

能否重排 第二个操作 第二个操作 第二个操作
第一个操作 普通变量的读 / 写 volatile变量的读 volatile变量的写
普通变量的读 / 写     NO
volatile变量的读 NO NO NO
volatile变量的写   NO NO

可以总结出三点:

  1. 当第一个操作为volatile变量的读时,在它之后的任何操作都不能被重排序到volatile读的前面

  2. 当第二个操作为volatile变量的写时,在它之前的任何操作都不能被重排序到volatile写的后面

  3. 当第一个操作为volatile写,第二个操作为volatile读时,不能重排序

第3点很好理解,怎么来理解第1点和第2点呢?

第1点:执行volatile读时,需要刷新工作内存中的所有缓存数据,如果重排序,那么后面的操作会被该volatile读操作刷新掉,相当于没有执行后面的操作。有不少朋友问,如何深入学习Java后端技术栈,今天分享一个,互联网牛人整理出来的Java深入学习路线图,以及开发工具包,【戳我进入】学习裙。

第2点:执行volatile变量的写时,需要保证该线程工作内存中的值是最新的值,从而刷新回主内存中,如果把前面的操作放到该操作的后面,那么在执行volatile写的时候不能保证工作内存中的值已经是最新值,导致出现内存可见性问题。

再简单一些理解: volatile变量的读和写都必须保证工作内存中的值是最新值,与主内存的值保持一致。

4.2 使用synchronized保证串行执行

使用 synchronized 关键字锁住相同的对象,被 synchronized 包裹的指令可以保证顺序执行,因为执行指令前必须先获得对应的锁。 但是在代码块内部没有禁止指令重排序!!

synchronized(this) {
    i = 1;
    j = 2;
}
复制代码

上面两行代码可能会进行指令重排序。

synchronized

5. Double Check Lock引发的重排序

/**
 * @author Zeng
 * @date 2020/4/24 13:15
 */
public class Singleton {

    private volatile static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) { // 第①步
            synchronized (Singleton.class) {
                if (instance == null) { // 第②步
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        Singleton.getInstance();
    }

}
复制代码

volatile变量的作用: 防止指令重排序 。

如果 instance 没有使用volatile关键字修饰,会不会出现问题?

new Singleton() 的过程简单分为下面三步:

  1. JVM为对象分配一小块内存区域
  2. 在内存区域上初始化对象
  3. 将内存区域的引用地址赋给引用变量

如果 instance 没有volatile关键字修饰,编译器有可能对第二第三步进行重排序:

  1. JVM为对象分配一小块内存区域
  2. 将内存区域的引用地址赋给引用变量
  3. 在内存区域上初始化对象

当线程A执行②进入代码块时,执行了重排序后的第二步,而此时线程B执行到①,发现 instance 不为空,直接返回给调用者,而此时 instance 还没有初始化完成, 调用者获得了一个未被初始化的对象,使用该对象会报空指针异常!

所以, synchronized 同步代码块内部不会禁止指令重排序,如果需要防止指令重排序,一定要想到 volatile 。

6. 完结撒花

花了半天的时间写了这篇文章,过程很艰苦,有些点很难想明白,靠了很多肩膀,下面来总结一下volatile的知识点:

  1. volatile能够防止指令重排序,但是不能保证线程安全
  2. volatile不保证操作的原子性
  3. 被volatile修饰的变量满足内存可见性
  4. synchronized关键字无法禁止指令序列内部进行重排序,能够确保同一个锁对象的不同指令序列串行执行
  5. DCL必须使用volatile保证内存可见性和synchronized保证线程安全性

最后,如果你觉得这篇文章写的不错,你的一个点赞是对我最大的支持!感谢你的阅读!

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/156692.html原文链接:https://javaforall.cn

【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛

【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...

(0)
blank

相关推荐

  • startActivityForResult解析

    startActivityForResult解析startActivity用于页面间的正常跳转。startActivityForResult用于页面间的跳转并回传数据,比如商城类的App下单时会有选择地址,选择之后地址更新。1.先用startActivityForResult方式启动一个页面 btn=(Button)findViewById(R.id.btn);btn.setOnClickListen

  • cookie和session「建议收藏」

    一、cookie和session的介绍cookie不属于http协议范围,由于http协议无法保持状态,但实际情况,我们却又需要“保持状态”,因此cookie就是在这样一个场景下诞生。cookie

  • PyCharm将py文件文件生成可exe可执行文件[通俗易懂]

    PyCharm将py文件文件生成可exe可执行文件[通俗易懂]1.安装pyinstraller工具2.在PyCharm最下方找到终端,在终端输入pyinstaller-F–onefilexxx.py

  • ebpf教程_宝马F底盘编程

    ebpf教程_宝马F底盘编程eBPF入门之编程•Feiskyhttps://feisky.xyz/posts/2021-01-29-ebpf-program/目录BCClibbpf-bootstrap内核源码小结eBPF提供了强大的跟踪、探测以及高效内核网络等功能,但由于其接口处于操作系统底层,新手入门起来还是有很大难度,特别是如何编写eBPF程序是入门的一大难点。本文将介绍一些常用的eBPF编程框架。BCC上篇文章介绍的BCC其实就提供了对eBPF的封装,前端提供Python

  • 一款能自动生成报表的软件,工作秒完成!「建议收藏」

    一款能自动生成报表的软件,工作秒完成!「建议收藏」报表软件是企业管理的基本措施和途径,是企业的基本业务要求和实施 BI战略的基础。报表可以帮助企业访问、格式化数据,并把数据信息以可靠和安全的方式呈现给使用者,深入洞察企业运营状况,是企业发

  • phpstorm2021永久激活码【2021最新】

    (phpstorm2021永久激活码)最近有小伙伴私信我,问我这边有没有免费的intellijIdea的激活码,然后我将全栈君台教程分享给他了。激活成功之后他一直表示感谢,哈哈~https://javaforall.cn/100143.htmlIntelliJ2021最新激活注册码,破解教程可免费永久激活,亲测有效,上面是详细链接哦~S32P…

发表回复

您的电子邮箱地址不会被公开。

关注全栈程序员社区公众号