天天看点

JVM系列(4)——内存模型

文章目录

    • 4 内存模型
      • 4.1 经典用例
      • 4.2 内存模型的官方描述
      • 4.3 program order
        • 4.3.1 一些概念
        • 4.3.2 几个例子
      • 4.4 synchronization order
      • 4.5 happens-before order
      • 4.6 Java 内存模型
        • 4.6.1 过于严格的模型
        • 4.6.2 过于宽松的模型
        • 4.6.3 Java 内存模型
      • 4.7 因果关系
        • 4.7.1 例一
        • 4.7.2 例二
        • 4.7.3 例三
        • 4.7.4 例四
        • 4.7.5 例五

4 内存模型

主要参考:

  • 《JSR-133:Java内存模型与线程规范》;
  • 《Java Language Specification 16》的 17.4 章节 Memory Model。

首先澄清运行时数据区和内存模型,这俩是完全不同的东西:

  • 运行时数据区:规定了 JVM 在使用内存时,应该将其分为几个部分,每个部分分别存储什么数据,有什么特性;
  • 内存模型:规定了在多线程场景下,程序以怎样的顺序执行是合法的。

4.1 经典用例

例一:

//共享变量
x = 0, y = 0;
//线程1执行
int r1 = x;
y = 1;
//线程2执行
int r2 = y;
x = 2;
           

如果编译器或 JVM 把 x = 2 或 y = 1 放到前面去执行,就可能观察到:r1 == 2,r2 == 1,这是否合法?

例二:

//共享变量
p = q;
p.x = 0;
//线程1
r1 = p;
r2 = r1.x;
r3 = q;
r4 = r3.x;
r5 = r1.x;
//线程2
r6 = p;
r6.x = 3;
           

编译器发现 r2 = r1.x,r5 = r1.x,于是直接把 r5 = r1.x 替换成 r5 = r2,这样效率更高,因为不用去内存里找某个对象的 x 变量,直接使用本地变量 r2。

但问题是,这样会观察到奇怪的结果:r2 == r5 == 0,r4 == 3,这是否合法?

这个问题不忙回答,是否合法不是随便说说的,需要有一个规范,这就是内存模型。

4.2 内存模型的官方描述

A memory model describes, given a program and an execution trace of that program, whether the execution trace is a legal execution of the program.
We do not need to concern ourselves with intra-thread actions (e.g., adding two local variables and storing the result in a third local variable). As previously mentioned, all threads need to obey the correct intra-thread semantics for Java programs.
Local variables (§14.4), formal method parameters (§8.4.1), and exception handler parameters (§14.20) are never shared between threads and are unaffected by the memory model.

可见,与 memory model (内存模型)并列的概念是 intra-thread semantics(线程内语义)。

当然,也可以理解为,memory model 在逻辑上包含了 intra-thread semantics,只是由于 intra-thread semantics 已经对线程内的程序顺序做了足够的约束,所以 memory model 对这部分不需要过分关注。

Java 程序的执行必须满足两个规范:

  • 线程内语义:决定线程孤立地执行过程;
  • 内存模型:决定线程从堆中取值时,能看到什么值。

这两个规范限制的不仅仅是 JVM(尤其是 JIT),同样也限制编译器。也就是说,如果编译器(特指前期编译,如 javac)或者 JIT 对程序进行了指令重排或者优化,并且不符合以上两个规范,那就是不合法的。

4.3 program order

Among all the inter-thread actions performed by each thread t, the program order of t is a total order that reflects the order in which these actions would be performed according to the intra-thread semantics of t.

简单理解,程序顺序就是指符合线程内语义的顺序。

4.3.1 一些概念

顺序一致性:

  • 所有动作(比如读、写、加锁等)的执行顺序与程序顺序一致;
  • 每个动作都是原子的;
  • 每个动作执行后立刻对所有线程可见。

访问冲突(Conflicting Accesses):对同一个共享内存存在多个访问,并且至少有一个是写操作。

数据争用(Data Race):存在访问冲突,并且没有通过正确的同步方式(比如加锁、volatile 等)对它们的执行顺序进行排序。

关于顺序一致、数据争用与同步:

  • 一个程序,当且仅当所有顺序一致的执行过程都不存在数据争用,这个程序是正确同步的;
  • 如果一个程序是正确同步的,那么它唯一允许的行为是顺序一致的行为。

4.3.2 几个例子

例一:

//假设有共享变量:
public static int x = 1;
public static int y = 2;

//线程一执行:
public void thread1() {
    x = 0;
}

//线程二执行:
public void thread2() {
    y = 3;
}
           

