# Hinweis. Vorsicht vor atomaren Operationen in ConcurrentHashMap



Java hat seit jeher eine wunderbare Kartenoberfläche und deren Implementierung, insbesondere HashMap . Und ab Java 5 gibt es auch ConcurrentHashMap . Betrachten Sie diese beiden Implementierungen, ihre Entwicklung und was diese Entwicklung zu unaufmerksamen Entwicklern führen kann.

Warnung: In diesem Artikel werden Zitate aus dem OpenJDK 8-Quellcode verwendet, der unter der GNU General Public License Version 2 vertrieben wird.

Zeiten vor Java 8


Diejenigen, die lange, lange Wartezeiten festgestellt haben, zuerst Java 7 und dann Java 8 (nicht wie jetzt alle sechs Monate eine neue Version), erinnern sich, welche Operationen mit Map am beliebtesten waren. Das:


Wenn Sie einen Wert in die Auflistung einfügen müssen, verwenden wir die erste Methode, und um den vorhandenen Wert abzurufen, verwenden Sie die zweite.

Was aber, wenn eine verzögerte Initialisierung erforderlich ist? Dann erschien ein Code dieser Art:

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. Wir bekommen den Wert per Schlüssel
  2. Überprüfen Sie, ob der gewünschte Wert gefunden wurde
  3. Wenn kein Wert gefunden wird, erstellen Sie ihn
  4. Mehrwert für die Sammlung durch Schlüssel

Es stellt sich als etwas umständlich heraus, nicht wahr? Wenn eine einfache HashMap verwendet wird, ist dies nur Code, dessen Lesen unbequem ist, weil er ist nicht eingefädelt. Bei ConcurrentHashMap wird jedoch eine zusätzliche Funktion angezeigt: Die Methode createValue (2) kann mehrmals aufgerufen werden, wenn mehrere Threads die Bedingung (1) überprüfen, bevor einer von ihnen den Wert in collection (3) schreibt. Ein solches Verhalten kann häufig zu unerwünschten Folgen führen.

Vor Java 8 gab es einfach keine eleganten Optionen. Wenn Sie mehreren Wertschöpfungen ausweichen mussten, mussten Sie zusätzliche Sperren verwenden.
Java 8 hat es einfacher gemacht. Es scheint ...

Java 8 kommt zu uns ...


Was ist die am meisten erwartete Funktion, die uns mit Java 8 begegnet ist? Das stimmt, Lambda. Und nicht nur Lamas, sondern deren Unterstützung in allen APIs der Standardbibliothek. Kartendatenstrukturen wurden nicht ignoriert. Insbesondere gab es Methoden wie:


Aufgrund dieser Methoden ist es viel einfacher, den zuvor angegebenen Code umzuschreiben:

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

Es ist klar, dass niemand die Gelegenheit aufgeben wird, seinen Code zu vereinfachen. Darüber hinaus wird im Fall von ConcurrentHashMap die computeIfAbsent- Methode auch atomar ausgeführt. Das heißt createValue wird genau einmal und nur dann aufgerufen, wenn der gewünschte Wert fehlt.

IDE ging auch nicht vorbei. IntelliJ IDEA bietet daher die automatische Ersetzung der alten durch die neue Version an:




Es ist klar, dass sowohl die Code-Vereinfachung als auch die IDE-Hinweise die Entwickler ermutigen, diese neue API zu verwenden. Infolgedessen tauchte derselbe computeIfAbsent an so vielen Stellen im Code auf.
Tschüss…

Ganz plötzlich!


Bis die Zeit für den nächsten Belastungstest gekommen ist. Und dann erschien eine schreckliche Sache:



Die Anwendung arbeitete auf der folgenden Version von Java:

 openjdk version "1.8.0_222"
 OpenJDK-Laufzeitumgebung (Build 1.8.0_222-8u222-b10-1ubuntu1 ~ 18.04.1-b10)
 OpenJDK 64-Bit Server VM (Build 25.222-b10, gemischter Modus)


Für diejenigen, die mit so einem wunderbaren Tool wie YourKit nicht vertraut sind.

