为什么这个Java程序会终止,尽管表面上它不应该(而且也没有)?

210
今天我实验室里的一个敏感操作完全出错了。电子显微镜上的一个驱动器超出了范围,在一系列事件之后,我损失了价值1200万美元的设备。我已经将故障模块中的40000多行缩小到了这个位置:
import java.util.*;

class A {
    static Point currentPos = new Point(1, 2);
    static class Point {
        int x;
        int y;
        Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }
    public static void main(String[] args) {
        new Thread() {
            void f(Point p) {
                synchronized(this) {}
                if (p.x + 1 != p.y) {
                    System.out.println(p.x + " " + p.y);
                    System.exit(1);
                }
            }
            @Override
            public void run() {
                while (currentPos == null);
                while (true)
                    f(currentPos);
            }
        }.start();
        while (true)
            currentPos = new Point(currentPos.x + 1, currentPos.y + 1);
    }
}

一些我得到的输出样本:
$ java A
145281 145282
$ java A
141373 141374
$ java A
49251 49252
$ java A
47007 47008
$ java A
47427 47428
$ java A
154800 154801
$ java A
34822 34823
$ java A
127271 127272
$ java A
63650 63651

由于这里没有浮点数运算,并且我们都知道在Java中,有符号整数在溢出时表现良好,所以我认为这段代码没有问题。然而,尽管输出表明程序没有达到退出条件,但它实际上已经达到了退出条件(既达到了又没有达到?)。为什么会这样呢?

我注意到这在某些环境中不会发生。我使用的是64位Linux上的OpenJDK 6。


41
  1. "12 million of equipment? i am really curious how that could happen..." 翻译:「1200万的设备?我真的很好奇怎么会发生这种事情...」
  2. "why you are using empty synchronization block : synchronized(this) {} ?" 翻译:「为什么你在使用空同步块: synchronized(this) {}?」
- Martin V.
86
这完全不能保证线程安全。 - Matt Ball
9
有趣的是:将final关键字(不影响产生的字节码)添加到字段xy中,“解决”了这个bug。尽管它不影响字节码,但这些字段会被标记上final,这让我想到这是JVM优化的一个副作用。 - Niv Steingarten
9
@Eugene: 它不应该结束。问题是“为什么会结束?”。构造了一个满足 p.x+1 == p.yPoint p,然后将其 引用 传递给轮询线程。最终,轮询线程决定退出,因为它认为其中一个接收到的 Point 不满足条件,但是控制台输出显示它应该已经满足条件。在这里缺少 volatile 意味着轮询线程可能会被卡住,但这显然不是问题的原因。 - Erma K. Pizarro
23
@JohnNicholas:真实的代码(很明显不是这段)有100%的测试覆盖率和数千个测试,其中许多测试以成千上万种顺序和排列方式测试各种事情... 测试并不能魔法般地找到由非确定性JIT / cache / scheduler引起的每个边缘情况。 真正的问题在于编写此代码的开发人员不知道构造不会在使用对象之前发生。注意,删除空的synchronized会使错误不会发生?这是因为我必须随机编写代码,直到找到可以确定重现此行为的代码。 - Dog
显示剩余13条评论
5个回答

141
显然,对currentPos的写操作不会在读取之前发生,但我不明白这可能是问题所在。
currentPos = new Point(currentPos.x + 1, currentPos.y + 1); 做了一些事情,包括将默认值(0)写入x和y,然后在构造函数中写入它们的初始值。由于对象没有安全发布,编译器/JVM可以自由地重新排序这4个写操作。
因此,从读取线程的角度来看,使用其新值读取x,但例如使用其默认值0读取y是合法的执行方式。当您到达println语句时(顺便说一下,它是同步的,因此影响读操作),变量具有它们的初始值,程序打印预期的值。
将currentPos标记为volatile将确保安全发布,因为您的对象实际上是不可变的——如果在构建后更改了对象,则volatile保证将不足以保证一致的对象。
或者,您可以使Point不可变,这也将确保安全发布,即使不使用volatile。要实现不可变性,只需将x和y标记为final即可。
作为旁注,如已经提到的,synchronized(this) {}可以被JVM视为无操作(我理解您包含它以重现行为)。

