为什么`synchronized (new Object()) {}`是一个无操作语句?

54
在下面的代码中:
class A {
    private int number;

    public void a() {
        number = 5;
    }

    public void b() {
        while(number == 0) {
            // ...
        }
    }
}

如果调用方法b,然后启动一个新线程并触发方法a,则不能保证方法b会看到number的更改,因此b可能永远不会终止。

当然,我们可以使number成为volatile以解决这个问题。但是出于学术原因,让我们假设volatile不是一个选项:

JSR-133 FAQs告诉我们:

当我们退出同步块时,我们释放监视器,这会刷新缓存到主内存中,以便其他线程可以看到该线程所做的写操作。在我们进入同步块之前,我们获取监视器,这会使本地处理器缓存无效,以便变量将从主内存中重新加载。

这听起来就像我只需要让ab进入和退出任何synchronized-Block,无论它们使用什么监视器。更确切地说,它听起来像是这样...:

class A {
    private int number;

    public void a() {
        number = 5;
        synchronized(new Object()) {}
    }

    public void b() {
        while(number == 0) {
            // ...
            synchronized(new Object()) {}
        }
    }
}

这段内容的翻译如下:

...将消除该问题,确保 b 看到对 a 的更改,并最终终止。

然而,常见问题解答也明确说明:

Another implication is that the following pattern, which some people use to force a memory barrier, doesn't work:

synchronized (new Object()) {}

This is actually a no-op, and your compiler can remove it entirely, because the compiler knows that no other thread will synchronize on the same monitor. You have to set up a happens-before relationship for one thread to see the results of another.

现在这很令人困惑。我以为同步语句会导致缓存刷新。它肯定不能以一种只有在同步于相同监视器的线程才能看到主内存中更改的方式刷新缓存,特别是对于基本上做同样事情的volatile,我们甚至不需要监视器,或者我错了吗?那么为什么这是一个无操作,并且不会通过保证使b终止?


3
该引用未被保存,因此没有其他线程能够等待该引用。你试图保护什么? - Elliott Frisch
15
这个问题完美地说明了我敦促人们不要试图用特定于实现的事物来解释或理解语言语义的原因,这些事物可能存在也可能不存在,比如虚构的“本地处理器缓存”。 - David Schwartz
1
@DavidSchwartz 没错,实际上,“缓存刷新”这一短语,特别是与“主内存”这一短语相伴时,容易对实际发生的事情产生误导。缓存一致性协议通常可以确保内存一致性,而无需实际到达缓慢的主内存。如果您放弃 JLS 和 JMM 并尝试推理底层架构,您需要考虑某些同步机制不会确保全局内存一致性(IRIW 证明了这一点)。所以,就像 yshavit 所说,坚持使用 JLS 是正确的方式。 - Dimitar Dimitrov
2个回答

50
FAQ不是权威,JLS才是权威。第17.4.4节规定了同步关系,并反映到先于关系(17.4.5)。相关的要点是:
- 在监视器m上解锁操作与随后的所有m上的锁操作同步(其中“随后”根据同步顺序定义)。
由于这里的m是对new Object()的引用,而且它从未被存储或发布到任何其他线程,我们可以确定没有其他线程会在此块中释放锁后获取m的锁。此外,由于m是一个新对象,我们可以确定没有任何动作以前解锁了它。因此,我们可以确定没有任何动作正式与此动作同步。

从技术上讲,您甚至不需要进行完整的缓存清除即可达到JLS规范;这超出了JLS的要求。典型的实现会这样做,因为这是硬件允许的最简单的操作,但可以说这是“超额履行”。在逃逸分析告诉优化编译器我们需要更少的情况下,编译器可以执行较少的操作。在您的示例中,逃逸分析可以告诉编译器该操作没有影响(由于上述原因)并且可以被完全优化掉。


5
这意味着从理论上讲,我们根本不需要刷新缓存。但是我们必须确保另一个在相同监视器上进行同步的线程可以看到前一个线程离开同步块时发生的一切,并且保证这样做的最简单方法是在进入时刷新读取缓存,在退出时刷新写入缓存。正确吗? - yankee
2
@yankee,FAQ的意思是,如果有这样的缓存,并且清除该缓存是使监视器正常工作所必需的,那么该缓存将被清除。我认为这种说法很笨拙和愚蠢,为什么要这样说聪明话,我不知道。在我看来,这会导致更多的误解而不是理解。 - David Schwartz
11
除了逃逸分析可以识别纯局部对象之外,还有其他代码转换可能破坏无效同步的全局缓存刷新效果。例如,将synchronized语句的保护区域扩展到合并多个后续同步或循环的多次迭代中是合法的,从而减少获取和释放操作的数量。由于没有其他线程可以在其中获取锁定,因此没有happens-before关系存在并且持有锁时不需要刷新。 - Holger
@Holger 对于这样的问题,我在这里会立即搜索5-10个人来理解。 :) 你是其中之一。感谢你的贡献。 - Eugene

21
以下模式用于强制内存屏障的一些人使用,但这并不能保证是无操作指令,规范允许它是无操作指令。规范只要求同步时建立在同一对象上的两个线程之间建立happens-before关系,但实际上,在不考虑对象标识的JVM上实现会更容易。
我认为synchronized语句将导致缓存刷新。 Java语言规范中没有“cache”这个概念。这只存在于某些(好吧,几乎所有)硬件平台和JVM实现的细节中。

1
那基本上意味着规范允许将同步操作推迟到同一监视器上的另一个同步动作发生时才执行? - yankee
1
Java语言规范中没有“缓存”一词。实际上,JLS在第17章中提到了类似“编译器不必刷新寄存器中缓存的写入”的内容。听起来像是他们将寄存器用作缓存,因此JLS中确实存在缓存。 - yankee
1
@Yankee 这是在JLS的非内存模型相关部分进行解释。如果您阅读JMM的实际定义,您不会找到任何关于缓存或寄存器的参考(除了可能在非规范的说明部分)。 - Voo
2
@yankee 这假设有寄存器并且缓存在寄存器中,这不是必需的。当然,编译器只需要遵守标准,而不需要做任何其他事情。如果编译器使用寄存器,则只有在需要满足标准实际要求时才需要刷新它们。编译器除非需要遵守标准,否则不需要做任何事情。 - David Schwartz
6
编译器不必向天花板扔一条鱼。这并不意味着Java中有鱼或者天花板。 - user253751

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