# Remarque. Attention aux opérations atomiques dans ConcurrentHashMap



Depuis des temps immémoriaux, Java possède une merveilleuse interface Map et son implémentation, en particulier HashMap . Et à partir de Java 5, il y a aussi ConcurrentHashMap . Considérez ces deux implémentations, leur évolution et ce que cette évolution peut conduire à des développeurs inattentifs.

Avertissement: cet article utilise des citations du code source d'OpenJDK 8, distribué sous la GNU General Public License version 2.

Temps avant Java 8


Ceux qui ont trouvé des temps d'attente très longs, d'abord Java 7, puis Java 8 (pas comme maintenant, tous les six mois une nouvelle version), se souviennent quelles opérations avec Map étaient les plus populaires. C’est:


Si vous devez mettre une valeur dans la collection, nous utilisons la première méthode et pour obtenir la valeur existante, utilisez la seconde.

Mais que faire si une initialisation paresseuse est requise? Puis un code de ce genre est apparu:

String getOrPut(String key) { String result = map.get(key); //(1) if (result == null) { //(2) result = createValue(key); //(3) map.put(key, result); //(4) } return result; } 

  1. nous obtenons la valeur par clé
  2. vérifier si la valeur souhaitée est trouvée
  3. si aucune valeur n'est trouvée, créez-la
  4. ajouter de la valeur à la collection par clé

Cela s'avère un peu lourd, n'est-ce pas? De plus, dans le cas où un simple HashMap est utilisé, ce n'est que du code qui n'est pas pratique à lire, car il n'est pas enfilé. Mais dans le cas de ConcurrentHashMap, une fonctionnalité supplémentaire apparaît: la méthode createValue (2) peut être appelée plusieurs fois si plusieurs threads parviennent à vérifier la condition (1) avant que l'un d'eux n'écrive la valeur dans collection (3). Un tel comportement peut souvent entraîner des conséquences indésirables.

Avant Java 8, il n'y avait tout simplement pas d'options élégantes. Si vous deviez éviter plusieurs créations de valeur, vous deviez utiliser des verrous supplémentaires.
Java 8 a facilité les choses. Il semblerait ...

Java 8 vient à nous ...


Quelle est la fonctionnalité la plus attendue de Java 8? C'est vrai, lambda. Et pas seulement les lamas, mais leur prise en charge dans toute la variété des API de la bibliothèque standard. Les structures de données cartographiques n'ont pas été ignorées. En particulier, des méthodes telles que:


En raison de ces méthodes, il est possible de réécrire le code donné plus tôt est beaucoup plus simple:

 String getOrPut(String key) { return map.computeIfAbsent(key, this::createValue); } 

Il est clair que personne n'abandonnera l'occasion de simplifier son code. De plus, dans le cas de ConcurrentHashMap, la méthode computeIfAbsent s'exécute également de manière atomique. C'est-à-dire createValue sera appelée exactement une fois et seulement si la valeur souhaitée est absente.

L'IDE n'est pas non plus passé. IntelliJ IDEA propose donc le remplacement automatique de l'ancienne version par la nouvelle:




Il est clair que la simplification du code et les conseils IDE encouragent les développeurs à utiliser cette nouvelle API. En conséquence, le même computeIfAbsent a commencé à apparaître à de nombreux endroits dans le code.
Au revoir ...

Tout d'un coup!


Jusqu'à ce que le moment soit venu pour le prochain test de charge. Et puis une chose terrible est apparue:



L'application a fonctionné sur la version suivante de Java:

 version openjdk "1.8.0_222"
 Environnement d'exécution OpenJDK (build 1.8.0_222-8u222-b10-1ubuntu1 ~ 18.04.1-b10)
 VM serveur OpenJDK 64 bits (build 25.222-b10, mode mixte)


Pour ceux qui ne connaissent pas un outil aussi merveilleux que YourKit .

Dans la capture d'écran, des lignes larges horizontales montrent le fonctionnement des threads d'application dans le temps. Selon l'état du flux à un moment donné, la bande est peinte dans la couleur correspondante:

  • jaune - le flux est inactif, en attente de travail;
  • vert - le thread est en cours d'exécution, exécutant le code du programme;
  • rouge - ce fil est bloqué par un autre fil.

Autrement dit, il s'avère que presque tous les threads (et en fait, il y en avait beaucoup plus que ce qui est montré dans la capture d'écran) sont presque toujours dans un état bloqué. Et pour tous, le verrou est dans le même computeIfAbsent de ConcurrentHashMap! Et cela malgré le fait qu'en raison des spécificités de ce test de charge particulier, pas plus de 6-8 valeurs peuvent être stockées dans cette collection. C'est-à-dire presque toutes les opérations dans un lieu donné sont exclusivement une lecture de valeurs existantes.

