Java 线程安全原生内存:VarHandle 访问模式详解
目录
- 什么是内存顺序,为什么它对原生内存很重要?
- 为什么需要这一切?
- 使用 JCStress 进行测试
- 普通访问 (Get/Set)
- Opaque 访问
- Acquire/Release
- Volatile
- 总结 (TL;DR)
- 结论
- 附录:字撕裂 (Word Tearing)
什么是内存顺序,为什么它对原生内存很重要?
外部函数与内存 (FFM) API 是 Java 与原生代码及内存进行交互的方式。在上一篇文章中,你已经了解了如何使用 Java 内置的 Arena 类型来实现这一点。Arena 提供了时间安全性和边界检查,但线程安全性又如何呢?通过 .ofShared()、.auto() 和 .global() 创建的 MemorySegment 可以被多个线程同时使用。如果你不使用锁之类的机制,仅对 VarHandle 使用 get/set 操作可能会适得其反。缺点是锁通常很慢且开销巨大。因此,让我们来看看一种更细粒度、更具硬件感知能力的方案:使用 VarHandle 访问模式。
为什么需要这一切?
当你编写并发代码时,你需要依赖硬件来保持数据同步。不同的 CPU 架构处理内存顺序的方式各不相同。在 x86 架构上,内存模型相对较强。读写操作大多保持顺序,这意味着你通常可以使用较宽松的同步。然而,ARM 架构拥有弱内存模型。CPU 可以为了优化性能而主动地对读写操作进行重排序。如果你编写代码时假设了 x86 的严格顺序,并将其运行在 ARM 处理器(如 Apple Silicon 或 AWS Graviton)上,你的应用程序可能会出现不可预知的崩溃。VarHandle 提供了一些方法来帮助处理这些情况,以确保你的代码在任何地方都能正常工作。
为了准确观察这些机制是如何运作的,我们将从限制最少的访问模式开始,逐步深入到完整的内存屏障。但在开始之前,我想先展示一下如何实际进行测试。
使用 JCStress 进行测试
Java Concurrency Stress (JCStress) 是一个实验性的测试框架,旨在帮助你验证并发代码的正确性。它通过并发运行测试用例并访问相同的共享状态来实现这一目标。在执行过程中,它会收集观察到的状态结果。其目的是查看代码如何被重排或优化,以及这些变化如何影响状态。它的一种测试方式是使用不同的编译模式(如解释器、C1 或 C2)来运行每个线程。JCStress 会测试各参与者在不同编译模式下的每种组合。如果有两个参与者,单次运行就包含九种组合。
编写这些测试需要一种不同的思维方式。通常,你希望两个线程能够和谐共处,但在 JCStress 测试中,你需要它们尽可能频繁地冲突,以观察代码可能产生的各种状态。这可能会让人困惑,让我们用一个例子来说明。假设你有两个线程运行以下代码:
如果你在 JCStress 中这样做,线程基本上会一个接一个地同步运行。当然,这确实能运行,但它无法证明任何东西。因此,在接下来的示例中,请记住我们的目标是让线程相互交错,并不断冲击状态以查看会发生什么,就像在现实世界中那样。使用 JCStress 的另一个建议是:不要在一个测试中放入太多逻辑。这会导致状态空间过于庞大。为了保持测试快速高效,请专注于解决单个同步或线程交错问题。
输出结果看起来是这样的:
它显示了采样结果及其出现的频率。预期值和描述由开发者设定,因此取决于具体的测试场景。
普通访问 (Get/Set)
普通访问是最简单的模式,没有任何规则,也没有内存屏障!它的行为类似于你在 Java 中习惯的任何 get/set/读取/赋值操作,例如 var x = 1。对于 MemorySegment,Get/Set 的工作方式相同,仅仅是设置和获取值。如果你在单个线程内工作且不与其他线程共享状态,这是完全没问题的。在这种模式下,编译器、CPU 和缓存被允许优化你的代码并重排指令,只要最终结果看起来像你编写的那样执行即可。这种幻觉在没有多线程竞争的情况下是成立的。那么它表现如何呢?让我们用两个线程和 JCStress 来打破这个幻觉。在下一个示例中,有一个共享的 MemorySegment,用于传输一个就绪标志位和一些数据。一个线程设置数据,另一个线程读取结果。
这些线程在传递消息:一个线程设置数据,另一个线程读取它。在这个例子中没有任何同步或内存屏障,所以一切都可以被重排。这会引入竞态条件。下表显示了运行代码时观察到的所有不同状态:
JCStress 使用不同的编译器组合(解释器、C1、C2)运行代码,正如你所见,我们得到了三种不同的组合。有时标志位已设置且获取到了值 42,有时标志位未设置。这两种都是正确状态。但 0 是一个有趣的状态……这意味着标志位已设置,但数据还没到位。代码被重排了!这显然不是我们想要的状态,因为 0 不应该出现,对吧?为了修复这个问题,我们需要 Acquire/Release,但让我们先看看 Opaque 模式,它是层级结构中的下一个。
Opaque 访问
Opaque 是个特例。它不会插入内存屏障,也不提供不同变量之间的顺序保证。它提供的是:位级原子性(无字撕裂)、一致性(所有线程以相同的顺序观察到对同一变量的写入)以及进度保证(写入最终会变得可见)。它还可以防止编译器消除对该特定变量的访问。例如,这对于存活状态检查非常有用。假设你有两个线程,Thread_1 运行一个 while 循环直到收到停止信号,而 Thread_0 控制这个信号。如果没有 Opaque,编译器可能会将该循环优化为 while(true),导致 Thread_1 永远不会停止。JCStress 并不太适合这种场景,所以让我们看另一个例子。在这个例子中,Thread_1 将 1 和 2 写入 MemorySegment 的同一个位置,Thread_2 执行两次读取以查看中间或最终结果。同样,目标是让线程尽可能频繁地冲突。
结果表明,尽管 Opaque 防止了极端的编译器优化,但它并不保证跨线程的立即可见性。绝大多数情况下,第二个执行者要么看到初始状态 (0, 0),要么看到最终状态 (2, 2)。然而,我们也观察到了中间状态 (1, 2) 或有序读取 (0, 2)。由于没有顺序约束或内存屏障,CPU 和缓存仍然可能延迟 actor1 的写入对 actor2 的可见性。(1, 2) 的出现证实了中间写入的 1 有时会在传输过程中被捕获。
当 C2 编译器介入时,它会对代码进行大量优化。在 Plain 访问下,C2 通常会完全优化掉中间的 write,因为它认为最终值是 2,中间的写入是多余的。这就是为什么你在 Plain Access 的 C2 表中几乎看不到 (1, 2) 的结果。然而,Opaque 访问明确禁止编译器移除该中间写入。因此,Opaque 的 C2 表仍然显示出相当数量的 (1, 2) 结果。编译器被迫保留了两次写入,而硬件缺乏内存屏障则允许观察到中间状态。
总而言之,Opaque 是以下特性的组合:
- 普通访问:如上所述的 get 和 set。
- 访问原子性:读写操作作为单个、不可分割的单元发生。没有字撕裂,即使对于像
long和double这样的 64 位类型也是如此。 - 一致性:对同一变量的写入,所有观察者看到的顺序一致。
- 进度保证:写入最终会变得可见。
Opaque 在特定场景下很有用,但对于大多数并发模式来说太弱了。一个很好的例子是你想广播给其他读取者的变量。由一个线程拥有并被其他线程收集的计数器就是此类应用的一个例子。
让我们更深入一层,看看当你加入因果关系时会发生什么。## Acquire/Release
Acquire 和 Release 提供了一种比 Opaque 更严格的模式,它们不仅包含 Opaque 的所有保证,还增加了一种“先发生(happens-before)”关系。这意味着它比 Opaque 更严格,但仍比 volatile 轻量。Release 和 Acquire 是两个独立的方法:
- setRelease():编译器/CPU 不允许将 Release 之前的读写指令移动到其之后执行。
- getAcquire():保证在此点之后的所有读写操作都能看到至少在对应 setRelease() 点可见的数据。编译器/CPU 不允许将 Acquire 之后的指令移动到其之前执行。
让我们看看这些规则在现实中是如何运作的。在下面的代码中,actor1 向 MemorySegment 设置了三个值。setRelease 用于设置一个标志,表示数据已准备好被读取。Actor2 则监视该标志的变化。当它读取到 1 时,它会从 segment 中获取数据。
使用 JCStress 运行此代码时,我得到了以下结果。两种结果都是有效的:-1 表示标志尚未设置,因此没有尝试读取数据;而 3 表示读取到了最后一次写入的数据。
如果仅使用普通的 set/get 进行相同的操作,编译器和 CPU 会对代码进行重排序,因为此时已不存在“先发生”关系。使用普通访问方式运行的结果如下:
当我们希望仅在数据真正可用时才读取它时,这种结果并不理想。值 0、1 和 2 表示就绪标志(ready flag)在数据实际写入之前就显示为已设置。这表明 Release/Acquire 在生产者-消费者设计、消息传递设计等场景中表现优异。
Volatile
这是最后一种也是最严格的模式。它处理的是全局顺序(total order)。通过使用 volatile,每个读取操作都能保证看到最近写入的值。写入值时,它会确保对所有其他线程可见。只有在此之后,线程才会继续进行下一个操作。你可以在下面的示例中看到它的作用。
这两个 actor 正在对 memorySegment 内的两个不同位置进行读写。通过使用 volatile,write 操作保证在当前线程进行任何后续操作之前,对所有线程完全可见。虽然这速度较慢,但它保证了所有线程对操作顺序达成一致。
如果使用较弱的模型(如 Release/Acquire),CPU 就不会等待 write 操作传播完成。你可以将其看作一种“发射后不管(fire and forget)”的机制。使用 release 时,你触发了 write 操作并直接继续下一个 read。当这种情况发生时,read 可能在 write 操作之前发生。Release 保证了在观察到已发布值(released value)的任何线程看来,所有先前的写入都是可见的。但它不保证你的线程在继续之前会看到其他线程的 release。这就是 volatile 所填补的全局排序差距。Release/Acquire 机制意味着你可能会观察到此处所示的 0, 0 情况:
当只有一个变量需要关注时,Release/Acquire 是可以的;但当你需要在两个或多个变量之间进行同步时,它就会失效,此时你需要更强的 volatile 模式。
TL;DR
直接使用 Get/Set 和 Volatile 就能过上安稳的生活。如果这还不够,且你确实需要这种细粒度的控制,也许可以考虑继续使用 get/set 和 volatile。如果我真的无法说服你,那么其他模式对于 volatile 导致性能问题的特殊情况来说是非常好的选择。
总结
在多线程环境下处理原生内存,迫使你面对硬件实际执行代码的方式。虽然 FFM API 提供了通往原生内存的直接桥梁,但它并不能保护你免受 CPU 重排序或缓存可见性问题的影响。普通访问方式对于单线程任务来说非常完美,但一旦共享内存段,就需要使用正确的 VarHandle 访问模式。Volatile 是最安全的默认选择,以性能为代价提供了严格的顺序保证。如果分析表明 volatile 是性能瓶颈,你可以降级到 Acquire/Release 或 Opaque,但你必须承担起自行管理内存顺序的责任。务必彻底测试并发内存访问,因为 x86 和 ARM 之间的架构差异很容易暴露你假设中的任何缺陷。
附录:字撕裂(Word Tearing)
当一段内存的读写操作不是原子的时候,就会发生“字撕裂”。如果你向未对齐的内存写入 64 位值,或者在 32 位系统上操作,CPU 可能会将其作为两个独立的 32 位操作执行。如果另一个线程在这两个操作之间读取该内存,它将得到旧值的一半和新值的一半。使用 Opaque 可以防止这种情况发生。为了演示,让我们看一个使用未对齐内存访问的例子。
结果明确显示了字撕裂。值 4294967294 既不是初始的 0,也不是预期的 Long.MAX_VALUE 或 Long.MAX_VALUE - 1。由于 MemorySegment 是以未对齐的布局访问的(8 字节的 long 使用 1 字节对齐),JVM 和 CPU 无法通过单一的原子硬件指令写入 64 位的 long。相反,它被拆分了。Actor2 在刚好只有一半新值被写入时读取了内存,导致了一个损坏的、混合的值。这凸显了在手动管理内存时,对齐和正确访问模式的必要性。