JMM (Java 内存模型)
作用
内存模型描述给定程序和该程序的执行跟踪,该执行跟踪是否为程序的合法执行。
Java编程语言内存模型通过检查执行跟踪中的每个读取并检查读取所观察到的写入是否根据某些规则有效来工作。
内存模型描述程序的可能行为。一个实现可以自由地生成它喜欢的任何代码,只要所有的结果执行都会产生一个可以由内存模型预测的结果。
这为实现人员执行大量代码转换提供了很大的自由,包括操作的重新排序和删除不必要的同步。
案例
意外的结果
Java编程语言的语义允许编译器和微处理器执行优化,这些优化可以与不正确的同步代码交互,从而产生看似矛盾的行为。这里有一些不正确的同步程序如何显示令人惊讶的行为的例子。
valid compiler transformation
例如,考虑 Table 17.4-A 所示的示例程序跟踪。本程序使用局部变量 r1 和 r2 以及共享变量 A 和 B。
初始化:
A = 0;
B = 0;
Table 17.4-A
由语句重新排序引起的令人惊讶的结果——原始代码
Thread 1 | Thread 2 |
1: r2 = A; | 3: r1 = B; |
2: B = 1; | 4: A = 2; |
似乎 r2 == 2
和 r1 == 1
的结果是不可能的。
直觉上,指令1或指令3应该在执行中首先出现。如果指令1首先出现,它不应该能够看到指令4的写操作。如果指令3是第一,它不应该能看到指令2的写。
如果某个行刑者表现出这种行为,那么我们就知道,指令4先于指令1,指令2之前,指令3之前,指令4之前。从表面上看,这是荒谬的。
但是,当这并不影响隔离线程的执行时,编译器可以在任何一个线程中重新排序指令。
如果指令1与指令2重新排序,如表17.4-B中所示,那么很容易看出结果 r2 == 2
和 r1 == 1
可能会发生。
Table 17.4-B
由语句重新排序引起的令人惊讶的结果——有效的编译器转换
Thread 1 | Thread 2 |
B = 1; | r1 = B; |
r2 = A; | A = 2; |
对一些程序员来说,这种行为可能看起来“坏了”。但是,应该注意的是,该代码不正确地同步:
-
有一个写在一个线程,
-
由另一个线程读取同一个变量,
-
读写不是同步排序的。
这种情况的一个例子数据竞赛(§17.4.5)。当代码包含数据竞争时,通常会出现违反直觉的结果。
Table 17.4-B 中有几种机制可以产生重新排序。
Java虚拟机实现中的即时编译器可以重新排列代码或处理器。
此外,运行Java虚拟机实现的体系结构的内存层次结构可能使它看起来像是代码正在被重新排序。
在本章中,我们将提及任何可以将代码重新排序为编译器的东西。
Table 17.4-C 是另一个令人惊讶的结果。
一开始,p == q
且 p.x == 0
。
这个程序同步错误;它向共享内存写入,而不强制在这些写入之间执行任何排序。
forward substitution
Table 17.4-C
前代换(forward substitution)所带来的惊人结果
Thread 1 | Thread 2 |
r1 = p; | r6 = p; |
r2 = r1.x; | r6.x = 3; |
r3 = q; | |
r4 = r3.x; | |
r5 = r1.x; |
一种常见的编译器优化包括让r2的值被r5重用:它们都是r1的读取。
没有插入的写。这种情况见 Table 17.4-D。
Table 17.4-D
前代换(forward substitution)
Thread 1 | Thread 2 |
r1 = p; | r6 = p; |
r2 = r1.x; | r6.x = 3; |
r3 = q; | |
r4 = r3.x; | |
r5 = r2; |
当 Thread 2 给 r6.x
赋值的操作介于第一次读取 Thread 1 中的 r1.x
和 r3.x
之间。
如果编译器决定为r5重用r2的值,那么r2和r5的值为0,r4的值为3。
从程序员的角度来看,值存储在 p.x
从0变化到3,然后又变回来。
内存模型
内存模型确定在程序的每个点上可以读取哪些值。
隔离中的每个线程的操作必须按照该线程的语义来运行,但是每个读取所看到的值都由内存模型决定。
当我们提到这个时,我们说程序遵循线程内语义。线程内语义是单线程程序的语义,它允许根据线程中读操作看到的值对线程的行为进行完整的预测。
为了确定线程t在执行中的操作是否合法,我们只需评估线程t的实现,就像在单线程上下文中执行的那样,在本规范的其余部分中定义的那样。
每次线程t的求值生成一个线程间动作时,它必须匹配下一个按程序顺序出现的线程间动作a (t)。
如果a是一个读取,那么对t的进一步评估将使用a所看到的由内存模型决定的值。
本节提供Java编程语言的规范处理final字段内存模型除了问题,§17.5中描述。
此处指定的内存模型并不是基于Java编程语言的面向对象的本质。
为了简化示例,我们经常展示没有类或方法定义或显式取消引用的代码片段。
大多数示例由两个或多个线程组成,这些线程包含访问本地变量、共享全局变量或对象实例字段的语句。
我们通常使用变量名(如r1或r2)来表示方法或线程本地的变量。其他线程无法访问这些变量。
Shared Variables
可以在线程之间共享的内存称为共享内存或堆内存。
所有实例字段、静态字段和数组元素都存储在堆内存中。
在本章中,我们使用术语变量来指代字段和数组元素。
本地变量(§14.4),正式的方法参数(§8.4.1)和异常处理程序参数(§14.20)从来都不是线程间共享内存模型的影响。
如果至少有一个访问是写的,那么对同一变量的两个访问(读或写)就被称为冲突(conflicting)。
Actions
线程间操作是由一个线程执行的操作,该操作可以被另一个线程检测或直接影响。
线程间的操作
程序可以执行几种类型的线程间操作:
非易失性: non-volatile
-
读(正常,或非易失性)。阅读一个变量。
-
写(正常,或非易失性)。写一个变量。
-
同步行动, 它是:
-
Volatile 读。变量的不稳定读数。
-
Volatile 写。变量的不稳定写。
-
锁。锁定一个监视器
-
解锁。打开监视器。
-
线程的第一个和最后一个的(合成)动作。
-
开始一个线程或检测到一个线程终止(§17.4.4)。
-
外部行为。外部动作是在执行之外可以观察到的动作,并基于执行的外部环境产生结果。
-
线程分歧的行动(§17.4.9)。线程发散操作仅由处于无限循环中的线程执行,在无限循环中不执行内存、同步或外部操作。 如果一个线程执行一个线程散度操作,那么它之后会有无数个线程散度操作。
引入了线程发散(thread divergence)操作,以建模一个线程如何导致所有其他线程停止并无法取得进展。
该规范只涉及线程间的操作。我们不需要关注线程内操作(例如,添加两个局部变量并将结果存储在第三个局部变量中)。 正如前面提到的,所有线程都需要遵循Java程序的正确的内部线程语义。
我们通常更简洁地将线程间的操作称为简单的操作。
动作
动作 a 由一个 tuple<t,k,v,u>
描述,包括:
-
t - 线程表现的行为
-
k - 线程的类型
-
v - 活动中涉及的变量(variable)或监视器(monitor)。
对于锁动作,v是被锁的监视器;对于解锁动作,v是正在解锁的监视器。
如果操作是 a (volatile 或 non-volatile)读取,则v是要读取的变量。
如果操作是 a (volatile 或 non-volatile)写,则v是正在写的变量。
- u - 操作的任意唯一标识符
外部操作元组包含一个附加组件,该组件包含执行操作的线程感知到的外部操作的结果。
这可能是关于操作的成功或失败的信息,以及操作所读取的任何值。
外部操作的参数(例如,将哪个字节写入哪个套接字中)不是外部操作元组的一部分。
这些参数由线程内的其他操作设置,可以通过检查线程内部语义来确定。在内存模型中没有显式地讨论它们。
在非终止执行中,并不是所有的外部操作都是可见的。终止死刑和可以观察到的行为进行§17.4.9。
Programs and Program Order
在每个线程t执行的所有线程间操作中,t 的程序顺序是一个总顺序,它反映了这些操作按照t的线程内语义执行的顺序。
如果所有操作都以与程序顺序一致的总顺序(执行顺序)发生,则一组操作是顺序一致的。此外,每个读取变量v的r都看到写入w到v的值,以便:
-
w在执行顺序上先于r,并且
-
在执行顺序中,w’在w’之前,w’在r之前。
序列一致性是在程序执行过程中关于可见性和排序的强有力的保证。
在顺序一致的执行中,所有单个操作(如读和写)都有一个总顺序,该顺序与程序的顺序一致,并且每个单独的操作都是原子性的,并且每个线程都可以立即看到。
如果一个程序没有数据竞争,那么该程序的所有执行看起来都是顺序一致的。
连续一致性 and/or 不受数据竞争的限制仍然允许由需要原子感知的操作组引起的错误。
如果我们使用顺序一致性作为内存模型,我们讨论过的许多编译器和处理器优化都是非法的。
例如,在表17.4-C的跟踪中,只要写 3 到 p.x 发生x时,需要对该位置进行后续读取以查看该值。
Synchronization Order
每个执行都有一个同步顺序。
同步顺序是对执行的所有同步操作的总顺序。
每个线程t,同步的同步行动(§17.4.2)t是符合程序顺序(§17.4.3)的t。
同步动作诱发动作上的同步与关联,定义如下:
-
监视器 m 同步上的解锁动作—与 m 上的所有后续锁定动作(其中“后续(
subsequent
)”是根据同步顺序定义的)。 -
写入volatile变量v(§8.3.1.4)与v的所有后续读取任何线程(在“后续”的定义是根据同步顺序)。
-
启动线程同步的操作——与它启动的线程中的第一个操作一起。
-
将默认值 (zero, false, or null) 写入每个变量,与每个线程中的第一个操作同步。
-
虽然在分配包含变量的对象之前向变量写入默认值似乎有点奇怪,但从概念上来说,每个对象都是在程序开始时使用其默认初始化值创建的。
-
线程T1中的最后一个操作与另一个线程T2中检测到T1已终止的任何操作进行同步。
-
T2 可以通过调用
T1.isAlive()
或T1.join()
来实现这一点。 -
如果线程T1中断了线程T2,那么T1的中断将与任何其他线程(包括T2)确定T2已被中断的点进行同步 (通过抛出一个 InterruptedException 或者通过调用
Thread.interrupted
或Thread.isInterrupted
。
与edge同步的源称为发布,目标称为获取。