Mais attendez, comment ça? En effet, même dans la documentation de la méthode de blocage, il n'est dit que dans l'application de mise à jour:
Msgstr "Si la clé spécifiée n'est pas déjà associée à une valeur, tente de calculer sa valeur à l 'aide de la fonction de mappage donnée et l' entre dans cette carte, sauf si elle est nulle. L'invocation de la méthode entière est effectuée de manière atomique, de sorte que la fonction est appliquée au plus une fois par clé. Certaines tentatives de mise à jour sur cette carte par d'autres threads peuvent être bloquées pendant le calcul, donc le calcul doit être court et simple, et ne doit pas tenter de mettre à jour d'autres mappages de cette carte. »

En fait, tout ne l'est pas tout à fait. Si vous regardez le code source de cette méthode, il s'avère qu'il contient deux blocs de synchronisation très épais:

Implémentation de ConcurrentHashMap.computeIfAbsent
 public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) { if (key == null || mappingFunction == null) throw new NullPointerException(); int h = spread(key.hashCode()); V val = null; int binCount = 0; for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & h)) == null) { Node<K,V> r = new ReservationNode<K,V>(); synchronized (r) { if (casTabAt(tab, i, null, r)) { binCount = 1; Node<K,V> node = null; try { if ((val = mappingFunction.apply(key)) != null) node = new Node<K,V>(h, key, val, null); } finally { setTabAt(tab, i, node); } } } if (binCount != 0) break; } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { boolean added = false; synchronized (f) { if (tabAt(tab, i) == f) { if (fh >= 0) { binCount = 1; for (Node<K,V> e = f;; ++binCount) { K ek; V ev; if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { val = e.val; break; } Node<K,V> pred = e; if ((e = e.next) == null) { if ((val = mappingFunction.apply(key)) != null) { added = true; pred.next = new Node<K,V>(h, key, val, null); } break; } } } else if (f instanceof TreeBin) { binCount = 2; TreeBin<K,V> t = (TreeBin<K,V>)f; TreeNode<K,V> r, p; if ((r = t.root) != null && (p = r.findTreeNode(h, key, null)) != null) val = p.val; else if ((val = mappingFunction.apply(key)) != null) { added = true; t.putTreeVal(h, key, val); } } } } if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (!added) return val; break; } } } if (val != null) addCount(1L, binCount); return val; } 


D'après l'exemple ci-dessus, on peut voir que le résultat ne peut être formé qu'en six points, et presque tous ces endroits sont à l'intérieur de blocs de synchronisation. De façon assez inattendue. De plus, un simple get ne contient pas du tout de synchronisation:

Implémentation de ConcurrentHashMap.get
 public V get(Object key) { Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek; int h = spread(key.hashCode()); if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) { if ((eh = e.hash) == h) { if ((ek = e.key) == key || (ek != null && key.equals(ek))) return e.val; } else if (eh < 0) return (p = e.find(h, key)) != null ? p.val : null; while ((e = e.next) != null) { if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) return e.val; } } return null; } 


Alors que faire? En fait, il n'y a que deux options: soit revenir au code d'origine, soit l'utiliser, mais dans une version légèrement modifiée:

 String getOrPut(String key) { String result = map.get(key); return (result != null) ? result : map.computeIfAbsent(key, this::createValue); } 

Conclusion


En général, de telles conséquences fatales de refactorisation apparemment banale se sont avérées très inattendues. La situation n'a été sauvée que par la présence d'un test de résistance qui a révélé avec succès une dégradation.

Heureusement, les nouvelles versions de Java ont résolu ce problème: JDK-8161372 .

Soyez donc prudent, ne faites pas confiance aux conseils tentants et écrivez des tests. Particulièrement stressant.

Java à tout le monde!

UPD1: correctement noté par coldwind , le problème est connu: JDK-8161372 . Et, il semble, il a été corrigé pour Java 9. Mais au moment de la publication de l'article en Java 8, Java 11 et même Java 13, cette méthode est restée inchangée.

UPD2: vkovalchuk m'a pris par négligence. En effet, pour Java 9 et plus récent, le problème est résolu en ajoutant une autre condition au retour du résultat sans blocage:

  else if (fh == h // check first node without acquiring lock && ((fk = f.key) == key || (fk != null && key.equals(fk))) && (fv = f.val) != null) return fv; 


Au départ, j'ai rencontré une situation dans la prochaine version de Java:

 version openjdk "1.8.0_222"
 Environnement d'exécution OpenJDK (build 1.8.0_222-8u222-b10-1ubuntu1 ~ 18.04.1-b10)
 VM serveur OpenJDK 64 bits (build 25.222-b10, mode mixte)


Et quand j'ai regardé les sources des versions ultérieures, j'ai honnêtement raté ces lignes, ce qui m'a induit en erreur.

Donc, pour des raisons de justice, j'ai corrigé le texte principal de l'article.

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


All Articles