在哪些情况下,空的同步块可以实现正确的线程语义?

30
我在查看代码库时翻阅了一份FindBugs报告,其中一个触发的模式是一个空的synchronized块(例如synchronized (var) {})。文档说:

空的同步块比大多数人认为的更加微妙和难以正确使用,并且空的同步块几乎从未是比其他不那么牵强的解决方案更好的解决方案。

在我的情况下,它出现的原因是块的内容已被注释掉,但是synchronized语句仍然存在。在什么情况下空的synchronized块可以实现正确的线程语义?

5个回答

20

一个空的同步块会一直等待,直到没有其他人在使用该监视器。

这可能是您想要的,但由于您没有保护同步块中的后续代码,没有任何东西阻止其他人在运行后续代码时修改您正在等待的内容。这几乎永远都不是您想要的。


1
侧面说明:我绝对会用java.util.concurrent类中的一个替换空同步块的概念。锁定/栅栏/闩锁都可以很好地完成这项工作,并且使用时含义始终明确(而不是神奇的空花括号)。 - basszero
7
另一个重要的用途是它起到类似于内存屏障的作用(就像读取/写入易失变量一样),@SnakE在下面讨论。 翻译:另一个重要的用途是它起到类似于内存屏障的作用(就像读取/写入易失变量一样),如下所述@SnakE。 - jtahlborn
2
没错。我有一个方法,让一些线程像工人一样工作,另一些线程则像消费者一样。所有消费者所做的就是使用空的synchronized等待工人完成修改实例,自此以后 - 就不需要进一步的同步,因此所有读取都在synchronized代码之外完成。我认为synchronized是比手动管理锁实例更清晰的解决方案。 - Pijusn
@Paul Tomblin 不是。Worker 是第一个同步实例的线程,一旦它释放了它,就没有其他线程会修改它。这是一个非常特殊的情况,我还没有在其他地方应用过。 - Pijusn
@Pius,所以只有一个Worker吗?或者在第一个Worker完成写入后,被消耗的东西再也没有被写入过,即使有其他的Worker?第一个Worker再次写入的可能性不存在吗?这似乎是一个非常特殊的情况 - 大多数情况下,我认为您希望同步写入和读取,或者更好的方法是使用java.util.concurrent.locks来获取只读的ReadWriteLock进行读取,并在写入时获取写锁。 - Paul Tomblin
显示剩余2条评论

19

在第一部分和第二部分中,我将解释一个空的 synchronized 块如何“实现正确的线程处理”。在第三部分中,我将解释它为什么可以是一个“更好的解决方案”。最后,在第四部分中,我将通过示例展示如何使用它仍然是“微妙且难以正确使用”的。

1. 正确的线程处理

在什么情况下,一个空的 synchronized 块会使线程正确处理?

考虑以下示例。

import static java.lang.System.exit;

class Example { // Incorrect, might never exit

    public static void main( String[] _args ) { new Example().enter(); }

    void enter() {
        new Thread( () -> { // A
            for( ;; ) {
                toExit = true; }})
            .start();
        new Thread( () -> { // B
            for( ;; ) {
                if( toExit ) exit( 0 ); }})
            .start(); }

    boolean toExit; }

上面的代码是不正确的。运行时可能会将线程A对布尔变量toExit的更改隔离起来,从而将其隐藏在B之外,导致B一直循环。可以通过引入空的synchronized块来纠正它,如下所示。
import static java.lang.System.exit;

class Example { // Corrected

    public static void main( String[] _args ) { new Example().enter(); }

    void enter() {
        new Thread( () -> { // A
            for( ;; ) {
                toExit = true;
                synchronized( o ) {} }}) // Force exposure of the change
            .start();
        new Thread( () -> { // B
            for( ;; ) {
                synchronized( o ) {} // Seek exposed changes
                if( toExit ) exit( 0 ); }})
            .start(); }

    static final Object o = new Object();

    boolean toExit; }

2. 正确性的基础

空的 synchronized 块如何使代码正确?

Java 内存模型保证‘对于监视器 m 的解锁操作与此后所有对 m 的加锁操作进行同步,并因此§17.4.4形成发生-之前关系(happens-before relation)。因此,在 A 的 synchronized 块末尾解锁对象 o 会先于 B 的 synchronized 块头部的加锁操作。由于 A 的变量写入在解锁前,B 的变量读取在加锁后,保证也扩展到了写和读操作:写操作在读操作之前发生。

