Java 8: Class.getName () ralentiza la cadena de concatenación de cadenas

13

Recientemente me he encontrado con un problema relacionado con la concatenación de cadenas. Este punto de referencia lo resume:

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

En JDK 1.8.0_222 (OpenJDK 64-Bit Server VM, 25.222-b10) obtuve los siguientes resultados:

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

Esto parece un problema similar al JDK-8043677 , donde una expresión que tiene efectos secundarios rompe la optimización de la nueva StringBuilder.append().append().toString()cadena. Pero el código en Class.getName()sí mismo no parece tener ningún efecto secundario:

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

Lo único sospechoso aquí es una llamada al método nativo, que de hecho ocurre solo una vez y su resultado se almacena en caché en el campo de la clase. En mi punto de referencia, lo he almacenado en caché explícitamente en el método de configuración.

Esperaba que el predictor de rama descubriera que en cada invocación de referencia el valor real de this.name nunca es nulo y optimiza toda la expresión.

Sin embargo, mientras que para el BrokenConcatenationBenchmark.fast()tengo esto:

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

es decir, el compilador puede en línea todo, porque BrokenConcatenationBenchmark.slow()es diferente:

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

Entonces, la pregunta es si este es el comportamiento apropiado de la JVM o el error del compilador.

Estoy haciendo la pregunta porque algunos de los proyectos todavía usan Java 8 y si no se solucionará en ninguna de las actualizaciones de lanzamiento, entonces para mí es razonable levantar llamadas Class.getName()manualmente desde puntos calientes.

PD En los últimos JDK (11, 13, 14-eap) el problema no se reproduce.

Sergey Tsypanov
fuente
Tienes un efecto secundario allí: la asignación a this.name.
RealSkeptic
@RealSkeptic la asignación ocurre solo una vez en la primera invocación de Class.getName()y en el setUp()método, no en el cuerpo de la referencia.
Sergey Tsypanov

Respuestas:

7

HotSpot JVM recopila estadísticas de ejecución por código de bytes. Si se ejecuta el mismo código en diferentes contextos, el perfil de resultados agregará estadísticas de todos los contextos. Este efecto se conoce como contaminación de perfil .

Class.getName()obviamente se llama no solo desde su código de referencia. Antes de que JIT comience a compilar el punto de referencia, ya sabe que la siguiente condición Class.getName()se cumplió varias veces:

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

Al menos, suficientes veces para tratar esta rama estadísticamente importante. Por lo tanto, JIT no excluyó esta rama de la compilación y, por lo tanto, no pudo optimizar el concat de cadenas debido a un posible efecto secundario.

Esto ni siquiera necesita ser una llamada a un método nativo. Solo una asignación de campo regular también se considera un efecto secundario.

Aquí hay un ejemplo de cómo la contaminación del perfil puede dañar más optimizaciones.

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

Esta es básicamente la versión modificada de su punto de referencia que simula la contaminación del getName()perfil. Dependiendo del número de getName()llamadas preliminares en un objeto nuevo, el rendimiento adicional de la concatenación de cadenas puede diferir dramáticamente:

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

Más ejemplos de contaminación del perfil »

No puedo llamarlo un error o un "comportamiento apropiado". Así es como se implementa la compilación adaptativa dinámica en HotSpot.

apangin
fuente
1
¿quién más si no es Pangin ... sabes si Graal C2 tiene la misma enfermedad?
Eugene
1

Ligeramente no relacionado, pero desde Java 9 y JEP 280: Indicar Concatenación de cadenas, la concatenación de cadenas ahora se hace con invokedynamicy no StringBuilder. Este artículo muestra las diferencias en el código de bytes entre Java 8 y Java 9.

Si el punto de referencia que se vuelve a ejecutar en la versión más nueva de Java no muestra el problema, es muy probable que no haya ningún error javacporque el compilador ahora usa un nuevo mecanismo. No estoy seguro de si sumergirse en el comportamiento de Java 8 es beneficioso si hay un cambio tan sustancial en las versiones más recientes.

Karol Dowbecki
fuente
1
Estoy de acuerdo en que es probable que sea un problema del compilador, no uno relacionado javac. javacgenera bytecode y no realiza optimizaciones sofisticadas. Ejecuté el mismo punto de referencia -XX:TieredStopAtLevel=1y recibí esta salida: 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 así que cuando no optimizamos mucho ambos métodos producen los mismos resultados, el problema se revela solo cuando el código se compila en C2.
Sergey Tsypanov
1
ahora se hace con invooked Dynamic y no StringBuilder es simplemente incorrecto . invokedynamicsolo le dice al tiempo de ejecución que elija cómo hacer la concatenación, y 5 de las 6 estrategias (incluida la predeterminada) aún se usan StringBuilder.
Eugene
@ Eugene, gracias por señalar esto. Cuando dices estrategias, ¿te refieres a StringConcatFactory.Strategyenum?
Karol Dowbecki
@KarolDowbecki exactamente.
Eugene