为什么“new”关键字比分配更有效?

2022-09-04 06:52:54

我有两种方法可以在字符串中读取,并创建Charth character对象:

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");

答案 1

好吧,我不确定马尔科是否有意复制最初的错误。TL;DR;不使用新实例,将被消除。调整基准测试会反转结果。不要相信有缺陷的基准,从中学习。

以下是 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代表“死代码消除”,这就是原始基准测试所遭受的。如果我们消除这种效应,以JMH的方式,它要求我们将值沉入黑洞,分数就会反转。因此,回想起来,这似乎表明原始代码对DCE有重大改进,而DCE并不那么成功。我不确定我们是否应该讨论为什么,因为这与实际使用生成的角色的现实世界用例无关。new Character()Character.valueOf

从这里,您可以在两个方面走得更远:

  • 获取基准测试方法的程序集以确认上面的猜想。请参见打印装配体
  • 使用更多线程运行。返回缓存的字符和实例化新字符之间的差异会随着线程数量的增加而减小,从而达到“分配墙”。

UPD:在Marko的问题之后,似乎主要影响确实在于消除分配本身,无论是通过EA还是DCE,请参阅*_prim测试。

UPD2:查看了组件。与确认的主要影响是由于消除分配,作为逃逸分析的效果:-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结果仍然更快,因为我们不支付拆箱的成本,并且通常以任何尊重对待返回的值。然而,基准在这方面是错误的。


答案 2

TL;DR 部分

好消息

您的测量确实会显示出真实的效果。

坏消息

它这样做主要是偶然的,因为你的基准测试有很多技术缺陷,它所暴露的影响可能不是你想到的。

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

效果说明

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

  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

这将是我对正在发生的事情的最佳猜测:

  • 中,您正在创建 一个新的实例。HotSpot的逃逸分析可以证明实例永远不会转义,因此它允许堆栈分配,或者在特殊情况下,可以完全消除分配,因为来自它的数据可以证明从未使用过;newCharCharacterCharacter

  • ,这涉及到缓存数组的查找,这会产生一些成本。justCharCharacter

更新

为了回应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
  • 有证据表明一些死码消除(DCE)在和变体中,但它只是部分;justCharnewChar
  • 对于变体,添加没有效果,所以显然它是DCE的;newCharcharValue
  • 与 ,确实有效果,所以似乎没有被消除;justCharcharValue
  • DCE具有较小的整体影响,正如 和 之间的稳定差异所证明的那样。newCharUsedjustCharUsed

推荐