Java并发篇:volatile关键字吐血整理

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保证线程安全性

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

上一篇:Centos7系统tmp目录下文件默认保留时长


下一篇:条件竞争(race condition)