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

我试图简单地测试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.fx; // guaranteed to see 3 int j = TestClass.fy; // 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。 我怎么能让这个失败?

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

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

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

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

 fy = 5; 

并测试

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

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

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

 r1 =  r1.x = 3; r1.y = 4; f = r1; 

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

 r1 =  f = r1; r1.x = 3; r1.y = 4; 

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

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

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

multithreading和测试

由于以下几个原因,您无法通过测试certificatemultithreading应用程序已损坏(或未损坏):

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

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

如何确定multithreading代码是否被破坏?

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

该代码真的可以破解吗?

在实践中,一些JVM会更好地使测试失败。 例如,一些编译器(参见本文中的“测试用例表明它不起作用”)可以转换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所以无法通过实validation明:-)

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

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

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

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

如果将场景更改为,该怎么办?

 public class TestClass { final int x; static TestClass f; public TestClass() { x = 3; } int y = 4; // etc... } 

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

UPD :我忘了提到一件重要的事情。 通过java内存,无法查看部分初始化的对象。 如果你没有在构造函数中做自我出版物,当然。

JLS :

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

JLS :

从对象的构造函数的末尾到该对象的终结器的开始有一个发生前的边缘。

对这一观点的更广泛的解释 :

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

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::@7 (line 12) ; - Test::main@4 (line 17) 0x02638864: movl $0x40,0xc(%eax) ;...c7400c40 000000 ;*putfield myVariable2 ; - Test::@13 (line 13) ; - Test::main@4 (line 17) 0x0263886b: nopl 0x0(%eax,%eax,1) ;...0f1f4400 00 ... 

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

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

结果:

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

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

这个post里发生了什么? 为什么那个代码首先会失败?

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

 TestClass.f = new TestClass(); 

那是做什么的,按顺序:

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

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

这意味着虽然TestClass()构造函数正在花费时间来完成它的工作,并且xy可能仍然是零,但对部分初始化的TestClass对象的引用只存在于该线程的堆栈或CPU寄存器中,并且还没有已写入TestClass.f

因此TestClass.f将始终包含:

  • 在程序开始时,在为其分配任何其他内容之前,为null
  • 或者完全初始化的TestClass实例。