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

今天我实验室的一个敏感手术完全出了差错。一个电子显微镜上的驱动器超出了它的边界,在一系列事件之后,我损失了价值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中溢出时表现良好,所以我认为这段代码没有任何问题。然而,尽管输出表明程序没有达到退出条件,但它达到了退出条件(它都达到了而且 not reached?)为什么?


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

50347 次浏览

因为currentPos是在线程外部被改变的,所以它应该被标记为volatile:

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

没有volatile,线程不能保证读入主线程中对currentPos的更新。因此,将继续为currentPos写入新的值,但出于性能原因,线程将继续使用以前缓存的版本。因为只有一个线程修改currentPos,所以你可以不使用锁,这将提高性能。

如果在线程中只读取一次值,用于比较和随后显示这些值,则结果会有很大不同。当我执行以下操作时,x总是显示为1,而y0和某个大整数之间变化。我认为在没有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);
}

显然,在读取currentPos之前,不会发生对currentPos的写入,但我不认为这可能是问题所在。

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

因此,从读取线程的角度来看,读取带有新值的x是合法的执行,但例如,读取带有默认值0的y。当你到达println语句时(顺便说一下,它是同步的,因此会影响读操作),变量有了它们的初始值,程序打印出预期的值。

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

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

作为旁注,正如已经提到的,synchronized(this) {}可以被JVM视为一个无操作(我知道你包括它来重现行为)。

你有普通的内存,'currentpos'引用和Point对象及其后面的字段,在两个线程之间共享,没有同步。因此,主线程中发生在内存上的写操作和创建线程中的读操作(称为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

鉴于main中的写操作和T中的读操作之间没有顺序关系,显然有几种方式可以产生结果,因为T可能会看到main的write to currentpos 之前的write to currentpos。Y或currentpos.x:

  1. 读取currentpos。首先是X,在X写入发生之前-得到0,然后读取currentpos。Y在写Y之前得到0。比较eval和true。的写操作 System.out.println被调用
  2. 读取为currentpos。在X写入发生后,首先读取currentpos。Y在写Y之前得到0。比较eval和true。写入对T…等。
  3. 读取为currentpos。首先是Y,在Y写发生之前(0),然后读取currentpos。X写入后的X,求值为true。等。

等等……这里有很多数据竞争。

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

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章

(最初的答案假设一个较弱的内存模型,因为我不确定JLS保证的volatile是否足够。回答被编辑以反映来自assylias的评论,指出Java模型更强——happens-before是可传递的——所以在currentpos上不稳定也足够了)。

您访问了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.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);
}
}