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


76

在调查使用以及将整数原语转换为字符串的辩论时"" + n,Integer.toString(int)我写了这个JMH微基准测试:

@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机器上的两个Java VM上都使用默认的JMH选项运行了它(最新的Mageia 4 64位,Intel i7-3770 CPU,32GB RAM)。第一个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

为什么StringBuilder.append(int)使用此JVM出现得这么快?查看StringBuilder类源代码并没有发现任何特别有趣的问题-该方法与Integer#toString(int)。有趣的是,附加Integer.toString(int)(stringBuilder2microbenchmark)的结果似乎并不快。

这种性能差异是否是测试工具的问题?或者我的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 vs 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:

与的源代码的并排比较AbstractStringBuilder,StringBuilder以及Integer来自src.zip文件的类均未发现缺点。除了进行了大量的外观和文档更改外,Integer现在还对无符号整数提供了一些支持,并且StringBuilder已进行了稍微的重构以与共享更多代码StringBuffer。StringBuilder#append(int)尽管我可能错过了一些内容,但这些更改似乎都没有影响所使用的代码路径。

比较为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实现中不会发生这种优化,或者为什么Integer#toString(int)在JVM 7中没有实现相同的优化。我猜想熟悉JRE源代码相关部分的人必须回答这些问题...


您不是说:new StringBuilder().append(this.counter++).toString();和第三次测试return "" + this.counter++;吗?
— 亚述2014年

4
@assylias:该stringBuilder方法转换为与完全相同的字节码return "" + this.counter++;。我将看到添加第三个测试而不附加空字符串的情况……
— thkala 2014年

@assylias:你去。我看不出有什么真正的不同…
— thkala 2014年

你可以添加一个测试String.format("%d",n);,以及

1
@JarrodRoberson:这个怎么样?String.format("%d",n)比所有事物
— 都要

Answers:


97

TL; DR:副作用append显然破坏了StringConcat优化。

原始问题和更新中的分析非常出色!

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

  • 请-XX:+PrintInlining同时参阅7u55和8u5。在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方法之后似乎什么也没叫-这很好地表明了字符串优化已生效。确实,如果您使用来运行7u55 -XX:-OptimizeStringConcat,子调用将重新出现,并且性能将下降到8u5级别。

  • 好的,因此我们需要弄清楚为什么8u5没有进行相同的优化。使用Grep http://hg.openjdk.java.net/jdk9/jdk9/hotspot查找“ StringBuilder”,以了解VM在哪里处理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邮件列表上查找审阅线程(很容易用Google搜索更改集摘要):http : //mail.openjdk.java.net/pipermail/hotspot-compiler-dev/2013-October/012084.html

  • 现货“字符串concat优化优化将模式折叠成单个字符串分配并直接形成结果。在优化代码中可能发生的所有可能的取消运行都从头开始重新启动该模式(从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上对其进行测量,看到内联效果会导致性能下降:

    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

这是8u5:

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

stringFormat实际上在8u5中要快一些,所有其他测试都相同。这巩固了原问题中主要罪魁祸首中SB链副作用断裂的假说。


1
做得非常好!这是一个微妙的小问题……错误……不是大多数Java程序员所期望的。我发现有一些关于字符串优化的引用存在正确性问题,所以我有所怀疑,但是我没有时间来确定它。我也很感谢Bug报告,即使它没有成功。
— thkala 2014年

1
哦,我还通过在StringBuilder致电和进行基准测试之前移动计数器增量来确认您的发现。我想知道还会有其他这种类型的小宝石吗?
— thkala 2014年

5

我认为这与CompileThreshold标志有关,该标志控制JIT将字节代码何时编译为机器代码。

在http://www.oracle.com/technetwork/java/javase/tech/vmoptions-jsp-140102.html上,Oracle JDK的默认计数为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 2014年

@thkala我想知道如果OP在这里尝试热身会有什么结果。但我同意您的看法,他的代码过于简单,无法进行很大的改进。还有一些JDK用通用代码代替了通用的核心性能代码,即那些具有字符串操作的代码。虽然不太确定OpenJDK的实现。
— Alex Suo)2014年

抱歉,您刚刚意识到自己是OP :)
— Alex Suo 2014年

似乎这比OpenJDK / HotSpot更像是Java7 / Java8问题-我在Oracle JDK 7u55上添加了一个基准...
— thkala 2014年

看起来,与字符串相关的VM选项在两个版本上都是相同的。也就是说,Java 8确实具有不同的GC机制……
— thkala 2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.