Java 8: Class.getName () memperlambat rantai penggabungan string

13

Baru-baru ini saya mengalami masalah tentang penggabungan String. Patokan ini merangkumnya:

@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class BrokenConcatenationBenchmark {

  @Benchmark
  public String slow(Data data) {
    final Class<? extends Data> clazz = data.clazz;
    return "class " + clazz.getName();
  }

  @Benchmark
  public String fast(Data data) {
    final Class<? extends Data> clazz = data.clazz;
    final String clazzName = clazz.getName();
    return "class " + clazzName;
  }

  @State(Scope.Thread)
  public static class Data {
    final Class<? extends Data> clazz = getClass();

    @Setup
    public void setup() {
      //explicitly load name via native method Class.getName0()
      clazz.getName();
    }
  }
}

Pada JDK 1.8.0_222 (OpenJDK 64-Bit Server VM, 25.222-b10) Saya mendapatkan hasil berikut:

Benchmark                                                            Mode  Cnt     Score     Error   Units
BrokenConcatenationBenchmark.fast                                    avgt   25    22,253 ±   0,962   ns/op
BrokenConcatenationBenchmark.fastgc.alloc.rate                     avgt   25  9824,603 ± 400,088  MB/sec
BrokenConcatenationBenchmark.fastgc.alloc.rate.norm                avgt   25   240,000 ±   0,001    B/op
BrokenConcatenationBenchmark.fastgc.churn.PS_Eden_Space            avgt   25  9824,162 ± 397,745  MB/sec
BrokenConcatenationBenchmark.fastgc.churn.PS_Eden_Space.norm       avgt   25   239,994 ±   0,522    B/op
BrokenConcatenationBenchmark.fastgc.churn.PS_Survivor_Space        avgt   25     0,040 ±   0,011  MB/sec
BrokenConcatenationBenchmark.fastgc.churn.PS_Survivor_Space.norm   avgt   25     0,001 ±   0,001    B/op
BrokenConcatenationBenchmark.fastgc.count                          avgt   25  3798,000            counts
BrokenConcatenationBenchmark.fastgc.time                           avgt   25  2241,000                ms

BrokenConcatenationBenchmark.slow                                    avgt   25    54,316 ±   1,340   ns/op
BrokenConcatenationBenchmark.slowgc.alloc.rate                     avgt   25  8435,703 ± 198,587  MB/sec
BrokenConcatenationBenchmark.slowgc.alloc.rate.norm                avgt   25   504,000 ±   0,001    B/op
BrokenConcatenationBenchmark.slowgc.churn.PS_Eden_Space            avgt   25  8434,983 ± 198,966  MB/sec
BrokenConcatenationBenchmark.slowgc.churn.PS_Eden_Space.norm       avgt   25   503,958 ±   1,000    B/op
BrokenConcatenationBenchmark.slowgc.churn.PS_Survivor_Space        avgt   25     0,127 ±   0,011  MB/sec
BrokenConcatenationBenchmark.slowgc.churn.PS_Survivor_Space.norm   avgt   25     0,008 ±   0,001    B/op
BrokenConcatenationBenchmark.slowgc.count                          avgt   25  3789,000            counts
BrokenConcatenationBenchmark.slowgc.time                           avgt   25  2245,000                ms

Ini terlihat seperti masalah yang mirip dengan JDK-8043677 , di mana ekspresi yang memiliki efek samping merusak optimasi StringBuilder.append().append().toString()rantai baru . Tetapi kode Class.getName()itu sendiri tampaknya tidak memiliki efek samping:

private transient String name;

public String getName() {
  String name = this.name;
  if (name == null) {
    this.name = name = this.getName0();
  }

  return name;
}

private native String getName0();

Satu-satunya hal yang mencurigakan di sini adalah panggilan ke metode asli yang hanya terjadi sekali dan hasilnya di-cache di bidang kelas. Dalam benchmark saya, saya telah secara eksplisit menyimpannya dalam metode pengaturan.

Saya mengharapkan prediktor cabang untuk mengetahui bahwa pada setiap permintaan benchmark, nilai aktual this.name tidak pernah nol dan mengoptimalkan seluruh ekspresi.

Namun, sementara untuk yang BrokenConcatenationBenchmark.fast()saya miliki ini:

