多线程—volatile关键字

宋正兵 on 2021-03-15

参考博客:JMM概述

在介绍 volatile 关键字之前,我们应该先了解了解 JMM(Java 内存模型)。

Java内存模型

JMM 本身是一个抽象的概念,并不真实存在,它描述的是通过一组规则或规范定制了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。因为它不是对物理内存的规范,而是在虚拟机基础上进行的规范从而能实现平台一致性,以达到 Java 程序能够“一次编写,到处运行”。

由于JVM 运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存(有些地方成为栈空间),工作内存是每个线程的私有数据区域,而 Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作空间,然后对变量进行操作,操作完成再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存储存着主内存中的变量副本拷贝,因此不同的线程无法访问对方的工作内存,此线程间的通讯(传值) 必须通过主内存来完成。

imagedceb6e4934633705.png

JMM 是围绕着在并发过程中如何处理可见性、原子性、有序性这三个特性而简历的模型。

有时间专门整理一篇 JMM 相关内容。

原子性(Atomicity)

一个操作不能被打断,要么全部执行完毕,要么不执行

基本类型数据的访问大都是原子操作,long 和 double 类型的变量是 64 位,但是在 32 位 JVM 中,32 位的 JVM 会将 64 位数据的读写操作分为 2 次 32 位的读写操作来进行,这就导致了 long、double 类型的变量在 32 位虚拟机中是非原子操作,数据有可能会被破坏,也就意味着多个线程在并发访问的时候是线程非安全的。

解决原子性问题(如多个线程同时操作一个共享变量),可以使用:

  • 加 synchronized 关键字
  • 使用 JUC 下的 AtomicInteger 类

可见性(visibility)

一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量的这种修改(变化)。

volatile 关键字、synchronized关键字、Lock接口、final 可以实现可见性。

volatile 关键字的特殊规则保证了被 volatile 修饰的变量的值修改后,新值会立刻同步到主内存,每次使用被 volatile 修饰的变量时会立刻从主内存中刷新,因此 volatile 关键字可以保证多线程之间的操作变量的可见性。

synchronized 关键字在同步方法/同步块开始时(Monitor Enter),使用共享变量时会从主内存中刷新变量值到工作内存中;在同步方法/同步块结束时(Monitor Exit),会将工作内存中的变量值同步到主内存中去。

使用 Lock 接口(拿最常用的实现 ReentrantLock 重入锁)来实现可见性:在方法开始的位置执行 lock() 方法,这和 synchronized 开始位置(Monitor Entry)有相同的语义,即使用共享变量时会从主内存中刷新变量值到工作内存中;在方法最后的执行 unlock() 方法,和 synchronized 结束位置(Monitor Exit)有相同的语义,即将工作内存中的变量值同步到主内存中去。

被 final 关键字修饰的变量,在构造函数完成且构造函数中没有把 this 的引用传递出去时,其他线程就可以看到 final 变量的值,实现了可见性。(this 引用逃逸非常危险,其他的线程很可能通过引用访问到只初始化了一半的对象)

有序性(Ordering)

在单线程中,代码的执行时从前往后依次执行的。但是在多线程并发时,程序的执行有可能是无序的。在本线程内观察,所有操作都是有序的。如果在一个线程中观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序、工作内存和主内存同步延迟现象(单线程中不存在)。

一个最经典的例子就是银行汇款问题,一个银行账户存款 100,这时一个人从该账户取 10 元,同时另一个人向该账户汇 10 元,那么余额应该还是 100。那么此时可能发生这种情况,A 线程负责取款,B 线程负责汇款,A 从主内存读到 100,B 从主内存读到 100,A 执行减 10 操作,并将数据刷新到主内存,这时主内存数据 $100-10=90$,而 B 内存执行加 10 操作,并将数据刷新到主内存,最后主内存数据 $100+10=110$,显然这是一个严重的问题,我们要保证 A 线程和 B 线程有序执行,先取款后汇款或者先汇款后取款,此为有序性。

可以通过 volatile 关键字和 synchronized 关键字来保证多线程操作的有序性,volatile 关键字通过加入内存屏障来禁止指令的重排序优化,而 synchronized 关键字通过加锁来允许同一时间只有一个线程对共享变量进行操作。