现在,‘[if] one action happens-before another, then the first is visible to and ordered before the second’ (§17.4.5)。正是这种可见性保证使得代码在内存模型方面的正确。

3. 比较实用性

一个空的synchronized块可能比其他解决方案更好吗?

与非空的`synchronized`块相比

另一种选择是一个-空的synchronized块。一个非空的synchronized块有两个作用:a)它提供了前面一节中描述的排序和可见性保证,有效地强制所有同步于相同监视器的线程之间的内存变化暴露;b)它使块内代码在这些线程中实际上是原子性的;该代码的执行不会与其他块同步的代码的执行交错。

一个空的 synchronized 块只执行 (a) 操作。在需要执行 (a) 操作且 (b) 操作代价较高时,空的 synchronized 块可能是更好的解决方案。

与 `volatile` 修饰符对比

另一种选择是将 volatile 修饰符附加到特定变量的声明上,从而强制暴露其更改。空的 synchronized 块应用于所有变量,而不是任何特定变量。在需要公开广泛变量变化的情况下,空的 synchronized 块可能是更好的解决方案。

此外,volatile 修饰符强制暴露每个单独写入变量的操作,在所有线程中暴露每个操作。空的 synchronized 块在曝光的时间(仅当块被执行时)和范围(仅限于同步到相同监视器的线程)方面有所不同。在需要更狭窄的时间和范围聚焦可能有显着的成本效益的情况下,出于这个原因,空的 synchronized 块可能是更好的解决方案。

4. 可靠性再审视

并发编程很难。因此,空的synchronized块“微妙且难以正确使用”也就不足为奇了。一种误用它们的方式(Holger提到)如下面的示例所示。

import static java.lang.System.exit;

class Example { // Incorrect, might exit with a value of 0 instead of 1

    public static void main( String[] _args ) { new Example().enter(); }

    void enter() {
        new Thread( () -> { // A
            for( ;; ) {
                exitValue = 1;
                toExit = true;
                synchronized( o ) {} }})
            .start();
        new Thread( () -> { // B
            for( ;; ) {
                synchronized( o ) {}
                if( toExit ) exit( exitValue ); }})
            .start(); }

    static final Object o = new Object();

    int exitValue;

    boolean toExit; }

Thread B的语句“if( toExit ) exit( exitValue )”假设两个变量之间存在同步,但代码并未保证。假设B在A写入toExitexitValue后读取它们,但在两个synchronized语句(先是A的,然后是B的)执行之前。那么B看到的可能是第一个变量的更新值(true)以及第二个变量的未更新值(零),导致它退出时出现错误的值。
通过使用final字段来修正代码的一种方法。
import static java.lang.System.exit;

class Example { // Corrected

    public static void main( String[] _args ) { new Example().enter(); }

    void enter() {
        new Thread( () -> { // A
            for( ;; ) if( !state.toExit() ) {
                state = new State( /*toExit*/true, /*exitValue*/1 );
                synchronized( o ) {} }})
            .start();
        new Thread( () -> { // B
            for( ;; ) {
                synchronized( o ) {}
                State state = this.state; /* Local cache.  It might seem
                  unnecessary when `state` is known to change once only,
                  but see § Subtleties in the text. */
                if( state.toExit ) exit( state.exitValue ); }})
            .start(); }

    static final Object o = new Object();

    static record State( boolean toExit, int exitValue ) {}

    State state = new State( /*toExit*/false, /*exitValue*/0 ); }

修改后的代码是正确的,因为Java内存模型保证了当B读取A写入的state的新值时,它将看到在State的声明中隐式为final的toExitexitValue这两个最终字段的完全初始化值。 "一个只能在完全初始化对象之后看到对象引用的线程保证会看到该对象最终字段的正确初始化值"(§17.5)。
对于该技术的一般实用性至关重要(尽管在当前示例中无关),规范进一步指出:“它还将看到由这些最终字段引用的任何对象或数组的版本,这些版本至少与最终字段一样更新。”因此,同步的保证深入到数据结构中。
细节:
线程B对state变量的本地缓存(如上面的示例)可能看起来是不必要的,因为已知state只会更改一次。当它具有其原始值时,“if( state.toExit ) exit( state.exitValue )”语句将短路并仅读取一次;否则它将具有其最终值,并保证在两次读取之间不会更改。但正如Holger指出的那样,没有这样的保证。
考虑如果我们省略缓存会发生什么。
new Thread( () -> { // B
    for( ;; ) {
        synchronized( o ) {}
     // State state = this.state;
    //// not required when it’s known to change once only
        if( state.toExit ) exit( state.exitValue ); }})
    .start(); }