这里不存在访问冲突,因为两个线程访问的是不同的变量。没有访问冲突,就更谈不上数据争用,自然也不需要同步。

例二:

public static int x = 0;
public static int y = 0;

public void thread1() {
    int r2 = x;
    y = 1;
}

public void thread2() {
    int r1 = y;
    x = 2;
}
           

这里存在对共享变量 x 和 y 的访问冲突。其次,这四个语句的执行顺序随便怎样都可以,假设线程一先执行,线程二也仍然无法确定 r1 究竟会读到 0 还是 1,所以这里存在数据争用,也就是没有正确同步。

例三:

public static int x = 0;
public static volatile int y = 0;

public void thread1() {
    int r2 = x;
    y = 1;
}

public void thread2() {
    int r1 = y;
    x = 2;
}
           

这个例子,由于共享变量 y 加了 volatile 关键字,如果 y = 1 先于 r1 = y 执行,那么 r2 = x 也一定先于 x = 2,这个执行过程不存在数据争用。

但是,如果 r1 = y 先于 y = 1 执行,这种情况下对共享变量 x 的访问仍然不存在明确的先后顺序,因此仍然存在数据争用,所以这个程序也没有正确同步。

例四:

public static int x = 0;
public static int y = 0;

public synchronized void thread1() {
    int r2 = x;
    y = 1;
}

public synchronized void thread2() {
    int r1 = y;
    x = 2;
}
           

这个例子,两个线程都通过 synchronized 做了同步。如果线程一先执行,那么一定是线程一对 x 和 y 变量都访问完了之后,线程二才会访问 x 和 y,所以这种情况不存在数据争用;如果线程二先执行,也是一样的结果。

这个程序只有这两种执行顺序,并且以这两种顺序执行过程中都不存在数据争用,因此是正确同步的。

例五:

public static int x = 0;

public void thread1() {
    x = 1;
    x = 2;
}
           

这个例子虽然是单线程,但对共享变量 x 的访问也是存在访问冲突的。但这里不可能有数据竞争,因为根据线程内语义,这里必须是 x = 1 执行完之后才能执行 x = 2,执行顺序是明确的。

这里编译器或许有可能把 x = 1 直接丢掉,因为编译器可以推断出 x = 1 没有任何意义。当然这只是猜测,也可能存在其它限制导致编译器不能这么做。

4.4 synchronization order

A synchronization order is a total order over all of the synchronization actions of an execution.

可以与程序顺序放在一起理解:

程序顺序:所有动作的一个全序关系。

同步顺序:所有同步动作的一个全序关系。

虽说同步顺序是所有同步动作的一个全序关系,但如果一个动作对 m 加锁,另一个动作对 n 加锁,这两个动作谁先谁后都是无所谓的;就像在程序顺序中,如果一个动作写入变量 u,一个动作读取变量 v,这两个动作虽然也存在一个顺序问题,但谁先谁后都无所谓。

这里的重点是 Java 语言规范定义的几个 synchronizes-with 关系:

  • 管程 m 上的解锁动作 synchronizes-with 后续在 m 上的所有加锁动作;
  • 对 volatile 变量 v 的写操作 synchronizes-with 后续任意线程对 v 的所有读操作;
  • 启动一个线程的动作 synchronizes-with 此新启动的线程中的第一个动作;
  • 线程 t1 的最后一个动作 synchronizes-with 线程 t2 中任一用于探测 t1 是否终止的动作(比如 t1.isAlive(),t1.join());
  • 如果线程 t1 中断线程 t2,t1 的中断操作 synchronizes-with 任意时刻任何其它线程(包括 t2)用于确定 t2 是否被中断的操作;
  • 为每个变量写默认值(比如 0,false,null)的动作 synchronizes-with 每个线程中的第一个动作(意思是在对象被分配之前,对象里的变量的默认值就已经写好了)。

4.5 happens-before order

happens-bofore 不能按字面意思理解为“在…之前发生”。实际上,在其之后发生是完全可以的。

A happens-bofore B 应该理解为,A 动作看起来对 B 动作可见。并不是 A 一定要对 B 可见,只要看起来 A 对 B 可见就可以了。意思是,如果 B 想去看一下 A 到底发生了没有,那么 A 必须已经发生了;如果 B 不想去看(不关心)A 到底发生了没有,那么 A 发生或未发生都行,因为看起来都一样。

举个例子:

x = 1; //A
y = x + 1; //B
           

这个例子中,A happens-before B。B 动作在发生的时候,它是要看 A 动作的结果的,换言之,B 对于 A 到底发生没有是关心(依赖)的,这关系到 B 能否得到正确的结果。所以 A 必须已经发生并且对 B 可见。

