Java内存模型有什么用?

JVM规范定义Java内存模型主要是为了屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各个平台下都能达到一致的内存访问效果。

在此之前,主流程序语言如C/C++直接使用物理硬件和操作系统的内存模型,因此,会由于不同内存模型的差异,有可能导致在平台A能运行,平台B却不能运行的情况。

什么是内存模型

在多CPU的系统中,每个CPU都有多级缓存,一般分为L1、L2、L3缓存,因为这些缓存的存在,提供了数据的访问性能,也减轻了数据总线上数据传输的压力,同时也带来了很多新的挑战,比如两个CPU同时去操作同一个内存地址,会发生什么?在什么条件下,它们可以看到相同的结果?这些都是需要解决的。

所以在CPU的层面,内存模型定义了一个充分必要条件,保证其他CPU的写入动作对该CPU是可见的,而且该CPU的写入动作对其他CPU也是可见的,那这种可见性,应该如何实现呢?

有些处理器提供了强内存模型,所有CPU在任何时候都能看到内存中任意位置相同的值,这种完全是硬件提供的支持。

其他处理器,提供了弱内存模型,需要执行一些特殊指令(就是经常看到货听到的,Memory Barries内存屏障),刷新CPU缓存的数据到内存中,保证这个写操作能够被其他CPU可见,或者将CPU缓存的数据设置为无效状态,保证其他CPU的写操作对本CPU可见。通过这些内存屏障的行为由底层实现,对于上层语言的程序员来说是透明的。

前面说到的内存屏障,除了实现CPU之外的数据可见性之外,还有一个重要的职责,可以禁止指令的重排序。

这里说的重排序可以发生在好几个地方:编译器、运行时、JIT等,比如编译器会觉得把一个变量的写操作放在最后更有效率,编译后,这个指令就是在最后了(前提是只要不改变程序的语义,编译器、执行器就可以这样自由的随意优化),一旦编译器对某个变量的写操作进行优化(放到最后),那么执行之前,另一个线程将不会看到这个执行结果。

当然了,写入动作可能被移到后面,那么也有可能挪到前面,这样的优化有什么影响呢?这种情况下,其他线程可能会在程序实现“发生”之前,看到这个写入动作(这里怎么理解,指令已经执行了,但是在代码层面还没执行到),通过内存屏障的功能,我们可以禁止一些不必要、或者会带来负面影响的重排序优化,在内存模型的范围内,实现更高性能,同时保证程序的正确性。

下面看一个重排序的例子:

1
2
3
4
5
6
7
8
9
10
11
12
Class Reordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}

public void reader() {
int r1 = y;
int r2 = x;
}
}

假设这段代码有2个线程并发执行,线程A执行writer方法,线程B执行reader方法,线程B看到y的值为2,因为把y设置成2发生在变量x的写入之后,所以能断定线程B这时看到的x就是1吗?

在Java内存模型中,描述了在多线程代码中,哪些行为是正确的、合法的、以及多线程之间如何进行通信,代码中变量的读写行为如何反应到内存、CPU缓存的底层细节。

在Java中包含了几个关键字:volatile、final和synchronized,帮助程序员把代码中的并发需求描述给编译器,Java内存模型中定义了他们的行为,确保正确同步的Java代码在所有的处理器上都能正确执行。

synchronization可以实现什么

Synchronizeation有多中语义,其中最容易理解的是互斥,对于一个monitor对象,只能够被一个线程持有,意味着一旦有线程进入了同步代码块,那么其他线程就不能进入直到第一个进入的线程退出代码块。

但是更多的时候,使用synchronization并非单单互斥功能,synchronization保证了线程在同步块之前或者期间写入动作,对于后续进入该代码块的线程是可见的(又是可见性,不过这里需要注意的是对同一个monitor对象而言)。在一个线程退出同步块时,线程释放monitor对象,它的作用是把CPU缓存数据(本地缓存数据)刷新到主内存中,从而实现该线程的行为可以被其他线程看到。在其他线程进入到该代码块时,需要获得monitor对象,它的作用是使CPU缓存失效,从而使变量从主内存中重新加载,然后就可以看到之前线程对该变量的修改。

但从缓存的角度看,似乎这个问题只会影响多处理器的机器,对于单核来说没什么问题,但是别忘了,它还有一个语义是禁止指令的重排序,对于编译器来说,同步块中的代码不会移动到获取和释放monitor外面。

下面这种代码,千万别写

1
synchronized(new Object()) 「×」

编译器完全可以删除这个同步语义,因为编译器知道没有其他线程会在同一个monitor对象上同步。

所以,请注意:对于两个线程来说,在相同的monitor对象上同步是很重要的,以便正确的设置happens-before关系。

final可以影响什么

如果一个类包含final字段,且在构造器中初始化,那么正确的构造一个对象后,final字段被设置后对于其他线程是可见的。

这里所说的正确构造对象,意思是在对象的构造过程中,不允许对该对象进行引用,不然的话,可能存在其他线程在对象还没构造完成时就对该对象进行访问,造成不必要的麻烦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}

static void writer() {
f = new FinalFieldExample();
}

static void reader() {
if (f != null) {
int i = f.x;
int j = f.y;
}
}
}

上面这个例子描述了应该如何使用final字段,一个线程A执行reader方法,如果f已经在线程B初始化好了,那么可以确保线程A看到x值是3,因为final它是final修饰的,而不能确保看到y的值是4.

volatile可以做什么

volatile字段主要用于线程之间进行通信,volatile字段的每次读行为都能看到其他线程最后一次对该字段的写行为,通过它就可以避免拿到缓存中陈旧数据。它们必须保证在被写入之后,会被刷新到主内存中,这样就可以立即对其他线程可以见。类似的,在读取volatile字段之前,缓存必须是无效的,以保证每次拿到的都是主内存的值,都是最新的值。volatile的内存语义和synchronized获取和释放monitor的实现目的差不多。

对于重排序,volatile也有额外的限制。

下面看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}

public void reader() {
if (v == true) {
//uses x - guaranteed to see 42.
}
}
}

同样的,假设一个线程A执行writer,另一个线程B执行reader,writer中对变量v的写入把x的写入也刷新到主内存中。reader方法会从主内存重新获取v的值,所以如果线程B看到v的值为true,就能保证拿到的x是42.(因为把x设置成42发生在把v设置成true之前,volatile禁止这两个写入行为的重排序)

如果变量v不是volatile,那么以上描述就不成立了,因为执行顺序可能是v=true,x=42,或者对于线程B来说,根本看不到v被设置为true。

double-checked locking的问题

臭名昭著的双重检查(单例模式的一种),是一种延时初始化的实现技巧,避免了同步的开销,因为在早起的JVM,同步操作性能很差,所以才出现了这样的小技巧。

1
2
3
4
5
6
7
8
9
10
11
private static Something instance = null;

public Something getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null)
instance = new Something();
}
}
return instance;
}

这个技巧看起来很聪明,避免了同步的开销,但是有一个问题,它可能不起作用,为什么呢?因为实例的初始化和实例字段的写入可能被编译器重排序,这样可能读到一个未初始化完成的对象。

当然,这种bug可以通过使用volatile修饰instance字段进行修复,但是我觉得这种代码格式实在太丑陋了,如果真要延时初始化,不妨使用下面这种方式:

1
2
3
4
5
6
7
private static class LazySomethingHolder {
public static Something something = new Something();
}

public static Something getInstance() {
return LazySomethingHolder.something;
}

由于是静态字段的初始化,可以确保对访问该类的所有线程都是可见的。