30

我试图简单地测试 JLS 保证的最终字段的初始化安全性。这是为了我正在写的一篇论文。但是,根据我当前的代码,我无法让它“失败”。有人可以告诉我我做错了什么,或者这只是我必须一遍又一遍地运行然后看到一些不幸的时机失败?

这是我的代码:

public class TestClass {

    final int x;
    int y;
    static TestClass f;

    public TestClass() {
        x = 3;
        y = 4;
    }

    static void writer() {
        TestClass.f = new TestClass();
    }

    static void reader() {
        if (TestClass.f != null) {
            int i = TestClass.f.x; // guaranteed to see 3
            int j = TestClass.f.y; // could see 0

            System.out.println("i = " + i);
            System.out.println("j = " + j);
        }
    }
}

我的线程这样称呼它:

public class TestClient {

    public static void main(String[] args) {

        for (int i = 0; i < 10000; i++) {
            Thread writer = new Thread(new Runnable() {
                @Override
                public void run() {
                    TestClass.writer();
                }
            });

            writer.start();
        }

        for (int i = 0; i < 10000; i++) {
            Thread reader = new Thread(new Runnable() {
                @Override
                public void run() {
                    TestClass.reader();
                }
            });

            reader.start();
        }
    }
}

我已经运行过这个场景很多很多次了。我当前的循环产生了 10,000 个线程,但我已经完成了这 1000、100000 甚至一百万个线程。仍然没有失败。对于这两个值,我总是看到 3 和 4。我怎样才能让它失败?

4

9 回答 9

20

我写了规范。TL; 这个答案的 DR 版本是,仅仅因为它可能会看到 0 表示 y,这并不意味着它一定看到 0 表示 y。

在这种情况下,正如您所指出的,最终字段规范保证您将看到 x 为 3。将编写器线程视为具有 4 条指令:

r1 = <create a new TestClass instance>
r1.x = 3;
r1.y = 4;
f = r1;

您可能看不到 3 for x 的原因是编译器重新排序了此代码:

r1 = <create a new TestClass instance>
f = r1;
r1.x = 3;
r1.y = 4;

在实践中通常实现对 final 字段的保证的方式是确保构造函数在任何后续程序操作发生之前完成。想象一下,有人在 r1.y = 4 和 f = r1 之间竖起了一道巨大的屏障。因此,在实践中,如果您有任何对象的最终字段,您可能会获得所有这些字段的可见性。

现在,理论上,有人可以编写一个没有以这种方式实现的编译器。事实上,很多人经常谈论通过编写最恶意的编译器来测试代码。这在 C++ 人中尤为常见,他们的语言中有很多未定义的角落,可能导致可怕的错误。

于 2013-03-20T06:55:26.053 回答
7

从 Java 5.0 开始,您可以保证所有线程都将看到构造函数设置的最终状态。

如果你想看到这个失败,你可以尝试像 1.3 这样的旧 JVM。

我不会打印出每个测试,我只会打印出失败。您可能会遇到百万分之一的失败,但会错过它。但如果你只打印失败,它们应该很容易被发现。

查看此失败的更简单方法是添加到编写器。

f.y = 5;

并测试

int y = TestClass.f.y; // could see 0, 4 or 5
if (y != 5)
    System.out.println("y = " + y);
于 2011-02-21T14:33:59.117 回答
5

我想看到一个失败的测试或解释为什么当前的 JVM 不可能。

多线程和测试

您无法通过测试来证明多线程应用程序已损坏(或未损坏),原因如下:

  • 该问题可能仅每运行 x 小时出现一次,x 太高以至于您不太可能在简短的测试中看到它
  • 问题可能只出现在 JVM/处理器架构的某些组合中

在您的情况下,要使测试中断(即观察 y == 0)将需要程序查看部分构造的对象,其中某些字段已正确构造而有些则未正确构造。这通常不会发生在 x86 / 热点上。

如何确定多线程代码是否损坏?

证明代码有效或损坏的唯一方法是对其应用 JLS 规则并查看结果。对于数据竞争发布(不围绕对象或 y 的发布进行同步),JLS 不保证 y 将被视为 4(可以通过默认值 0 看到)。

那段代码真的能破解吗?

在实践中,一些 JVM 会更好地使测试失败。例如,一些编译器(参见本文中的“A test case shows that it doesn't work” )可以转换TestClass.f = new TestClass();成类似的东西(因为它是通过数据竞赛发布的):

(1) allocate memory
(2) write fields default values (x = 0; y = 0) //always first
(3) write final fields final values (x = 3)    //must happen before publication
(4) publish object                             //TestClass.f = new TestClass();
(5) write non final fields (y = 4)             //has been reodered after (4)

JLS 要求 (2) 和 (3) 在对象发布 (4) 之前发生。但是,由于数据竞争,不能保证 (5) - 如果线程从未观察到写入操作,它实际上是合法的执行。因此,通过适当的线程交错,可以想象如果reader在 4 到 5 之间运行,您将获得所需的输出。

我手头没有赛门铁克 JIT,所以无法通过实验证明:-)

