Accélérer la création de ConcurrentReferenceHashMap

Salutations, dans cet article, je vais discuter de la façon dont vous pouvez accélérer la création de org.springframework.util.ConcurrentReferenceHashMap avec peu d'effort.


Intéressé à augmenter les performances? Bienvenue


Intelligence


Nous allons commencer, bien sûr, par des mesures et essayer de comprendre exactement ce que nous allons améliorer. Pour cela, prenez JMH 1.21, JDK 8 et JDK 11, ainsi que async-profiler .


Pour savoir combien il faut pour créer un dictionnaire vide, nous mettons une expérience simple:


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

Le profil ressemble à ceci:


 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 direction est claire, vous pouvez continuer.


Math


Nous passons donc la part du lion du temps dans la méthode calculateShift . Le voici:


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

Il est difficile de trouver quelque chose de nouveau, alors passons à son utilisation:


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

Notez l'utilisation du constructeur Segment :


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

La valeur de roundedUpSegmentCapacity constante lors du passage dans la boucle, par conséquent l'expression 1 << calculateShift(initialCapacity, MAXIMUM_SEGMENT_SIZE) exécutée dans le constructeur Segment sera également toujours constante. Ainsi, nous pouvons prendre l'expression spécifiée en dehors du constructeur et de la boucle.


La même instruction est vraie pour l'expression (int) (this.references.length * getLoadFactor()) , car le tableau de references est créé à l'aide de la variable initialCapacity et sa taille est constante lors de la création de chaque segment. Extraire l'expression des limites du constructeur et de la boucle.


Tableaux


Considérez la méthode createReferenceArray :


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

Utiliser Array::newInstance clairement redondant, rien ne nous empêche de créer un tableau en utilisant le constructeur:


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

Les performances du constructeur ne sont pas inférieures à celles d'appeler Array::newInstance au niveau C2, mais les dépassent considérablement pour les petits tableaux en modes C1 (propriété -XX:TieredStopAtLevel=1 ) et l'interpréteur (propriété -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 

Le remplacement n'affectera pas notre benchmark, mais il accélérera le code au démarrage de l'application, lorsque C2 n'a pas encore fonctionné. Plus d'informations sur ce mode seront dites à la fin de l'article.


Petites choses clés


Passons à nouveau au constructeur 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); } } 

D'un curieux pour nous: remplacer Array.newInstance par un constructeur conduit à une erreur de compilation, nous passons. Mais le cycle est très intéressant, ou plutôt, l'appel au domaine des segments . Pour voir à quel point les performances peuvent être dévastatrices (parfois), un tel appel peut être conseillé par l'article de Nitzan Wakart, The volatile read suprise .


Le cas décrit dans l'article, me semble-t-il, est en corrélation avec le code en question. Focus sur les segments:


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

Immédiatement après la création du tableau, il est écrit dans le champ ConcurrentReferenceHashMap.segments et c'est avec ce champ que la boucle interagit. À l'intérieur du constructeur de segment, il y a un enregistrement dans les references champ de volatilité:


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

Cela signifie qu'il est impossible d'améliorer l'accès au champ segments , c'est-à-dire que son contenu est lu à chaque tour de cycle. Comment vérifier la véracité de cette déclaration? Le moyen le plus simple consiste à copier le code dans un package séparé et à supprimer les éléments volatile de la déclaration du champ Segment.references :


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

Vérifiez si quelque chose a changé:


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

Nous constatons des gains de performances significatifs (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 

Le 11 JDK, le temps passé a été réduit, mais l'écart relatif est quasiment inchangé:


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

Bien sûr, volatile doit être retourné à l'endroit et chercher une autre façon. Un goulot d'étranglement est découvert - c'est un appel au terrain. Et si c'est le cas, vous pouvez créer la variable segments , remplir le tableau et ensuite l'écrire dans le champ:


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

En conséquence, même avec de telles améliorations simples, une bonne croissance a été obtenue:


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 

Que signifie le remplacement de 'Arrays :: newInstance' par 'new T []'


Lors du lancement de l'application Spring Booth depuis Idea, les développeurs définissent souvent le drapeau `` Activer les optimisations de lancement '', qui ajoute -XX:TieredStopAtLevel=1 -noverify aux arguments de la machine virtuelle, ce qui accélère le lancement en désactivant le profilage et C2. Faisons une mesure avec les arguments spécifiés:


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

Plus de 3 fois plus!


À quoi ça sert?


En particulier, cela est nécessaire pour accélérer les requêtes renvoyant des projections dans Spring Data JPA.



Le profil JMC montre que la création d'un ConcurrentReferenceHashMap prend près d'un cinquième du temps passé à exécuter une requête du formulaire


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

HasIdAndName est une projection de vue


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

De plus, ConcurrentReferenceHashMap plusieurs dizaines de fois dans le code Spring, il ne sera donc certainement pas superflu.


Conclusions


  • l'amélioration des performances n'est pas aussi difficile qu'il y paraît à première vue
  • l'accès volatil au voisinage du cycle est l'un des goulots d'étranglement possibles
  • rechercher des invariants et les retirer des cycles

Que lire


Article par Nitzan Wakart


Exemple de code


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

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


All Articles