Acelerando la creación de ConcurrentReferenceHashMap

Saludos, en este artículo analizaré cómo puede acelerar la creación de org.springframework.util.ConcurrentReferenceHashMap con poco esfuerzo.


¿Interesado en aumentar el rendimiento? Bienvenido


Inteligencia


Comenzaremos, por supuesto, con mediciones y trataremos de entender exactamente qué mejoraremos. Para esto, tome JMH 1.21, JDK 8 y JDK 11, así como async-profiler .


Para saber cuánto se necesita para crear un diccionario vacío, ponemos una experiencia simple:


 @Benchmark public Object original() { return new ConcurrentReferenceHashMap(); } 

El perfil se ve así:


 55.21% 2429743 osuConcurrentReferenceHashMap.calculateShift 20.30% 891404 osuConcurrentReferenceHashMap$Segment.<init> 8.79% 387198 osuConcurrentReferenceHashMap.<init> 3.35% 147651 java.util.concurrent.locks.ReentrantLock.<init> 2.34% 102804 java.lang.ref.ReferenceQueue.<init> 1.61% 70748 osuConcurrentReferenceHashMap.createReferenceManager 1.53% 67265 osuConcurrentReferenceHashMap$Segment.createReferenceArray 0.78% 34493 java.lang.ref.ReferenceQueue$Lock.<init> 0.76% 33546 osuConcurrentReferenceHashMap$ReferenceManager.<init> 0.36% 15948 osuAssert.isTrue 

La dirección es clara, puedes continuar.


Matemáticas


Por lo tanto, pasamos la mayor parte del tiempo en el método calculateShift . Aquí esta:


 protected static int calculateShift(int minimumValue, int maximumValue) { int shift = 0; int value = 1; while (value < minimumValue && value < maximumValue) { value <<= 1; shift++; } return shift; } 

