Java 8奇怪的时间/内存问题

24

我遇到了一个相当奇怪的问题,当运行Java 8时,我可以创建这个问题。该问题表现为JVM本身发生某种定时错误。它是间歇性的,但在我的测试环境中很容易重现。问题是,在某些情况下,明确设置的数组值被销毁并替换为0.0。具体来说,在下面的代码中,array[0]在执行new Double(r.nextDouble());后评估为0.0。然后,如果您立即再次查看array[0]的内容,它现在显示为正确的值1.0。运行此测试用例的示例输出如下:

claims array[0] != 1.0....array[0] = 1.0
claims array[0] now == 1.0...array[0] = 1.0`

我正在运行64位的Windows 7,并且能够从Eclipse中或者通过命令行编译时,使用JDKs 1.8_45、1.8_51和1.8_60来重现这个问题。但是在运行1.7_51时,我无法出现这个问题。另外,在另一台64位的Windows 7电脑上也得到了相同的结果。
这个问题出现在一个庞大而复杂的软件中,但是我已经将其压缩到了几行代码中。下面是一个小的测试用例,它演示了这个问题。它看起来非常奇怪,但是似乎所有的部分都是必要的才能引起错误。不需要使用Random - 我可以将所有的r.nextDouble()替换成任何double值并演示出问题。有趣的是,如果将someArray [0] = .45;替换成someArray [0]= r.nextDouble(); ,我就无法复制该问题(尽管.45没有特殊之处)。Eclipse调试也没办法 - 它会改变时间,使得它不再出现。即使一个恰当放置的System.err.println()语句也会导致该问题不再出现。
同样,这个问题是间歇性的,所以为了复制该问题,您可能需要运行此测试用例多次。我最多运行了大约10次,才得到了上面显示的输出。在Eclipse中,我在运行后等待一两秒钟,如果没有发生,就杀掉它。从命令行也是一样 - 运行它,如果不出现问题,请使用CTRL+C退出并重试。看起来如果它要发生,它发生得非常快。
我遇到过类似这样的问题,但它们都是线程问题。我无法弄清楚这里发生了什么 - 我甚至查看了字节码(顺便说一下,在1.7_51和1.8_45之间是相同的)。你们有什么想法吗?
import java.util.Random;

public class Test { 
    Test(){
        double array[] = new double[1];     
        Random r = new Random();

        while(true){
            double someArray[] = new double[1];         
            double someArray2 [] = new double [2];

            for(int i = 0; i < someArray2.length; i++) {
                someArray2[i] = r.nextDouble();
            }

            // for whatever reason, using r.nextDouble() here doesn't seem
            // to show the problem, but the # you use doesn't seem to matter either...

            someArray[0] = .45;

            array[0] = 1.0;

            // commented out lines also demonstrate problem
            new Double(r.nextDouble());
            // new Float(r.nextDouble();
            // double d = new Double(.1) * new Double(.3);
            // double d = new Double(.1) / new Double(.3);
            // double d = new Double(.1) + new Double(.3);
            // double d = new Double(.1) - new Double(.3);

            if(array[0] != 1.0){
                System.err.println("claims array[0] != 1.0....array[0] = " + array[0]);

                if(array[0] != 1.0){
                    System.err.println("claims array[0] still != 1.0...array[0] = " + array[0]);
                }else {
                    System.err.println("claims array[0] now == 1.0...array[0] = " + array[0]);
                }

                System.exit(0);
            }else if(r.nextBoolean()){
                array = new double[1];
            }
        }
    }

    public static void main(String[] args) {
        new Test();
    }
}

3
我无法重现此问题,这里的情况符合预期。 - marstran
3
如果您能重现此问题,建议您向JDK提交错误报告。我只能猜测这与JIT有关。顺便问一下:new Double(...)是否必需?在实际代码中,我不希望发现这种情况。@PM77-1:将“1.0”的整数值存储在“double”中永远不应导致此类问题,因为它可以表示而不损失精度。如果“==”给出错误的结果,则更可能由于JVM中的某些错误而使用了装箱值。 - Axel
3
我可以重现这个问题(在64位Linux机器上,使用Oracle Java 1.8.0_60)。 - Roman
4
从生产代码追踪到这个小例子,一定是很费力的工作... - Holger
3
这明显是一个JIT优化bug。使用-XX:-TieredCompilation或者-XX:-EliminateAllocations很可能是一个可接受的解决方法,不会显著降低性能。 - apangin
显示剩余14条评论
2个回答

21
更新: 看起来我的原始答案是错误的,OnStackReplacement 只是揭示了这种特定情况下的问题,但原始 bug 是在逃逸分析代码中。逃逸分析是一个编译器子系统,用于确定对象是否从给定方法中逃逸。未逃逸的对象可以被标量化(而不是在堆上分配)或完全优化掉。在我们的测试中,逃逸分析确实很重要,因为几个创建的对象肯定没有逃逸出该方法。
我下载并安装了JDK 9早期访问版本83,注意到该漏洞在那里消失了。然而,在JDK 9早期访问版本82中仍然存在。b82和b83之间的更改日志只显示了一个相关的错误修复(如果我错了,请纠正):JDK-8134031“具有内联和逃逸分析的复杂代码的JIT编译不正确”。提交的测试用例与我们的测试中的类似:大循环,几个框(类似于我们测试中的单元素数组),导致框内值突然改变,因此结果变得无声但不正确(没有崩溃,没有异常,只是错误的值)。和我们的情况一样,据报告,在8u40之前问题不会出现。引入的修复非常简短:逃逸分析源中只有一行变化。
根据OpenJDK bug跟踪器,修复已经被反向移植到了JDK 8u72分支,该分支计划在2016年1月发布。看起来这个修复程序太晚了,无法反向移植到即将发布的8u66版本。
建议的解决方法是禁用逃逸分析(-XX:-DoEscapeAnalysis)或者禁用消除分配优化(-XX:-EliminateAllocations)。因此@apangin 实际上比我更接近答案
以下是原始答案。

首先,我无法在JDK 8u25上重现问题,但可以在JDK 8u40和8u60上重现:有时它会正确运行(陷入无限循环),有时它会输出并退出。因此,如果您可以接受将JDK降级到8u25,您可以考虑这样做。请注意,如果您需要javac的后续修复(许多事情,特别是涉及lambda的修复都在1.8u40中进行了修复),您可以使用更新的javac进行编译,但在旧的JVM上运行。

对我来说,这个特定的问题可能是OnStackReplacement机制的一个错误(当OSR发生在第4层时)。如果您不熟悉OSR,可以阅读this answer。在您的情况下,OSR肯定会发生,但方式有点奇怪。以下是无法运行的-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+TraceNMethodInstalls%表示OSR JIT,@ 28表示OSR字节码位置,(3)(4)表示层级):

...
     91   37 %     3       Test::<init> @ 28 (194 bytes)
Installing osr method (3) Test.<init>()V @ 28
     93   38       3       Test::<init> (194 bytes)
Installing method (3) Test.<init>()V 
     94   39 %     4       Test::<init> @ 16 (194 bytes)
Installing osr method (4) Test.<init>()V @ 16
    102   40 %     4       Test::<init> @ 28 (194 bytes)
    103   39 %     4       Test::<init> @ -2 (194 bytes)   made not entrant
...
Installing osr method (4) Test.<init>()V @ 28
    113   37 %     3       Test::<init> @ -2 (194 bytes)   made not entrant
claims array[0] != 1.0....array[0] = 1.0
claims array[0] now == 1.0...array[0] = 1.0

因此,在第四层的OSR中,会发生两个不同字节码偏移量:偏移量16(即while循环入口点)和偏移量28(即嵌套for循环入口点)。似乎在方法的两个OSR编译版本之间进行上下文转换时发生了某种竞态条件,导致上下文破裂。当执行权交给OSR方法时,它应该将当前上下文(包括本地变量如arrayr的值)传递到OSR方法中。这里发生了一些问题:可能在短时间内<init>@16 OSR版本起作用,然后被<init>@28替换,但上下文更新有一点延迟。很可能OSR上下文转移干扰了“消除分配”优化(正如@apangin所指出的,在您的情况下关闭此优化可以帮助)。我的专业知识不足以深入挖掘这里,可能@apangin可以发表评论。
相比之下,在正常运行中只创建并安装一个第四层OSR方法的副本:
...
Installing method (3) Test.<init>()V 
     88   43 %     4       Test::<init> @ 28 (194 bytes)
Installing osr method (4) Test.<init>()V @ 28
    100   40 %     3       Test::<init> @ -2 (194 bytes)   made not entrant
   4592   44       3       java.lang.StringBuilder::append (8 bytes)
...

在这种情况下,似乎没有两个OSR版本之间的竞争,并且一切都运作得非常完美。

如果您将外部循环主体移动到单独的方法中,则问题也会消失:

import java.util.Random;

public class Test2 {
    private static void doTest(double[] array, Random r) {
        double someArray[] = new double[1];
        double someArray2[] = new double[2];

        for (int i = 0; i < someArray2.length; i++) {
            someArray2[i] = r.nextDouble();
        }

        ... // rest of your code
    }

    Test2() {
        double array[] = new double[1];
        Random r = new Random();

        while (true) {
            doTest(array, r);
        }
    }

    public static void main(String[] args) {
        new Test2();
    }
}

同时手动展开嵌套的for循环可以消除错误:

int i=0;
someArray2[i++] = r.nextDouble();
someArray2[i++] = r.nextDouble();

要遇到这个bug,似乎你应该在同一个方法中至少有两个嵌套循环,这样OSR才能发生在不同的字节码位置。所以为了解决你特定代码中的问题,你可以做同样的事情:将循环体提取到单独的方法中。
另一种解决方案是使用-XX:-UseOnStackReplacement完全禁用OSR。这在生产代码中很少有帮助。循环计数器仍然有效,如果你的多次迭代循环方法被调用至少两次,第二次运行将被JIT编译。即使由于禁用了OSR而未对具有长循环的方法进行JIT编译,它所调用的任何方法仍将被JIT编译。

干得好。请将此内容包含在错误报告中,因为它可能有助于JDK开发人员解决问题。如果可以的话,我会给+2... :-) - Axel
是的,在堆栈替换技术中,如果你的方法非常长,并且其代码对性能至关重要,那么它将会有所帮助。这种情况通常出现在典型人工基准测试代码中,而不是典型的应用程序代码中。 - Holger
哇,干得好!我已经提交了错误报告,它仍在审核中。如果被接受,我会添加信息到报告中,当然也会包括这个。再次感谢! - bcothren
1
@bcothren,我编辑了答案。看起来问题有些不同,现在已经修复了。 - Tagir Valeev

0

我可以在Zulu(OpenJDK的认证版本)中复现此错误,使用发布在http://www.javaspecialists.eu/archive/Issue234.html的代码。

在Oracle VM中,只有在我使用Zulu运行代码后才能复现此错误。看起来Zulu污染了共享查找缓存。在这种情况下的解决方案是使用-XX:-EnableSharedLookupCache运行代码。


1
Azul有两个JVM,Zulu和Zing。从你提供的链接(已经失效)来看,似乎你指的是Zulu而非Zing。Zulu是一个OpenJDK构建版本,完全由OpenJDK代码编写,但经过测试和支持。它应该在相应版本上表现出相同的行为。Zing则是一种完全不同的东西。 - Nitsan Wakart

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