测试最终字段的初始化安全性

时间:2011-02-21 14:00:38

标签: java multithreading thread-safety final jls

我试图简单地测试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。我怎么能让它失败?

9 个答案:

答案 0 :(得分:17)

我写了规范。 TL;这个答案的DR版本只是因为它可能看到0代表y,这并不意味着保证为y看到0。

在这种情况下,最终的字段规范保证你会看到3为x,正如你所指出的那样。将编写器线程视为有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;

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

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

答案 1 :(得分:6)

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

如果您希望看到此失败,您可以尝试使用较旧的JVM,如1.3。

我不会打印出每个测试,我只打印出失败。你可能会在一百万人中失败,但却错过了。但是,如果你只打印失败,它们应该很容易被发现。

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

f.y = 5;

并测试

int y = TestClass.f.y; // could see 0, 4 or 5
if (y != 5)
    System.out.println("y = " + y);

答案 2 :(得分:4)

  

我希望看到一个测试失败或解释为什么当前的JVM无法实现。

多线程和测试

由于以下几个原因,您无法通过测试证明多线程应用程序已损坏(<或p>)

  • 问题可能每运行x小时才出现一次,x太高,以至于你不太可能在短暂的测试中看到它
  • 问题可能只出现在JVM /处理器体系结构的某些组合中

在你的情况下,为了使测试中断(即观察y == 0),需要程序看到一个部分构造的对象,其中某些字段已经正确构造而一些字段没有。这通常不会发生在x86 / hotspot上。

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

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

该代码真的可以破解吗?

在实践中,一些JVM会更好地使测试失败。例如,某些编译器(参见this article中的“测试用例显示它不起作用”)可以将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,所以无法通过实验证明: - )

答案 3 :(得分:2)

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

答案 4 :(得分:1)

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

人们在这里所说的次数足够多,以至于JLS严格规则 –您应该能够看到所需的行为。但是编译器(我的意思是C1C2),尽管他们必须尊重JLS,但是他们可以进行优化。我稍后再讲。

让我们以第一个简单的场景为例,该场景中有两个non-final变量,并查看是否可以发布不正确的对象。对于此测试,我正在使用专门针对此类测试而定制的specialized tool。这是使用它的测试:

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

您不必对代码太了解。尽管极少的解释是这样的:有两个Actor会使某些共享数据发生突变,并且这些结果已被注册。 @Outcome批注控制着这些注册结果并设定了某些期望值(在幕后,事情变得更加有趣和冗长)。只需记住,这是一个非常敏锐和专业的工具。在两个线程同时运行的情况下,您做不到真正的事情。

现在,如果我运行此命令,则会得到以下两个结果:

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

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

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

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

我不会过多地介绍这些功能,而是使用here is what StressLCM and StressGCM does,当然还有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都不会成为输出的一部分。怎么来的?

事实证明,when you write even to a single final variableJVM(特别是C1)将一直执行the correct thing即使是单个字段,因此也无法证明。至少此刻。


从理论上讲,您可以将Shenandoah放入其中,这是一个有趣的标志:ShenandoahOptimizeInstanceFinals(不打算深入其中)。我尝试使用以下示例运行上一个示例:

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

但是这不起作用,正如我希望的那样。对于我什至尝试这样做的论点,更糟糕的是,这些标志正在to be removed in jdk-14处。

底线:目前尚无办法打破这一点。

答案 5 :(得分:-1)

你怎么修改构造函数来做到这一点:

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

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

答案 6 :(得分:-2)

更好地理解为什么这个测试没有失败可以来自对构造函数被调用时实际发生的事情的理解。 Java是一种基于堆栈的语言。 TestClass.f = new TestClass();包含四项操作。调用第一个new指令,它与C / C ++中的malloc类似,它分配内存并在堆栈顶部放置对它的引用。然后重复引用以调用构造函数。实际上,构造函数与任何其他实例方法一样,它使用重复的引用调用。只有在该引用存储在方法框架或实例字段中之后,才能从其他任何地方访问。在最后一步之前,对象的引用仅存在于创建线程堆栈的顶部,而其他任何人都无法看到它。事实上,您正在使用的字段没有区别,如果TestClass.f != null,这两个字段都将被初始化。您可以从不同的对象中读取x和y字段,但这不会导致y = 0。有关详细信息,请参阅JVM SpecificationStack-oriented programming language条款。

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

JLS

  

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

JLS

  

在构造函数的结尾处有一个发生前的边缘   反对该对象的终结者的开始。

Broader explanation of this point of view

  

事实证明,对象的构造函数的结束发生在之前   执行其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指令,其中一个目的是prevent instruction reordering

为什么会发生这种情况?根据规范,在构造函数返回后进行终结。因此GC线程无法看到部分初始化的对象。在CPU级别上,GC线程与其他任何线程都不区分。如果向GC提供此类保证,则将其提供给任何其他线程。这是这种限制的最明显的解决方案。

<强>结果:

1)构造函数未同步,同步由other instructions完成。

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

答案 7 :(得分:-2)

如果将场景更改为

,该怎么办?
public class TestClass {

    final int x;
    static TestClass f;

    public TestClass() {
        x = 3;
    }

    int y = 4;

    // etc...

}

答案 8 :(得分:-3)

这个帖子里发生了什么?为什么该代码首先会失败?

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

TestClass.f = new TestClass();

这是做什么的,按顺序:

  1. 评估TestClass.f以找出其内存位置
  2. 评估new TestClass():这会创建一个新的TestClass实例,其构造函数将初始化xy
  3. 将右侧值分配给左侧内存位置
  4. 赋值是一种原子操作,始终在生成右手值后执行。来自Java语言规范的Here is a citation(参见第一个项目符号),但它确实适用于任何理智的语言。

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

    因此TestClass.f将始终包含:

    • TestClass.f,在您的计划开始之前,在分配任何其他内容之前,
    • 或完全初始化的null实例。