# Nota Precaución operaciones atómicas en ConcurrentHashMap



Desde tiempos inmemoriales, Java ha tenido una maravillosa interfaz de Mapa y su implementación, en particular, HashMap . Y comenzando con Java 5, también hay ConcurrentHashMap . Considere estas dos implementaciones, su evolución y lo que esta evolución puede llevar a desarrolladores desatentos.

Advertencia: este artículo utiliza citas del código fuente de OpenJDK 8, distribuido bajo la GNU General Public License versión 2.

Tiempos anteriores a Java 8


Aquellos que encontraron largos, largos tiempos de espera primero Java 7, y luego Java 8 (no como ahora, cada seis meses una nueva versión), recuerdan qué operaciones con Map fueron las más populares. Esto es:


Si necesita poner un valor en la colección, usamos el primer método, y para obtener el valor existente, use el segundo.

Pero, ¿qué pasa si se requiere una inicialización perezosa? Entonces apareció un código de este tipo:

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. obtenemos el valor por clave
  2. comprobar si se encuentra el valor deseado
  3. si no se encuentra ningún valor, créelo
  4. agregar valor a la colección por clave

Resulta un poco engorroso, ¿no? Además, en el caso de que se use un HashMap simple, este es solo un código que es inconveniente de leer, porque No está enhebrado. Pero en el caso de ConcurrentHashMap, aparece una característica adicional: se puede llamar varias veces al método createValue (2) si varios hilos logran verificar la condición (1) antes de que uno de ellos escriba el valor en la colección (3). Tal comportamiento a menudo puede conducir a consecuencias indeseables.

Antes de Java 8, simplemente no había opciones elegantes. Si necesitaba esquivar la creación de varios valores, tenía que usar bloqueos adicionales.
Java 8 hizo las cosas más fáciles. Parecería ...

Java 8 viene a nosotros ...


¿Cuál es la característica más esperada que nos llegó con Java 8? Así es, lambda. Y no solo llamas, sino su soporte en toda la variedad de API de la biblioteca estándar. Las estructuras de datos del mapa no han sido ignoradas. En particular, aparecieron métodos como:


Debido a estos métodos, es posible reescribir el código proporcionado anteriormente mucho más fácilmente:

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

Está claro que nadie dará la oportunidad de simplificar su código. Además, en el caso de ConcurrentHashMap, el método computeIfAbsent también se ejecuta atómicamente. Es decir Se llamará a createValue exactamente una vez y solo si el valor deseado está ausente.

IDE tampoco pasó de largo. Entonces IntelliJ IDEA ofrece el reemplazo automático de la versión anterior con la nueva:




Está claro que tanto la simplificación de código como las sugerencias de IDE alientan a los desarrolladores a usar esta nueva API. Como resultado, el mismo computeIfAbsent comenzó a aparecer en muchos lugares del código.
Adios ...

De repente!


Hasta que haya llegado el momento de la próxima prueba de carga. Y entonces apareció una cosa terrible:



La aplicación funcionó en la siguiente versión de Java:

 versión de openjdk "1.8.0_222"
 OpenJDK Runtime Environment (compilación 1.8.0_222-8u222-b10-1ubuntu1 ~ 18.04.1-b10)
 OpenJDK 64-Bit Server VM (compilación 25.222-b10, modo mixto)


Para aquellos que no están familiarizados con una herramienta tan maravillosa como YourKit .

En la captura de pantalla, las líneas horizontales anchas muestran el funcionamiento de los hilos de la aplicación a tiempo. Dependiendo del estado de la corriente en un momento particular en el tiempo, la tira se pinta en el color correspondiente:

  • amarillo: el flujo está inactivo, esperando trabajo;
  • verde: el hilo se está ejecutando, ejecutando el código del programa;
  • rojo: este hilo está bloqueado por otro hilo.

Es decir, resulta que casi todos los hilos (y de hecho había mucho más de lo que se muestra en la captura de pantalla) están casi todo el tiempo en estado bloqueado. ¡Y para todos, el bloqueo está en el mismo cómputo si está ausente de ConcurrentHashMap! Y esto a pesar del hecho de que debido a los detalles de esta prueba de carga en particular, no se pueden almacenar más de 6-8 valores en esta colección. Es decir Casi todas las operaciones en un lugar dado son exclusivamente una lectura de valores existentes.

Pero espera, ¿cómo es eso? De hecho, incluso en la documentación del método sobre el bloqueo, solo se dice en la aplicación de actualización:
"Si la clave especificada aún no está asociada con un valor, intenta calcular su valor utilizando la función de mapeo dada y la ingresa en este mapa a menos que sea nulo. La invocación del método completo se realiza atómicamente, por lo que la función se aplica como máximo una vez por tecla. Algunas operaciones de actualización intentadas en este mapa por otros subprocesos pueden bloquearse mientras el cálculo está en progreso, por lo que el cálculo debe ser breve y simple, y no debe intentar actualizar ninguna otra asignación de este mapa ".

De hecho, no todo es así. Si observa el código fuente de este método, resulta que contiene dos bloques de sincronización muy gruesos:

Implementación 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; } 


Del ejemplo anterior, se puede ver que el resultado se puede formar solo en seis puntos, y casi todos estos lugares están dentro de bloques de sincronización. Muy inesperadamente. Además, un simple get no contiene sincronización en absoluto:

Implementación 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; } 


Entonces que hacer? De hecho, solo hay dos opciones: volver al código original o usarlo, pero en una versión ligeramente modificada:

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

Conclusión


En general, tales consecuencias fatales de la refactorización aparentemente banal resultaron ser muy inesperadas. La situación se salvó solo por la presencia de una prueba de esfuerzo, que reveló con éxito la degradación.

Afortunadamente, las nuevas versiones de Java arreglaron este problema: JDK-8161372 .

Así que tenga cuidado, no confíe en los consejos tentadores y escriba pruebas. Especialmente estresante.

Java para todos!

UPD1: como notó correctamente coldwind , el problema es conocido: JDK-8161372 . Y, al parecer, se corrigió para Java 9. Pero en el momento de la publicación del artículo en Java 8, Java 11 e incluso Java 13, este método se mantuvo sin cambios.

UPD2: vkovalchuk me sorprendió por descuido. De hecho, para Java 9 y versiones posteriores, el problema se soluciona agregando otra condición con la devolución del resultado sin bloquear:

  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; 


Inicialmente, me encontré con una situación en la próxima versión de Java:

 versión de openjdk "1.8.0_222"
 OpenJDK Runtime Environment (compilación 1.8.0_222-8u222-b10-1ubuntu1 ~ 18.04.1-b10)
 OpenJDK 64-Bit Server VM (compilación 25.222-b10, modo mixto)


Y cuando miré las fuentes de versiones posteriores, honestamente me perdí estas líneas, lo que me llevó por mal camino.

Entonces, por el bien de la justicia, corregí el texto principal del artículo.

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


All Articles