文章目录
并发编程三要素
- 原子性: 一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
- 有序性: 程序执行的顺序按照代码的先后顺序执行
- 可见性: 一个线程对共享变量的修改,另一个线程能够立刻看到
volatile关键字是什么
volatile是java虚拟机提供的最轻量级的同步机制,主要特点有三个:
- 保证线程间的可见性
- 禁止指令重排
- 不保证原子性
volatile是轻量级的synchronized,不会引起线程上下文切换
volatile的可见性
JMM
Java内存模型的主要目的是定义程序中各种变量的访问规则,JMM规定了所有的变量都存储在主存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主存中的数据。不同的线程也无法直接访问对方工作内存中的数据,线程间变量值的传递均需要通过主内存来完成。
/**
* VolatileDemo1
*
* @author xgSama
* @date 2021/1/7 21:29
*/
public class VolatileDemo1 {
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();
}
// 修改A的值
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);
}
}
没有volatile的结果
添加volatile后
禁止指令重排
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分为三种:
- 编译器优化的重排序: 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
- 指令级并行的重排序: 如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
- 内存系统的重排序: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能乱序执行
从Java源代码到最终实际执行的指令序列,会分别经历这三种重排序
为保证内存可见性,Java编译器在指令序列的适当位置会插入内存屏障(也称内存栅栏,是一组处理器指令)指令来禁止特定类型的处理器重排序。JMM把内存屏障分为4类:
屏障类型 | 指令示例 | 说明 |
---|---|---|
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变量的读操作 |
StoreLoad Barriers是一个全能型的屏障,他同时具有其他三个屏障的效果
volatile变量的内存语义及实现
volatile写的内存语义,当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
volatile读内存语义,当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量
JVM对volatile内存语义的实现
- 在每个volatile写操作的前面插入一个StoreStore屏障
- 在每个volatile写操作的后面插入一个StoreLoad屏障
- 在每个volatile读操作的后面插入一个LoadLoad屏障
- 在每个volatile读操作的后面插入一个LoadStore屏障
DCL中关于重排序的问题
以下是一个简单的双检锁单例模式代码:
/**
1. DCLDemo
2. 3. @author xgSama
3. @date 2021/1/7 22:04
*/
public class DCLDemo {
/**
* 私有构造方法
*/
private DCLDemo() { }
private static DCLDemo /*volatile*/ instance = null;
// step 1
public static DCLDemo getInstance() {
// step 2
if (instance == null) {
// step 3
synchronized (DCLDemo.class) {
// step 4
if (instance == null) {
// step 5
instance = new DCLDemo();
}
}
}
return instance;
}
}
这种方式也可能出现问题,因为底层存在指令重排的原因,检查的顺序可能发生了变化,可能会发生读取到的instance != null
,但是instance
的引用对象可能没有完成初始化,导致另一个线程读取到了还没有初始化的结果.
出现这种情况的原因在于step 5
初始化对象的过程:
第五步初始化过程会分为三步完成:
- 分配对象内存空间
memory = allocate()
- 初始化对象
instance(memory)
- 设置instance执行刚分配的内存地址
instance = memory
以上步骤看起来是多么美好,但是在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能提高并行度。所以,计算机底层编译器想着让你加速,就可能自作聪明的将2、3步骤调整顺序(重排序),优化成了
- 分配对象内存空间
memory = allocate()
- 设置instance执行刚分配的内存地址
instance = memory
- 初始化对象
instance(memory)
这种优化在单线程下问题不大,但是在多线程下就会出现上面提到的问题,如下图:
为解决上述问题,可以将instance
使用volatile
关键字修饰
volatile不保证原子性
验证非原子性
/**
* VolatileAtomic
*
* @author xgSama
* @date 2021/1/7 22:40
*/
public class VolatileAtomic {
public static void main(String[] args) {
Resource resource = new Resource();
// 创建200个线程,每个线程自增1000次
for (int i = 0; i < 200; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
resource.add();
}
}, String.valueOf(i)).start();
}
// 若上述20线程未计算完,主线程暂时让出执行时间
while (Thread.activeCount() > 2) {
Thread.yield();
}
// 查看num的值,若可以保证原子性输出值为200000
System.out.println(resource.num);
}
}
对num变量进行20万次的自增操作,理论上执行完以后num= 20W,是否真的是这样呢?
多次运行结果如下图所示
我们可以看到每一次的num都小于200000,即使已经用volatile修饰了,也不能保证线程安全的原子性这一特点。
非原子性的原因
问题出在自增运算 num++ 中
在JMM中定义了8种原子操作保证线程的操作具有内存可见性。
- lock - read - load - use 表示一个线程需要 读取并使用共享变量 的过程
- assign - store - write - unlock 表示一个线程需要 写入共享变量 的过程。
这两个读-写操作都是内存可见的,如果破坏了这些顺序,那么就无法保证内存可见性,我们可以这样理解:
- 线程读共享变量时必须获取变量的锁,从主内存中获取共享变量的值并加载到工作内存中使用;
- 线程写共享变量时必须事先获取共享变量的锁,更新工作内存中的共享变量值后立即写入到主内存,然后释放该变量的锁。
我们不需要记住这8种原子操作的顺序,只要满足happens-before原则,就必定满足上图的情况。 换言之,我们可以通过happens-before原则判断内存访问操作是否是线程间可见。
需要注意的是,类似于synchronized这样的关键字才会具有lock和unlock操作,而volatile是保证在读取和写入共享变量时都要在主内存中读取和写入, 简单来说,volatile并不会锁住一个volatile变量。volatile的读和写可以简化成下图
num++经历了三步操作:
- 获取num的值(从主内存中获取)
- num= num+ 1(加法操作)
- 写回num的值(同步到主内存中)
所以在第一步就有可能出现错误了,两个线程同时读取r主内存的值num= 2 -> num= 2 + 1 -> 写回主内存num= 3。结果就是少增了一次1,当多个线程一起执行 num++ 时,重叠的次数将会大大增加。
保证volatile线程安全的两个条件
-
运算结果不依赖于当前值,或者能够保证只有一个线程更新变量的值
-
变量不需要与其它的状态变量共同参与不变约束
/**
* VolatileDemo2
*
* @author xgSama
* @date 2021/1/7 23:05
*/
public class VolatileDemo2 {
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 ,思考一下如果执行代码,会发生什么现象?也许你会觉得一直打印 start < end : xxx xxx 。事实上它会在某个不确定的地方停下来。你可以多运行几遍看看效果。
那么为什么会出现这种情况呢?
线程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变量不能与其它状态共同参与不变性约束。