易失性保证和乱序执行

43

重要编辑:我知道关于“发生在”在进行两个赋值的线程中,我的问题是另一个线程是否可能在"a"仍然为null的情况下读取非空的"b"。所以我知道如果你从与之前调用setBothNonNull(...)的线程相同的线程中调用doIt(),它不会抛出NullPointerException。但是如果有人从另一个线程中调用doIt(),而不是调用setBothNonNull(...)的那个线程呢?

请注意,本问题仅涉及volatile关键字和volatile保证:它与synchronized关键字无关(因此请不要回答“您必须使用synchronize”,因为我没有任何问题需要解决:我只想了解关于乱序执行的volatile保证(或缺乏保证))。

假设我们有一个对象包含两个volatile字符串引用,它们由构造函数初始化为null,并且我们只有一种方法来修改这两个字符串:通过调用setBoth(...),并且之后只能将它们的引用设置为非null引用(只允许构造函数将它们设置为null)。

例如(这只是一个示例,还没有问题):

public class SO {

    private volatile String a;
    private volatile String b;

    public SO() {
        a = null;
        b = null;
    }

    public void setBothNonNull( @NotNull final String one, @NotNull final String two ) {
        a = one;
        b = two;
    }

    public String getA() {
        return a;
    }

    public String getB() {
        return b;
    }

}

setBothNoNull(...)中,为非空参数"a"赋值的行出现在为非空参数"b"赋值的行之前。

那么如果我这样做(再次强调,这里没有问题,问题稍后会提出):

doIt() {
    if ( so.getB() != null ) {
        System.out.println( so.getA().length );
    }
}

由于乱序执行,我能否理解为我会得到 NullPointerException?

换句话说:读取一个非空的"b"并不能保证我读取到一个非空的"a"?

因为由于乱序(多)处理器和volatile的工作方式,“b”可能会被赋值在“a”之前?

volatile保证连续写入后的读取始终可以看到最后写入的值,但这里存在乱序的“问题”,对吗?(再次说明,“问题”是故意制造的,以尝试理解volatile关键字和Java内存模型的语义,而不是解决问题)。


如果不能保证在b之前分配a,那么有可能会得到一个非空的b,但仍然得到一个空的a。现在,使用volatile有哪些保证呢?确实是个好问题+1。 - kiwicptn
5个回答

27

不会,您永远不会遇到NPE。这是因为volatile还具有引入happens-before关系的内存效果。换句话说,它将防止重新排序。

a = one;
b = two;

以上陈述不会重新排序,并且如果b已经有值“two”,则所有线程都将观察到a的值为“one”。以下是David Holmes解释的线程:
http://markmail.org/message/j7omtqqh6ypwshfv#query:+page:1+mid:34dnnukruu23ywzy+state:results 编辑(回复跟进):Holmes的意思是,编译器理论上可以对单个线程A进行重新排序。然而,存在其他线程,并且它们可以检测到重新排序。这就是为什么编译器不允许进行这种重新排序的原因。Java内存模型要求编译器特别确保没有线程会检测到这样的重新排序。
但是,如果从调用setBothNonNull(...)的线程之外的另一个线程调用doIt(),您仍将永远不会遇到NPE。volatile语义强制执行线程间排序。这意味着对于所有现有线程,分配“one”的操作发生在分配“two”的操作之前。

David Holmes所写的更加令人困惑,因为他说:“正如你所说,调用methodA()的线程无法知道methodA()内部是否被重新排序,但另一个线程可以知道。”那么,如果在我的示例中我无法读取b非空和a为空,另一个线程如何知道已经发生了重新排序呢?(在我的示例中,这将是检测是否发生重新排序的唯一方法)。 - SyntaxT3rr0r
嗨,我已经添加了我的回复到原始答案。 - Enno Shioji
非常感谢您的跟进,我会接受您的答案,但首先我想给其他人留下评论/答案,如果有人想要补充什么的话,非常感谢您抽出时间向我解释这个问题 :) - SyntaxT3rr0r
1
@WizardOfOdds: NP :) 未来,您可能希望将您的问题直接发送到http://altair.cs.oswego.edu/mailman/listinfo/concurrency-interest。这是一个邮件列表,有许多JSR166人员经常出没,他们非常友好和乐于助人。当然,他们对Java并发编程非常熟悉。 - Enno Shioji

