Java中同步的内存影响

45

JSR-133 FAQ表示:

但是同步不仅仅意味着互斥。同步确保线程在同步块之前或期间写入的内存对于在同一监视器上同步的其他线程以可预测的方式可见。在我们退出同步块后,我们会释放监视器,这会刷新缓存到主内存中,使得该线程进行的写入可以对其他线程可见。在进入同步块之前,我们需要获取监视器,这会使本地处理器缓存失效,以便从主内存重新加载变量。然后,我们就能看到前一个发布所公开的所有写入。

我还记得在现代Sun VM中,无争用锁同步是低成本的。但是这种说法让我有点困惑。考虑以下代码:

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

对x的更新需要同步,但是锁的获取是否也会从缓存中清除y的值?我无法想象这种情况存在,因为如果是真的,那么像锁分离这样的技术可能就没有帮助了。另外,JVM可否可靠地分析代码,以确保在使用相同锁的另一个同步块中不修改y,从而在进入同步块时不将y的值转储到缓存中?


4
最近我看到了一篇文章CPU Cache Flushing Fallacy,它有助于更好地理解这个问题。 - Binil Thomas
6个回答

45
简短的回答是,JSR-133在其解释中过于深入。这不是一个严重的问题,因为JSR-133是一个非规范性文件,不是语言或JVM标准的一部分。相反,它只是一个解释实现内存模型的一种可能策略的文档,但通常不是必要的。此外,“缓存刷新”的评论基本上完全不适用,因为基本上零个架构会通过任何类型的“缓存刷新”来实现Java内存模型(许多架构甚至没有这样的指令)。
Java内存模型在可见性、原子性、happens-before关系等方面以正式方式定义,使用精确(数学上)定义的模型准确地解释线程必须看到什么,哪些操作必须在其他操作之前发生以及其他关系。未经正式定义的行为可能是随机的,在某些硬件和JVM实现上是明确定义的,但当然您不应该依赖此,因为它可能会在未来发生变化,并且除非您编写了JVM并且对硬件语义非常清楚,否则您永远无法确定它是否在第一时间就被很好地定义。
因此,您引用的文本并没有正式描述Java所保证的内容,而是描述了一些假设的架构如何使用缓存刷新满足Java内存模型要求的非正式定义。任何有关缓存刷新、主存等的实际讨论显然不适用于Java,因为这些概念不存在于抽象语言和内存模型规范中。
实际上,内存模型提供的保证要比完全刷新弱得多 - 让每个原子、并发相关或锁操作都刷新整个缓存将是难以承受的昂贵代价 - 在实践中几乎从未这样做。相反,使用特殊的原子CPU操作,有时结合memory barrier指令,可以帮助确保内存可见性和排序。因此,便宜的非争用同步和“完全刷新缓存”之间的明显不一致性是通过注意到第一个是真实的而第二个不是 - Java内存模型不需要完全刷新(实际上也没有刷新)来解决的。
如果正式的内存模型有点太过沉重难懂(您并不孤单),您还可以通过查看Doug Lea's cookbook更深入地了解这个主题,实际上在JSR-133 FAQ中链接了该书,但它从具体的硬件角度来看待这个问题,因为它是为编译器编写者设计的。在那里,他们讨论了特定操作所需的确切障碍,包括同步 - 在那里讨论的障碍可以很容易地映射到实际的硬件。大部分实际映射都在食谱中讨论。