另一个例子:

x = 1; //A
y = 2; //B
           

这个例子中,仍然有 A happens-before B。不同的是,B 动作在发生的时候,它并不需要去看 A 动作的结果,所以 A 是否已经发生都无关紧要。这种情况下,虽然 A happens-before B,但 A 在 B 之后发生也是完全可以的。

Java 语言规范中定义了以下几种 happens-before 关系:

  • 如果在同一个线程中,A 动作在程序顺序上排在 B 动作之前,那么 A happens-before B;
  • 构造方法的结束 happens-before finalize 方法的开始;
  • 如果 A synchronizes-with B,那么 A happens-before B;
  • 如果 A happens-before B,B happens-before C,那么 A happens-before C。

4.6 Java 内存模型

4.6.1 过于严格的模型

指的是顺序一致模型,也就是前面提到的顺序一致性。它要求所有动作都是原子性的,并且要按照程序顺序执行,任何一个动作的执行结果必须立刻对其它所有线程可见。

其实也就相当于所有对象的所有字段全都是 volatile 的,性能损耗比较大。

前面的一个例子:

//共享变量
p = q;
p.x = 0;
//线程1
r1 = p;
r2 = r1.x;
r3 = q;
r4 = r3.x;
r5 = r1.x;
//线程2
r6 = p;
r6.x = 3;
           

如果按照顺序一致模型,r5 = r1.x 替换成 r5 = r2 就是不合法的。因为如果做这样的替换,r5 读取的是本地变量的值,如果内存中 x 变量的值发生了变化,它是感知不到的,这不符合“动作的执行结果要立刻对其它所有线程可见”。

4.6.2 过于宽松的模型

指的是 happens-before 模型,即前面提到的那些 happens-before 关系。

如果只依靠 happens-before 模型,有时候会出现不符合逻辑的结果。

比如:

//共享变量
x = 0, y = 0;
//线程1
r1 = x;
if (r1 != 0)
    y = 1;
//线程2
r2 = y;
if (r2 != 0)
    x = 1;
           

这里不存在任何有数据依赖的 happens-before 关系,所以线程 1 的 r1 = x 可能会看见线程 2 写入的 x = 1,happens-before 模型不禁止这种事情发生。

但实际上,这个程序不存在数据争用,因为 x = 1,y = 1 不可能被执行。r1 == r2 == 0 是唯一合法的行为,如果按照 happens-before 模型,执行结果可能出错。

归根结底,happens-before 没有对因果关系做任何限制。

比如上面这个例子,只有 x = 1 发生,才能导致 x = 1 发生。因为只有 x = 1 发生了,r1 = x 才能读到 1,才能导致 y = 1 发生,才能导致 r2 = y 能读到 1,才能导致 x = 1 发生。x = 1 既是因又是果,对于这种存在因果关系的情况,happens-before 无能为力。

4.6.3 Java 内存模型

鉴于:

  • 顺序一致模型,过于严格,严重影响 Java 程序性能;
  • happens-before 模型,过于宽松,可能会允许一些不合法的行为发生。

Java 内存模型包含两大块:

  • happens-before;
  • 因果关系。

4.7 因果关系

因果关系是内存模型中比较难以分析的部分,这里列举 JSR-133 里面的几个关于因果关系的例子。

4.7.1 例一

//共享变量
x = 0;
//线程1
r1 = x;
x = 1;
//线程2
r2 = x;
x = 2;
           

r1 == 1 或 r2 == 2 是不合法的,违背了 happens-before 原则。

r1 == 2 且 r2 == 1 是合法的,因为不存在跨线程的 happens-before 原则,所以 x = 1 对 r2 = x 可见,x = 2 也对 r1 = x 可见。

注意,happens-before 原则要求的是可见性,而不是谁先发生。所以上面的例子中,x = 1 和 x = 2 可以先于 r1 = x 和 r2 = x 发生,只要 x =1 不被 r1 = x 看见,x = 2 不被 r2 = x 看见即可。虽然感觉有点奇怪,但如果真有 JVM 这么实现,原则上不能说它错。

4.7.2 例二

//共享变量
x = 0, y = 1;
//线程1
r1 = x;
r2 = x;
if (r1 == r2)
    y = 2;
//线程2
r3 = y;
x = r3;
           

r1 == r2 == r3 == 2 是合法的。

y = 2 对 r3 = y 是可见的,因为不存在 happens-before 关系限制。于是 x = r3 会读到 2。x = r3 对 r1 = x 和 r2 = x 也是可见的,所以 r1 = 2,r2 = 2 也不违反 happens-before 原则。