4
我不确定,但是将x和y都声明为final是否有相同的效果,避免了内存屏障? - Michael Böckling
3
设计更简单的方法是创建一个不可变的点对象,并在构建时测试不变量。这样,您就不会冒险发布危险的配置。 - Ron
2
不可变性本身并不能保证安全发布(如果x和y是私有的,但只通过getter暴露,则仍然存在相同的发布问题)。final或volatile确实可以保证它。我更喜欢使用final而不是volatile。 - Steve Kuo
@SteveKuo 不可变性需要使用final关键字 - 如果没有final,你最好能得到的是有效的不可变性,但它并没有相同的语义。 - assylias
我不懂Java。问题是因为“静态类Point”使得所有的“new”共享相同的变量/内存地址,所以在构造函数中它可以写入一个变量,而另一个线程读取另一个变量吗?(没有锁定/同步)。如果没有使用“静态类”,这会遭受到错误吗?我的直觉告诉我,基于数字,问题是正在读取p.x,ctor正在锁定自身(这是一个特性吗?),然后分配x、y,然后+p.y发生。他使用的if语句很奇怪,为什么是“.x+1==.y”,它似乎应该是“141373 141373”。 - user34537
显示剩余2条评论

29

由于currentPos在线程外被更改,因此应将其标记为volatile

static volatile Point currentPos = new Point(1,2);

如果没有使用volatile,线程不能保证读取到主线程正在更新的currentPos变量的值。因此,新值会继续被写入currentPos变量中,但线程为了性能原因而继续使用之前缓存的旧值。由于只有一个线程修改currentPos变量,因此可以不使用锁来提高性能。

如果在线程中仅读取一次值进行比较和后续显示,则结果将大不相同。当我执行以下操作时,x始终显示为1,而y的值在0和某个大整数之间变化。我认为,在没有使用volatile关键字的情况下,它的行为在这一点上有些未定义,并且代码的JIT编译可能导致它表现出这种行为。此外,如果我注释掉空的synchronized(this) {}块,那么代码也能正常工作,我怀疑是因为锁定引起足够的延迟,以便重新读取currentPos及其字段,而不是从缓存中使用它们。

int x = p.x + 1;
int y = p.y;

if (x != y) {
    System.out.println(x+" "+y);
    System.exit(1);
}

2
是的,我也可以在所有内容周围放置一个锁。你的意思是什么? - Dog
我为使用volatile添加了一些额外的解释。 - Ed Plese

19

您有普通的内存,'currentpos' 引用和 Point 对象及其后面的字段,由 2 个线程共享,没有同步。因此,在主线程中发生的写入和创建的线程(称之为 T)中的读取之间没有定义的顺序。

主线程正在执行以下写入操作(忽略 point 的初始设置,将导致 p.x 和 p.y 具有默认值):

  • 对 p.x 的写入
  • 对 p.y 的写入
  • 对 currentpos 的写入

因为在同步/障碍方面这些写入没有特殊之处,运行时可以自由地允许 T 线程以任何顺序看到它们发生(当然,主线程始终按照程序顺序有序地进行写入和读取),并且出现在 T 中的读取的任何点。

因此,T 执行以下操作:

  1. 读取 currentpos 到 p
  2. 读取 p.x 和 p.y(任意顺序)
  3. 比较,并进行分支
  4. 读取 p.x 和 p.y(任意顺序)并调用 System.out.println

鉴于在主函数中的写入和T中的读取没有任何顺序关系,因此这可能会以几种方式产生结果,因为T可能会在currentpos.y或currentpos.x的写入之前就看到main对currentpos的写入:

  1. 首先读取currentpos.x,在x写入发生之前-获得0,然后在y写入发生之前读取currentpos.y- 获得0。比较结果为真。写操作变为T可见,System.out.println被调用。
  2. 在x写入发生后,它首先读取currentpos.x,然后在y写入发生之前读取currentpos.y-获得0。比较结果为真。写操作变为T可见...等等。
  3. 首先读取currentpos.y,在y写入发生之前(0),然后在x写入之后读取currentpos.x,结果为真等。

诸如此类... 这里存在许多数据竞争。

我怀疑这里存在的错误假设是认为由这一行产生的写操作在执行该线程的程序顺序中对所有线程都可见:

currentPos = new Point(currentPos.x+1, currentPos.y+1);