Es difícil encontrar algo nuevo, así que pasemos a usarlo:


 public ConcurrentReferenceHashMap(/*...*/ int concurrencyLevel, /*...*/) { //... this.shift = calculateShift(concurrencyLevel, MAXIMUM_CONCURRENCY_LEVEL); //... } // ConcurrentReferenceHashMap$Segment public Segment(int initialCapacity) { this.referenceManager = createReferenceManager(); this.initialSize = 1 << calculateShift(initialCapacity, MAXIMUM_SEGMENT_SIZE); this.references = createReferenceArray(this.initialSize); this.resizeThreshold = (int) (this.references.length * getLoadFactor()); } 

Tenga en cuenta el uso del constructor de Segment :


 int roundedUpSegmentCapacity = (int) ((initialCapacity + size - 1L) / size); //... for (int i = 0; i < this.segments.length; i++) { this.segments[i] = new Segment(roundedUpSegmentCapacity); } 

El valor de roundedUpSegmentCapacity constante al pasar por el bucle, por lo tanto, la expresión 1 << calculateShift(initialCapacity, MAXIMUM_SEGMENT_SIZE) ejecutada en el constructor de Segment también siempre será constante. Por lo tanto, podemos tomar la expresión especificada fuera del constructor y el bucle.


La misma declaración es verdadera para la expresión (int) (this.references.length * getLoadFactor()) , ya que la matriz de references se crea utilizando la variable initialCapacity y su tamaño es constante al crear cada segmento. Extraiga la expresión de los límites del constructor y el bucle.


Matrices


Considere el método createReferenceArray :


 private Reference<K, V>[] createReferenceArray(int size) { return (Reference<K, V>[]) Array.newInstance(Reference.class, size); } 

Usar Array::newInstance claramente redundante, nada nos impide crear una matriz usando el constructor:


 private Reference<K, V>[] createReferenceArray(int size) { return new Reference[size]; } 

El rendimiento del constructor no es inferior a llamar a Array::newInstance en el nivel C2, pero lo supera significativamente para las matrices pequeñas en los modos C1 (propiedad -XX:TieredStopAtLevel=1 ) y el intérprete (propiedad -Xint ):


 //C2 length Mode Cnt Score Error Units constructor 10 avgt 50 5,6 ± 0,0 ns/op constructor 100 avgt 50 29,7 ± 0,1 ns/op constructor 1000 avgt 50 242,7 ± 1,3 ns/op newInstance 10 avgt 50 5,5 ± 0,0 ns/op newInstance 100 avgt 50 29,7 ± 0,1 ns/op newInstance 1000 avgt 50 249,3 ± 9,6 ns/op //C1 length Mode Cnt Score Error Units constructor 10 avgt 50 6,8 ± 0,1 ns/op constructor 100 avgt 50 36,3 ± 0,6 ns/op constructor 1000 avgt 50 358,6 ± 6,4 ns/op newInstance 10 avgt 50 91,0 ± 2,4 ns/op newInstance 100 avgt 50 127,2 ± 1,8 ns/op newInstance 1000 avgt 50 322,8 ± 7,2 ns/op //-Xint length Mode Cnt Score Error Units constructor 10 avgt 50 126,3 ± 5,9 ns/op constructor 100 avgt 50 154,7 ± 2,6 ns/op constructor 1000 avgt 50 364,2 ± 6,2 ns/op newInstance 10 avgt 50 251,2 ± 11,3 ns/op newInstance 100 avgt 50 287,5 ± 11,4 ns/op newInstance 1000 avgt 50 486,5 ± 8,5 ns/op 

El reemplazo no afectará nuestro punto de referencia, pero acelerará el código cuando se inicie la aplicación, cuando C2 aún no haya funcionado. Más sobre este modo se dirá al final del artículo.


Pequeñas cosas clave


Volvamos nuevamente al constructor ConcurrentReferenceHashMap


 ConcurrentReferenceHashMap(/*...*/) { Assert.isTrue(initialCapacity >= 0, "Initial capacity must not be negative"); Assert.isTrue(loadFactor > 0f, "Load factor must be positive"); Assert.isTrue(concurrencyLevel > 0, "Concurrency level must be positive"); Assert.notNull(referenceType, "Reference type must not be null"); this.loadFactor = loadFactor; this.shift = calculateShift(concurrencyLevel, MAXIMUM_CONCURRENCY_LEVEL); int size = 1 << this.shift; this.referenceType = referenceType; int roundedUpSegmentCapacity = (int) ((initialCapacity + size - 1L) / size); this.segments = (Segment[]) Array.newInstance(Segment.class, size); for (int i = 0; i < this.segments.length; i++) { this.segments[i] = new Segment(roundedUpSegmentCapacity); } } 

De uno curioso para nosotros: reemplazar Array.newInstance con un constructor conduce a un error de compilación, pasamos por Array.newInstance . Pero el ciclo es muy interesante, o más bien, el atractivo para el campo de segments . Para ver cuán devastador (a veces) puede ser el rendimiento, el artículo de Nitzan Wakart, The voltile read suprise, puede recomendar tal atractivo.


El caso descrito en el artículo, me parece, se correlaciona con el código en cuestión. Centrarse en segmentos:


 this.segments = (Segment[]) Array.newInstance(Segment.class, size); for (int i = 0; i < this.segments.length; i++) { this.segments[i] = new Segment(roundedUpSegmentCapacity); } 

Inmediatamente después de crear la matriz, se escribe en el campo ConcurrentReferenceHashMap.segments , y es con este campo que interactúa el bucle. Dentro del constructor de segmentos, hay un registro en las references campo de volatilidad:


 private volatile Reference<K, V>[] references; public Segment(int initialCapacity) { //... this.references = createReferenceArray(this.initialSize); //... } 

Esto significa que es imposible mejorar el acceso al campo de segments , en otras palabras, su contenido se lee en cada vuelta del ciclo. ¿Cómo verificar la verdad de esta declaración? La forma más fácil es copiar el código en un paquete separado y eliminar el volatile de la declaración del campo Segment.references :


 protected final class Segment extends ReentrantLock { //  private volatile Reference<K, V>[] references; //  private Reference<K, V>[] references; } 

Comprueba si algo ha cambiado:


 @Benchmark public Object original() { return new tsypanov.map.original.ConcurrentReferenceHashMap(); } @Benchmark public Object nonVolatileSegmentReferences() { return new tsypanov.map.nonvolatile.ConcurrentReferenceHashMap(); } 

Encontramos ganancias de rendimiento significativas (JDK 8):


 Benchmark Mode Cnt Score Error Units original avgt 100 732,1 ± 15,8 ns/op nonVolatileSegmentReferences avgt 100 610,6 ± 15,4 ns/op 

En JDK 11, el tiempo empleado se redujo, pero la brecha relativa casi no cambió:


 Benchmark Mode Cnt Score Error Units original avgt 100 473,8 ± 11,2 ns/op nonVolatileSegmentReferences avgt 100 401,9 ± 15,5 ns/op 

Por supuesto, lo volatile necesita ser devuelto al lugar y buscar otra forma. Se descubre un cuello de botella, esto es un atractivo para el campo. Y si es así, puede crear la variable de segments , llenar la matriz y solo luego escribirla en el campo:


 Segment[] segments = (Segment[]) Array.newInstance(Segment.class, size); for (int i = 0; i < segments.length; i++) { segments[i] = new Segment(roundedUpSegmentCapacity); } this.segments = segments; 

Como resultado, incluso con mejoras tan simples, se logró un buen crecimiento:


Jdk 8


 Benchmark Mode Cnt Score Error Units originalConcurrentReferenceHashMap avgt 100 712,1 ± 7,2 ns/op patchedConcurrentReferenceHashMap avgt 100 496,5 ± 4,6 ns/op 

Jdk 11


 Benchmark Mode Cnt Score Error Units originalConcurrentReferenceHashMap avgt 100 536,0 ± 8,4 ns/op patchedConcurrentReferenceHashMap avgt 100 486,4 ± 9,3 ns/op 

¿Qué significa reemplazar 'Arrays :: newInstance' por 'new T []'?


Al iniciar la aplicación Spring Booth desde Idea, los desarrolladores a menudo establecen el indicador 'Habilitar optimizaciones de inicio', que agrega -XX:TieredStopAtLevel=1 -noverify a los argumentos de VM, lo que acelera el inicio al deshabilitar la creación de perfiles y C2. Hagamos una medición con los argumentos especificados:


 // JDK 8 -XX:TieredStopAtLevel=1 -noverify Benchmark Mode Cnt Score Error Units originalConcurrentReferenceHashMap avgt 100 1920,9 ± 24,2 ns/op patchedConcurrentReferenceHashMap avgt 100 592,0 ± 25,4 ns/op // JDK 11 -XX:TieredStopAtLevel=1 -noverify Benchmark Mode Cnt Score Error Units originalConcurrentReferenceHashMap avgt 100 1838,9 ± 8,0 ns/op patchedConcurrentReferenceHashMap avgt 100 549,7 ± 6,7 ns/op 

¡Aumento de más del triple!


¿Para qué es esto?


En particular, esto es necesario para acelerar las consultas que devuelven proyecciones en Spring Data JPA.



El perfil de JMC muestra que la creación de un ConcurrentReferenceHashMap lleva casi una quinta parte del tiempo dedicado a ejecutar una consulta del formulario


 public interface SimpleEntityRepository extends JpaRepository<SimpleEntity, Long> { List<HasIdAndName> findAllByName(String name); } 

donde HasIdAndName es una proyección de vista


 public interface HasIdAndName { int getId(); String getName(); } 

Además, ConcurrentReferenceHashMap varias decenas de veces en el código Spring, por lo que definitivamente no será superfluo.


Conclusiones


  • mejorar el rendimiento no es tan difícil como parece a primera vista
  • El acceso volátil en las proximidades del ciclo es uno de los posibles cuellos de botella.
  • buscar invariantes y sacarlos de los ciclos

Que leer


Artículo de Nitzan Wakart


Ejemplo de código


Cambios:
https://github.com/spring-projects/spring-framework/pull/1873
https://github.com/spring-projects/spring-framework/pull/2051

Source: https://habr.com/ru/post/es432824/


All Articles