专栏的上一篇文章简单介绍了一下导致原子性、可见性、有序性的很容易看到的问题(可能是违背我们的意愿的出现),现在来说一下如何解决其中的可见性和有序性导致的问题,引出了今天的问题——Java内存模型。
Java内存模型,不要和JVM内存模型混淆,但是二者确实是有联系的,Java内存模型更倾向于Java并发方面的知识,JVM内存模型在Java方面全都会涉及的。。。
什么是Java内存模型
之前提到过volatile关键字,可以让变量的修改直接写入主存做到可见性,本质:是因为缓存才导致无法实现可见性,编译优化无法实现有序性。。
最简单的解决方法就是禁用缓存和编译优化,但是程序性能就堪忧了。所以需要按需禁用缓存以及编译优化,问题是如何做到按需禁用呢???
Java内存模型是个很复杂的规范,本质上可以理解为:Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括volatile、synchronized和final三个关键字,以及六项Happens-Before规则。
Volatile
volatile关键字不是Java语言的特产,C语言中也有,最原始的意义就是禁用CPU缓存。。。
例如声明一个volatile变量:
volatile int x = 0;
表达的是:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入。这个语义看上去相当明确但是实际使用的时候却会带来困惑。。。
例如下面的代码,假如我们创建一个线程A执行write()方法,再创建一个线程B执行reader()方法,我们来想想x到底是多少。
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这⾥x会是多少呢?
}
}
}
Java版本不同答案也是不一样的,低于java1.5版本,可能是42也可能是0,因为x=42可能会被CPU缓存,所以会出现0,但是java1.5之后修复了这个Bug,所以是42。
修改的方法是增强了volatile的语义,增强的方法是依据Happens-Before规则。
Happens-Before 规则
字面意思来说就是先行发生的意思。但是Happens-Before真正的意思是:前面一个操作的结果对后续操作是可见的。用比较正式的说法来说,Happens-Before虽然约束了编译器的优化,但是又允许编译器优化,只需要保证Happens-Before规则即可。
下面来说一下Happens-Before的六项规则:
- 程序的顺序性规则
这条规则是指在一个线程中,按照程序顺序,前面的操作Happens-Before于后续的任意操作。比如x=42 Happens-Before于v=true 并且前面的代码修改之后对于后面的代码是可见的。
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这⾥x会是多少呢?
}
}
}
- volatile变量规则
是指对一个volatile变量的写操作,Happens-Before于后续对这个volatile变量的读操作。
如果单独看这个规则,是禁用缓存、强制写入主存的意思,但是如果关联一下规则3,就有点不一样的感觉了。。。
- 传递性
这条规则是指如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。
从图中,可以看到:
(1)、x=42 Happens-Before写变量 v=true,这是规则1的内容。
(2)、写变量v=true Happens-Before读变量v=true,这是规则2的内容。。
根据传递性规则,得到结果:“x=42” Happens-Before 读变量“v=true”。这意味着什么呢?
如果线程B读到了“v=true”,那么线程A设置的“x=42”对线程B是可见的。也就是说,线程B能看到“x==42”,这就是1.5版本对volatile语义的增强。。。
- 管程中的规则
这条规则是指对一个锁的Happens-Before于后续对这个锁的加锁。。
管程:一种通用的同步原语,在Java中指的就是synchronized,synchronized是Java里对管程的实现。。
管程中的锁在Java里是隐式实现的,例如下面的代码,在进入同步块之前会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。。
synchronized (this) { //此处⾃动加锁
// x是共享变量,初始值=10
if (this.x < 12) {
this.x = 12;
}
} //此处⾃动解锁
假设x的初始值是10,线程A执行完代码块后x的值会变成12,线程B进入代码块时,能够看到线程A对x的写操作,也就是线程B能够看到x==12。
- 线程start()规则
是指主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。。。
换句话说就是,如果线程A调用线程B的start()方法(即在线程A中启动线程B),那么该start()操作
Happens-Before 于线程B中的任意操作。
Thread B = new Thread(()->{
// 主线程调⽤B.start()之前
// 所有对共享变量的修改,此处皆可⻅
// 此例中,var==77
});
// 此处对共享变量var修改
var = 77;
// 主线程启动⼦线程
B.start();
- 线程join()规则
意思就是等待某个线程的任务结束之后才能继续下一个线程的任务。
换句话说就是,如果在线程A中,调用线程B的 join() 并成功返回,那么线程B中的任意操作Happens-Before 于该 join()操作的返回。
Thread B = new Thread(()->{
// 此处对共享变量var修改
var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程B可⻅
// 主线程启动⼦线程
B.start();
B.join()
// ⼦线程所有对共享变量的修改
// 在主线程调⽤B.join()之后皆可⻅
// 此例中,var==66
- 线程interrupt()原则
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否发生中断。
- 线程finalize()原则
一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
Final
前面我们讲volatile为的是禁用缓存以及编译优化,我们再从另外一个方面来看,有没有办法告诉编译器优化得更好一点呢?这个可以有,就是final关键字。
final修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。
当然了,在1.5以后Java内存模型对final类型变量的重排进行了约束。现在只要我们提供正确构造函数没 有“逸出”,就不会出问题了。
“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将this赋值给了全局变量
global.obj,这就是“逸出”,线程通过global.obj读取x是有可能读到0的。因此我们一定要避免“逸出”。
final int x;
// 错误的构造函数
public FinalFieldExample() {
x = 3;
y = 4;
// 此处就是讲this逸出,
global.obj = this;
}
Java内存模型底层实现
通过内存屏障(memory barrier)禁止重排序的,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的CPU指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于处理器而言,内存屏障将会导致缓存的刷新操作。比如,对于volatile,编译器将在volatile字段的读写操作前后各插入一些内存屏障。