@ 19   tsypanov.strings.benchmark.concatenation.BrokenConcatenationBenchmark::fast (30 bytes)   force inline by CompileCommand
  @ 6   java.lang.Class::getName (18 bytes)   inline (hot)
    @ 14   java.lang.Class::initClassName (0 bytes)   native method
  @ 14   java.lang.StringBuilder::<init> (7 bytes)   inline (hot)
  @ 19   java.lang.StringBuilder::append (8 bytes)   inline (hot)
  @ 23   java.lang.StringBuilder::append (8 bytes)   inline (hot)
  @ 26   java.lang.StringBuilder::toString (35 bytes)   inline (hot)

yaitu kompiler dapat inline semuanya, karena BrokenConcatenationBenchmark.slow()berbeda:

@ 19   tsypanov.strings.benchmark.concatenation.BrokenConcatenationBenchmark::slow (28 bytes)   force inline by CompilerOracle
  @ 9   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)
  @ 14   java.lang.StringBuilder::append (8 bytes)   inline (hot)
    @ 2   java.lang.AbstractStringBuilder::append (50 bytes)   inline (hot)
      @ 10   java.lang.String::length (6 bytes)   inline (hot)
      @ 21   java.lang.AbstractStringBuilder::ensureCapacityInternal (27 bytes)   inline (hot)
        @ 17   java.lang.AbstractStringBuilder::newCapacity (39 bytes)   inline (hot)
        @ 20   java.util.Arrays::copyOf (19 bytes)   inline (hot)
          @ 11   java.lang.Math::min (11 bytes)   (intrinsic)
          @ 14   java.lang.System::arraycopy (0 bytes)   (intrinsic)
      @ 35   java.lang.String::getChars (62 bytes)   inline (hot)
        @ 58   java.lang.System::arraycopy (0 bytes)   (intrinsic)
  @ 18   java.lang.Class::getName (21 bytes)   inline (hot)
    @ 11   java.lang.Class::getName0 (0 bytes)   native method
  @ 21   java.lang.StringBuilder::append (8 bytes)   inline (hot)
    @ 2   java.lang.AbstractStringBuilder::append (50 bytes)   inline (hot)
      @ 10   java.lang.String::length (6 bytes)   inline (hot)
      @ 21   java.lang.AbstractStringBuilder::ensureCapacityInternal (27 bytes)   inline (hot)
        @ 17   java.lang.AbstractStringBuilder::newCapacity (39 bytes)   inline (hot)
        @ 20   java.util.Arrays::copyOf (19 bytes)   inline (hot)
          @ 11   java.lang.Math::min (11 bytes)   (intrinsic)
          @ 14   java.lang.System::arraycopy (0 bytes)   (intrinsic)
      @ 35   java.lang.String::getChars (62 bytes)   inline (hot)
        @ 58   java.lang.System::arraycopy (0 bytes)   (intrinsic)
  @ 24   java.lang.StringBuilder::toString (17 bytes)   inline (hot)

Jadi pertanyaannya adalah apakah ini perilaku yang sesuai dari JVM atau bug kompiler?

Saya mengajukan pertanyaan karena beberapa proyek masih menggunakan Java 8 dan jika tidak diperbaiki pada pembaruan rilis apa pun, bagi saya masuk akal untuk mengangkat panggilan ke Class.getName()manual dari hot spot.

PS Pada JDK terbaru (11, 13, 14-eap) masalah tidak direproduksi.

Sergey Tsypanov
sumber
Anda memiliki efek samping di sana - penugasan ke this.name.
RealSkeptic
@RealSkeptic penugasan terjadi hanya sekali pada permohonan pertama Class.getName()dan dalam setUp()metode, bukan dalam tubuh yang dibandingan.
Sergey Tsypanov

Jawaban:

7

HotSpot JVM mengumpulkan statistik eksekusi per bytecode. Jika kode yang sama dijalankan dalam konteks yang berbeda, profil hasil akan mengumpulkan statistik dari semua konteks. Efek ini dikenal sebagai polusi profil .

Class.getName()jelas disebut tidak hanya dari kode tolok ukur Anda. Sebelum JIT mulai mengkompilasi tolok ukur, sudah diketahui bahwa kondisi berikut ini Class.getName()telah terpenuhi beberapa kali:

    if (name == null)
        this.name = name = getName0();

Setidaknya, cukup waktu untuk memperlakukan cabang ini secara statistik penting. Jadi, JIT tidak mengecualikan cabang ini dari kompilasi, dan dengan demikian tidak dapat mengoptimalkan string concat karena kemungkinan efek samping.

