为什么Java 7中的StringBuilder#append(int)比Java 8中更快?

76

在探讨将整数原始类型转换为字符串时,我写了这个JMH微基准测试,目的是对使用"" + nInteger.toString(int)进行一次小辩论

@Fork(1)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class IntStr {
    protected int counter;


    @GenerateMicroBenchmark
    public String integerToString() {
        return Integer.toString(this.counter++);
    }

    @GenerateMicroBenchmark
    public String stringBuilder0() {
        return new StringBuilder().append(this.counter++).toString();
    }

    @GenerateMicroBenchmark
    public String stringBuilder1() {
        return new StringBuilder().append("").append(this.counter++).toString();
    }

    @GenerateMicroBenchmark
    public String stringBuilder2() {
        return new StringBuilder().append("").append(Integer.toString(this.counter++)).toString();
    }

    @GenerateMicroBenchmark
    public String stringFormat() {
        return String.format("%d", this.counter++);
    }

    @Setup(Level.Iteration)
    public void prepareIteration() {
        this.counter = 0;
    }
}

我在我的Linux机器上(最新的Mageia 4 64位,Intel i7-3770 CPU,32GB RAM)使用默认的JMH选项运行了它,并且使用了两个Java虚拟机。第一个JVM是Oracle JDK 8u5 64位版本提供的。

java version "1.8.0_05"
Java(TM) SE Runtime Environment (build 1.8.0_05-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.5-b02, mixed mode)

使用这个JVM,我得到了我预期的结果:

Benchmark                    Mode   Samples         Mean   Mean error    Units
b.IntStr.integerToString    thrpt        20    32317.048      698.703   ops/ms
b.IntStr.stringBuilder0     thrpt        20    28129.499      421.520   ops/ms
b.IntStr.stringBuilder1     thrpt        20    28106.692     1117.958   ops/ms
b.IntStr.stringBuilder2     thrpt        20    20066.939     1052.937   ops/ms
b.IntStr.stringFormat       thrpt        20     2346.452       37.422   ops/ms

使用StringBuilder类会更慢,因为需要创建StringBuilder对象并附加空字符串。而使用String.format(String, ...)则更慢,大约慢一个数量级。
另一方面,发行版提供的编译器基于OpenJDK 1.7。
java version "1.7.0_55"
OpenJDK Runtime Environment (mageia-2.4.7.1.mga4-x86_64 u55-b13)
OpenJDK 64-Bit Server VM (build 24.51-b03, mixed mode)

这里的结果很有

Benchmark                    Mode   Samples         Mean   Mean error    Units
b.IntStr.integerToString    thrpt        20    31249.306      881.125   ops/ms
b.IntStr.stringBuilder0     thrpt        20    39486.857      663.766   ops/ms
b.IntStr.stringBuilder1     thrpt        20    41072.058      484.353   ops/ms
b.IntStr.stringBuilder2     thrpt        20    20513.913      466.130   ops/ms
b.IntStr.stringFormat       thrpt        20     2068.471       44.964   ops/ms

为什么在这个JVM中,StringBuilder.append(int)看起来会更快?查看StringBuilder类的源代码并没有发现特别有趣的内容——所讨论的方法与Integer#toString(int)几乎完全相同。有趣的是,附加Integer.toString(int)的结果(stringBuilder2微基准测试)似乎不会更快。
这种性能差异是测试工具的问题吗?还是我的OpenJDK JVM包含会影响这个特定代码(反)模式的优化?
编辑:
为了进行更直接的比较,我安装了Oracle JDK 1.7u55:
java version "1.7.0_55"
Java(TM) SE Runtime Environment (build 1.7.0_55-b13)
Java HotSpot(TM) 64-Bit Server VM (build 24.55-b03, mixed mode)

结果类似于OpenJDK:
Benchmark                    Mode   Samples         Mean   Mean error    Units
b.IntStr.integerToString    thrpt        20    32502.493      501.928   ops/ms
b.IntStr.stringBuilder0     thrpt        20    39592.174      428.967   ops/ms
b.IntStr.stringBuilder1     thrpt        20    40978.633      544.236   ops/ms