Im Screenshot zeigen horizontale breite Linien die zeitliche Funktionsweise der Anwendungsthreads. Je nach Zustand des Streams zu einem bestimmten Zeitpunkt wird der Streifen in der entsprechenden Farbe lackiert:

  • gelb - der Strom ist im Leerlauf und wartet auf Arbeit;
  • grün - der Thread läuft und führt Programmcode aus;
  • rot - Dieser Thread wird von einem anderen Thread blockiert.

Das heißt, es stellt sich heraus, dass fast alle Threads (und in der Tat gab es viel mehr als das, was im Screenshot gezeigt wird) fast die ganze Zeit in einem gesperrten Zustand sind. Und für alle ist die Sperre in der gleichen computeIfAbsent von ConcurrentHashMap! Und dies trotz der Tatsache, dass aufgrund der Besonderheiten dieses speziellen Belastungstests nicht mehr als 6-8 Werte in dieser Sammlung gespeichert werden können. Das heißt Bei fast allen Vorgängen an einem bestimmten Ort werden ausschließlich die vorhandenen Werte gelesen.

Aber warte, wie so? Selbst in der Dokumentation der Methode zum Blockieren heißt es nur in der Anwendung zum Aktualisieren:
"Wenn der angegebene Schlüssel noch keinem Wert zugeordnet ist, wird versucht, seinen Wert mit der angegebenen Zuordnungsfunktion zu berechnen, und er wird in diese Zuordnung eingegeben, sofern er nicht null ist. Der gesamte Methodenaufruf wird atomar ausgeführt, sodass die Funktion höchstens einmal pro Taste angewendet wird. Einige versuchte Aktualisierungsvorgänge für diese Karte durch andere Threads werden möglicherweise blockiert, während die Berechnung ausgeführt wird. Daher sollte die Berechnung kurz und einfach sein und es darf nicht versucht werden, andere Zuordnungen dieser Karte zu aktualisieren. “

In der Tat ist nicht alles ganz so. Wenn Sie sich den Quellcode dieser Methode ansehen, stellt sich heraus, dass sie zwei sehr dicke Synchronisationsblöcke enthält:

Implementierung von 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; } 


Aus dem obigen Beispiel ist ersichtlich, dass das Ergebnis nur an sechs Punkten gebildet werden kann und dass sich fast alle diese Stellen innerhalb von Synchronisationsblöcken befinden. Ganz unerwartet. Darüber hinaus enthält ein einfaches Get überhaupt keine Synchronisation:

Implementierung von 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; } 


Also, was ist zu tun? Tatsächlich gibt es nur zwei Möglichkeiten: entweder zum ursprünglichen Code zurückkehren oder ihn verwenden, jedoch in einer leicht modifizierten Version:

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

Fazit


Im Allgemeinen erwiesen sich solche fatalen Folgen von scheinbar banalem Refactoring als sehr unerwartet. Die Situation wurde nur durch die Anwesenheit eines Stresstests gerettet, der eine Verschlechterung erfolgreich aufzeigte.

Glücklicherweise haben neuere Versionen von Java dieses Problem behoben : JDK-8161372 .

Also sei vorsichtig, vertraue nicht den verlockenden Tipps und schreibe Tests. Besonders stressig.

Java an alle!

UPD1: Wie von Kaltwind richtig festgestellt, ist das Problem bekannt: JDK-8161372 . Und anscheinend wurde es für Java 9 behoben. Zum Zeitpunkt der Veröffentlichung des Artikels in Java 8, Java 11 und sogar Java 13 blieb diese Methode unverändert.

UPD2: vkovalchuk hat mich wegen Nachlässigkeit erwischt. Tatsächlich wird das Problem ab Java 9 behoben, indem eine weitere Bedingung mit der Rückgabe des Ergebnisses hinzugefügt wird, ohne Folgendes zu blockieren:

  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; 


Anfangs bin ich in der nächsten Java-Version auf eine Situation gestoßen:

 openjdk version "1.8.0_222"
 OpenJDK-Laufzeitumgebung (Build 1.8.0_222-8u222-b10-1ubuntu1 ~ 18.04.1-b10)
 OpenJDK 64-Bit Server VM (Build 25.222-b10, gemischter Modus)


Und als ich mir die Quellen späterer Versionen ansah, vermisste ich ehrlich diese Zeilen, was mich in die Irre führte.

Aus Gründen der Gerechtigkeit habe ich den Haupttext des Artikels korrigiert.

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


All Articles