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)不会在线程之间共享;运行时将所有变量放入 主内存中,同时在多线程访问的情况下,每个线程会开辟自己的 工作内存(抽象);工作内存中用于存放该线程所用到的每个主内存中的变量副本,图例如下:
2、线程读写变量规则
JMM 规定,多线程情况下,每个线程对变量的操作 必须在自己的工作内存 中完成,不允许线程直接对主内存进行操作。
多线程变量传递需要借助主内存中转;也就是说,当A线程需要改变变量值时,需要先改变当前工作内存中的变量,再将其刷新到主内存,最后通过主内存刷新到线程B的工作内存。图例如下:
三、synchronized 实现可见性
Java 中对代码块或方法使用 symchroized
关键字,可保证其内部的共享变量实现多线程可见性。
1、JMM 对 synchroized 相关规定
- 线程解锁前,必须将工作内存中数据刷新到主内存。
- 线程加锁时,必须先清空工作内存,然后将主内存数据刷新到工作内存
2、线程执行互斥代码过程
- 1、线程在
synchroized
入口处获得互斥锁 - 2、线程清空自己的工作内存
- 3、线程将主内存数据刷新到工作内存
- 4、线程执行互斥代码
- 5、线程将工作内存数据刷新到主内存
- 6、线程退出
synchroized
代码块并释放互斥锁
3、指令重排序
定义:指令重排序,简单地说就是编译器和CPU为了提高并行执行速度,进行的代码重排序执行。
在CPU执行层面,每执行一个指令也会类似 Java 语言的 I/O 操作,在某一个命令未执行完成时必须进入等待(阻塞状态),然后执行下一个命令;而编译器和CPU为了最大限度利用有限时间执行更多的任务,可能会进行指令重排序;指令重排序结果如下:
1 |
|
在编译器和CPU优化后,重排序可能会出现如下结果:
1 |
|
但是指令重排序会遵循一点:as-if-serail 语义,即在单线程的情况下,保证最终执行结果不变,这时才会进行指令重排序;如上所示,a与b哪个先定义都不会产生结果的变更时才会重排序。
4、代码示例
1 |
|
运行 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 |
|
当以上代码运行多次后,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 更轻量级。