Java多线程笔记(3)-volatile与Java内存模型

线程安全三大特性

在多线程代码编写的过程中,线程安全是一个非常重要的话题。而线程安全通常需要考虑三个方面:原子性,可见性和有序性

  • 原子性:一个线程内多行代码以一个整体运行

  • 可见性:一个线程对共享变量修改,另一个线程能够看到最新的结果

  • 有序性:一个线程内代码按照编写顺序执行

原子性

原子性问题很好理解,与临界区和临界资源相关。对于临界区的代码,同一时刻最多只能有一个线程执行。

可见性

可见性问题指的是线程对于变量的可见性,考虑如下代码:

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
26
27
28
static volatile boolean stop = false;

public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
log.debug("modify stop to true...");
}, "thread1").start();
new Thread(() -> {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("stop: {}", stop);
}, "thread2").start();


int i = 0;
while (!stop) {
i++;
}
log.debug("stopped... count:{}", i);
}

上面的代码中,主线程启动了两个线程分别是thread1和thread2,主线程中循环对i进行自增;thread1在睡眠100ms之后将stop标志修改为true,希望让主线程停下来;thread2在睡眠200ms之后输出它看到的目前stop的值。

我们预期希望主线程能够正常停止,但是实际运行发现无法暂停:

1
2
10:17:01.289 [thread1] c.ThreadTest - modify stop to true...
10:17:01.367 [thread2] c.ThreadTest - stop: true

出现这种情况的原因是由于可见性。thread1在修改了stop之后,将其同步到了主存中,但是主线程中的循环会经过JIT热点代码。优化后,JIT发现循环中stop一直是false,那么它就认为这个stop一直是false,将其缓存到工作内存中。而其他线程thread2没有热点代码,没有进行JIT优化,因此可以看到stop的最新值true

如果我们增加虚拟机参数-Xint,只用解释器,禁用JIT优化,则发现主线程可以正常停下来:

IDEA虚拟机参数:Edit Configurations->Modify options->Add VM options

1
2
3
10:18:17.259 [main] c.ThreadTest - stopped... count:17411102
10:18:17.259 [thread1] c.ThreadTest - modify stop to true...
10:18:17.359 [thread2] c.ThreadTest - stop: true

热点代码的优化是有次数阈值的,如果我们降低Thread1的睡眠时间,让循环执行次数减少,那么主线程也是可以正常停止下来。这里我们将thread1的睡眠时间修改为1ms(修改为Thread.sleep(1)),发现主线程也是能够正常地停止,循环的次数对应也降低了很多:

1
2
3
10:22:07.228 [main] c.ThreadTest - stopped... count:351417
10:22:07.228 [thread1] c.ThreadTest - modify stop to true...
10:22:07.431 [thread2] c.ThreadTest - stop: true

当然上面的两种方式都不是通用的解决方式,解决方式应该是使用关键字volatile来修饰stop。volatile可以解决可见性问题,其修饰的变量每次用到的时候都到主存中找。增加volatile修饰之后,主线程也能够正常停止:

1
2
3
10:23:05.430 [main] c.ThreadTest - stopped... count:376220487
10:23:05.430 [thread1] c.ThreadTest - modify stop to true...
10:23:05.524 [thread2] c.ThreadTest - stop: true

有序性

CPU的基本工作就是执行存储的指令序列。现代CPU支持多级指令流水线,我们可以将一条指令分成5个阶段:取指令--指令译码--执行指令--访存取数--结果写回,称之为五级指令流水线。CPU可以在一个时钟周期内,同时运行五条指令的不同阶段。流水线计数本质上不能缩短单条指令的执行时间,但是可以提高指令的吞吐率

在不改变程序结果的前提下,指令的各个阶段可以通过重排序和组合来实现指令级并行。指令重排序的目的是减少流水线阻塞。在单线程的场景下,指令重排序并不会影响程序的正确性,但是在多线程的场景下,指令重排序就可能带来预期之外的结果

