为什么这个Java程序会终止,尽管显然它不应该(并没有)?

时间:2013-04-23 01:03:59

标签: java concurrency java-memory-model memory-visibility

今天我实验室的敏感操作完全错了。电子显微镜上的执行器越过它的边界,在一系列事件之后,我损失了1200万美元的设备。我已将故障模块中超过40K的线路缩小到:

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。

5 个答案:

答案 0 :(得分:138)

  

显然,在读取之前写入currentPos并不会发生,但我不知道这是怎么回事。

currentPos = new Point(currentPos.x+1, currentPos.y+1);做了一些事情,包括将默认值写入xy(0),然后在构造函数中写入它们的初始值。由于您的对象未安全发布,因此编译器/ JVM可以自由地重新排序这4个写操作。

因此,从阅读线程的角度来看,使用其新值读取x是合法的执行,但y的默认值为0。当您到达println语句(顺便说一下它同步并因此影响读取操作)时,变量具有其初始值,程序将打印预期值。

currentPos标记为volatile将确保安全发布,因为您的对象实际上是不可变的 - 如果在您的实际用例中,对象在构造后发生变异,volatile保证将不够你可以再次看到一个不一致的对象。

或者,即使不使用Point,您也可以使volatile不可变,这也将确保安全发布。要实现不变性,您只需标记xy final。

作为旁注,如前所述,JVM可以将synchronized(this) {}视为无操作(我知道你将其包括在内以重现行为)。

答案 1 :(得分:29)

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

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

如果没有volatile,则不保证线程会读取正在主线程中生成的currentPos的更新。因此,继续为currentPos编写新值,但由于性能原因,线程继续使用以前的缓存版本。由于只有一个线程修改了currentPos,因此您可以在没有锁定的情况下离开,从而提高性能。

如果您在线程中仅读取一次值以用于比较和随后显示它们,则结果会有很大不同。当我执行以下操作时,x始终显示为1y0和某个大整数之间变化。我认为在没有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 :(得分:19)

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

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

  • 到p.x
  • 到p.y
  • to currentpos

因为这些写入在同步/障碍方面没有什么特别之处,所以运行时可以自由地允许T线程看到它们以任何顺序出现(主线程当然总是看到根据程序顺序排序的写入和读取) ,并在T中的读数之间的任何点发生。

所以T正在做:

  1. 将currentpos读到p
  2. 读取p.x和p.y(按任意顺序)
  3. 比较,并采取分支
  4. 读取p.x和p.y(任一顺序)并调用System.out.println
  5. 鉴于main中的写入与T中的读取之间没有排序关系,显然有几种方法可以产生结果,因为T可能会在写入之前看到main对currentpos 的写入currentpos.y或currentpos.x:

    1. 首先读取currentpos.x,在x写入发生之前 - 得到0,然后在y写入发生之前读取currentpos.y - 得到0.比较evals为true。写道 变得可见T. System.out.println被调用。
    2. 首先读取currentpos.x,在x写入发生之后,然后在y写入发生之前读取currentpos.y - 得到0.比较evals为true。写作对T ......等可见。
    3. 首先读取currentpos.y,在y写入发生之前(0),然后在x写入后读取currentpos.x,evals为true。等
    4. 依此类推......这里有很多数据竞赛。

      我怀疑这里有缺陷的假设是认为从这一行产生的写操作在执行它的程序的程序顺序中的所有线程中都是可见的:

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

      Java没有这样的保证(性能很糟糕)。如果您的程序需要保证相对于其他线程中的读取的写入顺序,则必须添加更多内容。其他人建议将x,y字段设为最终字段,或者使currentpos不稳定。

      • 如果你将x,y字段设为final,那么Java保证在构造函数返回之前,在所有线程中都会看到它们的值写入。因此,由于对currentpos的赋值在构造函数之后,因此T线程保证以正确的顺序查看写入。
      • 如果你使currentpos易失,那么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模型更强 - 发生 - 之前是传递性的 - 并且如此易变currentpos也足够了。)

答案 3 :(得分:-2)

您可以使用对象来同步写入和读取。否则,正如其他人之前所说的那样,对currentPos的写入将发生在两个读取p.x + 1和p.y的中间。

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);
    }
}

答案 4 :(得分:-3)

您正在访问currentPos两次,并且不保证在这两次访问之间不会更新它。

例如:

  1. x = 10,y = 11
  2. 工作线程将p.x评估为10
  3. 主线程执行更新,现在x = 11,y = 12
  4. 工作线程将p.y评估为12
  5. 工作线程注意到10 + 1!= 12,因此打印并退出。
  6. 您实际上是在比较两个不同的点。

    请注意,即使使currentPos volatile不会保护您,也不会保护您,因为它是工作线程的两次单独读取。

    添加

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

    你的积分课的方法。这将确保在检查x + 1 == y时仅使用currentPos的一个值。