Java 中同步的记忆效应

2022-09-01 00:04:01

JSR-133 常见问题解答 说:

但是,同步不仅仅是相互排斥。同步可确保线程在同步块之前或期间写入的内存以可预测的方式对在同一监视器上同步的其他线程可见。退出同步块后,我们释放监视器,其效果是将缓存刷新到主内存,以便此线程进行的写入可以对其他线程可见。在我们可以进入同步块之前,我们获取监视器,其效果是使本地处理器缓存无效,以便从主内存中重新加载变量。然后,我们将能够看到上一版本显示的所有写入。

我还记得读过,在现代Sun VM上,未经控制的同步很便宜。我对这种说法有点困惑。考虑如下代码:

class Foo {
    int x = 1;
    int y = 1;
    ..
    synchronized (aLock) {
        x = x + 1;
    }
}

对 x 的更新需要同步,但是锁的采集是否也从缓存中清除了 y 的值?我无法想象会是这样,因为如果这是真的,像锁条带这样的技术可能无济于事。或者,JVM是否可以可靠地分析代码,以确保y不会使用同一锁在另一个同步块中修改,从而在进入同步块时不会在缓存中转储y的值?


答案 1

简短的回答是JSR-133在解释上走得太远了。这不是一个严重的问题,因为JSR-133是一个非规范性文档,它不是语言或JVM标准的一部分。相反,它只是一个文档,它解释了一种可能的策略,该策略足以实现内存模型,但通常不是必需的。最重要的是,关于“缓存刷新”的评论基本上是完全不合适的,因为基本上零架构会通过执行任何类型的“缓存刷新”来实现Java内存模型(许多架构甚至没有这样的指令)。

Java内存模型是根据可见性,原子性,发生前关系等来正式定义的,这确切地解释了哪些线程必须看到什么,哪些操作必须在其他操作之前发生,以及使用精确(数学上)定义的模型的其他关系。未正式定义的行为可能是随机的,或者在某些硬件和JVM实现上在实践中定义良好 - 但当然你永远不应该依赖它,因为它将来可能会发生变化,除非你编写JVM并且非常了解硬件语义,否则你永远无法真正确定它首先被很好地定义了。

因此,您引用的文本并不是正式描述Java保证的内容,而是描述一些具有非常弱的内存排序和可见性保证的假设体系结构如何使用缓存刷新来满足Java内存模型的要求。任何关于缓存刷新、主内存等的实际讨论显然不适用于Java,因为这些概念在抽象语言和内存模型规范中并不存在。

在实践中,内存模型提供的保证比完全刷新要弱得多 - 让每个原子,并发相关或锁定操作刷新整个缓存将非常昂贵 - 这在实践中几乎从未做过。相反,使用特殊的原子CPU操作,有时与内存屏障指令结合使用,这有助于确保内存可见性和排序。因此,廉价的无控制同步和“完全刷新缓存”之间的明显不一致通过注意第一个是真的,第二个不是 - Java内存模型不需要完全刷新来解决(并且在实践中没有发生刷新)。

如果正式的内存模型有点太重而无法消化(你不会孤单),你也可以通过查看Doug Lea的食谱来更深入地研究这个主题,这实际上是在JSR-133 FAQ中链接的,但从具体的硬件角度来看是这个问题,因为它是为编译器编写者准备的。在那里,他们谈论特定操作(包括同步)需要哪些障碍 - 并且那里讨论的障碍可以很容易地映射到实际的硬件。大部分实际映射都在说明书中进行了讨论。


答案 2

BeeOnRope是对的,你引用的文本更多地深入到典型的实现细节,而不是Java内存模型确实保证了什么。在实践中,当您在 x 上进行同步时,您可能会经常看到 y 实际上已从 CPU 缓存中清除(此外,如果示例中的 x 是易失性变量,在这种情况下,显式同步不需要触发该效果)。这是因为在大多数CPU上(请注意,这是一种硬件效应,而不是JMM所描述的),缓存适用于称为缓存行的单元,这些单元通常比机器字长(例如64字节宽)。由于只有完整的行可以在缓存中加载或失效,因此 x 和 y 很有可能会落入同一行,并且刷新其中一个行也会刷新另一个行。

可以编写一个显示此效果的基准测试。创建一个只有两个易失性int字段的类,并让两个线程执行一些操作(例如,在长循环中递增),一个在一个字段上,一个在另一个字段上。对操作进行计时。然后,在两个原始字段之间插入 16 个 int 字段并重复测试 (16*4=64)。请注意,数组只是一个引用,因此包含 16 个元素的数组不会起作用。您可能会看到性能的显著提高,因为一个字段上的操作不会再影响另一个字段。这是否适合您将取决于 JVM 实现和处理器体系结构。我在Sun JVM和典型的x64笔记本电脑上已经看到了这一点,性能差异是几倍。


推荐