为什么“new”关键字比赋值更高效?

13

我有两种方法来读取一个字符串,并创建字符对象:

static void newChar(String string) {
    int len = string.length();
    System.out.println("Reading " + len + " characters");
    for (int i = 0; i < len; i++) {
        Character cur = new Character(string.charAt(i));

    }       
}

static void justChar(String string) {
    int len = string.length();
    for (int i = 0; i < len; i++) {
        Character cur = string.charAt(i);

    }
}

当我使用一个由18,554,760个字符组成的字符串运行这些方法时,我得到了非常不同的运行时间。我获得的输出结果是:

newChar took: 20 ms
justChar took: 41 ms

当输入较小 (4,638,690个字符) 时,时间变化不太大。

newChar took: 12 ms
justChar took: 13 ms

为什么在这种情况下,新的更加高效?

编辑:

我的基准测试代码相当粗糙。

start = System.currentTimeMillis();
newChar(largeString);
end = System.currentTimeMillis();
diff = end-start;
System.out.println("New char took: " + diff + " ms");

start = System.currentTimeMillis();
justChar(largeString);
end = System.currentTimeMillis();
diff = end-start;
System.out.println("just char took: " + diff+ " ms");

9
请展示您的基准测试代码。 - Martijn Courteaux
4
将您的基准测试顺序反转,看看是否会得到相同的结果。 - Charles Duffy
3
你需要循环的次数比那多得多,否则你只会触发JVM的热身效应。你尝试过调整测试顺序吗? - Mysticial
3
顺便提一下,我强烈建议使用适当的微基准测试框架,以考虑预热等因素。Criterium 是 Clojure 的黄金标准;对于纯 Java,请尝试使用 https://code.google.com/p/caliper/。 - Charles Duffy
3
我已经确认了OP在jmh中的结果:对于我的字符串,justChar花费了38微秒,而newChar花费了19微秒。 - Marko Topolnik
显示剩余7条评论
2个回答

22

嗯,我不确定Marko是否有意复制原始错误。简而言之,没有使用新实例,它会被删除。调整基准可以扭转结果。不要相信有问题的基准测试,要从中学习。

这是JMH基准:

@OutputTimeUnit(TimeUnit.MICROSECONDS)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 3, time = 1)
@Fork(3)
@State(Scope.Thread)
public class Chars {

    // Source needs to be @State field to avoid constant optimizations
    // on sources. Results need to be sinked into the Blackhole to
    // avoid dead-code elimination
    private String string;

    @Setup
    public void setup() {
        string = "12345678901234567890";
        for (int i = 0; i < 10; i++) {
            string += string;
        }
    }

    @GenerateMicroBenchmark
    public void newChar_DCE(BlackHole bh) {
        int len = string.length();
        for (int i = 0; i < len; i++) {
            Character c = new Character(string.charAt(i));
        }
    }

    @GenerateMicroBenchmark
    public void justChar_DCE(BlackHole bh) {
        int len = string.length();
        for (int i = 0; i < len; i++) {
            Character c = Character.valueOf(string.charAt(i));
        }
    }

    @GenerateMicroBenchmark
    public void newChar(BlackHole bh) {
        int len = string.length();
        for (int i = 0; i < len; i++) {
            Character c = new Character(string.charAt(i));
            bh.consume(c);
        }
    }

    @GenerateMicroBenchmark
    public void justChar(BlackHole bh) {
        int len = string.length();
        for (int i = 0; i < len; i++) {
            Character c = Character.valueOf(string.charAt(i));
            bh.consume(c);
        }
    }

    @GenerateMicroBenchmark
    public void newChar_prim(BlackHole bh) {
        int len = string.length();
        for (int i = 0; i < len; i++) {
            char c = new Character(string.charAt(i));
            bh.consume(c);
        }
    }

    @GenerateMicroBenchmark
    public void justChar_prim(BlackHole bh) {
        int len = string.length();
        for (int i = 0; i < len; i++) {
            char c = Character.valueOf(string.charAt(i));
            bh.consume(c);
        }
    }
}

...并且这是结果:

Benchmark                   Mode   Samples         Mean   Mean error    Units
o.s.Chars.justChar          avgt         9       93.051        0.365    us/op
o.s.Chars.justChar_DCE      avgt         9       62.018        0.092    us/op
o.s.Chars.justChar_prim     avgt         9       82.897        0.440    us/op
o.s.Chars.newChar           avgt         9      117.962        4.679    us/op
o.s.Chars.newChar_DCE       avgt         9       25.861        0.102    us/op
o.s.Chars.newChar_prim      avgt         9       41.334        0.183    us/op

DCE代表“Dead Code Elimination”,这就是原始基准测试所遭受的影响。如果我们消除这种影响,按照JMH的方式,需要将值放入Blackhole中,分数就会反转。因此,回顾一下,这似乎表明原始代码中的new Character()在DCE方面有了重大改进,而Character.valueOf则不太成功。我不确定我们是否应该讨论为什么,因为这与实际使用情况无关,在实际使用情况中,生成的字符实际上被使用。

您可以从以下两个方面进一步进行:

  • 获取基准测试方法的汇编以确认上述猜测。请参见PrintAssembly
  • 使用更多线程运行。随着我们增加线程数并相应地击中“分配壁垒”,返回缓存的Character和实例化新的Character之间的差异将减小。

更新:针对Marko的问题进行跟进,似乎主要影响是消除分配本身,无论是通过EA还是DCE,请参见*_prim测试。

更新2:查看汇编。在-XX:-DoEscapeAnalysis运行相同的代码时,确认主要影响是由于消除分配,因为逃逸分析的影响:

Benchmark                   Mode   Samples         Mean   Mean error    Units
o.s.Chars.justChar          avgt         9       94.318        4.525    us/op
o.s.Chars.justChar_DCE      avgt         9       61.993        0.227    us/op
o.s.Chars.justChar_prim     avgt         9       82.824        0.634    us/op
o.s.Chars.newChar           avgt         9      118.862        1.096    us/op
o.s.Chars.newChar_DCE       avgt         9       97.530        2.485    us/op
o.s.Chars.newChar_prim      avgt         9      101.905        1.871    us/op

这证明了原始的DCE猜想是不正确的。EA是主要的贡献者。 尽管如此,DCE结果仍然更快,因为我们没有支付取消装箱以及一般对返回值的任何尊重的成本。不过在这方面,基准测试是有缺陷的。


你不能返回单个字符实例,因为在循环中会产生许多实例。你需要将每一个实例都处理完毕,否则你就会面临循环专业化和循环部分删除的问题。 - Aleksey Shipilev
是的,那个我没注意到...但如果我真的想修复基准测试,我会对“Character”做一些处理而不让它逃脱。我认为这样更公平,因为EA可能是故事的一部分,而不是一个杂散的效果。 - Marko Topolnik
我仍然无法将您的结果解释为证明任何DCE。假设“justChar”涉及比在“newChar”情况下将char值复制到附近堆栈位置更昂贵的数组查找操作仍然存在。请注意,“*_prim”变体在两种情况下都添加相同的常量时间,即“charValue”调用。 - Marko Topolnik
(请看更新;我不知道为什么我一开始没有想到这个实验) - Aleksey Shipilev
2
我不会说“不稳定”,而是“复杂”!这就是我在基准测试演讲中的整个思路:猫头鹰并不是它们看起来的那样。 - Aleksey Shipilev
显示剩余5条评论

8

简短总结

好消息

您的测量结果确实显示出了真正的效果。

坏消息

这主要是因为您的基准测试存在许多技术缺陷,而且它所显示的效果可能不是您想要的那个。

new Character() 方法更快,但仅当HotSpot的逃逸分析成功地证明生成的实例可以安全地分配在堆栈上时才会如此。因此,其效果并不像您的问题中暗示的那样普遍。

效果的解释

new Character() 更快的原因是“引用局部性”:您的实例位于堆栈上,所有对它的访问都通过CPU高速缓存命中进行。当您重用缓存的实例时,您必须:

  1. 访问远程的 static 字段;
  2. 将其反引用到远程数组;
  3. 将数组条目反引用到远程的 Character 实例;
  4. 访问该实例中包含的 char

