【问题标题】:Possible synchronization issue due to code rearrangement by compiler由于编译器重新排列代码可能导致同步问题
【发布时间】:2016-02-19 15:16:24
【问题描述】:

考虑以下代码示例:

private Object lock = new Object();
private volatile boolean doWait = true;

public void conditionalWait() throws Exception {
    synchronized (lock) {
        if (doWait) {
            lock.wait();
        }
    }
}

public void cancelWait() throws Exception {
    doWait = false;
    synchronized (lock) {
        lock.notifyAll();
    }
}

如果我正确理解了Java Memory Model,那么上面的代码不是线程安全的。它很可能会阻塞,因为编译器可能会决定重新排列代码如下:

public void cancelWait() throws Exception {
    synchronized (lock) {
        lock.notifyAll();
    }
    doWait = false;
}

在这种情况下,线程 T1 可能会调用cancelWait() 方法,获取锁,调用notifyAll() 并释放锁。在此之后,并行线程 T2 可以调用 conditionalWait() 并获取现在可用的锁。变量doWait 的值仍然为true,因此线程T2 执行lock.wait() 并阻塞。

我的理解正确吗?如果不是,请提供 Java 规范中反驳上述情况的相应参考资料。

是否有解决此问题的解决方案不需要需要将doWait 拉入同步块?

【问题讨论】:

  • 如果我错了,请纠正我,但就目前而言,是否任何调用 conditionalWait 的线程(当 doWait 为真时)都必须无限期等待? cancelWait 尝试获取从 conditionalWait 锁定的同一对象上的锁定,因此永远无法执行 notifyAll。
  • @user1431765 wait() 释放锁,所以其他线程可以获取锁。

标签: java multithreading synchronization synchronized volatile


【解决方案1】:

你问的问题其实是

监视器输入是否可以在 volatile 存储之上重新排序?

不,你的转变不可能发生。查看http://gee.cs.oswego.edu/dl/jmm/cookbook.html 顶部链接的网格。

First Operation:  Volatile Store
Second Operation: Monitor Enter
Result: No

因此编译器无法按照您的建议重新排序。

