208

A sensitive operation in my lab today went completely wrong. An actuator on an electron microscope went over its boundary, and after a chain of events I lost $12 million of equipment. I've narrowed down over 40K lines in the faulty module to this:

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

Some samples of the output I'm getting:

$ 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

Since there isn't any floating point arithmetic here, and we all know signed integers behave well on overflow in Java, I'd think there's nothing wrong with this code. However, despite the output indicating that the program didn't reach the exit condition, it reached the exit condition (it was both reached and not reached?). Why?


I've noticed this doesn't happen in some environments. I'm on OpenJDK 6 on 64-bit Linux.

4

5 回答 5

140

显然,在读取它之前不会发生对 currentPos 的写入,但我不明白这可能是什么问题。

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

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

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

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

作为旁注,正如已经提到synchronized(this) {}的,JVM 可以将其视为无操作(我知道您将其包括在内以重现行为)。

于 2013-05-01T17:29:10.083 回答
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);
}
于 2013-04-23T02:06:45.193 回答
19

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

主线程正在执行以下写入(忽略点的初始设置,将导致 px 和 py 具有默认值):

  • 到像素
  • 到py
  • 到当前位置

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

所以 T 正在做:

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

鉴于 main 中的写入和 T 中的读取之间没有顺序关系,显然有几种方法可以产生结果,因为 T 可能会在写入 currentpos.y 或 currentpos.x之前看到 main 对 currentpos 的写入:

  1. 它首先读取 currentpos.x,在 x 写入发生之前 - 获取 0,然后在 y 写入发生之前读取 currentpos.y - 获取 0。将 evals 与 true 进行比较。写入对 T 可见。调用 System.out.println。
  2. 它在 x 写入发生后首先读取 currentpos.x,然后在 y 写入发生之前读取 currentpos.y - 得到 0。将 evals 与 true 进行比较。写入对 T... 等变得可见。
  3. 它首先读取 currentpos.y,在 y 写入发生之前 (0),然后在 x 写入之后读取 currentpos.x,评估结果为 true。等等

等等......这里有许多数据竞争。

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

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

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

  • 如果您将 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 上的 volatile 也足够了)。

于 2013-08-15T17:54:00.287 回答
-2

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

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);
    }
}
于 2013-08-15T21:19:14.267 回答
-3

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

例如:

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

您本质上是在比较两个不同的点。

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

添加一个

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

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

于 2013-08-15T18:10:46.567 回答