与System.out相关的Java线程表现异常

7

我有一个简单的 TestThreadClientMode 类来测试竞态条件。 我尝试了两次:

  1. 当我使用注释掉第二个线程中的 System.out.println(count); 的以下代码运行时,输出为:

操作系统:Windows 8.1 flag done 设为 true ...

第二个线程永远处于活动状态。 因为第二个线程从未看到由主线程设置为 true 的 done 标志的变化。

  1. 当我取消注释 System.out.println(count); 后,输出结果为:

    操作系统:Windows 8.1 0 ... 190785 190786 flag done 设为 true 完成! Thread-0 true

程序在 1 秒后停止。

为什么 System.out.println(count); 使第二个线程看到了 done 中的变化?

代码

public class TestThreadClientMode {
    private static boolean done;
    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            public void run() {
                int count = 0;
                while (!done) {
                    count ++;
                    //System.out.println(count);
                }
                System.out.println("Done! " + Thread.currentThread().getName() + "  " + done);
            }
        }).start();
        System.out.println("OS: " + System.getProperty("os.name"));

        Thread.sleep(1000);
        done = true;

        System.out.println("flag done set true ");
    }
}
3个回答

6

这是内存一致性错误的一个很好的例子。简单来说,变量被更新了,但第一个线程并不总是看到变量的改变。可以通过声明done变量为volatile来解决这个问题:

private static volatile boolean done;

在这种情况下,对变量的更改对所有线程都是可见的,并且程序始终在一秒钟后终止。
更新:看起来使用System.out.println确实解决了内存一致性问题 - 这是因为打印函数使用底层流,该流实现了同步。同步建立了如我链接的教程中描述的happens-before关系,具有与volatile变量相同的效果。(详细信息请参见this answer。同时感谢@Chris K指出流操作的副作用。)

Parker,感谢你的回答。是的,你说得对。如果我将done声明为volatile,那么它一定会被第二个线程看到,因为它会导致主线程跨越内存屏障,从而使其可见于其他线程。但我想知道这种行为背后的原因。 - Humoyun Ahmad
@Humoyun 我更新了答案。System.out.println 的影响可能只是巧合 - 在我的测试中没有相关性。 - Parker Hoyes
1
@Humoyun 我的错 - print 确实 建立了一个"先行发生"关系,因为在底层流中使用了同步。 - Parker Hoyes

2
System.out.println(count); 如何使第二个线程看到done的变化? 您正在目睹println的副作用;您的程序受到并发竞争条件的影响。在协调CPU之间的数据时,重要的是告诉Java程序您希望在CPU之间共享数据,否则CPU可以自由延迟彼此的通信。 在Java中有几种方法可以做到这一点。主要的两种方法是关键字'volatile'和'synchronized',它们都在代码中插入了硬件人员称为“内存屏障”的东西。如果不在代码中插入“内存屏障”,则并发程序的行为是未定义的。也就是说,我们不知道“done”何时对其他CPU可见,因此它是一个竞态条件。
下面是System.out.println的实现;请注意使用synchronized。synchronized关键字负责在生成的汇编中放置内存屏障,从而产生了使变量'done'对其他CPU可见的副作用。
public void println(boolean x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

你的程序需要进行正确的修复,读取done时需要加入读内存屏障,写入done时需要加入写内存屏障。通常情况下,可以在同步块中读取或写入'done'变量来实现此操作。在这种情况下,将变量done标记为volatile也会产生相同的效果。你还可以使用AtomicBoolean代替boolean来定义该变量。


是的,使用同步将建立与标志读取的“先于发生”关系,基本上具有与易失性变量相同的效果。 - Parker Hoyes
@ParkerHoyes 是的,没错。println 的使用是一个误导/副作用。其中的同步块确实为代码添加了内存屏障,但它们放置的位置不正确,无法保证正确的行为。 - Chris K
@ChrisK 这真的很重要吗?考虑到从 println 退出后 done = true 产生了同步边缘 A,而在循环内调用 println 则产生了同步边缘 B,那么在 A 之前的操作不会是 happens-before B 吗?尽管如此,它只影响下一次递归,并且由于同步边缘防止循环提升,代码可能已经足够地利用了 println。你有什么想法? - user2982130
1
@xTrollxDudex 是的,没错。 - Chris K
我喜欢你用“内存屏障”来解释它。奇怪的是,一个操作在另一个操作之前发生并不总是被技术上认为是“先于”关系,这就是为什么你需要同步它或声明它为易失性的原因。而且,你很好地抓住了println调用由于其同步而具有相同的效果。 - Parker Hoyes
显示剩余2条评论

1

println() 的实现包含显式的内存屏障:

public void println(String x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

这会导致调用线程刷新所有变量。

下面的代码将与您的代码具有相同的行为:

    public void run() {
        int count = 0;
        while (!done) {
            count++;
            synchronized (this) {
            }
        }
        System.out.println("Done! " + Thread.currentThread().getName() + "  " + done);
    }

事实上,任何对象都可以用作监视器,以下内容也适用:
synchronized ("".intern()) {
}

另一种创建显式内存屏障的方法是使用volatile,因此以下代码将起作用:
new Thread() {
    private volatile int explicitMemoryBarrier;
    public void run() {
        int count = 0;
        while (!done) {
            count++;
            explicitMemoryBarrier = 0;
        }
        System.out.println("Done! " + Thread.currentThread().getName() + "  " + done);
    }
}.start();

@xTrollxDudex,这里奇怪的是我们用作锁的对象并不重要,如果我们使用同步关键字来进行互斥,那么你说得对。但是我们追求的是插入内存屏障的副作用,在这种情况下,使用哪个对象作为锁都无所谓。我不确定的一件事是何时Hotspot会选择从代码中剥离同步块。如果真的这样做了,我们就失去了内存屏障。 - Chris K
无论如何,使用同步块来产生其副作用,无论是像这里明确写出的那样还是隐藏在println中,都会引发错误。更好的方法是明确地使变量成为易失性变量,正如Parker的答案中所讨论的那样。但是ultracoms确实解释了OP为什么看到了这种行为。 - Chris K
@ChrisK 我在这里感到困惑 - 只有当栅栏引用相同的状态时,它们才会起作用,是吗? - user2982130
@xTrollxDudex 很遗憾,不是的。 - Chris K
1
@xTrollxDudex,我们正在涉及一些真正的低级细节,这些细节开始变得CPU中心化。Java内存模型在Java 5中进行了更新,值得一读,因为它被有意地更改以传达锁定监视器外部的数据应包含在栅栏内。这包括volatile行为(及其对其他字段的影响),甚至不涉及对象监视器。 - Chris K
显示剩余8条评论

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