考虑单例模式的经典实现,双重检验锁模式,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final class Singleton{
private static volatile Singleton instance;
private Singleton(){};
public static Singleton getInstance(){
if(instance == null){
synchronized(Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}

双重检验锁有三个注意点:

  • 第一个注意点是使用final修饰类,防止子类的方法修改单例
  • 第二个注意点是在synchronzied同步代码块内外都需要进行null的判断,第二个null的判断是为了防止在第一次创建单例的时候有多个线程进入第一个if内的代码块
  • 第二个注意点是使用volatile修饰instance,防止指令重排序

考虑不使用volatile的情况

在这种情况下,可能会出现指令的重排序。问题的关键在于instance = new Singleton()的底层并不是一个原子操作,而是可以分成四个部分:创建对象,复制引用地址,调用构造方法,将引用地址赋值给instance。但是由于可能有指令重排,一种可能的顺序是先将引用地址赋值给instance,再调用构造方法。在这种情况下,可能出现一个线程执行第一个if发现不为null,直接返回结果,但是得到的单例对象还没有被初始化,使用的话就会出现错误

解决方式就是使用volatile关键字,防止指令的重排序

volatile

Java中提供volatile关键字,用来修饰成员变量或者静态变量。可以保证可见性和有序性,但是没有办法保证原子性

对于可见性来说:被volatile修饰的变量,每次都会到主存中进行最新值的读取

对于有序性来说:使用内存屏障来达到效果,具体来说又分为写屏障和读屏障

  • 在对volatile变量的写指令之前加入写屏障^^^^^,保证前面的代码不会跑到后面去,但是后面的代码可以跑到前面

  • 在对volatile变量的读指令之后加入读屏障vvvvv,保证后面的代码不会跑到前面去,但是前面的代码可以跑到后面

考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
int x;
volatile int y;

// 在写操作前增加写屏障
x = 1;
// ^^^^^
y = 2;


// 在读操作之后增加读屏障
y;
// vvvvv
x;

一般来说,对于volatile变量的读操作在最先完成,写操作在最后完成,这样能够最大程度利用内存屏障。

Java内存模型

JMM

Java内存模型(Java Memory Model,JMM)是Java虚拟机中的一种规范,目的是屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。它的主要目的是定义程序中各种变量的访问规则,包括实例字段、静态字段和数组元素等。(不包括局部变量与方法参数,因为这些是线程私有的,不共享,也就不存在竞争关系)

JMM规定所有变量都存储在主内存(Main Memory,可以类比物理硬件的主内存,实际上是虚拟机内存的一部分)中,主内存对于所有线程来说是共享的。而每条线程还有自己的工作内存(Working Memory,可以类比高速缓存),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接操作主内存中的变量。线程之间无法相互直接访问,线程间变量值的传递均需要通过主内存来完成

注意:JMM和Java内存区域中的堆、栈、方法区等并不是一个层次上对内存的划分,两者基本上是没有任何关系的

happens-before

JMM中定义了8种操作来描述主内存与工作内存之间的交互,包括lock、unlock、read、load、use、assign、write。同时JMM还规定了在执行上述8种基本操作时必须满足的规则。规则定义非常严谨,但是也是极为繁琐。happens-before则是这些定义的等效判断原则,通过happens-before原则,我们可以确定操作在并发环境下是安全的

happens-before原则由一系列原则构成。happens-before是JMM中定义的两个操作之间的偏序关系。JMM向程序员提供保证,如果两个操作满足happens-before原则,例如操作A happens-before 操作B,那么就可以保证操作A的结果对操作B是可见的。而如果不满足happens-before原则,则不能得到可见性的保证。happens-before原则也是我们判断是否存在竞争,线程是否安全的主要依据

具体来说,happens-before原则由如下的规则构成:

  • 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,前面的操作 happens-before 后面的操作。

    即同一个线程中前面的所有操作对后面的操作可见

  • 管程锁定规则(Monitor Lock Rule):一个unlock操作 happens-before 后面对于同一个锁的lock操作。这里的lock和unlock指的是对Monitor的操作,与synchronized原理有关。

    举例来说,如果线程1解锁了Monitor a,然后线程2锁定了Monitor a,那么线程1解锁a之前的所有操作对线程2可见

  • volatile变量规则(Volatile Variable Rule):对于一个volatile变量的写操作 happens-before 之后对于这个变量的读操作。

    举例来说,如果线程1写入了volatile变量,之后线程2读取了v,那么线程1写入v以及之前的操作对线程2可见

  • 线程启动规则(Thread Start Rule):Thread对象的start方法 happens-before 该线程的每一个操作。

    举例来说,线程1在执行过程中通过调用Thread2.start()来启动线程2,那么线程1在调用start方法之前的操作对线程2是可见的。注意在线程2启动之后,线程1的操作对于线程2来说未必可见

  • 线程终止规则(Thread Termination Rule):线程中所有的操作 happens-before 对于此线程的终止检测。终止检测可以通过Thread::join()Thread::isAlive()等手段来进行检测。

    举例来说,线程1的所有操作都对线程2调用线程1的t1.join()或t1.isAlive()并成功返回之后可见

  • 线程中断规则(Thread Interruption Rule):线程Thread::interrupt()方法的调用 happens-before 被中断线程代码检测到中断事件的发生

    举例来说,线程1在执行线程2.interrupt()之前的操作,对于线程2在检测到打断之后是可见的

  • 对象终结规则(Finalizer Rule):一个对象的初始化完成 happens-before 它的finalize()方法的开始。

    即对象调用finalize()方法的时候,对象初始化完成的任意操作都对其可见

  • 传递性(Transitivity):如果操作A happens-before 操作B,操作B happens-before 操作C,那么操作A happens-before 操作C

上面的原则描述起来可能有些拗口,但是实际含义与我们直觉相符合。事实上,这是JMM做出的努力,它通过定义的8种操作和烦琐的规则,让程序员能够更加直接方便地进行多线程的编程


Java多线程笔记(3)-volatile与Java内存模型
http://example.com/2022/09/10/Java多线程笔记-3-volatile与Java内存模型/
作者
EverNorif
发布于
2022年9月10日
许可协议