[java] Java 8 : Class.getName ()이 문자열 연결 체인을 느리게합니다.

최근에 문자열 연결에 관한 문제가 발생했습니다. 이 벤치 마크는 다음을 요약합니다.

@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();
    }
  }
}

JDK 1.8.0_222 (OpenJDK 64 비트 서버 VM, 25.222-b10)에서 다음과 같은 결과가 나타납니다.

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

이는 부작용이있는 표현식이 새로운 체인의 최적화를 방해 하는 JDK-8043677 과 유사한 문제처럼 보입니다 StringBuilder.append().append().toString(). 그러나 코드 Class.getName()자체에는 부작용이없는 것 같습니다.

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

여기서 가장 의심스러운 것은 실제로 한 번만 발생하는 기본 메소드 호출이며 결과는 클래스의 필드에 캐시됩니다. 내 벤치 마크에서 설정 방법으로 명시 적으로 캐시했습니다.

브랜치 예측자는 각 벤치 마크 호출에서 this.name의 실제 값이 null이 아니며 전체 표현식을 최적화한다는 것을 알 것으로 예상했습니다.

그러나 동안 BrokenConcatenationBenchmark.fast()나는 이것을 가지고있다 :

@ 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)

즉, 컴파일러는 모든 것을 인라인 할 수 BrokenConcatenationBenchmark.slow()있습니다.

@ 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)

문제는 이것이 JVM의 적절한 동작인지 컴파일러 버그입니까?

일부 프로젝트에서 여전히 Java 8을 사용하고 있기 때문에 릴리스 업데이트 중 하나에서 수정되지 않으면 Class.getName()핫 스팟에서 수동으로 호출을 올리는 것이 합리적 입니다.

PS 최신 JDK (11, 13, 14-eap)에서는 문제가 재현되지 않습니다.



답변

HotSpot JVM은 바이트 코드 당 실행 통계를 수집합니다. 동일한 코드가 다른 컨텍스트에서 실행되는 경우 결과 프로파일은 모든 컨텍스트에서 통계를 집계합니다. 이 효과를 프로파일 오염이라고 합니다.

Class.getName()벤치 마크 코드뿐만 아니라 분명히 호출됩니다. JIT가 벤치 마크 컴파일을 시작하기 전에 다음 조건 Class.getName()이 여러 번 충족 되었음을 이미 알고 있습니다 .

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

적어도이 지점을 통계적으로 중요하게 다루기에 충분한 시간. 따라서 JIT는이 분기를 컴파일에서 제외하지 않았으므로 부작용으로 인해 문자열 연결을 최적화 할 수 없습니다.

이것은 네이티브 메소드 호출 일 필요조차 없습니다. 규칙적인 필드 할당 만 부작용으로 간주됩니다.

다음은 프로파일 오염이 추가 최적화에 해를 끼치는 방법의 예입니다.

@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();
    }
}

이것은 기본적으로 getName()프로파일 오염을 시뮬레이션하는 수정 된 벤치 마크 버전입니다 . getName()새로운 객체에 대한 예비 호출 수에 따라 문자열 연결의 추가 성능이 크게 달라질 수 있습니다.

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  << !

프로파일 오염의 더 많은 예»

나는 그것을 버그 나 “적절한 행동”이라고 부를 수 없다. 이것이 바로 핫스팟에서 동적 적응 컴파일이 구현되는 방식입니다.


답변

약간 관련이 없지만 Java 9 및 JEP 280 이후 : Indify String Concatenation 이제 문자열 연결이 수행 invokedynamic되고 그렇지 않습니다 StringBuilder. 이 기사 에서는 Java 8과 Java 9 사이의 바이트 코드 차이점을 보여줍니다.

최신 Java 버전에서 벤치 마크를 다시 실행해도 문제가 표시되지 않으면 javac컴파일러는 이제 새로운 메커니즘을 사용하기 때문에 버그가 거의 없습니다 . 최신 버전에 상당한 변화가있을 경우 Java 8 동작에 뛰어 들지 않는지 확실하지 않습니다.


답변