9
BeeOnRope说得对,你引用的文本更多地深入探讨了典型实现细节,而不是Java内存模型确实保证的内容。 在实践中,当你在x上进行同步操作时,你可能会经常看到y实际上被从CPU缓存中清除(同样,如果x在你的示例中是一个易失性变量,那么就不需要显式同步来触发影响)。这是因为在大多数CPU上(请注意,这是硬件效应,不是JMM描述的内容),缓存工作在称为缓存行的单元上,通常比机器字长(例如64字节宽)长。由于只有完整的行可以在缓存中加载或使无效,因此很有可能x和y将落入同一行,并且刷新其中一个也将刷新另一个。
可以编写一个显示这种影响的基准测试。创建一个类,只有两个易失性int字段,并让两个线程执行一些操作(例如在长循环中递增),一个在线上执行操作,另一个在线下执行操作。计算操作的时间。然后,在两个原始字段之间插入16个int字段并重复测试(16*4=64)。请注意,数组只是一个引用,因此16个元素的数组不能起到作用。您可能会看到性能显着提高,因为对一个字段的操作将不再影响另一个字段。这是否适用取决于JVM实现和处理器架构。我在Sun JVM和典型的x64笔记本电脑上看到了这种情况,性能差异是数倍。

1
Disruptor框架(http://code.google.com/p/disruptor/)在实践中使用了这种对齐技巧。 - gub
如果可能的话,您能否详细说明一下基准测试?最好附带代码。 - sgp15
我在现场演示中看到了基准测试,不幸的是我找不到任何幻灯片。但是这个想法是:由于数据在物理内存中的对齐方式以及CPU缓存的组织方式,内存可见性效应及其性能成本可能会作为副作用影响到标记为易失性的字段以及相邻字段。当然,这是一个依赖于实现的副作用,因此不要依赖它。我希望 disruptor 只依赖于“可见性搭载”,这完全符合 JLS 的规定,而且不依赖于硬件,但我不确定是否百分之百正确。 - Michał Kosmulski
基本上发生的情况是并发协议(MESI和朋友们)在缓存行级别上运作,在x86架构上为64字节。如果两个线程从不同的CPU并发地写入相同的缓存行,则缓存行在核心之间来回“抖动”。这会消耗大量内存和嗅探带宽,并导致各种停顿,因为CPU等待来回反弹的更新缓存行。这种情况发生在有或没有易失性的情况下,因为写入引起的问题与是否使用屏障/原子操作无关。 - BeeOnRope

6
更新x需要同步,但获取锁是否也会从缓存中清除y的值?我无法想象这种情况存在,因为如果是这样,像锁条纹(lock striping)这样的技术可能无法提供帮助。 我不确定,但我认为答案可能是“是”。请考虑以下内容:
class Foo {
    int x = 1;
    int y = 1;
    ..
    void bar() {
        synchronized (aLock) {
            x = x + 1;
        }
        y = y + 1;
    }
}

现在这段代码是不安全的,取决于程序的其余部分发生了什么。但是,我认为内存模型意味着由bar看到的y的值不应该比获取锁时的“真实”值旧。这意味着缓存必须使yx都无效。

此外,JVM能否可靠地分析代码,以确保使用相同锁定的另一个同步块中没有修改y

如果锁定对象是this,那么一旦所有类都被预加载,这种分析似乎就可以作为全局优化来完成。(我并不是说这很容易或值得做...)

在更一般的情况下,证明给定的锁仅与给定的“所有”实例连接使用的问题可能是棘手的。


3
我们是Java开发人员,只懂虚拟机,不懂实际机器!
让我推测一下正在发生的事情 - 但我必须说我不知道我在说什么。
假设线程A在CPU A上运行,带有缓存A,线程B在CPU B上运行,带有缓存B,
1. 线程A读取y; CPU A从主内存中获取y,并将值保存在缓存A中。 2. 线程B为'y'分配新值。此时VM不必更新主内存;就线程B而言,它可以在本地图像上读/写;也许'y'只是一个CPU寄存器。 3. 线程B退出同步块并释放监视器。(何时和何处进入该块并不重要)。到此为止,线程B已经更新了相当多的变量,包括'y'。现在必须将所有这些更新写入主内存。 4. CPU B将新的y值写入主内存中的位置'y'。(我想)几乎瞬间,“main y已更新”的信息被连接到缓存A,并且缓存A使其自己的y副本无效。那肯定在硬件上发生得非常快。 5. 线程A获取监视器并进入同步块 - 此时它不必对缓存A进行任何操作。'y'已经从缓存A中消失了。当线程A再次读取y时,它是新值由B分配后从主内存中获得的。
考虑另一个变量z,在步骤(1)中也由A缓存,但在步骤(2)中不被线程B更新。它可以一直存活在缓存A中,直到步骤(5)。由于同步而导致对'z'的访问并不会减慢速度。
如果上述陈述有意义,则成本确实不高。
补充步骤(5):线程A可能具有比缓存A更快的自己的缓存 - 例如,它可以使用寄存器来存储变量'y'。这不会被步骤(4)使其无效,因此在步骤(5)中,线程A必须在同步进入时清除其自己的缓存。不过,这不是一个巨大的惩罚。

2
仅供参考,根据我的理解,在(3)中你说“现在必须将所有这些更新写入主内存。”从我对双重检查锁定问题的阅读来看,我认为这是不正确的。同步块的开始保证您看到最新的数据,但没有显式的刷新结束块。在此之间,语句可以被重新排序,除非您也在同步块中,否则无法期望获得一致的数据视图。volatile关键字改变了其中一些语义,但主要保证了排序和刷新。 - PSpeed
1
更新必须在同步块结束时刷新到主内存,以便由另一个线程启动的下一个同步块可以看到这些更新。重排会发生,但不能违反“先行发生”关系。 - irreputable
不是技术上正确的,无法撤销...数据在最后不需要被刷新。'刷新'可以在下一个线程获取锁之前完全合法地发生,它不需要在更新线程释放锁后立即发生(或之前)以满足内存模型。 - Cowan

3
您可能需要查看JDK 6.0文档:http://java.sun.com/javase/6/docs/api/java/util/concurrent/package-summary.html#MemoryVisibility 内存一致性属性:
Java语言规范第17章定义了关于内存操作(如共享变量的读写)的happens-before关系。只有当写操作happens-before读操作时,一个线程的写入结果才能被另一个线程读取到。synchronized和volatile构造以及Thread.start()和Thread.join()方法都可以形成happens-before关系。特别地:
1. 每个线程中的每个动作都happens-before该线程程序中后续发生的所有动作。
2. 解锁(同步块或方法结束)监视器会happens-before对该相同监视器的后续锁定(同步块或方法进入)。由于happens-before关系是可传递的,因此在解锁之前的所有线程动作都happens-before在任何线程锁定该监视器之后的所有动作。
3. 对volatile字段的写入happens-before对同一字段的后续读取。 volatile字段的写入和读取具有与进入和退出监视器类似的内存一致性效果,但不需要互斥锁定。
4. 对线程调用start的任何动作都happens-before启动线程中的任何动作。
5. 在成功从该线程上的join返回之前,所有线程中的所有动作都happens-before任何其他线程。
因此,根据上面突出显示的点所述:在监视器上解锁之前发生的所有更改对于所有那些获取同一监视器上锁的线程(以及它们自己的同步块)都是可见的。这符合Java的happens-before语义。因此,当其他线程获取“aLock”上的监视器时,对y进行的所有更改也将被刷新到主内存中。

1

同步保证了只有一个线程可以进入代码块。但它并不能保证在同步块内进行的变量修改对其他线程可见。只有进入同步块的线程才能保证看到这些更改。 在Java中,同步的内存效果可以与C++和Java相关的双重检查锁问题进行比较。 双重检查锁被广泛引用和用作在多线程环境下实现延迟初始化的有效方法。不幸的是,在Java中实现时无法以平台独立的方式可靠地工作,需要额外的同步。在其他语言(如C++)中实现时,它取决于处理器的内存模型、编译器执行的重新排序以及编译器和同步库之间的交互。由于这些都没有在像C++这样的语言中指定,因此很难说它会在哪些情况下起作用。可以使用显式内存屏障来使其在C++中工作,但在Java中不存在这些屏障。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接