Aspectos básicos de la entrevista sobre tecnología Java Core (lección 10) | ¿Cómo garantizar que las colecciones sean seguras para subprocesos? ¿Cómo logra ConcurrentHashMap una seguridad eficaz para subprocesos?

En las dos conferencias anteriores, presenté las clases de contenedor típicas del marco de la colección de Java. La mayoría de ellas no son seguras para subprocesos. Las únicas implementaciones seguras para subprocesos, como Vector y Stack, están lejos de ser satisfactorias en términos de rendimiento. Afortunadamente, el lenguaje Java proporciona un paquete concurrente (java.util.concurrent), que proporciona un soporte de herramientas más completo para requisitos de alta concurrencia.

La pregunta que quiero hacerle hoy es, ¿cómo garantizar que el contenedor sea seguro para subprocesos? ¿Cómo logra ConcurrentHashMap una seguridad de subprocesos eficiente?


Respuesta típica

Java proporciona diferentes niveles de soporte de seguridad para subprocesos. En el marco de recopilación tradicional, además de los contenedores sincronizados como Hashtable, también proporciona un contenedor sincronizado. Podemos llamar al método de empaquetado proporcionado por la clase de herramienta Colecciones para obtener un contenedor de empaquetado sincronizado (como Collections.synchronizedMap) , Pero todos utilizan métodos de sincronización muy generales y su rendimiento es relativamente bajo en el caso de alta concurrencia.

Además, una opción más común es utilizar la clase de contenedor segura para subprocesos proporcionada por el paquete concurrente, que proporciona:

  • Varios contenedores simultáneos, como ConcurrentHashMap, CopyOnWriteArrayList.
  • Varias colas seguras para subprocesos (Queue / Deque), como ArrayBlockingQueue, SynchronousQueue.
  • Versiones seguras para subprocesos de varios contenedores pedidos, etc.

Las formas específicas de garantizar la seguridad de los subprocesos incluyen desde métodos de sincronización simples hasta métodos más refinados, como implementaciones simultáneas como ConcurrentHashMap basadas en bloqueos separados. La elección específica depende de los requisitos del escenario de desarrollo En general, el escenario general del contenedor proporcionado en el paquete concurrente es mucho mejor que la implementación de sincronización simple inicial.

Análisis del sitio de prueba

Cuando se trata de seguridad y concurrencia de subprocesos, se puede decir que es un punto de prueba obligatorio en la entrevista de Java. La respuesta que di anteriormente es un resumen relativamente amplio, y la implementación de contenedores concurrentes como ConcurrentHashMap también está evolucionando y no se puede generalizar.

Si desea pensar profundamente y responder esta pregunta y sus extensiones, necesita al menos:

  • Comprender las herramientas básicas de seguridad para hilos.
  • Comprender los problemas de Map en la programación concurrente del marco de recopilación tradicional y ser consciente de las deficiencias de los métodos de sincronización simples.
  • Ordene el paquete de simultaneidad, especialmente los métodos que ha tomado ConcurrentHashMap para mejorar el rendimiento de simultaneidad.
  • Es mejor poder comprender la evolución de ConcurrentHashMap en sí, muchos de los datos de análisis actuales todavía se basan en su versión anterior.

Hoy continuaré principalmente el contenido de las dos conferencias anteriores en la columna, centrándome en la interpretación de HashMap y ConcurrentHashMap, que a menudo se examinan al mismo tiempo. La conferencia de hoy no es una revisión exhaustiva de la concurrencia. Después de todo, esta no es una columna que pueda introducir una completa. Es un aperitivo. Es similar a CAS y otros mecanismos de nivel inferior. Más adelante, discutiré el tema de concurrencia en el módulo avanzado de Java Hay una introducción más sistemática.

Expansión del conocimiento

1. ¿Por qué necesito ConcurrentHashMap?

Hashtable en sí es relativamente ineficiente, porque su implementación es básicamente para agregar "sincronizados" a poner, obtener, tamaño y otros métodos. En pocas palabras, esto hace que todas las operaciones concurrentes compitan por el mismo bloqueo.Cuando un subproceso está realizando una operación de sincronización, otros subprocesos solo pueden esperar, lo que reduce en gran medida la eficiencia de las operaciones concurrentes.

Como se mencionó anteriormente, HashMap no es seguro para subprocesos. La concurrencia causará problemas como el uso del 100% de la CPU. ¿Podemos usar el contenedor de sincronización provisto por Colecciones para resolver el problema?

Al observar el fragmento de código a continuación, encontramos que el contenedor de sincronización solo usa el mapa de entrada para construir otra versión sincronizada. Aunque todas las operaciones ya no se declaran como métodos sincronizados, todavía usan "this" como un mutex mutuamente excluyente, que no tiene significado real mejora de!

