并发编程(五):Java内存模型、可见性、有序性、volatile原理、happen before原则
本文目录
一、Java内存模型(Java Memory Model)
1.定义
Java内存模型(即Java Memory Model,简称JMM)是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式,底层对应着CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
本图片引用自:链接
主内存: 主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。
工作内存: 主要存储当前方法的所有本地变量信息(工作内存中还存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
2.JMM与JVM
- 根据JVM规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中,但倘若本地变量是引用类型,那么该变量的引用会存储在工作内存的帧栈中,而对象实例将存储在主内存的堆中。
- 对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。
- static变量以及类本身相关信息将会存储在主内存中。
- 需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存,简单示意图如下所示:
本图片引用自:链接
3.JMM与硬件内存架构的关系
- Java内存模型和硬件内存架构并不完全一致,对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,因为JMM只是一种抽象的概念,是一组规则。
- 不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉
4.JMM的体现
JMM体现在以下几个方面:
- 原子性 - 保证指令不会受 线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响 (JIT对热点代码的缓存优化) - 有序性 - 保证指令不会受 cpu 指令并行优化的影响
二、可见性
1.小例子:不会退出的循环
main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止。
public class Test32 {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (true) {
if (!run) {
break;
}
}
});
t.start();
sleep(1);
run = false; // 线程t不会如预想的停下来
}
}
分析一下:
-
初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
-
因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。
-
一秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。
2.解决方法
volatile关键字,它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。
public class Test32 {
// 易变
volatile static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (true) {
if (!run) {
break;
}
}
});
t.start();
sleep(1);
run = false; // 线程t会停下来
}
}
3.思考
//加了sout之后,即使不加volatile不会无法退出while循环了,为什么?
public class Test32 {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (true) {
System.out.println("主线程的修改对t可见,因为println源码对入参加synchronized锁");
if (!run) {
break;
}
}
});
t.start();
sleep(1);
run = false; // 线程t会停下来
}
}
原因:JMM中关于synchronized有如下规定,
- 线程加锁时,必须清空工作内存中共享变量的值,从而使用共享变量时需要从主内存重新读取;
- 线程在解锁时,需要把工作内存中最新的共享变量的值写入到主存,以此来保证共享变量的可见性。
4.可见性与原子性对比
前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况。
比较一下之前我们将线程安全时举的例子:两个线程一个 i++ 一个 i-- ,只能保证看到最新值,不能解决指令交错。
volatile
// 假设i的初始值为0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
synchronized
getstatic i // 线程1-获取静态变量i的值 i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 i=1
putstatic i // 线程1-将修改后的值存入静态变量i 1
getstatic i // 线程2-获取静态变量i的值 i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 i=0
putstatic i // 线程2-将修改后的值存入静态变量i i=0
- 注: synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized 是属于重量级操作,性能相对更低
三、有序性
1.重排序
(1) 编译器的重排
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
举个例子: 单线程下,先执行i的赋值还是先执行j的赋值,对结果无影响。但是在多线程下,会影响正确性。
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
boolean ready = false;//会发生重排
//volatile boolean ready = false; // 不会发生重排,也就不会出现结果为0的情况
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
线程1执行actor1方法, 线程2执行actor2方法,I_Result 是一个对象,有一个属性 r1 用来保存结果,问可能的结果有几种?
- 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
- 情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
- 情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为4(因为 num 已经执行过了)
但是结果还有可能是 0 ,这种情况下是:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2。
这种现象是JIT 编译器在运行时的一些优化,需要通过大量测试才能复现,可以使用jcstress工具进行测试。
(2)处理器的指令重排
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
小故事:
CPU指令重排:
-现代 CPU 支持多级指令流水线,指令还可以再划分成一个个更小的阶段,例如,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这 5 个阶段(汇编指令)。
(3)内存系统的重排
由于处理器使用缓存和读写缓存冲区,这使得加载和存储操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
2.解决方法
加volatile
四、volatile原理
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)。
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。
由于编译器和处理器都能执行指令重排优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。
1.可见性保证
- 对 volatile 变量的写指令后会加入写屏障,保证写屏障之前的写操作, 都能同步到主存中
- 对 volatile 变量的读指令前会加入读屏障,保证读屏障之后的读操作, 都能读到主存的数
//写屏障
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障,保证写屏障之前的写操作, 都能同步到主存中
}
//读屏障
public void actor1(I_Result r) {
// ready是被volatile修饰的 ,读取值带读屏障
if(ready) { // ready, 读取的就是主存中的新值
r.r1 = num + num; // num, 读取的也是主存中的新值
} else {
r.r1 = 1;
}
}
2.有序性保证
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
public void actor1(I_Result r) {
// 读屏障
// ready是被volatile修饰的 ,读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
3.不能保证原子性
- 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读, 跑到它前面去
- 有序性的保证也只是保证了本线程内相关代码不被重排序
下图t2线程, 就先读取了i=0, 此时还是会出现指令交错的现象, 可以使用synchronized来解决原子性
3.double-check locking问题
(1)分步拆解,以单例模式为例
//第一步
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
/*
多个线程同时调用getInstance(), 如果不加synchronized锁, 如果两个线程同时判断INSTANCE为空,此时就破坏单例了.
所以要加锁,防止多线程操作共享资源,造成的安全问题
*/
public static Singleton getInstance() {
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1,t2
INSTANCE = new Singleton();
}
}
return INSTANCE;
}
}
//第二步
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
/*
首先上面代码的效率是有问题的, 因为当我们创建了一个单例对象后, 又来一个线程获取到锁了,还是会加锁, 严重影响性能;
要双重检查, 在第一次线程调用getInstance(), 直接在synchronized外,判断instance对象是否存在了,提升效率
*/
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有 synchronized
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
/*以上实现的特点:懒汉式单例
首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁 (也就是上面的第二个单例)。
有隐含的但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在synchronized同步块之外, 此时若出现重排问题,多线程下就不能保证线程安全。
*/
(2)字节码层面解释: getInstance 方法对应的字节码,关注带注释的几行
0: getstatic #2 //获取静态变量 t2
3: ifnonnull 37 // 判断是否为空 t2
6: ldc #3
8: dup
9: astore_0
10: monitorenter
11: getstatic #2
14: ifnonnull 27
17: new #3// 新建一个实例 t1
20: dup // 复制了一个实例的引用 t1
21: invokespecial #4 // 通过这个复制的引用调用它的构造方法 t1
24: putstatic #2 // 最开始的这个引用用来进行赋值操作 t1
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2
40: areturn
- 17 表示创建对象,将对象引用入栈 // new Singleton
- 20 表示复制一份对象引用 // 复制了引用地址, 解锁使用
- 21 表示利用一个对象引用,调用构造方法 // 根据复制的引用地址调用构造方法
- 24 表示利用一个对象引用,赋值给 static INSTANCE
通过上面的字节码发现, 这一步INSTANCE = new Singleton()操作不是一个原子操作, 此时可能就会发生指令重排的问题,jvm 可能会优化为:先执行 24(赋值),再执行 21(构造方法)。
关键在于“ 0: getstatic” 这行代码在 monitor 控制之外,可以越过 monitor 读取 INSTANCE 变量的值,这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例。
(3)解决方法:加上volatile保证有序性,new Singleton()不发生重排
//加上volatile
public final class Singleton {
private Singleton() { }
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
五、happen before原则
倘若在程序开发中,仅靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,在JMM中,还提供了happens-before原则来辅助保证程序执行的原子性、可见性以及有序性的问题,内容如下
-
锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
-
volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
-
线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
-
传递性 A先于B ,B先于C 那么A必然先于C
-
线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
-
线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
-
对象终结规则 对象的构造函数执行,结束先于finalize()方法
六、总结
暂无