这是我参与11月更文挑战的第9天,活动详情查看:2021最后一次更文挑战
Java内存模型
由于硬件具备高速缓存,指令重排序等特征,由于硬件的不同,使用有所差异。jvm为了保证多线程执行的正确性,抽象了了Java内存模型。在此模型中,每个线程读写变量的副本,Java内存模型控制是否对其它线程可见。
重排序
从Java源码到真正执行的机器码会经历Java编译器的重排序以及CPU编译的重排序。
大多数书中以及博客都是讲到了重排序但是没有说为什么会做重排序?以下是一波强行解释,纯属个人就见解。
为什么java编译器要进行重排序?
举个例子。看过《并发编程艺术》,如果栈帧中的某个变量不在,局部变量表中的插槽中,那么这个变量也会被垃圾回收。那如何让变量不在这个插槽呢?如果在插槽的变量后面都没有被使用,可以让后面的变量声明时占用该插槽。所以下面的这段重排序的代码优化就合情合理:
原代码
class Main {
void hello() {
Object a = new Object();
Object b = new Object();
Object c = new Object();
a = b.hashcode();// b只在此处使用
// 下面代码中使用变量a,c
...
}
}
java编译重排序后代码:
class Main {
void hello() {
Object a = new Object();
Object b = new Object();
a = b.hashcode();// b只在此处使用
Object c = new Object();
// 下面代码中使用变量a,c
...
}
}
由于变量b只在方法中第三行使用,所以将变量C的定义放在后面,可以变量c占用变量b插槽,让变量b引用的对象更早的释放掉内存。
为什么CPU机器码要进行重排序?
CPU编译的重排序我是从缓冲行理解的。试想原代码中,CPU读取三个变量a,b,c。此时a,c存在缓冲行中,并且此时缓存行块满了,按照这个顺序,读取b时候从主内存读取并且放入缓存行可能会使得变量c的值被淘汰掉,那么此时读取变量的顺序重排序为a,c,b是不是更好一点。
小小总结一下,由于java编译器或者cpu编译的优化,会使得真正执行的指令被重排序。
重排序带来的不可见问题
有一段代码:
class Main{
int a = 0;
int b = 0;
int x = 0;
int y = 0;
void task1() {
a = 1;
x = b;
}
void task2() {
b = 1;
y = a;
}
}
task1,task2 分别用两个线程执行,最终x,y的值会是什么? 当task1先执行完,在执行task2结果0,1。反过来,结果1,0。交叉执行,结果可能是1,1。让你触不及防的是0,0也是有可能的。当两个线程执行时,由于重排序优化导致task1变为 x=b,a=1。task2变为y=1,a=1。此时交替执行,结果可能是0,0。
happen bofore原则
为了让解决上述重排序带来的不可见问题。Java内存模型引入CPU的内存屏障来阻止重排序。
- load load屏障: 读读屏障,第一个读一定对第二个读可见
- load store屏障: 第一个读一定对第二个写可见
- store load屏障: 第一个写对第二个读可见
- store store屏障: 第一个写到一定对第二个写可见
以上屏障是CPU提供的,不同的CPU可能只提供以上几个屏障,但是读写屏障基本都会提供。而在jvm中,我们开发者可以对变量使用volatile,对volatile变量的读操作一定先与普通变量的读写,即volatile变量读操作不会与后面的普通读写操作重排序。下图是jvm内存模型对volatile读写是否重排序定义:
其实现是通过对:
- volatile写操作钱插入storestore屏障
- volatile读操作后插入loadstore屏障
- volatile读操作后插入loadloade屏障
- volatile写操作后插入storeload屏障 volatile通过内存屏障解决了指令重排序的问题。
以上仅仅是多线程开发的一个顺序性问题,除了该问题之外jvm内存模型还定义了其它线程的顺序规则,称为happen before原则。
- 线程的解锁,happen before线程的加锁 比如下面这段代码:
void main() {
int x = 10;
synchorized(this) {
// 后续线程进来一定能看到x=12
if(x<12) {
x=12;
}
}
}
- volatile变量规则,volatole变量写 happen before volatile 变量读
- final 语义的happen before原则
- as-if-serial原则,不管你如何重排序,jvm始终会保证单线程的最终结果不会改变。
- 线程start原则:线程A执行B.start()happen before 与线程B的任意操作。
void main() {
int x = 10;
Thread t = new Thread(){
public void run() {
System.out.println(x);
}
}
x = 20;
// 线程t执行一定能看到 x=20
t.start();
}
- 线程join原则: 线程B的任意操作一定happen before 与b.join();
void main() {
int x = 10;
Thread t = new Thread(){
public void run() {
x = 200;
}
}
// 线程t执行一定能看到 x=20
t.start();
t.join();
// 此处一定能看到x=200;
}
- final变量规则,对final变量的写,会在后面加上storestore屏障,所以如果final变量在构造器中写。一定会保证在引用返回之前,其内部的final变量一定是经过初始化的。比如下面代码,
obj=new Hello();是分为几个步骤的分配内存,执行构造方法,赋值引用,赋值引用与执行构造方法可能被重排序,而构造方法中的final变量后的storestore屏障会保证a的写一定发生在赋值引用之前。
class Hello{
final int a;
int b;
Hello() {
b = 10;
a = 20;
}
}
上面是final的写语义,当读final变量时,会在变量前插入loadload指令,从而保证final变量所在对象的引用赋值,一定发生在final变量的读取之钱。不会发生重排序。