继续执行 isFinish=true 这个指令。而因为当前 CPU0 缓存了 isFinish 并且是 Exclusive 状态,所以可以直接修改 isFinish=true。这个时候 CPU1 发起 read
这下硬件工程师也抓狂了,我们也能理解,从硬件层面很难去知道软件层面上的这种前后依赖关系,所以没有办法通过某种手段自动去解决。
所以硬件工程师就说: 既然怎么优化都不符合你的要求,要不你来写吧。
所以在 CPU 层面提供了 memory barrier(内存屏障)的指令,从硬件层面来看这个 memroy barrier 就是 CPU flush
store bufferes 中的指令。软件层面可以决定在适当的地方来插入内存屏障。
CPU 层面的内存屏障
什么是内存屏障?从前面的内容基本能有一个初步的猜想,内存屏障就是将 store bufferes 中的指令写入到内存,从
而使得其他访问同一共享内存的线程的可见性。X86 的 memory barrier 指令包括 lfence(读屏障) sfence(写屏障) mfence(全屏障)Store Memory Barrier(写屏障)
告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,
简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的。
Load Memory Barrier(读屏障) 处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
Full Memory Barrier(全屏障) 确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作有了内存屏障以后,对于上面这个例子,我们可以这么来
改,从而避免出现可见性问题
总的来说,内存屏障的作用可以通过防止 CPU 对内存的乱序访问来保证共享数据在多线程并行执行下的可见性但是这个屏障怎么来加呢?
回到最开始我们讲 volatile 关键字的代码,这个关键字会生成一个 Lock 的汇编指令,这个指令其实就相当于实现了一种内存屏障这个时候问题又来了,
内存屏障、重排序这些东西好像是和平台以及硬件架构有关系的。作为 Java 语言的特性,一次编写多处运行。我们不应该考虑平台相关的问题,并且
这些所谓的内存屏障也不应该让程序员来关心。
三、JMM
什么是 JMM
JMM 全称是 Java Memory Model. 什么是 JMM 呢?
通过前面的分析发现,导致可见性问题的根本原因是缓存以及重排序。 而 JMM 实际上就是提供了合理的禁用缓存
以及禁止重排序的方法。所以它最核心的价值在于解决可见性和有序性。
JMM 属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作
的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节,
通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了 CPU 多级缓存、处理器优化、指令重排序
导致的内存访问问题,保证了并发场景下的可见性。需要注意的是,JMM 并没有限制执行引擎使用处理器的寄
存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在 JMM 中,也会存在缓存
一致性问题和指令重排序问题。只是 JMM 把底层的问题抽象到 JVM 层面,再基于 CPU 层面提供的内存屏障指令,
以及限制编译器的重排序来解决并发问题。
JMM 抽象模型分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在
堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主
内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成。
Java 内存模型底层实现可以简单的认为:通过内存屏障(memory barrier)禁止重排序,即时编译器根据具体的底层
体系架构,将这些内存屏障替换成具体的 CPU 指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。
而对于处理器而言,内存屏障将会导致缓存的刷新操作。比如,对于 volatile,编译器将在 volatile 字段的读写操作
前后各插入一些内存屏障。
JMM 是如何解决可见性有序性问题的
简单来说,JMM 提供了一些禁用缓存以及进制重排序的方法,来解决可见性和有序性问题。这些方法大家都很熟悉:
volatile、synchronized、final 以及happends before规则
JMM 如何解决顺序一致性问题
重排序问题
为了提高程序的执行性能,编译器和处理器都会对指令做重排序,其中处理器的重排序在前面已经分析过了。所谓
的重排序其实就是指执行的指令顺序。编译器的重排序指的是程序编写的指令在编译之后,指令
可能会产生重排序来优化程序的执行性能。从源代码到最终执行的指令,可能会经过三种重排序。
2 和 3 属于处理器重排序。这些重排序可能会导致可见性问题。
编译器的重排序,JMM 提供了禁止特定类型的编译器重排序。
处理器重排序,JMM 会要求编译器生成指令时,会插入内存屏障来禁止处理器重排序
当然并不是所有的程序都会出现重排序问题编译器的重排序和 CPU 的重排序的原则一样,会遵守数据
依赖性原则,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,比如下面的代码
a=1; b=a;
a=1;a=2;
a=b;b=1;
这三种情况在单线程里面如果改变代码的执行顺序,都会
导致结果不一致,所以重排序不会对这类的指令做优化。
这种规则也成为 as-if-serial。不管怎么重排序,对于单个
线程来说执行结果不能改变。比如
int a=2; //1
int b=3; //2
int rs=a*b; //3
1 和 3、2 和 3 存在数据依赖,所以在最终执行的指令中,3 不能重排序到 1 和 2 之前,否则程序会报错。
由于 1 和 2不存在数据依赖,所以可以重新排列 1 和 2 的顺序
JMM 层面的内存屏障
为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障来禁止特定类型的处理器的重排序,
在 JMM 中把内存屏障分为四类
HappenBefore
它的意思表示的是前一个操作的结果对于后续操作是可见的,所以它是一种表达多个线程之间对于内存的可见性。
所以我们可以认为在 JMM 中,如果一个操作执行的结果需要对另一个操作课件,那么这两个操作必须要存在
happens-before 关系。这两个操作可以是同一个线程,也可以是不同的线程JMM 中有哪些方法建立 happen-before 规则
程序顺序规则
1. 一个线程中的每个操作,happens-before 于该线程中的任意后续操作; 可以简单认为是 as-if-serial。
单个线程中的代码顺序不管怎么变,对于结果来说是不变的顺序规则表示
1 happenns-before 2; 3 happens-before 4
2. volatile 变量规则,对于 volatile 修饰的变量的写的操作,一定 happen-before 后续对于 volatile 变量的读操作;
根据 volatile 规则,2 happens before 3
3. 传递性规则,如果 1 happens-before 2; 3happens-before 4; 那么传递性规则表示: 1 happens-before 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 从 ThreadB.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。