private static class SynchronizedMap<K,V>
    implements Map<K,V>, Serializable {
    private final Map<K,V> m;     // Backing Map
    final Object      mutex;        // Object on which to synchronize
    // …
    public int size() {
        synchronized (mutex) {return m.size();}
    }
 // … 
}

Por lo tanto, las versiones de empaquetado sincronizado o Hashtable solo son adecuadas para escenarios no altamente concurrentes.

2. Análisis ConcurrentHashMap

Echemos un vistazo a cómo se diseña e implementa ConcurrentHashMap, y por qué puede mejorar en gran medida la eficiencia de la concurrencia.

En primer lugar, enfatizo aquí que el diseño y la implementación de ConcurrentHashMap ha estado evolucionando. Por ejemplo, ha habido cambios muy grandes en Java 8 (Java 7 tiene muchas actualizaciones), así que compararé la estructura, la implementación mecanismo, etc., Compare las principales diferencias entre las diferentes versiones.

Early ConcurrentHashMap, su implementación se basa en:

  • Separe el candado, es decir, segmente el interior, que es una matriz de HashEntry, que es similar a HashMap, y las entradas con el mismo hash también se almacenan en forma de lista enlazada.
  • HashEntry utiliza el campo de valor volátil internamente para garantizar la visibilidad, y también utiliza el mecanismo de objeto inmutable para mejorar el uso de las capacidades subyacentes proporcionadas por Unsafe, como el acceso volátil, para completar directamente algunas operaciones para optimizar el rendimiento. Después de todo, muchas operaciones en Inseguro Todos están optimizados por JVM intrínseco. 

Puede consultar el siguiente diagrama de la estructura interna de los primeros ConcurrentHashMap. Su núcleo es utilizar el diseño de segmentación. Cuando se realizan operaciones simultáneas, solo es necesario bloquear el segmento correspondiente. Esto evita eficazmente el problema de la sincronización general del Hashtable y mejora enormemente el rendimiento.

Al construir, el número de segmentos viene determinado por el llamado concurrentcyLevel, que por defecto es 16, o puede especificarse directamente en el constructor correspondiente. Tenga en cuenta que Java requiere que tenga un valor de potencia de 2. Si la entrada es un valor que no es de potencia como 15, se ajustará automáticamente a un valor de potencia de 2, como 16.

Para la situación específica, echemos un vistazo al código fuente de algunas operaciones básicas de Map . Este es un código de obtención relativamente nuevo para JDK 7. Para la parte de optimización específica, con el fin de facilitar la comprensión, comenté directamente en el segmento de código que la operación de obtención debe garantizar la visibilidad, por lo que no hay una lógica de sincronización.

public V get(Object key) {
        Segment<K,V> s; // manually integrate access methods to reduce overhead
        HashEntry<K,V>[] tab;
        int h = hash(key.hashCode());
       //利用位操作替换普通数学运算
       long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
        // 以Segment为单位,进行定位
        // 利用Unsafe直接进行volatile access
        if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
            (tab = s.table) != null) {
           //省略
          }
        return null;
    }

En cuanto a la operación put, la primera es evitar conflictos de hash a través del segundo hash, y luego usar el método de llamada Unsafe para obtener directamente el segmento correspondiente, y luego realizar la operación put segura para subprocesos:

public V put(K key, V value) {
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
        // 二次哈希,以保证数据的分散性,避免哈希冲突
        int hash = hash(key.hashCode());
        int j = (hash >>> segmentShift) & segmentMask;
        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            s = ensureSegment(j);
        return s.put(key, hash, value, false);
    }

La lógica central se implementa en los siguientes métodos internos:

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            // scanAndLockForPut会去查找是否有key相同Node
            // 无论如何,确保获取锁
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
                        // 更新已有value...
                    }
                    else {
                        // 放置HashEntry到特定位置,如果超过阈值,进行rehash
                        // ...
                    }
                }
            } finally {
                unlock();
            }
            return oldValue;
        }

Por lo tanto, del código fuente anterior se desprende claramente que al realizar operaciones de escritura simultáneas:

  •  ConcurrentHashMap adquirirá un bloqueo de reentrada para garantizar la coherencia de los datos. El segmento en sí se basa en una implementación extendida de ReentrantLock. Por lo tanto, durante la modificación simultánea, el segmento correspondiente se bloquea.
  • En la etapa inicial, se realiza un escaneo repetitivo para determinar si el valor de clave correspondiente ya está en la matriz, y luego para decidir si actualizar o colocar la operación, puede ver el comentario correspondiente en el código. El escaneo repetido y la detección de conflictos son técnicas comunes de ConcurrentHashMap.
  • Cuando presenté HashMap en la última columna, mencioné el posible problema de expansión, que también existe en ConcurrentHashMap. Sin embargo, hay una diferencia obvia, es decir, no amplía la capacidad total, sino que amplía el segmento por separado. No se introducen los detalles.