每次反引用都有可能导致CPU高速缓存未命中。此外,它会强制将高速缓存的一部分重定向到这些远程位置,从而导致输入字符串和/或堆栈位置更多的高速缓存未命中。

详细信息

我已经使用 jmh 运行了此代码:

@OutputTimeUnit(TimeUnit.MICROSECONDS)
@BenchmarkMode(Mode.AverageTime)
public class Chars {
  static String string = "12345678901234567890"; static {
    for (int i = 0; i < 10; i++) string += string;
  }

  @GenerateMicroBenchmark
  public void newChar() {
    int len = string.length();
    for (int i = 0; i < len; i++) new Character(string.charAt(i));
  }

  @GenerateMicroBenchmark
  public void justChar() {
    int len = string.length();
    for (int i = 0; i < len; i++) Character.valueOf(string.charAt(i));
  }
}

这样可以保留代码的精华,但消除一些系统性错误,例如预热和编译时间。以下是结果:
Benchmark              Mode Thr    Cnt  Sec         Mean   Mean error    Units
o.s.Chars.justChar     avgt   1      3    5       39.062        6.587  usec/op
o.s.Chars.newChar      avgt   1      3    5       19.114        0.653  usec/op

这可能是我对发生的情况的最佳猜测:
- 在 `newChar` 中,您正在创建 `Character` 的一个新实例。 HotSpot 的逃逸分析可以证明该实例永远不会逃逸,因此它允许堆栈分配,或者在 `Character` 的特殊情况下,可以完全消除分配,因为可以明显地证明从中提取的数据永远不会被使用; - 在 `justChar` 中,您涉及查找 `Character` 缓存数组,这会产生一些成本。
更新
回应Aleks的批评,我添加了更多的基准测试方法。主要效果保持稳定,但我们获得了更细粒度的有关较小优化效果的详细信息。
  @GenerateMicroBenchmark
  public int newCharUsed() {
    int len = string.length(), sum = 0;
    for (int i = 0; i < len; i++) sum += new Character(string.charAt(i));
    return sum;
  }

  @GenerateMicroBenchmark
  public int justCharUsed() {
    int len = string.length(), sum = 0;
    for (int i = 0; i < len; i++) sum += Character.valueOf(string.charAt(i));
    return sum;
  }

  @GenerateMicroBenchmark
  public void newChar() {
    int len = string.length();
    for (int i = 0; i < len; i++) new Character(string.charAt(i));
  }

  @GenerateMicroBenchmark
  public void justChar() {
    int len = string.length();
    for (int i = 0; i < len; i++) Character.valueOf(string.charAt(i));
  }

  @GenerateMicroBenchmark
  public void newCharValue() {
    int len = string.length();
    for (int i = 0; i < len; i++) new Character(string.charAt(i)).charValue();
  }

  @GenerateMicroBenchmark
  public void justCharValue() {
    int len = string.length();
    for (int i = 0; i < len; i++) Character.valueOf(string.charAt(i)).charValue();
  }

描述:

  • 基础版本是justCharnewChar
  • ...Value方法在基础版本上增加charValue调用;
  • ...Used方法隐式添加charValue调用,并使用该值以防止死代码消除。

结果:

Benchmark                   Mode Thr    Cnt  Sec         Mean   Mean error    Units
o.s.Chars.justChar          avgt   1      3    1      246.847        5.969  usec/op
o.s.Chars.justCharUsed      avgt   1      3    1      370.031       26.057  usec/op
o.s.Chars.justCharValue     avgt   1      3    1      296.342       60.705  usec/op
o.s.Chars.newChar           avgt   1      3    1      123.302       10.596  usec/op
o.s.Chars.newCharUsed       avgt   1      3    1      172.721        9.055  usec/op
o.s.Chars.newCharValue      avgt   1      3    1      123.040        5.095  usec/op
  • justCharnewChar变量中,有一些死代码消除(DCE)的证据,但只是部分的;
  • 使用newChar变量时,添加charValue没有任何效果,因此显然已经被DCE了;
  • 对于justCharcharValue确实有影响,所以似乎没有被消除;
  • DCE对整体影响较小,如newCharUsedjustCharUsed之间稳定的差异所示。

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