指令重排带来的问题

计算机在执行程序时,为了提高性能,编译器和处理器常常会进行指令重排

imagefb56c72e0dac516b.png

虽然处理器进行指令重排会考虑指令之间的数据依赖性,但是不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

对于代码

1
2
3
4
5
6
7
8
9
10
11
int a = 0, b = 0, x = 0, y = 0;
// 线程1
{
x = a;
b = 1;
}
// 线程2
{
y = b;
a = 2;
}
时间片 线程1 线程2
0 x=a; y=b;
1 b=1; a=2;

执行的结果是 x = 0, y = 0

如果编译器对这段代码进行指令重排,可能会出现

时间片 线程1 线程2
0 b=1; a=2;
1 x=a; y=b;

执行的结果是 x = 2, y = 1

这也就说明在多线程环境下,由于指令重排的存在,两个线程使用的变量能否保持一致性是无法确定的。

volatile

volatile 关键字的底层实现原理是内存屏障(Memory Barrier),它能保证被修饰的变量对所有线程的可见性,还能够禁止指令重排序优化。

volatile 仅保证可见性,有序性需要结合 synchronized 来实现。

如果指令间插入了内存屏障,则会告诉编译器和处理器,不管什么指令都不能和这条内存屏障指令重排序,即通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障另一个作用就是强制刷新各种缓存数据,保证线程能够读到这些数据的最新版本。

保证内存可见性

  • 对 volatile 变量的写指令后加入写屏障,写屏障保证在该屏障之前对 volatile 变量的改动,都同步到主内存当中
  • 对 volatile 变量的读指令前加入读屏障,读屏障保证在该屏障之后,对 volatile 变量的读取,加载的都是主内存当中的新数据

如何保证有序性?

  • 写屏障确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  • 读屏障确保指令重排序时,不会讲读屏障之后的代码排在读屏障之前

但是它还是不能够解决指令交错的问题,即并发有序性。

以 double-checked locking 单例模式问题为例,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class Singleton {
private Singleton() {}
// 懒汉式
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized(this) {
if (INSTANCE == null) {
INSTANC = new Singleton();
}
}
}
return INSTANCE;
}
}

此时它对应的部分字节码如下:

1
2
3
4
5
6
7
8
9
10
 0: getstatic		#2	// 获取 INSTANCE 引用
3: ofnonnull 37 // 判断不为空,跳转到 37
……
17: new #3 // 创建对象,将对象引用入栈
20: dup // 复制一份对象引用
21: invokespecial #4 // 利用一个对象引用,调用构造方法
24: putstatic #2 // 利用一个对象引用,赋值给 static
……
37: getstatic #2 // 获取 INSTANCE 引用
INSTACE

存在一种可能:JVM 会优化为先执行 24,再执行 21。如果恰好有两个线程 t1、t2。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sequenceDiagram
participant t1
participant INSTANCE
participant t2

t1 ->> t1: 17: new
t1 ->> t1: 20: dup
t1 ->> INSTANCE: 24: putstatic(给 INSTANCE 赋值)
t2 ->> INSTANCE: 0: getstatic(获取 INSTANCE 引用)
t2 ->> t2: 3: ifnonnull 37(判断不为空,跳转 37 行)
t2 ->> INSTANCE: 37: getstatic(获取 INSTANCE 引用)
t2 ->> t2: 40: areturn(返回)
t2 ->> t2: 使用对象
t1 ->> t1: 21: invokespecial(调用构造方法)

当 t1 执行到了 24 时,t2 执行到了 0 去获取 INSTANCE。此时这个 INSTANCE 已经被 t1 执行 24 给赋值了,但是还没调用构造方法进行初始化,接着 t2 判断 INSTANCE 不为空,跳转到 37 去获取 INSTANCE 的引用,然后将该引用返回,并使用 INSTANCE 对象。注意!==这里的 INSTANCE 并没有进行构造方法初始化就被使用==,这就是问题所在。

如何解决呢?对 INSTANCE 使用 volatile 关键字修饰即可,可以禁用指令重排,所有的写(write)操作都将发生在读(read)操作之前。(JDK 1.5 以上版本的 volatile 才会真正有效)

