Java 多线程可见性问题

一、相关定义

1、可见性

在多线程中,如果一个线程对某一 共享变量 的修改,能及时被其他线程所感知,这个特性或者说过程称之为线程可见性。

2、共享变量

当多线程同时操作一个变量时,该变量在多线程的 工作内存(私有内存) 中都存在一个副本,那么这个变量称之为这几个线程的共享变量。

3、工作内存

多线程工作时,每个线程都会复制主内存中的变量副本到自己的私有内存,这个私有内存称之为每个线程的工作内存。

二、Java 内存模型(Java Memory Model)

Java 内存模型(JMM) 描述了 Java 程序中对线程共享变量的访问规则,以及在 JVM 层面对内存读写变量的底层细节,详细可参考 InfoQ-深入理解Java内存模型

1、概述

简单地说,Java 内存模型规定,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享,局部变量(Local variables),方法定义参数(java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享;运行时将所有变量放入 主内存中,同时在多线程访问的情况下,每个线程会开辟自己的 工作内存(抽象)**;工作内存中用于存放该线程所用到的每个主内存中的变量副本**,图例如下:

Java 内存模型1

2、线程读写变量规则

JMM 规定,多线程情况下,每个线程对变量的操作 必须在自己的工作内存 中完成,不允许线程直接对主内存进行操作

多线程变量传递需要借助主内存中转;也就是说,当A线程需要改变变量值时,需要先改变当前工作内存中的变量,再将其刷新到主内存,最后通过主内存刷新到线程B的工作内存。图例如下:

Java 内存模型2

Java 内存模型3

三、synchronized 实现可见性

Java 中对代码块或方法使用 symchroized 关键字,可保证其内部的共享变量实现多线程可见性。

1、JMM 对 synchroized 相关规定

  • 线程解锁前,必须将工作内存中数据刷新到主内存。
  • 线程加锁时,必须先清空工作内存,然后将主内存数据刷新到工作内存

2、线程执行互斥代码过程

  • 1、线程在 synchroized 入口处获得互斥锁
  • 2、线程清空自己的工作内存
  • 3、线程将主内存数据刷新到工作内存
  • 4、线程执行互斥代码
  • 5、线程将工作内存数据刷新到主内存
  • 6、线程退出 synchroized 代码块并释放互斥锁

3、指令重排序

具体可参考 InfoQ 深入理解Java内存模型(二)-重排序

定义:指令重排序,简单地说就是编译器和CPU为了提高并行执行速度,进行的代码重排序执行。

在CPU执行层面,每执行一个指令也会类似 Java 语言的 I/O 操作,在某一个命令未执行完成时必须进入等待(阻塞状态),然后执行下一个命令;而编译器和CPU为了最大限度利用有限时间执行更多的任务,可能会进行指令重排序;指令重排序结果如下:

1
2
3
// 原有代码
int a = 10;
int b = 20;

在编译器和CPU优化后,重排序可能会出现如下结果:

1
2
3
// 重排序后
int b = 20;
int a = 10;

但是指令重排序会遵循一点:as-if-serail 语义,即在单线程的情况下,保证最终执行结果不变,这时才会进行指令重排序;如上所示,a与b哪个先定义都不会产生结果的变更时才会重排序。

4、代码示例

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package mkw.demo.syn;

public class SynchronizedDemo {
//共享变量
private boolean ready = false;
private int result = 0;
private int number = 1;
//写操作
public void write(){
ready = true; //1.1
number = 2; //1.2
}
//读操作
public void read(){
if(ready){ //2.1
result = number*3; //2.2
}
System.out.println("result的值为:" + result);
}

//内部线程类
private class ReadWriteThread extends Thread {
//根据构造方法中传入的flag参数,确定线程执行读操作还是写操作
private boolean flag;
public ReadWriteThread(boolean flag){
this.flag = flag;
}
@Override
public void run() {
if(flag){
//构造方法中传入true,执行写操作
write();
}else{
//构造方法中传入false,执行读操作
read();
}
}
}

public static void main(String[] args) {
SynchronizedDemo synDemo = new SynchronizedDemo();
//启动线程执行写操作
synDemo .new ReadWriteThread(true).start();
//启动线程执行读操作
synDemo.new ReadWriteThread(false).start();
}
}

运行 mian 方法,由于可见性问题,可能出现多种情况,比如 写线程执行到 1.1,读线程获取CPU资源立即执行、写线程指令 1.1、1.2重排序等等情况。

四、volatile 实现可见性控制

1、实现原理

volatile 通过加入内存屏障和禁止指令重排序实现可见性控制,其过程大致分为以下两个过程:

  • 对 volatile 变量进行写操作时,会在写操作后加入一条 store 屏蔽指令;该命令会将工作内存中内容强制刷新到主内存(覆盖);同时会防止编译器/CPU指令重排序时将前面的变量重排到 volatile 修饰的变量之后(禁止颠倒顺序)。
  • 对 volatile 变量进行读操作时,会在读操作前加入一条 load 屏蔽指令;该指令会强制使工作内存失效,从而达到必须从主内存刷新到工作内存的效果

总结:使用 valotile 修饰变量后,线程在每次读取该变量时,会强制从主内存刷新最新状态到工作内存;在每次写入该变量时,会强制从工作内存刷新到主内存,以保证线程可见性。

2、volatile 不能保证原子性

由上可知 volatile 在保证可见性的原理大致和 synchroized 类似,主要是控制工作内存与主内存的刷新关系;但是相对于 synchroized ,volatile 不能保证原子性操作,测试代码如下:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package mkw.demo.vol;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class VolatileDemo {

// 测试变量
private volatile int number = 0;

public int getNumber(){
return this.number;
}

public void increase(){
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}

this.number++;
}

public static void main(String[] args) {
// TODO Auto-generated method stub
final VolatileDemo volDemo = new VolatileDemo();
for(int i = 0 ; i < 500 ; i++){
new Thread(new Runnable() {

@Override
public void run() {
volDemo.increase();
}
}).start();
}

//如果还有子线程在运行,主线程就让出CPU资源,
//直到所有的子线程都运行完了,主线程再继续往下执行
while(Thread.activeCount() > 1){
Thread.yield();
}

System.out.println("number : " + volDemo.getNumber());
}

}

当以上代码运行多次后,number 打印的值会出现很多小于500的情况;其原因是 volatile 无法保证 number++ 这行代码的原子性操作;实质上 number++ 执行了3个动作,首先读取 number 值,然后进行+1,最后写会 number;

首先假设 number=2,在多线程并发的情况下,A线程由于 volatile 原因,首先将 number 从主内存强制刷新到工作内存,然后进行 +1 操作,此时A线程工作内存中 number=3,当要回写到工作内存时。线程 B 获取了CPU资源开始执行;不难想象,B线程对 number+1 后,可能被A线程覆盖掉;此时 volatile 无法保证原子性的问题就暴露了出来。

3、volatile 适用场景

  • 对变量的写不依赖于当前值;即直接强制写,同时写的值跟上一次没关系。
  • 该变量不包含在其他变量的不变式中;即与其他 volatile 变量没关系。

4、volatile 与 synchroized 比较

在可见性上,volatile 与 synchroized 基本是相同的;但是在原子性上,volatile不支持;同时 synchroized 会执行加锁动作,开销较大,而 volatile 更轻量级。


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 国际许可协议进行许可,转载请注明出处。