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, ) {
Notez l'utilisation du constructeur Segment
:
int roundedUpSegmentCapacity = (int) ((initialCapacity + size - 1L) / size);
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) {
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 {
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); }
où 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