正确的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class Singleton {
private Singleton() {}
// 懒汉式
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized(this) {
if (INSTANCE == null) {
INSTANC = new Singleton();
}
}
}
return INSTANCE;
}
}

happens-before

A happens-before B 就是 A 先行发生于 B,定义为 hb(A,B)。在 Java 内存模型中,happens-before 的意思是钱一个操作的结果可以被后续操作获取。

happens-before 的作用

JVM 对代码进行编译优化,会出现指令重排序的情况,为了避免编译优化对并发编程安全性的影响,需要 happens-before 规则定义一些禁止编译优化的场景,保证并发编程的正确性。

还是以 double-checked locking 单例模式问题为例,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public final class Singleton {
private Singleton() {}
// 懒汉式
private static Singleton INSTANCE = null;
// 双重检查(两个if) 可以减小锁的粒度,提高性能
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized(this) {
if (INSTANCE == null) {
INSTANC = new Singleton();
}
}
}
return INSTANCE;
}
}

上述代码中,代码 INSTANC = new Singleton(); 并不是原子操作,JVM 会分解成以下几个命令执行:

  1. 创建对象,分配内存空间
  2. 调用构造方法初始化对象
  3. 将对象引用赋值给变量

按照上面分解的顺序(1->2->3)执行不存在任何问题,但是由于 JVM 编译优化的存在,可能会导致步骤 2 和步骤 3 颠倒,即按照 1->3->2 的顺序的执行【发生指令重排序】。按照 1->3->2 的顺序的执行,在多线程环境下就有可能出现 INSTANCE 虽然和内存空间关联起来了,但是对象还未完成初始化,会导致在使用 INSTANCE 实例的时候报错。添加 volatile 关键字可以禁用指令重排序,解决该问题。

happens-before 规定了哪些写操作对其他线程的读操作可见,它是可见性与有序性的一套规则总结。

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其他线程对该变量的读可见。

    说人话就是,对于同一个锁,一个线程解锁,另一个线程获取这个锁之后能看到前一个线程的操作结果。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    static int x;
    static Object m = new Object();
    new Thread(()->{
    synchronized(m) {
    x = 10;
    }
    },"t1").start();
    new Thread(()->{
    synchronized(m) {
    System.out.println(x);
    }
    },"t2").start();
  • 线程对 volatile 变量的写,对接下来其他线程对该变量的读可见。

    说人话就是,一个线程去写 volatile 变量,另一个线程去读这个变量,那么写操作的结果一定对读的这个线程是可见的。

    1
    2
    3
    4
    5
    6
    7
    volatile static int x;
    new Thread(()->{
    x = 10;
    },"t1").start();
    new Thread(()->{
    System.out.println(x);
    },"t2").start();
  • start 前对变量的写,对该线程开始后对该变量的读可见。

    说人话就是,在线程启动前,对共享变量的修改结果对线程可见。

    1
    2
    3
    4
    5
    static int x;
    x = 10;
    new Thread(()->{
    System.out.println(x);
    },"t2").start();
  • 线程结束前对变量的写,对其他线程得知它结束后的读可见。

    说人话就是,其他线程调用 t1.isAlive()t1.join() 等待它结束,那么线程 t1 的修改对它可见。

    1
    2
    3
    4
    5
    6
    7
    static int x;
    Thread t1 = new Thread(()->{
    x = 10;
    },"t1");
    t1.start();
    t1.join();
    System.out.println(x);
  • 线程 t1 打断 t2 (t2.interrupted()t2.isInterrupted())前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    static int x;
    public static void main(String[] args) {
    Thread t2 = new Thread(()->{
    while(true) {
    if(Thread.currentThread().isInterrupted()) {
    System.out.println(x);
    break;
    }
    }
    },"t2");
    t2.start();
    new Thread(()->{
    try {
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    x = 10;
    t2.interrupt();
    },"t1").start();
    while(!t2.isInterrupted()) {
    Thread.yield();
    }
    System.out.println(x);
    }
  • 具有传递性,即 hb(A, B) , hb(B, C),那么 hb(A, C)。

  • 变量都是指成员变量或静态成员变量。