内存模型
理解 Java 内存模型、缓存一致性与重排序等并发规则
简介
物理学定律限制了处理器速度的上限。多年来,提升处理器时钟频率变得越来越困难。因此,人们转而通过并行计算、并发处理以及代码优化等手段来提高程序的运行效率和性能。在这种背景下,如果开发者未能使用同步机制来告知平台某些数据是被多线程共享访问的,那么运行时或编译器所做的优化就可能导致并发问题。内存模型与同步机制共同决定了哪些优化是合法的。
从形式上讲,内存模型描述的是:给定一个程序及其某次执行的轨迹,该执行轨迹是否属于该程序的合法执行之一。
通俗地说,你可以将内存模型理解为一组规则,它决定了一个线程对变量的写操作何时能被其他线程看到。因此,在不违反内存模型所保证的写操作可见性的前提下,编译器、处理器或运行时系统可以为了优化和性能目的,对内存操作或程序语句进行重排序。然而,如果开发者对内存模型缺乏清晰理解,这种自由度在多线程程序中可能引发严重问题,导致出人意料的程序行为。理解内存模型有助于我们分析程序的行为,推断其所有可能的结果。
考虑以下在 main 线程中执行的代码片段。假设应用程序还创建了若干其他线程,这些线程会执行方法 runMethodForOtherThreads():
11. public class BadExample { 22. 33. int myVariable = 0; 44. boolean neverQuit = true; 55. 66. public void runMethodForMainThread() { 77. 88. // 将变量值改为幸运数字 7 99. myVariable = 7; 1010. } 1111. 1212. public void runMethodForOtherThreads() { 1313. 1414. while (neverQuit) { 1515. System.out.println("myVariable : " + myVariable); 1616. } 1717. } 1818. }
你可能会认为,一旦 main 线程执行了第 9 行的赋值操作,其他线程就会立即看到 myVariable 的值变为 7。但在现代计算机架构中,这个假设并不成立。其他线程可能立即看到该变量值的变化,也可能延迟看到,甚至根本看不到。造成这种情况的原因包括:
-
使用了复杂的多级内存缓存或处理器缓存,这些缓存可能与主内存不同步,即缺乏 缓存一致性
-
编译器对语句进行了重排序,使其顺序不同于源代码中的顺序
-
硬件、运行时或编译器应用的其他优化。
缓存一致性
维基百科将多处理器系统中具有本地缓存的情况下的缓存一致性定义为:共享资源的数据在各个本地缓存中保持一致的状态。设想一个系统中有两个处理器,它们共享主内存,同时各自维护独立的本地缓存。两个处理器都从主内存中缓存了同一个内存块。其中一个处理器修改了其本地缓存中的副本,但这一修改可能不会立即写回到主内存。这就导致两个本地缓存对该主内存块的内容产生了不同的视图。即使更改被立即传播到主内存,另一个处理器也可能不会很快更新自己的缓存。缓存一致性的目标正是缓解此类问题,确保所有内存子系统对共享数据具有一致的视图。
回到前面的例子程序,一种可能的情况是:变量的新值已被写入某个处理器的缓存,但尚未写入主内存。当另一个核心上的线程尝试从内存读取 myVariable 的值时,它仍然读到的是旧值 0。这就是违反了 缓存一致性 的表现。不同的处理器架构对于何时将本地缓存与主内存同步有着不同的策略。
JSR-133
Java 内存模型(JMM)最初于 1995 年提出,但普遍被认为存在问题。2004 年,通过 Java 规范请求 133(JSR-133)对其进行了修订和完善。我们不会深入探讨 JSR-133 的技术细节,但需要掌握足够的知识,以避免在编写 Java 并发代码时遇到意外或缺陷。
简而言之,内存模型描述了一个程序可能出现的行为或结果。它规定了在多个线程同时写入共享变量的情况下,一个线程何时能够观察到该变量的最新值。只要程序的所有执行结果符合内存模型的规则,具体的实现就可以自由地生成任何代码。这为平台提供了极大的灵活性,使其能够进行各种代码变换,包括操作重排序和消除不必要的同步。
线程内的“仿佛串行”语义
Java 语言规范(JLS)要求 Java 虚拟机(JVM)必须保持 线程内仿佛串行(within-thread as-if-serial) 的语义。这意味着,只要程序的最终执行结果与按照源代码顺序串行执行时完全相同,JVM 就可以自由地进行任何必要的优化。
让我们通过一个单线程程序来理解“线程内仿佛串行”的含义:
1 int x = 5; 2 int y = 10; 3 4 y*=y; 5 x*=x; 6 7 System.out.println(x); 8 System.out.println(y);
上述程序中六条语句的执行顺序称为 程序顺序。程序的功能很简单:计算两个变量 x 和 y 的平方并打印出来。JVM 实现可以将这些语句重新排序如下,同时仍然满足 线程内仿佛串行 的语义要求:
1 int x = 5; 2 x*=x; 3 4 int y = 10; 5 y*=y; 6 7 System.out.println(x); 8 System.out.println(y);
这种重排序是合法的。注意,程序输出始终先打印 x 再打印 y,只要保持这一输出顺序,JVM 就可以自由调整其他语句的位置或引入其他优化。当单个线程独立执行这些经过优化和重排的语句时,其结果与按原始程序顺序串行执行的结果完全一致——这正是“仿佛串行”这一名称的由来。
目前一切正常。但一旦变量 x 和 y 被多个线程共享,这些重排序和优化就会带来新的可能性:其他线程可能在不同时间点观察到这些变量的不同值。在下一课中,我们将看到这些优化在多线程场景下可能导致令人惊讶的结果。