为什么该Java程序终止了,尽管它显然不应该(也不应该)终止?


205

今天,我实验室中的一个敏感操作完全出错。电子显微镜上的执行器越过边界,经过一连串的事件,我损失了1200万美元的设备。我将故障模块中的40,000多行缩小为:

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
1200万设备?我真的很好奇这怎么可能...为什么要使用空的同步块:Synchronized(this){}?
Martin V.

84
这甚至不是远程线程安全的。
马特·鲍尔

8
有趣的是:将final限定符(对产生的字节码没有影响)添加到字段中xy“解决”该错误。尽管它不影响字节码,但是用它标记了字段,这使我认为这是JVM优化的副作用。
Niv Steingarten

9
@Eugene:应该没有结束。问题是“为什么会结束?”。Point p构建满足的A p.x+1 == p.y,然后将引用传递给轮询线程。最终,轮询线程决定退出,因为它认为接收到的条件之一不满足该条件Point,但是控制台输出显示它应该已经满足。缺少volatile此处只是意味着轮询线程可能会卡住,但这显然不是问题所在。
Erma K. Pizarro

21
@JohnNicholas:真正的代码(显然不是这样)具有100%的测试覆盖率和数千个测试,其中许多测试以成千上万的不同顺序和排列对事物进行了测试...测试无法神奇地发现由不确定性引起的所有边缘情况JIT /缓存/调度程序。真正的问题是,编写此代码的开发人员在使用该对象之前并不知道构造不会发生。请注意,删除空白内容synchronized不会导致错误发生吗?那是因为我不得不随机编写代码,直到找到可以确定性地重现此行为的代码为止。
2013年

Answers:


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可以将其视为无操作(我知道您将其包括在内可以重现行为)。


4
我不确定,但是避免x和y final具有相同的效果,从而避免了内存障碍吗?
MichaelBöckling'13年

3
一个简单的设计是一个不变的点对象,它可以测试构造上的不变性。因此,您永远不会冒险发布危险的配置。
罗恩

@BuddyCasino是的-我已经添加了。老实说,我不记得3个月前的整个讨论(在评论中建议使用final,所以不确定为什么我没有将它作为选择)。
assylias 2013年

2
不变性本身并不能保证发布的安全性(如果x和y是私有的,但仅使用吸气剂暴露,则仍然存在相同的发布问题)。final或volatile可以保证。我更喜欢final而不是volatile。
史蒂夫·郭

@SteveKuo不变性需要最终的-如果没有最终的,最好的是不具有相同语义的有效不变性。
assylias

29

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

2
是的,我还可以锁定所有内容。你想说什么?
2013年

我为的使用添加了其他说明volatile
Ed Plese

19

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

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

  • 到px
  • py
  • 到currentpos

因为就同步/屏障而言,这些写入没有什么特别的,所以运行时可以自由地允许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。将eval与true进行比较。写入对T变为可见。将调用System.out.println。
  2. 在发生x写入之后,它将先读取currentpos.x,然后在进行y写入之前读取currentpos.y-变为0。将eval与true进行比较。写入对T ...可见。
  3. 它先读取currentpos.y,然后进行y写入(0),然后在x写入之后读取currentpos.x,等效为true。等等

依此类推...这里有许多数据竞赛。

我怀疑这里有一个错误的假设,认为该行产生的写入在执行该线程的程序顺序的所有线程中均可见:

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

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

  • 如果将x,y字段定为最终值,则Java保证在所有线程中都将在构造函数返回之前对它们的值进行写操作。因此,由于对currentpos的分配是在构造函数之后,因此T线程可以确保以正确的顺序查看写入。
  • 如果使currentpos易变,则Java保证这是一个同步点,它将与其他同步点一起按顺序排序。通常,对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也足够)。


2
我认为这是最好的解释。非常感谢!
2013年

1
@skyde,但是在volatile语义上是错误的。volatile保证对volatile变量的读取将看到volatile变量的最新可用写入以及任何先前的写入。在这种情况下,如果currentPoscurrentPos其设为易失性,则分配可以确保对象及其成员的安全发布,即使它们本身也不易失。
assylias

好吧,我说的是,对于我自己,我无法确切地看到JLS如何保证volatile与其他正常读取和写入形成障碍。从技术上讲,我在这上不会错;)。当涉及到内存模型时,谨慎地假设不能保证排序,并且与其他方法相反,该命令是错误的(您仍然很安全),并且是错误和不安全的。如果volatile提供了保证,那就太好了。您能解释一下JLS第17章如何提供吗?
paulj

2
简而言之,在中Point currentPos = new Point(x, y),您有3个写入:(w1)this.x = x,(w2)this.y = y和(w3)currentPos = the new point。程序顺序保证了hb(w1,w3)和hb(w2,w3)。在程序的后面,您阅读(r1)currentPos。如果currentPos不是非易失性的,则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开头附近给出的属性列表中。传递性闭包仅在一些解释性注释之后进一步提及!无论如何,很高兴知道,谢谢您的回答!:)。注意:我将更新答案以反映亚述的评论。
paulj,2013年

-2

您可以使用一个对象来同步写入和读取。否则,就像其他人之前所说的那样,将在两次读取p.x + 1和py的中间发生对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);
    }
}

实际上,这可以完成工作。在我的第一次尝试中,我将读取内容放入了同步块中,但是后来我意识到这并不是必须的。
2013年

1
-1 JVM可以证明sem不共享,并将同步语句视为无操作...它解决了这个问题,这真是倒霉。
assylias

4
我讨厌多线程编程,运气太强了。
乔纳森·艾伦

-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的一个值。


currentPos仅读取一次,其值复制到p中。p被读取了两次,但是它总是指向同一位置。
乔纳森·艾伦,
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.