什么是 JMM?
JMM 全称是 Java Memory Model. 通过《线程安全性原理分析—(硬件层面)》的分析发现,导致可见性问题的根本原因是缓存以及重排序。 而 JMM 实际上就是提供了合理的禁用缓存以及禁止指令重排序的方法来保证可见性和有序性。
JMM 属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节,通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了 CPU 多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。
需要注意的是,JMM 并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在 JMM 中,也会存在缓存一致性问题和指令重排序问题。只是 JMM 把底层的问题抽象到 JVM 层面,再基于 CPU 层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题。
抽象模型分为主内存、工作内存;
主内存是所有线程共享的,一般是实列对象、静态字段、数组对象等存储在堆内存中的变量。
工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成的。
Java 内存模型底层实现可以简单的认为:通过内存屏障(memory barrier)禁止重排序,即使编译器根据具体的底层体系架构,将这些内存屏障替换成具体的CPU 指令。对于编译器而言,内存屏障将限制它所能的做重排序优化。对于处理器而言,内存屏障将会导致缓存的刷新操作。
比如,对于 volatile,编译器将在 volatile 字段的读写操作前后各插入一些内存屏障。
JMM是如何解决可见有序性问题的?
简单来说,JMM 提供了一些禁用缓存以及禁止重排序的方法,来解决可见性和有序性问题。这些方法是volatile、synchronized、final;
JMM 如何解决顺序一致性问题重排序问题
为了提高程序的执行性能,编译器和处理器都会对指令做重排序,其中处理器的重排序在前面已经分析过了。所谓的重排序其实就是指执行的指令顺序。
编译器的重排序指的是程序编写的指令在编译之后,指令可能会产生重排序来优化程序的执行性能。
从源代码到最终执行的指令,可能会经过三种重排序 过程如下。
后面两者处于处理器级重排序,这些重排序可能会导致可见性。
编译器重排序,JMM 提供了禁止特定类型的编译器重排序。
处理器重排序,JMM 会要求编译器生成指令时,会插入内存屏障来禁止处理器重排序。
当然并不是所有的程序都会出现重排序的问题,编译器的重排序和CPU的重排序会遵守数据依赖性原则,编译器和处理器不会改变存在数据依赖关系的两个操作的指向顺序,比如以下代码,
int a =1; int b=a;
int a=1; a=2;
这两种情况再单线程立马如果改变执行顺序,会导致结果不一致,所有重排序不会对这类的指令做优化。这种原则也称为“as-if-serial”。
不管怎么重排序,对于单个线程来说执行结果不能改变,比如:
int a=2; //1
int b=3; //2
int c=a*b; //3
1 和 3、2 和 3 存在数据依赖,所以在最终执行的指令中,3 不能重排序到 1 和 2 之前,否则程序会报错。由于 1 和 2
不存在数据依赖,所以可以重新排列 1 和 2 的顺序。
JMM 层面的内存屏障
为了保证内存可见性,Java 编译器再生成指令序列的适当位置会插入内存屏障来禁止特定类型的处理器的重排序。
在JMM 中把内存屏障分为四类:
屏障类型 | 指令 | 说明 |
---|---|---|
LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保Load1数据的装载,在Load2及所有后续装载指令的装载之前。 |
StoreStore Barriers | Load1; LoadLoad; Load2 | 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。 |
LoadStore Barriers | Load1; LoadLoad; Load2 | 确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。 |
StoreLoad Barriers | Load1; LoadLoad; Load2 | 确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。 |
happens-before原则
它的意思表示的是前一个操作的结果对于后续操作是可见的,所以它是一种表达多个线程之间对于内存的可见性。所以我们可以认为在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在happens-before 关系。这两个操作可以是同一个线程,也可以是不同的线程
JMM 中 有 哪些方法建立 happens- before
1.程序顺序原则
一个线程中的每个操作,happens-before 于该线程中的任意后续操作。可以简单的认为是 as-if -seral,单个线程中的代码顺序不管怎么变,对于结果来说是不变的顺序规则表示 1 happens-before 2; 3hanppens-before 4
class VolatileExamlle {
int a =0;
volatile boolean flag=false;
public void writer(){
a=1;//1
flasg=true;//2
}
public void reader(){
if(flag=){//3
int i =a;//4
}
}
}
2.volatile 变量原则,对于volatile 修饰的变量写的操作,一定happens-before 后续对于volatile 变量的都操作;根据volatile规则,2happens before 3
class VolatileExamlle {
int a =0;
volatile boolean flag=false;
public void writer(){
a=1;//1
flasg=true;//2
}
public void reader(){
if(flag=){//3
int i =a;//4
}
}
}
3.传递性原则,如果1 happens before2; 2happens before 3; 那么 1happens before 3;
class VolatileExamlle {
int a =0;
volatile boolean flag=false;
public void writer(){
a=1;//1
flasg=true;//2
}
public void reader(){
if(flag=){//3
int i =a;//4
}
}
}
4.start原则,如果线程A执行操作ThreadB.start(),那么线程A 的ThreadB.start()操作 happens before 线程B 中的任意操作。
public StartDemo{
int x=0;
Thread t1 = new Thread(()->{
// 主线程调用 t1.start() 之前
// 所有对共享变量的修改,此处皆可见
// 此例中,x==10
});
// 此处对共享变量 x 修改
x = 10;
// 主线程启动子线程
t1.start();
}
5.Join 规则,如果线程A执行操作ThreadB.join()并成功返回,那么线程B 中的任意操作happens before 于线程A从TheadB.join()操作成功返回。
Thread t1 = new Thread(()->{
// 此处对共享变量 x 修改
x= 100;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 t1 可见
// 主线程启动子线程
t1.start();
t1.join()
// 子线程所有对共享变量的修改
// 在主线程调用 t1.join() 之后皆可见
// 此例中,x==100
6.监视器锁的规则,对一个锁的解锁,happens-before 于随后对这个锁的加锁。
synchronized (this) { // 此处自动加锁
// x 是共享变量, 初始值 =10
if (this.x < 12) {
this.x = 12;
}
} // 此处自动解锁
假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x=12。