这似乎是Java 7和Java 8之间更一般的问题。也许Java 7有更积极的字符串优化?
编辑2: 为了完整起见,以下是这两个JVM的与字符串相关的VM选项:
对于Oracle JDK 8u5:
$ /usr/java/default/bin/java -XX:+PrintFlagsFinal 2>/dev/null | grep String
     bool OptimizeStringConcat                      = true            {C2 product}
     intx PerfMaxStringConstLength                  = 1024            {product}
     bool PrintStringTableStatistics                = false           {product}
    uintx StringTableSize                           = 60013           {product}

对于 OpenJDK 1.7:

$ java -XX:+PrintFlagsFinal 2>/dev/null | grep String
     bool OptimizeStringConcat                      = true            {C2 product}        
     intx PerfMaxStringConstLength                  = 1024            {product}           
     bool PrintStringTableStatistics                = false           {product}           
    uintx StringTableSize                           = 60013           {product}           
     bool UseStringCache                            = false           {product}   

UseStringCache选项在Java 8中被删除,没有替代品,因此我怀疑这不会有任何影响。其余选项似乎具有相同的设置。

编辑3:

src.zip文件中比较AbstractStringBuilderStringBuilderInteger类的源代码,没有发现任何值得注意的东西。除了大量的美化和文档更改外,Integer现在对无符号整数有一些支持,StringBuilder已经稍微重构,以与StringBuffer共享更多的代码。这些变化似乎都不会影响StringBuilder#append(int)使用的代码路径,但我可能错过了什么。

< p >比较 IntStr#integerToString()IntStr#stringBuilder0() 生成的汇编代码更有趣。对于 IntStr#integerToString() 生成的代码布局在两个JVM中都很相似,尽管 Oracle JDK 8u5 在内联 Integer#toString(int) 代码中的一些调用方面似乎更为激进。即使对于具有最少汇编经验的人来说,Java源代码也有明显的对应关系。

然而,IntStr#stringBuilder0() 的汇编代码却截然不同。由Oracle JDK 8u5生成的代码再次直接与Java源代码相关 - 我可以轻松地识别出相同的布局。相反,由OpenJDK 7生成的代码对于未经训练的眼睛(如我)几乎无法识别。看起来似乎删除了 new StringBuilder() 调用以及在 StringBuilder 构造函数中创建数组。此外,反汇编插件无法像JDK 8那样提供与源代码的许多引用。

我认为这可能是OpenJDK 7中更为激进的优化过程的结果,或者更可能是插入手写低级代码处理某些StringBuilder操作的结果。我不确定为什么在我的JVM 8实现中没有发生此优化,或者为什么在JVM 7中没有为Integer#toString(int)实现相同的优化。我猜想熟悉JRE源代码相关部分的人应该回答这些问题...


你是不是想使用以下代码:new StringBuilder().append(this.counter++).toString();,还有一个用途为return "" + this.counter++的第三个测试? - assylias
4
“stringBuilder”方法编译后的字节码与“return "" + this.counter++;”完全相同。我会考虑添加第三个测试,而不附加空字符串... - thkala
@assylias:就是这样。我看不出有什么实质性的区别... - thkala
1
@JarrodRoberson:这个怎么样?String.format("%d",n)比其他所有东西慢一个数量级... - thkala
@thkala:后者,我对自己测试结果的信心更加充足。 - nosid
显示剩余7条评论
2个回答

97

TL;DR:append中的副作用似乎破坏了StringConcat优化。

原问题和更新中有非常好的分析!