于 2013-03-14T08:55:50.727 回答
3

是一个观察到非最终值的默认值的示例,尽管构造函数设置了它们并且没有泄漏this。这是基于我的另一个更复杂的问题。我一直看到人们说它不会发生在 x86 上,但我的例子发生在 x64 linux openjdk 6 上......

于 2013-04-26T18:35:07.147 回答
3

这是一个很好的问题,答案很复杂。为了便于阅读,我将其分成几部分。

人们在这里已经说了足够多的时间了,在严格的规则JLS-您应该能够看到所需的行为。但是编译器(我的意思是C1and C2),虽然他们必须尊重JLS,但他们可以进行优化。我稍后会谈到这个。

让我们采用第一个简单的场景,其中有两个non-final变量,看看我们是否可以发布不正确的对象。对于这个测试,我使用了一个专门为这种测试量身定制的工具。这是一个使用它的测试:

@Outcome(id = "0, 2", expect = Expect.ACCEPTABLE_INTERESTING, desc = "not correctly published")
@Outcome(id = "1, 0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "not correctly published")
@Outcome(id = "1, 2", expect = Expect.ACCEPTABLE, desc = "published OK")
@Outcome(id = "0, 0", expect = Expect.ACCEPTABLE, desc = "II_Result default values for int, not interesting")
@Outcome(id = "-1, -1", expect = Expect.ACCEPTABLE, desc = "actor2 acted before actor1, this is OK")
@State
@JCStressTest
public class FinalTest {

    int x = 1;
    Holder h;

    @Actor
    public void actor1() {
        h = new Holder(x, x + 1);
    }

    @Actor
    public void actor2(II_Result result) {
        Holder local = h;
        // the other actor did it's job
        if (local != null) {
            // if correctly published, we can only see {1, 2} 
            result.r1 = local.left;
            result.r2 = local.right;
        } else {
            // this is the case to "ignore" default values that are
            // stored in II_Result object
            result.r1 = -1;
            result.r2 = -1;
        }
    }

    public static class Holder {

        // non-final
        int left, right;

        public Holder(int left, int right) {
            this.left = left;
            this.right = right;
        }
    }
}

您不必对代码了解太多;尽管最小的解释是这样的:有两个Actors 改变了一些共享数据,并且这些结果被注册。@Outcome注释控制那些注册的结果并设定一定的期望(在幕后事情更有趣和冗长)。请记住,这是一个非常锋利和专业的工具;你不能在两个线程运行的情况下做同样的事情。

现在,如果我运行它,结果如下:

 @Outcome(id = "0, 2", expect = Expect.ACCEPTABLE_INTERESTING....)
 @Outcome(id = "1, 0", expect = Expect.ACCEPTABLE_INTERESTING....)

将被观察到(意味着对象的不安全发布,其他 Actor/线程实际上已经看到了)。

具体来说,这些是在所谓的TC2测试套件中观察到的,它们实际上是这样运行的:

java... -XX:-TieredCompilation 
        -XX:+UnlockDiagnosticVMOptions 
        -XX:+StressLCM 
        -XX:+StressGCM

我不会过多介绍它们的作用,但这就是StressLCM 和StressGCM 的作用,当然还有TieredCompilation标志的作用。

整个测试的重点是:

这段代码证明了在构造函数中设置的两个非最终变量被错误地发布并且运行在x86.


现在要做的是理智的事情,因为有一个专门的工具,将单个字段更改为final并看到它崩溃。因此,更改它并再次运行,我们应该观察到失败:

public static class Holder {

    // this is the change
    final int right;
    int left;

    public Holder(int left, int right) {
        this.left = left;
        this.right = right;
    }
}

但如果我们再次运行它,故障就不会出现了。即@Outcome我们上面谈到的两个都不会成为输出的一部分。怎么来的?

事实证明,即使您写入单个 final 变量JVM(特别是C1)总是会做正确的事情即使对于单个字段,也无法证明这一点。至少目前是这样。


从理论上讲,您可以投入Shenandoah其中,这是一个有趣的标志:(ShenandoahOptimizeInstanceFinals不打算深入研究)。我尝试过运行前面的示例:

 -XX:+UnlockExperimentalVMOptions  
 -XX:+UseShenandoahGC  
 -XX:+ShenandoahOptimizeInstanceFinals  
 -XX:-TieredCompilation  
 -XX:+UnlockDiagnosticVMOptions  
 -XX:+StressLCM  
 -XX:+StressGCM 

但这并不像我希望的那样工作。我什至尝试这样做的论点更糟糕的是,这些标志将在 jdk-14 中删除

底线:目前没有办法打破这一点。

于 2019-12-18T04:35:25.693 回答
-1

您如何修改构造函数来执行此操作:

public TestClass() {
 Thread.sleep(300);
   x = 3;
   y = 4;
}

我不是 JLF 决赛和初始化程序方面的专家,但常识告诉我这应该延迟设置 x 足够长的时间让作者注册另一个值?

于 2013-03-14T07:52:16.093 回答
-2

如果将场景更改为

public class TestClass {

    final int x;
    static TestClass f;

    public TestClass() {
        x = 3;
    }

    int y = 4;

    // etc...

}

?

于 2013-03-12T06:22:19.693 回答
-3

更好地理解为什么这个测试不会失败可以通过理解调用构造函数时实际发生的事情来实现。Java 是一种基于堆栈的语言。TestClass.f = new TestClass();由四个动作组成。第一的new指令被调用,就像 C/C++ 中的 malloc 一样,它分配内存并将对它的引用放在堆栈的顶部。然后引用被复制以调用构造函数。构造函数实际上就像任何其他实例方法一样,它使用重复的引用来调用。只有在该引用存储在方法框架或实例字段中并且可以从其他任何地方访问之后。在最后一步之前,对对象的引用仅存在于创建线程堆栈的顶部,其他主体无法看到它。实际上,您使用哪种字段没有区别,如果TestClass.f != null. 您可以从不同对象读取 x 和 y 字段,但这不会导致y = 0. 有关更多信息,您应该查看JVM 规范面向堆栈的编程语言文章。

UPD:我忘了提到一件重要的事情。通过 java 内存,无法看到部分初始化的对象。如果你不在构造函数中进行自我发布,当然可以。

捷豹路虎

当一个对象的构造函数完成时,它被认为是完全初始化的。只有在对象完全初始化后才能看到对该对象的引用的线程可以保证看到该对象的最终字段的正确初始化值。

捷豹路虎

从对象的构造函数的末尾到该对象的终结器的开头有一条happens-before边缘。

对这一观点的更广泛解释

事实证明,对象构造函数的结束发生在执行其 finalize 方法之前。实际上,这意味着在构造函数中发生的任何写入都必须完成并且对终结器中相同变量的任何读取都是可见的,就像这些变量是易失的一样。

UPD:那是理论,让我们转向实践。

考虑以下代码,带有简单的非最终变量:

public class Test {

    int myVariable1;
    int myVariable2;

    Test() {
        myVariable1 = 32;
        myVariable2 = 64;
    }

    public static void main(String args[]) throws Exception {
        Test t = new Test();
        System.out.println(t.myVariable1 + t.myVariable2);
    }
}

以下命令显示由 java 生成的机器指令,如何使用它可以在wiki中找到:

java.exe -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp -XX:PrintAssemblyOptions=hsdis-print-bytes -XX:CompileCommand=print,*Test.main Test

它的输出:

...
0x0263885d: movl   $0x20,0x8(%eax)    ;...c7400820 000000
                                    ;*putfield myVariable1
                                    ; - Test::<init>@7 (line 12)
                                    ; - Test::main@4 (line 17)
0x02638864: movl   $0x40,0xc(%eax)    ;...c7400c40 000000
                                    ;*putfield myVariable2
                                    ; - Test::<init>@13 (line 13)
                                    ; - Test::main@4 (line 17)
0x0263886b: nopl   0x0(%eax,%eax,1)   ;...0f1f4400 00
...

字段分配之后是NOPL指令,其目的之一是防止指令重新排序

为什么会这样?根据规范,在构造函数返回后完成。所以 GC 线程看不到部分初始化的对象。在 CPU 级别上,GC 线程与任何其他线程没有区别。如果将此类保证提供给 GC,则它们将提供给任何其他线程。这是对这种限制最明显的解决方案。

结果:

1)构造函数不同步,同步由其他指令完成。

2) 在构造函数返回之前不能对对象的引用赋值。

于 2013-03-11T15:31:27.807 回答
-3

这个线程发生了什么?为什么该代码首先会失败?

您启动 1000 个线程,每个线程将执行以下操作:

TestClass.f = new TestClass();

这是做什么的,按顺序:

  1. 评估TestClass.f以找出它的内存位置
  2. evaluate new TestClass():这将创建一个新的 TestClass 实例,其构造函数将同时初始化xy
  3. 将右侧的值分配给左侧的内存位置

赋值是一个原子操作,它总是在生成右手值之后执行这是来自 Java 语言规范的引用(参见第一个项目符号点),但它确实适用于任何理智的语言。

这意味着虽然TestClass()构造函数正在花时间完成它的工作,并且x可能y仍然为零,但对部分初始化TestClass对象的引用仅存在于该线程的堆栈或 CPU 寄存器中,并且尚未写入TestClass.f

因此TestClass.f将始终包含:

  • 或者null,在您的程序开始时,在分配任何其他内容之前,
  • 或完全初始化的TestClass实例。
于 2013-03-17T11:14:55.000 回答