El método de tamaño de otro mapa también requiere atención, y su implementación implica un efecto secundario de bloqueos separados.

Imagínese, si simplemente calcula el valor total de todos los segmentos sin sincronización, los resultados pueden ser inexactos debido a las colocaciones simultáneas, pero bloquear directamente todos los segmentos para los cálculos será muy costoso. De hecho, el bloqueo de separación también restringe la inicialización del mapa y otras operaciones.

Por lo tanto, la implementación de ConcurrentHashMap es intentar obtener un valor confiable a través del mecanismo de reintento (RETRIES_BEFORE_LOCK, especificar el número de reintentos 2). Si no se detecta ningún cambio (comparando Segment.modCount), regresa directamente; de ​​lo contrario, se adquiere el bloqueo y se realiza la operación.

Permítanme comparar, en Java 8 y versiones posteriores, ¿qué cambios han tenido lugar en ConcurrentHashMap?

  • En términos de la estructura general, su almacenamiento interno se ha vuelto muy similar a la estructura HashMap que presenté en la columna. También es una gran matriz de cubos, y luego también hay una llamada estructura de lista enlazada (bin) dentro, con la granularidad de la sincronización. Sea más detallado.
  • Todavía hay una definición de segmento en el interior, pero es solo para garantizar la compatibilidad durante la serialización y ya no tiene ningún uso estructural.
  • Debido a que el segmento ya no se usa, la operación de inicialización se simplifica en gran medida y se modifica a la forma de carga diferida, lo que puede evitar de manera efectiva la sobrecarga inicial y resolver la queja de muchas personas en la versión anterior. El almacenamiento de datos utiliza volátiles para garantizar la visibilidad.
  • Utilice CAS y otras operaciones para realizar operaciones simultáneas sin bloqueos en escenarios específicos.
  • Utilice métodos de bajo nivel como Unsafe y LongAdder para optimizar situaciones extremas.

Al observar la implementación interna actual del almacenamiento de datos, podemos encontrar que la clave es definitiva, porque es imposible que la clave de un elemento cambie durante el ciclo de vida; al mismo tiempo, val se declara como volátil para garantizar la visibilidad.

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;
        // … 
    }

Ya no presentaré el método get y el constructor aquí, es relativamente simple, solo mire cómo se implementa la colocación simultánea.

final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh; K fk; V fv;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 利用CAS去进行无锁线程安全操作,如果bin是空的
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                break; 
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else if (onlyIfAbsent // 不加锁,进行检查
                 && fh == hash
                 && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                 && (fv = f.val) != null)
            return fv;
        else {
            V oldVal = null;
            synchronized (f) {
                   // 细粒度的同步修改操作... 
                }
            }
            // Bin超过阈值,进行树化
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

La operación de inicialización se implementa en initTable, que es un escenario de uso típico de CAS, utilizando volátil sizeCtl como un medio mutuamente excluyente: si se encuentra una inicialización competitiva, gire allí y espere a que se recupere la condición; de lo contrario, use CAS para establecer el exclusivo bandera. Si tiene éxito, inicialice; de ​​lo contrario, vuelva a intentarlo.

Consulte el siguiente código: 

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        // 如果发现冲突,进行spin等待
        if ((sc = sizeCtl) < 0)
            Thread.yield(); 
        // CAS成功返回true,则进入真正的初始化逻辑
        else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

Cuando el contenedor está vacío, no es necesario bloquearlo y se coloca mediante la operación CAS.

¿Ha notado que en términos de lógica de sincronización, utiliza sincronizado en lugar del ReentrantLock comúnmente recomendado. En el JDK moderno, la sincronización se ha optimizado continuamente, por lo que ya no puede preocuparse demasiado por las diferencias de rendimiento. Además, en comparación con ReentrantLock, puede reducir el consumo de memoria, lo cual es una gran ventaja.

Al mismo tiempo, se optimizan implementaciones más detalladas mediante el uso de Unsafe. Por ejemplo, tabAt usa getObjectAcquire directamente para evitar la sobrecarga de llamadas indirectas.

static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectAcquire(tab, ((long)i << ASHIFT) + ABASE);
}

Echemos un vistazo a cómo se implementa ahora la operación de tamaño. Al leer el código, encontrará que la lógica real está en el método sumCount, entonces, ¿qué hace sumCount? 

final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

Descubrimos que aunque la idea sigue siendo similar a la anterior, se divide y conquista para contar y luego sumar, pero la implementación se basa en un extraño CounterCell. ¿Es su valor más exacto? ¿Cómo se garantiza la coherencia de los datos?