Java无法保证线程在读取其他线程的写操作时具有有序性(这对性能非常不利)。如果您的程序需要保证写入相对于其他线程的读取顺序,那么必须添加其他措施。有人建议使x、y字段为final,或者将currentpos设置为volatile。
  • 如果将x、y字段设置为final,则Java保证它们的值的写入将在构造函数在所有线程中返回之前发生。因此,在分配给currentpos之后,T线程保证以正确的顺序看到写入。
  • 如果将currentpos设置为volatile,则Java保证这是一个同步点,与其他同步点完全有序。由于在main函数中,对x和y的写入必须发生在对currentpos的写入之前,因此在另一个线程中对currentpos的任何读取也必须看到先发生的x、y的写入。
  • 使用final的优点是可以使字段变成不可变的,从而允许缓存值。使用volatile会导致在每次读写currentpos时进行同步,这可能会影响性能。

    请参阅Java语言规范的第17章,了解详细信息: http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html

    (最初的答案假定内存模型较弱,因为我不确定JLS是否保证volatile足够。答案已经编辑以反映assylias的评论,指出Java模型更强 - happens-before是传递性的 - 所以当前位置上的volatile也足够)。


    2
    这是我认为最好的解释。非常感谢! - skyde
    1
    @skyde 对于 volatile 的语义理解有误。volatile 保证对于一个 volatile 变量的读取将会看到最新的可用的写入,以及任何之前的写入。在这种情况下,如果将 currentPos 声明为 volatile,那么赋值操作将确保 currentPos 对象及其成员的安全发布,即使它们本身不是 volatile 的。 - assylias
    我之前说过,就我个人而言,我无法确切地看到JLS如何保证volatile与其他正常的读写形成屏障。从技术上讲,我不可能错;)。在涉及内存模型时,最好假设没有顺序保证,这样即使出现错误也是安全的,而不是相反,出现错误并且不安全。如果volatile提供了这种保证,那就太好了。您能解释一下JLS的第17章是如何提供这种保证的吗? - paulj
    2
    简而言之,在 Point currentPos = new Point(x, y) 中,你有三个写操作:(w1) this.x = x,(w2) this.y = y 和 (w3) currentPos = the new point。程序顺序保证了 hb(w1, w3) 和 hb(w2, w3)。稍后在程序中,你会读取 (r1) currentPos。如果 currentPos 不是 volatile 的,那么 r1 和 w1、w2、w3 之间就没有 hb 关系,因此 r1 可以观察到其中任何一个(或者没有)。使用 volatile,你引入了 hb(w3, r1)。hb 关系是可传递的,所以你还引入了 hb(w1, r1) 和 hb(w2, r1)。这在《Java 并发编程实战》(3.5.3. 安全发布惯用法)中有总结。 - assylias
    2
    啊,如果hb以那种方式是可传递的,那么这就足够强的“屏障”了。我必须说,很难确定JLS的17.4.5定义了hb具有该属性。它肯定不在17.4.5开头附近给出的属性列表中。传递闭包只在一些解释性注释之后才提到!无论如何,好知道,谢谢你的答案!:)。注意:我会更新我的答案以反映assylias的评论。 - paulj
    显示剩余2条评论

    -2
    你可以使用一个对象来同步写入和读取。否则,就像其他人之前所说的那样,在两个读取 p.x+1 和 p.y 的中间会发生对 currentPos 的写入。
    new Thread() {
        void f(Point p) {
            if (p.x+1 != p.y) {
                System.out.println(p.x+" "+p.y);
                System.exit(1);
            }
        }
        @Override
        public void run() {
            while (currentPos == null);
            while (true)
                f(currentPos);
        }
    }.start();
    Object sem = new Object();
    while (true) {
        synchronized(sem) {
            currentPos = new Point(currentPos.x+1, currentPos.y+1);
        }
    }
    

    实际上这个可以完成任务。在我的第一次尝试中,我把读取操作放在了同步块内部,但后来我意识到那并不是必要的。 - Germano Fronza
    1
    JVM可以证明“sem”没有被共享,并将同步语句视为无操作......它能解决这个问题纯粹是运气好。 - assylias
    4
    我不喜欢多线程编程,因为很多事情都是靠运气才能正常工作。 - Jonathan Allen

    -3

    您正在两次访问currentPos,并且没有提供任何保证它在这两次访问之间没有被更新。

    例如:

    1. x = 10,y = 11
    2. 工作线程将p.x评估为10
    3. 主线程执行更新,现在x = 11,y = 12
    4. 工作线程将p.y评估为12
    5. 工作线程注意到10 + 1!= 12,因此打印并退出。

    您实际上正在比较两个不同的点。

    请注意,即使将currentPos设置为volatile也无法保护您免受此影响,因为这是工作线程进行的两次单独读取。

    添加一个

    boolean IsValid() { return x+1 == y; }
    

    将该方法添加到您的points类中。这将确保在检查x+1 == y时仅使用一个currentPos值。

    currentPos 只被读取一次,它的值被复制到 p 中。p 被读取两次,但它总是指向同一个位置。 - Jonathan Allen

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