JVM如何优化循环代码?

6

有一种方法可以从文本中搜索子字符串(使用暴力算法,请忽略空指针)

public static int forceSearch(String text, String pattern) {
    int patternLength = pattern.length();
    int textLength = text.length();

    for (int i = 0, n = textLength - patternLength; i <= n; i++) {
        int j = 0;
        for (; j < patternLength && text.charAt(i + j) == pattern.charAt(j); j++) {
            ;
        }
        if (j == patternLength) {
            return i;
        }
    }
    return -1;
}

奇怪的是,虽然使用相同的算法,但以下代码更快!!!

public static int forceSearch(String text, String pattern) {
    int patternLength = pattern.length();
    int textLength = text.length();

    char first = pattern.charAt(0);
    for (int i = 0, n = textLength - patternLength; i <= n; i++) {
        if (text.charAt(i) != first) {
            while (++i <= n && text.charAt(i) != first)
                ;
        }

        int j = 0;
        for (; j < patternLength && text.charAt(i + j) == pattern.charAt(j); j++) {
            ;
        }
        if (j == patternLength) {
            return i;
        }
    }
    return -1;
}

如果我在JVM上运行,我发现第二段代码明显比第一段快。然而,当我将其编写为C并运行时,这两个函数花费的时间几乎相同。因此,我认为原因是JVM优化了循环代码。

if (text.charAt(i) != first) {
    while (++i <= max && text.charAt(i) != first)
        ;
}

我说的对吗?如果我说的对,我们应该如何使用JVM优化策略来优化我们的代码呢?

希望有人能帮忙,谢谢:)


首先要开始的是编译您的代码,然后反编译它以查看编译器生成的内容。当我对您发布的代码进行了这样的操作时,我注意到编译器进行了静态优化。从那里开始,您可能会对特定优化有更精确的问题。 - ernest_k
1
JVM 优化了第一个循环、第二个循环和基本上整个方法。但 JIT 编译器并不总是像静态 C 编译器那样出色。你已经手动进行了一项 HotSpot JIT 无法实现的优化,所以这里没有什么奇怪的。 - apangin
@ErnestKiwele 我反编译了这个类文件,发现并没有太大的区别。我认为JIT像apangin所说的那样优化了循环。 - Sam
@Sam,编译器优化只是其中一方面。JIT 做的事情更多。 - ernest_k
@Marco13 C代码与Java代码非常相似。我使用devcpp进行测试。我发现第二个版本比第一个版本稍微慢一些。因此,我不认为这是由于缓存引起的。 - Sam
显示剩余4条评论
3个回答

1
这个if语句简化了很多工作(尤其是在模式出现在输入字符串的结尾时)。
   if (text.charAt(i) != first) {
        while (++i <= n && text.charAt(i) != first)
            ;
    }

在第一个版本中,在比较第一个字符之前,您必须在每个i处检查j < patternLength
在第二个版本中,您不需要这样做。
但奇怪的是,我认为对于小输入,这并没有太大的区别。
您能分享您用于基准测试的项目长度吗?

输入文本是一个仅包含数字和字母的随机字符串(长度为100000)。输入模式是文本的子字符串(长度为5000)。我进行了20000次测试。 - Sam
但是当我用C语言编写时,这两个版本的时间花费相同。 - Sam
你是獨立執行程式碼還是在主方法中同時運行兩個版本? - Mạnh Quyết Nguyễn
在一个函数中运行 - Sam
因此,第二个版本使用了来自第一个版本的缓存。尝试独立运行每个版本(一次只运行一个)。我非常确定它们会是相同的。 - Mạnh Quyết Nguyễn
显示剩余2条评论

1
如果你真的想深入了解这个问题,可能需要指示JVM打印汇编代码。根据我的经验,对循环进行微小的调整可能会导致出乎意料的性能差异。但这并不一定是由于循环本身的优化造成的。
有许多因素会影响您的代码如何被JIT编译。例如,调整方法的大小可以影响您的内联树,这可能意味着更好或更差的性能,具体取决于您的调用堆栈的情况。如果一个方法在调用堆栈中进一步内联,它可能会阻止嵌套调用站点被内联到同一帧中。如果这些嵌套调用站点特别“热”,则添加的调用开销可能很大。我并不是说这就是原因;我只是指出,有许多阈值来管理JIT如何安排您的代码,并且性能差异的原因并不总是显而易见的。
使用JMH进行基准测试的一个好处是,您可以通过明确设置内联边界来减少此类更改的影响。但是,您可以使用-XX:CompileCommand手动实现相同的效果。
当然,还有其他因素,如缓存友好性需要更直观的分析。考虑到你的基准测试可能没有特别深的调用树,我倾向于将缓存行为作为更可能的解释。我猜测你的第二个版本表现更好是因为你的比较对象(pattern的第一块)通常在L1缓存中,而你的第一个版本会导致更多的缓存抖动。如果你的输入很长(听起来似乎确实如此),那么这是一个可能的解释。如果不是,原因可能更微妙,例如你的第一个版本可能会“欺骗”CPU采用更积极的缓存预取方式,但以一种实际上会损害性能的方式(至少对于你正在进行基准测试的输入)。不管怎样,如果要解释缓存行为,那么我想知道为什么你在C版本中没有看到类似的差异。你使用了什么优化标志编译C版本?
死代码消除也可能是一个因素。我需要看看你的输入是什么,但是你手动优化的版本可能导致某些指令块在插装解释阶段从未被命中,导致JIT将其排除在最终汇编之外。
我再次强调:如果你想彻底了解这个问题,你需要强制JIT为每个版本转储汇编代码(并与C版本进行比较)。

0
如果你在网上搜索JVM编译器优化,应该会遇到"循环展开"或者"loop unrolling"。同时,进行基准测试也很棘手。你可以找到许多关于此问题的SO答案。

您能根据这个案例详细解释一下吗?谢谢! - Sam
1
@sam 如果你想了解不同的编译器优化,可以看一下这些笔记:http://www.cs.cornell.edu/courses/cs4120/2013fa/lectures/lec25-fa13.pdf。现在对于Java来说,因为JIT会/可以进一步重写和优化.class字节码,所以需要更多的工作。如果你打算花更多时间,可以看一下Oracle JVM标志:http://www.oracle.com/technetwork/articles/java/architect-evans-pt1-2266278.html。 - Baski
这并没有解释两个Java版本之间的区别,更不用说Java和C之间的区别了。 - Marco13

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