‘只要程序的所有执行结果都可以被内存模型预测,实现可以产生任何代码,这为实现者提供了很大的自由度来执行各种代码转换,包括操作重排序和不必要同步的删除。’(§17.4
因此,由于“if(state.toExit)exit(state.exitValue)”位于同步块之外,并且state是非易失性变量,因此以下转换是有效的。
new Thread( () -> { // B
    for( ;; ) {
        synchronized( o ) {}
     // State state = this.state;
    //// not required when it’s known to change once only
        State s = state;
        if( state.toExit ) exit( s.exitValue ); }})
    .start(); }

这可能是代码的实际执行方式。然后第一次读取 state(到 s)可能会产生其原始值,而下一次读取则产生其最终值,导致程序意外退出并返回0而不是1。

1
"volatile修饰符的影响不会延伸到变量的内容。这是相当混乱的语言。我认为你想表达的是两个线程读取一个volatile变量不会创建happens-before关系。然而,如果读取成功读取了写入操作,则写入和读取操作确实会创建这样的关系。happens-before关系延伸到线程执行的所有操作。" - Aleksandr Dubinsky
1
此外,所有现代处理器都是缓存一致的。发生在之前的关系更多地涉及编译器允许执行的操作,而不是CPU本身。 - Aleksandr Dubinsky
2
这是一个很大的改进。但有一件事需要考虑:仍然有可能按照以下顺序执行操作:A:写入,B:同步块,A:同步块,B:读取。那么,就没有先行发生关系,但通过竞争性读取,B仍然可以感知到写入的值。对于这个特定的示例,这并不重要,因为它只是一个单独的原子int变量(但是,还有很多替代方案,如volatileAtomicIntegerVarHandle)。但在涉及多个变量的任何情况下,这都将被破坏。 - Holger
2
注意。if(state.toExit)exit(state.exitValue);在这里不能保证有效,因为这是两个没有线程间语义的状态读取。如果没有正确的同步,读取和写入可能会被认为是无序的,因此,在if条件内部的第一次读取可能会感知到比exit(…)调用语句内部的读取更新的值。您必须首先将引用读入本地变量中(而且这又是一个案例,其中替代方案volatileAtomicIntegerVarHandle比空的synchronized块更好)。 - Holger
1
这就是错的。正如所说,你有一个竞争读取,并不能保证第二个竞争读取会看到新值,当第一个竞争读取看到新值时。if( state.toExit ) 可能会看到 state 的新值,然后 exit( state.exitValue ); 看到 state 的旧值。为了防止无序读取,您必须将变量声明为 volatile,但由于整个示例都是关于避免这种情况,因此必须使用局部变量。 - Holger
显示剩余4条评论

5

过去规范意味着会发生某些内存屏障操作。然而,现在规范已经改变,原始规范从未被正确实现。它可以用于等待另一个线程释放锁,但协调其他线程已经获取锁可能会很棘手。


3
同步不仅仅是等待,尽管这种编码方式不够优雅,但可以达到所需的效果。
来源于http://www.javaperformancetuning.com/news/qotm030.shtml
  1. 线程获取对象this的监视器锁(假设监视器未锁定,否则线程将等待,直到监视器被解锁)。
  2. 线程刷新其所有变量的内存,即它的所有变量都从“主”内存中有效读取(JVM可以使用脏集来优化此过程,以便只刷新“脏”变量,但在概念上这是相同的。请参见Java语言规范的第17.9节)。
  3. 执行代码块(在本例中将返回值设置为i3的当前值,该值可能刚刚从“主”内存中重置)。
  4. (任何对变量的更改现在通常都会写入“主”内存,但对于geti3(),我们没有更改。)
  5. 线程释放对象this的监视器锁。

4
这是对真实规则的危险简化。同步块并不会“将其变量刷新到(全局)内存”。唯一的保证是,如果线程A在特定对象上进行同步,然后线程B稍后在同一对象上进行同步,则线程B将看到线程A的更改。 - Nayuki

0

如果想深入了解Java的内存模型,可以观看Google“编程语言高级主题”系列中的这个视频: http://www.youtube.com/watch?v=1FX4zco0ziY

它提供了一个非常好的概述,介绍了编译器可以对你的代码进行哪些操作(通常是理论上的,但有时也会在实践中出现)。对于任何认真的Java程序员来说,这都是必不可少的知识!


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