再看因果关系,只有 y = 2 才能导致 r3 = 2,x = 2,才能导致 r1 = 2,r2 = 2,但在这里,因果关系断了,y = 2 并不依赖于 r1 = 2,r2 = 2。由于 r1 = x,r2 = x,可以推知 r1 == r2 恒成立,所以这个 if 语句可以去掉,y = 2 一定会发生,不依赖任何条件,所以 r1 == r2 == r3 == 2 也不违反因果关系。

4.7.3 例三

//共享变量
x = 0, y = 0;
//线程1
r1 = x;
if (r1 == 1)
    y = 1;
//线程2
r2 = y;
if (r2 == 1)
    x = 1;
else
    x = 1;
           

r1 == r2 == 1 是合法的。

编译器能够推断这个 if…else 实际上可以去掉,x = 1 不依赖于任何条件,一定会发生,所以不违反因果关系。

4.7.4 例四

//共享变量
x = 0, y = 0, z = 0;
//线程1
r3 = x;
if (r3 == 0)
    x = 42;
r1 = x;
y = r1;
//线程2
r2 = y;
x = r2;
           

r1 == r2 == r3 == 42 是合法的。

分析这个程序,如果 r2 == 42,那么它必然已经看见了 y = r1 的结果,也就是写入 r1 = 42,这又依赖于 x = 42,而 x = 42 又依赖于 r3 == 0,这就矛盾了。

或者换个思路分析,如果 r3 == 42,那么必然是 r3 = x 读取到了 x = 42 的写入,但只有 r3 == 0 时 x 才会写入 42。

看上去违反了因果关系,其实不然。

编译器可以推断,x 要么是初始值 0,要么是 42,不存在其它值写入。

那么分两种情况讨论:

  • 如果 x 是 0,那么走 if 给 x 写入 42,然后 r1 = 42;
  • 如果 x 是 42,那么 r1 = 42;

所以,不管怎样,r1 = x 结果都是 42。既然如此,r1 = x 直接替换为 r1 = 42,进而 y = r1 也可以替换成 y = 42。这样就打破了因果关系,使得 r1 == r2 == r3 == 42 成了合法的结果。

4.7.5 例五

JSR-133 列举了两个相似的程序。

//共享变量
x = 0, y = 0, z = 0;
//线程1
r1 = x;
y = r1;
//线程2
r2 = y;
x = r2;
//线程3
z = 42;
//线程4
r0 = z;
x = r0;
           

r0 == 0,r1 == r2 == 42 是不合法的。

JSR-133 没有解释得很清楚。个人理解,虽然不存在跨线程的 happens-before 关系,但 r1 == r2 == 42 依赖于 x = r0 写入 42,而 r0 = z 和 x = r0 是存在 happens-before 关系的,所以如果 x = r0 写入了 42,r0 = z 必须读取的是 42,也就不可能 r0 == 0。

//共享变量
x = 0, y = 0, z = 0;
//线程1
r1 = x;
if (r1 != 0)
    y = r1;
//线程2
r2 = y;
if (r2 != 0)
    x = r2;
//线程3
z = 1;
//线程4
r0 = z;
if (r0 == 1)
    x = 42;
           

r0 == 0,r1 == r2 == 42 也是不合法的。

这个程序与上面一个有一点区别,如果只考虑 happens-before 原则,上面的结果是合法的。

由于各线程之间不存在 happens-before 关系,x = 42 对线程 1,2,3 都是允许可见的,所以如果 JVM 先把 x 写入 42,只要保证这个写入不对线程 4 可见,这样 r1 = x 就能读到 42,再写入 y,r2 也能读到 42。

如果 x = r0 写入了 42,r0 = z 必须读取的是 42,也就不可能 r0 == 0。

//共享变量
x = 0, y = 0, z = 0;
//线程1
r1 = x;
if (r1 != 0)
    y = r1;
//线程2
r2 = y;
if (r2 != 0)
    x = r2;
//线程3
z = 1;
//线程4
r0 = z;
if (r0 == 1)
    x = 42;
           

r0 == 0,r1 == r2 == 42 也是不合法的。

这个程序与上面一个有一点区别,如果只考虑 happens-before 原则,上面的结果是合法的。

由于各线程之间不存在 happens-before 关系,x = 42 对线程 1,2,3 都是允许可见的,所以如果 JVM 先把 x 写入 42,只要保证这个写入不对线程 4 可见,这样 r1 = x 就能读到 42,再写入 y,r2 也能读到 42。

但 x 能写入 42 依赖于 r0 == 1,所以上面的结果不符合因果关系。