Java 内存模型
我们之前说过,导致可见性的的原因是缓存,导致有序性的原因是编译优化,那么如何解决这两个问题呢?当然,最简单暴力的方法就是禁用缓存和编译优化。但是这么做的话,我们为性能所做的努力就都白费了,肯定是行不通的。问题还是要解决的,我们可以按照我们的需要有选择性的禁用缓存和编译优化。那么,问题的关键是:如何禁用?这个时候我们需要 Java 内存模型来帮助我们。
Java 内存模型是个很复杂的规范。Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。这些方法包括:volatile、synchronized、final 三个关键字以及六项 Happens-Before 规则。
volatile 的困惑
volatile 关键字并不是 Java 特有的,古老的 C语言里也有,它最原始的意义就是禁用 CPU 缓存。比如:
volatile int x = 0;
上面这段代码的要表达的是:对 x 这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入。看起来很符合我们的需求,但是在 Java 中,1.5版本以后我们才可以放心的这么使用,看下面的代码:
public class VolatileDemo {
int x = 0;
volatile boolean v = false;
public static void main(String[] args) {
VolatileDemo volatileDemo = new VolatileDemo();
new Thread(new Runnable() {
@Override
public void run() {
volatileDemo.writer();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
volatileDemo.read();
}
}).start();
}
private void writer() {
v = true;
x = 2;
}
private void read() {
if (v) {
System.out.println("Volatile: x = " + x);
}
}
}
这段代码的运行结果输出,我们预期的是 x = 2;但是如果在 Java1.5 之前的版本,这个结果是不可预期的,因为有可能是 x = 0;为什么呢?因为在 1.5 版本,Java 内存模型对 volatile 的语义进行了增强,其中一项就是我们要说的 Happens-Before 规则。
Happens-Before 规则
什么是 Happens-Before 规则?简单来说就是:前面一个操作的结果对于后续的操作是可见的。Happens-Before 规则允许编译器的优化行为,但是要求编译优化后要遵守它的规则。它的规则有哪些?
- 程序的顺序性规则:
在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后面的操作
比如
x = 2;
v = true;
x = 2 Happens-Before 于 v = true;这个就是规则一的含义。这个比较符合单线程的思维:程序前面对某个变量的修改对于后续操作是可见的。
- volatile 变量规则
对一个 volatile 变量的写操作 Happens-Before 于后续对这个变量的读操作。
看起来就是禁用缓存的意思,我们和规则三联系起来看一下再看我们之前的例子。
- 传递性
如果 A Happens-Before 于 B,B Happens-Before 于 C,那么 A Happens-Before 于 C。
这个规则应用到我们之前的例子就是:
- 首先,x = 2 Happens-Before 于 v = true,这是规则一;
- v = true 写操作 Happens-Before 于读变量 v = true,这是规则二;
- 根据传递性,x = 2 Happens-Before 于读变量 v = true,这是规则三;
就是说第一个线程设置的 x = 2 对于第二个线程进行读操作是可见的。如图:
这就是 Java 1.5 版本对于 volatile 语义的增强,1.5版本的并发工具包(java.utile.concurrent)就是靠 volatile 语义来搞定可见性问题的。
- 管程中锁的规则
对一个锁的解锁 Happens-Before 于后续对于这个锁的加锁。
首先,管程是什么?管程是一种通用的同步原语,直接点,在 Java 中就是指synchronized。如下代码:
public class SynchroziedDemo {
int x = 10;
private void change() {
//此处自动加锁
synchronized (this) {
if (this.x < 12) {
x = 12;
}
}
//此处自动解锁
}
}
如上代码,我们可以这么理解,线程一执行完代码块后 x = 12;线程二进入代码块后,能看到线程一对于 x 的操作,即知道 x = 12。
- 线程 start() 规则
主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程之前的操作。
下面示例代码,线程 B 中的输出值应该是:x: 100;
int x = 10;
Thread B = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("x: " + x);
}
});
//此处对共享变量 x 的修改对 B 可见
x = 100;
B.start();
- 线程 join() 规则
主线程 A 等待线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能看到子线程对于共享变量的操作。
Thread B = new Thread(new Runnable() {
@Override
public void run() {
x = 111;//线程B对于共享变量的修改
}
});
B.start();
B.join();//线程B对于共享变量的修改在B.join()之后皆可见
System.out.println("x: " + x);// 此处输出:x: 111