Ini bahkan tidak perlu menjadi pemanggilan metode asli. Hanya tugas lapangan biasa juga dianggap sebagai efek samping.

Berikut adalah contoh bagaimana polusi profil dapat membahayakan pengoptimalan lebih lanjut.

@State(Scope.Benchmark)
public class StringConcat {
    private final MyClass clazz = new MyClass();

    static class MyClass {
        private String name;

        public String getName() {
            if (name == null) name = "ZZZ";
            return name;
        }
    }

    @Param({"1", "100", "400", "1000"})
    private int pollutionCalls;

    @Setup
    public void setup() {
        for (int i = 0; i < pollutionCalls; i++) {
            new MyClass().getName();
        }
    }

    @Benchmark
    public String fast() {
        String clazzName = clazz.getName();
        return "str " + clazzName;
    }

    @Benchmark
    public String slow() {
        return "str " + clazz.getName();
    }
}

Ini pada dasarnya adalah versi modifikasi dari tolok ukur Anda yang mensimulasikan polusi getName()profil. Bergantung pada jumlah getName()panggilan pendahuluan pada objek baru, kinerja rangkaian string lebih lanjut mungkin berbeda secara dramatis:

Benchmark          (pollutionCalls)  Mode  Cnt   Score   Error  Units
StringConcat.fast                 1  avgt   15  11,458 ± 0,076  ns/op
StringConcat.fast               100  avgt   15  11,690 ± 0,222  ns/op
StringConcat.fast               400  avgt   15  12,131 ± 0,105  ns/op
StringConcat.fast              1000  avgt   15  12,194 ± 0,069  ns/op
StringConcat.slow                 1  avgt   15  11,771 ± 0,105  ns/op
StringConcat.slow               100  avgt   15  11,963 ± 0,212  ns/op
StringConcat.slow               400  avgt   15  26,104 ± 0,202  ns/op  << !
StringConcat.slow              1000  avgt   15  26,108 ± 0,436  ns/op  << !

Lebih banyak contoh polusi profil »

Saya tidak bisa menyebutnya bug atau "perilaku yang sesuai". Ini adalah bagaimana kompilasi adaptif dinamis diterapkan di HotSpot.

apangin
sumber
1
siapa lagi kalau bukan Pangin ... apakah Anda tahu kalau Graal C2 memiliki penyakit yang sama?
Eugene
1

Sedikit tidak terkait tetapi sejak Java 9 dan JEP 280: Indify Concatenation string, kini penggabungan string dilakukan dengan invokedynamicdan tidak StringBuilder. Artikel ini menunjukkan perbedaan dalam bytecode antara Java 8 dan Java 9.

Jika benchmark dijalankan kembali pada versi Java yang lebih baru tidak menunjukkan masalah, ada kemungkinan besar tidak ada bug javackarena kompiler sekarang menggunakan mekanisme baru. Tidak yakin apakah menyelam ke perilaku Java 8 bermanfaat jika ada perubahan substansial dalam versi yang lebih baru.

Karol Dowbecki
sumber
1
Saya setuju ini cenderung menjadi masalah kompiler, bukan yang terkait javacsekalipun. javacmenghasilkan bytecode dan tidak melakukan optimasi canggih. Saya telah menjalankan patokan yang sama dengan -XX:TieredStopAtLevel=1dan menerima output ini: Benchmark Mode Cnt Score Error Units BrokenConcatenationBenchmark.fast avgt 25 74,677 ? 2,961 ns/op BrokenConcatenationBenchmark.slow avgt 25 69,316 ? 1,239 ns/op Jadi, ketika kita tidak mengoptimalkan banyak kedua metode menghasilkan hasil yang sama, masalah ini hanya akan muncul ketika kode dikompilasi C2.
Sergey Tsypanov
1
sekarang dilakukan dengan invokedynamic dan tidak StringBuilder hanya salah . invokedynamichanya memberi tahu runtime untuk memilih cara melakukan penggabungan, dan 5 dari 6 strategi (termasuk default) masih digunakan StringBuilder.
Eugene
@Eugene terima kasih telah menunjukkan ini. Ketika Anda mengatakan strategi yang Anda maksud StringConcatFactory.Strategyenum?
Karol Dowbecki
@KarolDowbecki tepatnya.
Eugene