为了完整起见,以下是一些缺失的步骤:

  • 查看7u55和8u5的-XX:+PrintInlining。在7u55中,您将看到类似于以下内容:

     @ 16   org.sample.IntStr::inlineSideEffect (25 bytes)   force inline by CompilerOracle
       @ 4   java.lang.StringBuilder::<init> (7 bytes)   inline (hot)
       @ 18   java.lang.StringBuilder::append (8 bytes)   already compiled into a big method
       @ 21   java.lang.StringBuilder::toString (17 bytes)   inline (hot)
    
    ...并在8u5中:
     @ 16   org.sample.IntStr::inlineSideEffect (25 bytes)   force inline by CompilerOracle
       @ 4   java.lang.StringBuilder::<init> (7 bytes)   inline (hot)
         @ 3   java.lang.AbstractStringBuilder::<init> (12 bytes)   inline (hot)
           @ 1   java.lang.Object::<init> (1 bytes)   inline (hot)
       @ 18   java.lang.StringBuilder::append (8 bytes)   inline (hot)
         @ 2   java.lang.AbstractStringBuilder::append (62 bytes)   already compiled into a big method
       @ 21   java.lang.StringBuilder::toString (17 bytes)   inline (hot)
         @ 13   java.lang.String::<init> (62 bytes)   inline (hot)
           @ 1   java.lang.Object::<init> (1 bytes)   inline (hot)
           @ 55   java.util.Arrays::copyOfRange (63 bytes)   inline (hot)
             @ 54   java.lang.Math::min (11 bytes)   (intrinsic)
             @ 57   java.lang.System::arraycopy (0 bytes)   (intrinsic)
    
    你可能会注意到7u55版本较浅,看起来在StringBuilder方法之后没有调用任何内容——这是优化字符串的一个很好的迹象。确实,如果你使用-XX:-OptimizeStringConcat运行7u55,子调用将重新出现,并且性能下降到8u5的水平。

  • 那么,我们需要弄清楚为什么8u5不进行相同的优化。使用grep命令在http://hg.openjdk.java.net/jdk9/jdk9/hotspot上查找“StringBuilder”,以确定虚拟机处理StringConcat优化的位置;这将带你进入src/share/vm/opto/stringopts.cpp

  • 使用hg log src/share/vm/opto/stringopts.cpp命令查找最近的更改。其中一个可能的候选者是:

    changeset:   5493:90abdd727e64
    user:        iveresov
    date:        Wed Oct 16 11:13:15 2013 -0700
    summary:     8009303: Tiered: incorrect results in VM tests stringconcat...
    
  • 在OpenJDK邮件列表上查找审核线程(很容易通过变更集摘要进行谷歌搜索):http://mail.openjdk.java.net/pipermail/hotspot-compiler-dev/2013-October/012084.html

  • 找到“字符串连接优化会将模式[...]折叠为一个字符串的单个分配并直接形成结果。在优化代码中可能发生的所有可能的deopts都会从头重新启动此模式(从StringBuffer分配开始)。这意味着整个模式必须是无副作用的。” 恍然大悟了吗?

  • 编写对比基准:

  • @Fork(5)
    @Warmup(iterations = 5)
    @Measurement(iterations = 5)
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    @State(Scope.Benchmark)
    public class IntStr {
        private int counter;
    
        @GenerateMicroBenchmark
        public String inlineSideEffect() {
            return new StringBuilder().append(counter++).toString();
        }
    
        @GenerateMicroBenchmark
        public String spliceSideEffect() {
            int cnt = counter++;
            return new StringBuilder().append(cnt).toString();
        }
    }
    
  • 在 JDK 7u55 上测量它,看到内联/拼接副作用的性能相同:

    Benchmark                       Mode   Samples         Mean   Mean error    Units
    o.s.IntStr.inlineSideEffect     avgt        25       65.460        1.747    ns/op
    o.s.IntStr.spliceSideEffect     avgt        25       64.414        1.323    ns/op
    
    把它在JDK 8u5上进行测量,看到内联效果导致性能下降:
  • Is there a way to identify which version of JRE/JDK a class file is compiled for?

    有没有办法确定一个类文件被编译为哪个版本的JRE/JDK?
  • Benchmark                       Mode   Samples         Mean   Mean error    Units
    o.s.IntStr.inlineSideEffect     avgt        25       84.953        2.274    ns/op
    o.s.IntStr.spliceSideEffect     avgt        25       65.386        1.194    ns/op
    
  • 提交错误报告 (https://bugs.openjdk.java.net/browse/JDK-8043677) 与VM开发者讨论此行为。原始修复的理由是非常充分的,但有趣的是我们是否可以/应该在某些微不足道的情况下重新引入这种优化。

  • ???

  • 收益。

是的,我应该发布将增量从StringBuilder链中移动到整个链之前的基准测试结果。同时,切换到平均时间和ns/op。这是JDK 7u55:

Benchmark                      Mode   Samples         Mean   Mean error    Units
o.s.IntStr.integerToString     avgt        25      153.805        1.093    ns/op
o.s.IntStr.stringBuilder0      avgt        25      128.284        6.797    ns/op
o.s.IntStr.stringBuilder1      avgt        25      131.524        3.116    ns/op
o.s.IntStr.stringBuilder2      avgt        25      254.384        9.204    ns/op
o.s.IntStr.stringFormat        avgt        25     2302.501      103.032    ns/op
Benchmark                      Mode   Samples         Mean   Mean error    Units
o.s.IntStr.integerToString     avgt        25      153.032        3.295    ns/op
o.s.IntStr.stringBuilder0      avgt        25      127.796        1.158    ns/op
o.s.IntStr.stringBuilder1      avgt        25      131.585        1.137    ns/op
o.s.IntStr.stringBuilder2      avgt        25      250.980        2.773    ns/op
o.s.IntStr.stringFormat        avgt        25     2123.706       25.105    ns/op
在8u5中,stringFormat 实际上更快一些,而其他测试结果相同。这巩固了“副作用破坏主要是原问题的罪魁祸首”的假设。

1
做得非常好!这是一个微妙的小问题...呃... 问题 - 不完全符合大多数Java程序员的期望。我找到了一些关于字符串优化存在正确性问题的参考资料,所以我有些怀疑,但我没有时间去确定它。我也很感激这个错误报告,即使它没有任何作用。 - thkala
1
哦,我也通过将计数器增量移动到StringBuilder调用之前并进行基准测试来确认了您的发现。我想知道还有哪些类似的小宝石... - thkala

5
我认为这与CompileThreshold标志有关,该标志控制JIT何时将字节码编译为机器代码。
Oracle JDK在文档http://www.oracle.com/technetwork/java/javase/tech/vmoptions-jsp-140102.html中默认计数为10,000。
至于OpenJDK,我找不到最新的关于此标志的文档;但是一些邮件线程建议设置更低的阈值:http://mail.openjdk.java.net/pipermail/hotspot-compiler-dev/2010-November/004239.html 此外,请尝试打开/关闭Oracle JDK标志,例如-XX:+UseCompressedStrings-XX:+OptimizeStringConcat。但我不确定这些标志是否在OpenJDK上默认打开。请有经验的人给出建议。
您可以进行一个实验,首先运行程序多次,例如30,000次循环,然后执行System.gc()并查看性能。我相信它们会产生相同的结果。
我假设您的GC设置也是相同的。否则,您正在分配大量对象,GC可能是运行时间的主要部分。

6
JMH默认执行20次热身迭代,每个迭代都包含本例中微基准方法的数百万次调用。从理论上讲,“CompileThreshold”不应该有太大影响... - thkala
@thkala,我想知道如果OP在这里尝试预热的结果是什么。但我同意你的看法,他的代码对于大量改进来说过于简单了。此外,一些JDK用本地代码替换了常见的核心性能代码,例如那些带有字符串操作的代码。不太确定OpenJDK的实现方式。 - Alex Suo
抱歉,刚才才意识到你是原帖作者 :) - Alex Suo
看起来这更像是Java7/Java8的问题,而不是OpenJDK/HotSpot的问题 - 我在Oracle JDK 7u55上添加了一个基准测试... - thkala
看起来字符串相关的VM选项在两个版本上都是相同的。话虽如此,Java 8确实有不同的GC机制... - thkala

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