static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

De hecho, el funcionamiento de CounterCell se basa en java.util.concurrent.atomic.LongAdder, que es una forma de que JVM use el espacio a cambio de una mayor eficiencia, utilizando la lógica compleja dentro de Striped64. Esto es muy específico y, en la mayoría de los casos, se recomienda utilizar AtomicLong, que es suficiente para cumplir con los requisitos de rendimiento de la mayoría de las aplicaciones.

Hoy comencé con problemas de seguridad de subprocesos, resumí conceptualmente las herramientas básicas del contenedor, analicé la sincronización temprana de contenedores y luego analicé cómo se diseñó e implementó ConcurrentHashMap en Java 7 y Java 8. Espero que las habilidades de concurrencia de ConcurrentHashMap sean útiles para usted en su la vida diaria.El desarrollo puede ayudar

Practica una lección 

¿Sabes de qué estamos hablando hoy? Deje una pregunta para usted. En el código del producto, ¿hay un escenario típico en el que se deba usar un contenedor concurrente como ConcurrentHashMap?


Otras respuestas clásicas

La siguiente es la respuesta del internauta Cai Guangming :

1.7
Poner cerraduras bloquea
segmentos por segmentos. Hay varios segmentos en un mapa de hash, y cada segmento tiene varios cubos. Los cubos almacenan una lista enlazada en forma de KV. Cuando se colocan los datos, el hash de la clave se usa para obtener el elemento que se agregará al segmento, luego bloquee el segmento y luego hash, calcule el segmento al que se agregará el elemento y luego recorra la lista vinculada en el segmento, reemplace o agregue un nodo al segmento y calcule el

tamaño del
segmento dos veces, el resultado es el mismo dos veces Retorno, de lo contrario, vuelva a calcular el bloqueo para todos los segmentos.

1.8
put CAS lock
1.8 no depende del bloqueo del segmento, y el número
de segmentos es el mismo que el número de cubos; primero, determine si el contenedor está vacío, y si está vacío, inicialícelo y use el volátil sizeCtl como el mutuo Si se encuentra una inicialización competitiva, haga una pausa allí y espere a que se recupere la condición. De lo contrario, use CAS para establecer el indicador exclusivo ( U.compareAndSwapInt (this, SIZECTL, sc, -1)); de lo contrario, intente
calcular el hash de la clave nuevamente. La ubicación del depósito donde se almacena la clave, determine si el depósito está vacío, use CAS para establecer un nuevo nodo
si está vacío, de lo contrario utilice sincronizar para bloquear, recorrer los datos en el depósito, reemplazar o agregar un nuevo punto al depósito y,
finalmente, determinar si es necesario convertirlo en un árbol rojo-negro. Antes de la conversión, determine si necesita expandir el

tamaño y
usar LongAdd para acumular el cálculo

La siguiente es la respuesta del internauta Sean a cada lección:

El escenario reciente del uso de ConcurrentHashMap es que debido a que el sistema es un servicio público, todo el proceso se procesa de forma asincrónica. El último enlace requiere http rest para responder activamente al sistema de acceso, por lo que para personalizar la demanda, use netty para escribir una versión asincrónica de http clinet. Se utiliza para almacenar en caché enlaces tcp.
Vi a un amigo a continuación hablar sobre cerraduras giratorias y cerraduras de desviación.
El bloqueo de giro comprende personalmente una aplicación de cas. Las clases atómicas en paquetes concurrentes son aplicaciones típicas.
La comprensión personal del bloqueo de polarización es la optimización del bloqueo de adquisición. Se utiliza en ReentrantLock para darse cuenta del problema de reentrada del hilo después de que se ha adquirido el bloqueo.
No sé si hay un error de comprensión. Bienvenido a corregir y discutir. Gracias

La siguiente respuesta proviene del internauta QQ Guai:

Recuerdo que el método de tamaño de concurrentHashMap es un ciclo anidado:
1: atravesar todos los segmentos;
2: acumular todos los elementos del segmento;
3: acumular el número de modificaciones de todos los segmentos;
4: determinar si el número de modificaciones del segmento es mayor que el anterior El número total de veces de modificación. Si es mayor que, significa que todavía hay modificaciones en el tiempo actual. Vuelva a contar y vuelva a intentarlo. Si no, significa que no hay modificación y finaliza;
5: Si el número de intentos excede el umbral, cada segmento será bloqueado y reiniciado Estadísticas, y finalmente reintentar 4 pasos, solo hasta que el número total de modificaciones sea mayor que el número de la última modificación, suelte el bloqueo y luego finalice la estadística.

 

 

 

 

 

Supongo que te gusta

Origin blog.csdn.net/qq_39331713/article/details/114151433
Recomendado
Clasificación