8
我理解正确吗?由于乱序执行,我可以得到一个NullPointerException吗?换句话说:因为我读取了非空的“b”,并不能保证我会读到非空的“a”?
假设分配给a和b的值都是非空的,我认为你的理解是不正确的。JLS说:
(1)如果x和y是同一线程的动作,并且x在程序顺序中先于y,则hb(x,y)。
(2)如果一个操作x与后面的操作y同步,则我们也有hb(x,y)。
(3)如果hb(x,y)和hb(y,z),则hb(x,z)。

(4)对易失性变量(§8.3.1.4)v的写入与任何线程的v的所有后续读取同步(其中后续根据同步顺序定义)。
定理
假设线程#1调用setBoth(...)一次,并且参数是非空的,并且线程#2观察到b是非空的,则线程#2随后不能观察到a为空。
非正式证明
1.通过(1)-hb(write(a,non-null),write(b,non-null))在线程#1中 2.通过(2)和(4)-hb(write(b,non-null),read(b,non-null)) 3.通过(1)-hb(read(b,non-null),read(a,XXX))在线程#2中, 4.通过(4)-hb(write(a,non-null),read(b,non-null)) 5.通过(4)-hb(write(a,non-null),read(a,XXX))
换句话说,对a的非空值的写入“发生在”对a的值(XXX)的读取之前。 XXX为空的唯一方法是有其他操作将null写入a,使得hb(write(a,non-null),write(a,XXX))和hb(write(a,XXX),read(a,XXX))。根据问题定义,这是不可能的,因此XXX不能为null。 QED。

解释 - JLS(Java语言规范)指出,hb(...)("happens-before")关系并不完全禁止重排序。但是,如果hb(xx,yy),那么只有当重排序后的代码具有与原始序列相同的可观察效果时,才允许对动作xx和yy进行重排序。


@Stephen C:我本应该称其为setBothNonNull,但将第一个参数设置为null是不可能发生的,因为@NotNull保证会抛出异常(我使用IntelliJ IDEA已经多年了,并且在它可用于IntelliJ之前就开始使用@NotNull,比它进入Eclipse的时间要早得多)。 - SyntaxT3rr0r
1
@Stephen C:“如果x和y是同一线程的操作...”但我的问题确切地说是另一个线程是否读取易失性变量。阅读您的答案,您似乎让乱序执行在Java中不存在,我认为这不是正确的。如果我要从“执行赋值”的相同线程中进行非空/空测试,那么我就知道我不会有NPE。我的问题是,如果我从另一个线程中进行此测试呢? - SyntaxT3rr0r
1
@Stephen C:+1...我真的很喜欢你问题非常清晰的编辑。现在我只需要掌握“发生在之前”的细节了。 - SyntaxT3rr0r

2
我找到了以下文章,它解释了在这种情况下volatile与synchronized具有相同的排序语义。 Java Volatile is Powerful

@Brandon Bodnar:感谢你提供的链接...不幸的是,这篇文章并没有解释重新排序的内容。它说在唯一的线程中保证了重新排序,但它继续给出一个例子,其中threadTwo()可以读取"b" true和"a" false。这与我的非null/null示例完全相同。它并没有清楚地表明这只是一个例子,以及volatile是否可以解决这个问题。 - SyntaxT3rr0r

2
虽然Stephen C和被接受的答案都很好,几乎涵盖了所有内容,但值得注意的是,变量a不必是volatile - 你仍然不会遇到NPE。 这是因为在a = one和b = two之间将存在happens-before关系,无论a是否是volatile。因此,Stephen C的正式证明仍然适用,只是不需要a是volatile。

0

我阅读了这个页面,并找到了一个非易失性和非同步版本的你的问题:

class Simple {
    int a = 1, b = 2;
    void to() {
        a = 3;
        b = 4;
    }
    void fro() {
        System.out.println("a= " + a + ", b=" + b);
    }
}

fro 可以获得 a 的值为 1 或 3,同时可以独立地获得 b 的值为 2 或 4。

(我意识到这并没有回答你的问题,但它是一个补充。)


请注意,您引用的页面来自Java语言规范的第二版,该版本已被第三版取代,第三版不再具有您引用的那个部分。 - Avi
@kiwicptn:感谢你的回复,但正如Avi所写,自那时起Java内存模型已经发生了变化(这就是为什么现在如果你使用volatile,双重检查锁定会起作用,而以前则不行)。 - SyntaxT3rr0r

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