【讨论】:

    【解决方案2】:

    您的代码已损坏,但不是因为重新排序或可见性问题。在没有足够同步的情况下会出现重新排序问题,这里不是这种情况。您已尽一切可能,将事物标记为 volatile 或 synchronized,以让 JVM 知道如何让正确的事物跨线程可见。

    你的问题是你做了几个错误的假设:

    • 您认为等待在收到通知之前永远不会返回(这可能不会经常发生,但它可能会发生,这称为“虚假唤醒”)。

    • 您假设另一个线程不能在通知发生时间和等待线程可以重新获取监视器的时间之间插入。 (Object#wait 释放监视器,在重新获取它时,线程需要重新检查当前状态,而不是根据可能过时的假设继续。)

    • 您假设您可以预测通知将在等待后发生(不能说在这种情况下是否属实,因为您没有发布完整的工作示例,但总的来说这不是什么你想假设)。

    有很多玩具示例(考虑奇偶赋值)可以解决这个问题,因为它们仅限于 2 个线程,导致虚假唤醒的竞争条件在 PC JVM 上并不经常发生,并且程序强制两个线程以锁步方式行动,因此事情发生的顺序是可预测的。但对于现实世界,这些假设并不现实。

    解决这些错误假设的方法是使用条件变量在循环中等待来决定何时完成等待(请参阅this Oracle tutorial):

    private final Object lock = new Object(); // final to emphasize this shouldn't change
    private volatile boolean doWait = true;
    
    public void conditionalWait() throws InterruptedException {
        synchronized (lock) {
            while (doWait) {
                lock.wait();
            }
        }
    }
    
    public void cancelWait() {
        doWait = false;
        synchronized (lock) {
            lock.notifyAll();
        }
    }
    

    (我缩小了抛出的异常范围,notifyAll 抛出的唯一异常是 IllegalMonitorStateException,它是未经检查的,只要你使用正确的锁就不会发生,它只是由于程序员错误而抛出的。 Object#wait 会抛出 InterruptedException 以及 IllegalMonitorStateException,放在这里抛出就可以了。)

    这里也可以将对 doWait 变量的引用移动到同步块中,如果对它的所有引用都是在持有锁的同时进行的,那么您不需要使其易失。但这不是必需的。

    【讨论】:

      【解决方案3】:

      当您的程序正确同步时,Java 内存模型保证顺序一致性。由于您上面的代码已正确同步,因此不会发生重新排序。

      Happens Before Order

      当且仅当所有顺序一致的执行都没有数据竞争时,程序才能正确同步。

      如果程序正确同步,则程序的所有执行将显示为顺序一致(第 17.4.3 节)。

      这对程序员来说是一个极强的保证。程序员不需要推理重新排序来确定他们的代码包含数据竞争。因此,在确定他们的代码是否正确同步时,他们不需要考虑重新排序。一旦确定代码正确同步,程序员就不必担心重新排序会影响他或她的代码。

      这可能会造成混淆,因为 顺序一致性 之前已在规范的线程间部分定义(仅处理单个线程)。

      Programs and Program Order

      如果所有动作都以与程序顺序一致的总顺序(执行顺序)发生,则一组动作是顺序一致的,并且变量v的每次读取r看到写入w写入v的值这样:

      w 在执行顺序中位于 r 之前,并且

      在执行顺序中没有其他写入 w' 使得 w 在 w' 之前并且 w' 在 r 之前。

      顺序一致性是对程序执行中的可见性和顺序的非常强大的保证。在顺序一致的执行中,所有单个操作(例如读取和写入)都有一个与程序顺序一致的总顺序,并且每个单独的操作都是原子的,并且对每个线程都是立即可见的。

      如果一个程序没有数据竞争,那么该程序的所有执行将看起来是顺序一致的。

      因此,顺序一致性归结为,您的程序在正确同步后必须看起来像每次读取和写入都完全按照程序中指定的顺序完成一样工作。不允许重新排序(或允许可见)。

      通常,当您谈论对写入进行重新排序时,您是在谈论 C++ 使用的 p 线程内存模型(我认为),它指定何时可以和不可以通过内存屏障对写入进行重新排序。这是一种流行的内存模型,很多人都知道。

      Java 没有内存屏障的概念。 Java 与 p-thread 规范相似但又不一样,所以不要混淆这两者。在 Java 中,要么您的程序完全按照您在程序中指定的顺序运行,要么您根本无法保证不同步。这是一个或另一个,你的情况是写入 volatile 必须按程序顺序出现。


      回复。您在下面的评论中提出的问题:我认为在规范中找到 happens-before 并不难。 Synchronization Order 说:

      每次执行都有一个同步顺序。同步顺序是执行的所有同步操作的总顺序。对于每个线程 t,t 中的同步动作(§17.4.2)的同步顺序与 t 的程序顺序(§17.4.3)一致。

      同步动作导致动作上的同步关系,定义如下:

      • 监视器 m 上的解锁操作与 m 上的所有后续锁定操作同步(其中“后续”根据同步顺序定义)。

      回到Happens Before Order:中的一些定义

      两个动作可以通过happens-before关系排序。如果一个动作发生在另一个动作之前,那么第一个动作对第二个动作可见并在第二个动作之前排序。

      如果我们有两个动作 x 和 y,我们写 hb(x, y) 来表示 x 发生在 y 之前。

      • 如果 x 和 y 是同一线程的操作,并且 x 在程序顺序中位于 y 之前,则为 hb(x, y)。

      • 如果动作 x 与后续动作 y 同步,那么我们也有 hb(x, y)。

      因此,在cancelWait() 中解锁您的监视器synchronized (lock) conditionalWait() 中的锁定获取操作同步。 Synchroizes-with 创建了一个 happens-before 关系(参见上面引用的最后一行)。因此doWait=false; 的赋值必须在conditionalWait() 中读取时可见。

      (在订购前发生也说:

      如果 hb(x, y) 和 hb(y, z),则 hb(x, z)。

      所以我们知道,如果在锁释放之前分配了 volatile,并且在锁释放之后发生了新的锁获取,那么它一定是 volatile 分配发生在锁获取之前,因此是可见的。)

      【讨论】:

      • 您能否真正指出规范中说 doWait 的波动性与同步块建立了先发生关系的部分? AFAIK,它只建立与后续读取的发生前关系。所以编译器可能仍然按照描述重新排列它。
      【解决方案4】:
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2020-07-29
      • 1970-01-01
      • 1970-01-01
      • 2011-12-03
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多