Artículo del código fuente de ConcurrentHashMap: análisis del principio de LongAdder

Prefacio

Recientemente, estaba estudiando el código fuente de ConcurrentHashMap y descubrí que utiliza una forma más única de contar la cantidad de elementos en el mapa. Naturalmente, es necesario estudiar sus principios e ideas y, al mismo tiempo, comprender mejor el propio ConcurrentHashMap.

La idea principal de este artículo se divide en las siguientes 4 partes

1. El efecto de contar

2. Ilustración intuitiva del principio

3. Análisis detallado del código fuente

4. Comparación con AtomicInteger

5. La abstracción del pensamiento

La entrada al aprendizaje es, naturalmente, el método put de map

public V put(K key, V value) {
    return putVal(key, value, false);
}

Ver método putVal

Aquí no hay mucha discusión sobre el principio de ConcurrentHashMap en sí, por lo que saltamos directamente a la parte de conteo

final V putVal(K key, V value, boolean onlyIfAbsent) {
    ...
    addCount(1L, binCount);
    return null;
}

Siempre que un elemento se agregue con éxito, se llamará al método addCount para realizar la operación de acumular el número por 1, que es el objetivo de nuestra investigación.

Debido a que la intención original de ConcurrentHashMap es resolver la operación del mapa en escenarios concurrentes de subprocesos múltiples, es natural considerar la seguridad de los subprocesos al agregar valores.

Por supuesto, la acumulación de valor de subprocesos múltiples es generalmente la primera lección en el aprendizaje de la programación concurrente. No es muy complicado. Puede usar AtomicInteger o bloqueos para resolver este problema.

Sin embargo, si miramos este método, encontraremos que la lógica de un método de acumulación que debería ser más simple parece bastante complicada.

Aquí solo publiqué la parte central del algoritmo de acumulación

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    if ((as = counterCells) != null ||
            !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell a; long v; int m;
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
                (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
                !(uncontended =
                        U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        s = sumCount();
    }
    ...
}

Estudiemos la idea de implementar esta lógica. Esta idea realmente copió la lógica de la clase LongAdder, por lo que miramos directamente la clase original del algoritmo

1. Uso de la clase LongAdder

Veamos primero el efecto de uso de LongAdder

LongAdder adder = new LongAdder();
int num = 0;

@Test
public void test5() throws InterruptedException {
    Thread[] threads = new Thread[10];
    for (int i = 0; i < 10; i++) {
        threads[i] = new Thread(() -> {
            for (int j = 0; j < 10000; j++) {
                adder.add(1);
                num += 1;
            }
        });
        threads[i].start();
    }
    for (int i = 0; i < 10; i++) {
        threads[i].join();
    }
    System.out.println("adder:" + adder);
    System.out.println("num:" + num);
}

Resultado de salida

adder:100000
num:40982

Se puede ver que el sumador puede garantizar la seguridad acumulativa del hilo en términos del efecto de uso.

2. Comprensión intuitiva del principio LongAdder

Para analizar mejor el código fuente, debemos comprender su principio de manera intuitiva; de lo contrario, si observa el código directamente, se confundirá.

El recuento de LongAdder se divide principalmente en 2 objetos

Un campo de tipo largo: base

Una matriz de objetos Cell, el objeto Cell mantiene un valor de campo largo para contar

/**
 * Table of cells. When non-null, size is a power of 2.
 */
transient volatile Cell[] cells;

/**
 * Base value, used mainly when there is no contention, but also as
 * a fallback during table initialization races. Updated via CAS.
 */
transient volatile long base;

Programación concurrente: una mejor solución para el conteo de múltiples subprocesos: análisis del principio LongAdder

 

Cuando no hay competencia de subprocesos, la acumulación se producirá en el campo base, lo que equivale a una acumulación de un solo subproceso dos veces, pero la acumulación de base es una operación cas

Programación concurrente: una mejor solución para el conteo de múltiples subprocesos: análisis del principio LongAdder

 

Cuando ocurre la competencia de subprocesos, debe haber un subproceso que falle la operación de acumulación de cas de base, por lo que primero determina si la celda se ha inicializado, si no, inicializa un arreglo de longitud 2 y encuentra el correspondiente de acuerdo al valor hash del subproceso Array index y acumula el valor del valor en el objeto Cell indexado (esta acumulación también es una operación de cas)

Programación concurrente: una mejor solución para el conteo de múltiples subprocesos: análisis del principio LongAdder

 

Si hay un total de 3 subprocesos compitiendo, entonces el primer subproceso acumula con éxito el cas de la base y los 2 subprocesos restantes deben acumular los elementos en la matriz Cell. Debido a que la acumulación del valor del valor en la celda también es una operación cas, si el índice de matriz correspondiente al valor hash del segundo subproceso y el tercer subproceso es el mismo, también se producirá competencia. Si el segundo subproceso tiene éxito, el primero Los tres subprocesos volverán a hacer un refrito de su propio valor hash. Si el nuevo valor hash obtenido corresponde a otro subíndice de matriz cuyo elemento es nulo, se agrega un nuevo objeto Cell y se acumula el valor del valor

Programación concurrente: una mejor solución para el conteo de múltiples subprocesos: análisis del principio LongAdder

 

Si el subproceso 4 participa en la competencia al mismo tiempo, entonces, para el subproceso 4, cas puede fallar en la competencia con el subproceso 3 incluso después del refrito. En este momento, si la capacidad actual del arreglo es menor que la cantidad de CPU disponibles en el sistema, entonces La matriz se expandirá y luego se repetirá nuevamente, tratando repetidamente de acumular un objeto de subíndice en la matriz de celdas

Programación concurrente: una mejor solución para el conteo de múltiples subprocesos: análisis del principio LongAdder

 

Lo anterior es la comprensión intuitiva general, pero todavía hay muchos detalles en el código que vale la pena aprender, así que comenzamos a ingresar al enlace de análisis del código fuente.

3. Análisis del código fuente

El método de entrada es agregar

public void add(long x) {
    Cell[] as; long b, v; int m; Cell a;
    /**
     * 这里优先判断了cell数组是否为空,之后才判断base字段的cas累加
     * 意味着如果线程不发生竞争,cell数组一直为空,那么所有的累加操作都会累加到base上
     * 而一旦发生过一次竞争导致cell数组不为空,那么所有的累加操作都会优先作用于数组中的对象上
     */
    if ((as = cells) != null || !casBase(b = base, b + x)) {
        /**
         * 这个字段是用来标识在对cell数组中的对象进行累加操作时是否发生了竞争
         * 如果发生了竞争,那么在longAccumulate方法中会多进行一次rehash的自旋
         * 这个在后面的方法中详细说明,这里先有个印象
         * true表示未发生竞争
         */
        boolean uncontended = true;
        /**
         * 如果cell数组为空或者长度为0则直接进入主逻辑方法
         */
        if (as == null || (m = as.length - 1) < 0 ||
                /**
                 * 这里的getProbe()方法可以认为就是获取线程的hash值
                 * hash值与(数组长度-1)进行位与操作后得到对应的数组下标
                 * 判断该元素是否为空,如果不为空那么就会尝试累加
                 * 否则进入主逻辑方法
                 */
                (a = as[getProbe() & m]) == null ||
                /**
                 * 对数组下标的元素进行cas累加,如果成功了,那么就可以直接返回
                 * 否则进入主逻辑方法
                 */
                !(uncontended = a.cas(v = a.value, v + x)))
            longAccumulate(x, null, uncontended);
    }
}

Cuando no haya competencia de hilos, la operación de acumulación será atendida por el casBase en el primer si, correspondiente a la situación de la ilustración anterior.

Cuando ocurre la competencia de subprocesos, la operación de acumulación será atendida por la matriz de celdas, correspondiente al caso 2 ilustrado anteriormente (la matriz se inicializa en el método longAccumulate)

Luego miramos el método lógico principal, porque el método es relativamente largo, así que lo analizaré sección por sección.

método longAccumulate

Parámetros en la firma

x representa el valor a acumular

fn indica cómo acumular, generalmente pasa nulo, no es importante

wasUncontended indica si el método externo ha encontrado una falla de competencia, porque la lógica de juicio de la capa externa es múltiple "o" (as == null || (m = as.length-1) <0 || (a = as [ getProbe () & m]) == null) , por lo que si la matriz está vacía o el elemento de subíndice correspondiente no se ha inicializado, este campo seguirá siendo falso

final void longAccumulate(long x, LongBinaryOperator fn,
                          boolean wasUncontended) {
  ...
}

Primero determine si el valor hash del hilo es 0, si es 0, necesita hacer una inicialización, es decir, refrito

Posteriormente, wasUncontended se establecerá en verdadero, porque incluso si ha estado en conflicto antes, después del refrito, primero asumirá que puede encontrar un subíndice de matriz con elementos no conflictivos

int h;//线程的hash值,在后面的逻辑中会用到
if ((h = getProbe()) == 0) {
    ThreadLocalRandom.current(); // force initialization
    h = getProbe();
    wasUncontended = true;
}

Luego hay un bucle sin fin. Hay 3 ramas if grandes en el bucle sin fin. La lógica de estas 3 ramas actúa cuando la matriz no está inicializada . Una vez que se inicializa la matriz, todo entrará en la lógica principal, así que pondré la lógica principal aquí Extraerlos y colocarlos por separado más adelante, lo que también puede evitar la influencia de las ramas externas en la idea.

/**
 * 用来标记某个线程在上一次循环中找到的数组下标是否已经有Cell对象了
 * 如果为true,则表示数组下标为空
 * 在主逻辑的循环中会用到
 */
boolean collide = false;
/**
 * 死循环,提供自旋操作
 */
for (; ; ) {
    Cell[] as;
    Cell a;
    int n;//cell数组长度
    long v;//需要被累积的值
    /**
     * 如果cells数组不为空,且已经被某个线程初始化成功,那么就会进入主逻辑,这个后面详细解释
     */
    if ((as = cells) != null && (n = as.length) > 0) {
        ...
        /**
         * 如果数组为空,那么就需要初始化一个Cell数组
         * cellsBusy用来标记cells数组是否能被操作,作用相当于一个锁
         * cells == as 判断是否有其他线程在当前线程进入这个判断之前已经初始化了一个数组
         * casCellsBusy 用一个cas操作给cellsBusy字段赋值为1,如果成功可以认为拿到了操作cells数组的锁
         */
    } else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
        /**
         * 这里就是初始化一个数组,不解释了
         */
        boolean init = false;
        try {                           
            if (cells == as) {
                Cell[] rs = new Cell[2];
                rs[h & 1] = new Cell(x);
                cells = rs;
                init = true;
            }
        } finally {
            cellsBusy = 0;
        }
        if (init)
            break;
        /**
         * 如果当前数组是空的,又没有竞争过其他线程
         * 那么就再次尝试去给base赋值
         * 如果又没竞争过(感觉有点可怜),那么就自旋
         * 另外提一下方法签名中的LongBinaryOperator对象就是用在这里的,不影响逻辑
         */
    } else if (casBase(v = base, ((fn == null) ? v + x :
            fn.applyAsLong(v, x))))
        break;                          // Fall back on using base
}

Luego observe la lógica principal de acumular los elementos de la matriz de celdas

/**
 * 如果cells数组不为空,且已经被某个线程初始化成功,进入主逻辑
 */
if ((as = cells) != null && (n = as.length) > 0) {
    /**
     * 如果当前线程的hash值对应的数组元素为空
     */
    if ((a = as[(n - 1) & h]) == null) {
        /**
         * Cell数组并未被其他线程操作
         */
        if (cellsBusy == 0) {
            /**
             * 这里没有理解作者为什么会在这里初始化单个Cell
             * 作者这里的注释是Optimistically create,如果有理解的同学可以说一下
             */
            Cell r = new Cell(x);
            /**
             * 在此判断cell锁的状态,并尝试加锁
             */
            if (cellsBusy == 0 && casCellsBusy()) {
                boolean created = false;
                try {
                    /**
                     * 这里对数组是否为空等状态再次进行校验
                     * 如果校验通过,那么就将之前new的Cell对象放到Cell数组的该下标处
                     */
                    Cell[] rs;
                    int m, j;
                    if ((rs = cells) != null &&
                            (m = rs.length) > 0 &&
                            rs[j = (m - 1) & h] == null) {
                        rs[j] = r;
                        created = true;
                    }
                } finally {
                    cellsBusy = 0;
                }
                /**
                 * 如果创建成功,就说明累加成功,直接退出循环
                 */
                if (created)
                    break;
                /**
                 * 走到这里说明在判空和拿到锁之间正好有其他线程在该下标处创建了一个Cell
                 * 因此直接continue,不rehash,下次就不会进入到该分支了
                 */
                continue;
            }
        }
        /**
         * 当执行到这里的时候,因为是在 if ((a = as[(n - 1) & h]) == null) 这个判断逻辑中
         * 就说明在第一个if判断的时候该下标处没有元素,所以赋值为false
         * collide的意义是:上一次循环中找到的数组下标是否已经有Cell对象了
         * True if last slot nonempty
         */
        collide = false;
    /**
     * 这个字段如果为false,说明之前已经和其他线程发过了竞争
     * 即使此时可以直接取尝试cas操作,但是在高并发场景下
     * 这2个线程之后依然可能发生竞争,而每次竞争都需要自旋的话会很浪费cpu资源
     * 因此在这里先直接增加自旋一次,在for的最后会做一次rehash
     * 使得线程尽快地找到自己独占的数组下标
     */
    } else if (!wasUncontended) 
        wasUncontended = true;
    /**
     * 尝试给hash对应的Cell累加,如果这一步成功了,那么就返回
     * 如果这一步依然失败了,说明此时整体的并发竞争非常激烈
     * 那就可能需要考虑扩容数组了
     * (因为数组初始化容量为2,如果此时有10个线程在并发运行,那就很难避免竞争的发生了)
     */
    else if (a.cas(v = a.value, ((fn == null) ? v + x :
            fn.applyAsLong(v, x))))
        break;
    /**
     * 这里判断下cpu的核数,因为即使有100个线程
     * 能同时并行运行的线程数等于cpu数
     * 因此如果数组的长度已经大于cpu数目了,那就不应当再扩容了
     */
    else if (n >= NCPU || cells != as)
        collide = false;
    /**
     * 走到这里,说明当前循环中根据线程hash值找到的数组下标已经有元素了
     * 如果此时collide为false,说明上一次循环中找到的下边是没有元素的
     * 那么就自旋一次并rehash
     * 如果再次运行到这里,并且collide为true,就说明明竞争非常激烈,应当扩容了
     */
    else if (!collide)
        collide = true;
    /**
     * 能运行到这里,说明需要扩容数组了
     * 判断锁状态并尝试获取锁
     */
    else if (cellsBusy == 0 && casCellsBusy()) {
        /**
         * 扩容数组的逻辑,这个扩容比较简单,就不解释了
         * 扩容大小为2倍
         */
        try {
            if (cells == as) { 
                Cell[] rs = new Cell[n << 1];
                for (int i = 0; i < n; ++i)
                    rs[i] = as[i];
                cells = rs;
            }
        } finally {
            cellsBusy = 0;
        }
        collide = false;
        /**
        * 这里直接continue,因为扩容过了,就先不rehash了
        */
        continue;               
    }
    /**
     * 做一个rehash,使得线程在下一个循环中可能找到独占的数组下标
     */
    h = advanceProbe(h);
}

En este punto, el código fuente de LongAdder ha terminado, de hecho, no hay mucho código, pero vale la pena aprender sus ideas.

4. Comparación con AtomicInteger

De hecho, el código fuente del análisis de luz es todavía un poco peor, todavía no hemos entendido por qué el autor debería diseñar una clase tan complicada cuando ya existe AtomicInteger.

Primero, analicemos el principio de AtomicInteger para garantizar la seguridad de los subprocesos.

Ver el método getAndIncrement más básico

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

Llamado el método getAndAddInt de la clase Unsafe, continúe mirando hacia abajo

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

Aquí ya no profundizamos en la implementación específica de los métodos getIntVolatile y compareAndSwapInt, porque ya son métodos nativos

Se puede ver que la capa inferior de AtomicInteger usa cas + spin para resolver el problema de atomicidad, es decir, si una asignación no tiene éxito, entonces gira hasta que la asignación sea exitosa.

Entonces se puede inferir que cuando un gran número de subprocesos son concurrentes y la competencia es muy feroz, AtomicInteger puede hacer que algunos subprocesos continúen compitiendo y fallen, y continúen girando, afectando así el rendimiento de las tareas.

Para resolver el problema de giro bajo alta concurrencia, el autor de LongAdder agregó una matriz para cambiar el objeto en competencia de un valor a múltiples valores durante el diseño, reduciendo así la frecuencia de la competencia y, por lo tanto, aliviándose El problema del giro, por supuesto, es el espacio de almacenamiento adicional.

Finalmente, hice una prueba simple para comparar el tiempo que requieren los dos métodos de conteo

Se puede ver del principio que la ventaja de LongAdder será más obvia solo cuando la competencia de subprocesos sea muy feroz, entonces aquí usé 100 subprocesos, cada subproceso acumula el mismo número 1000000 veces, los resultados son los siguientes, la brecha es muy grande, alcanzando ¡15 veces!

LongAdder consume mucho tiempo: 104292242nanos

AtomicInteger consume mucho tiempo: 1583294474nanos

Por supuesto, esta es solo una prueba simple, que contiene mucha aleatoriedad.Los estudiantes interesados ​​pueden probar múltiples pruebas con diferentes niveles de competencia.

5. La abstracción del pensamiento

Finalmente, necesitamos abstraer el código específico del autor y la lógica de implementación para aclarar el proceso de pensamiento.

1) El problema encontrado por AtomicInteger: la competencia de un solo recurso conduce a la aparición de spin

2) La idea de la solución: expandir la competencia de un solo objeto a la competencia de múltiples objetos (hay algunas ideas para dividir y conquistar)

3) Capacidad de control de la expansión: varios competidores deben pagar espacio de almacenamiento adicional, por lo que no pueden expandirse sin pensar (en casos extremos, un hilo cuenta como un objeto, lo que obviamente no es razonable)

4) Estratificación del problema: Debido a que la escena cuando se usan clases es incontrolable, es necesario expandir dinámicamente el espacio de almacenamiento adicional de acuerdo con la intensidad de la concurrencia (similar a la expansión de sincronizados)

5) 3 estrategias jerárquicas: cuando no hay competencia, usa un valor para acumular; cuando ocurre cierto grado de competencia, crea un arreglo con capacidad de 2 para expandir los recursos en competencia a 3; cuando la competencia es más Cuando sea intenso, continúe expandiendo la matriz (correspondiente al proceso de 1 hilo a 4 hilos en el diagrama)

6) Detalles de la estrategia: agregue un refrito durante el giro. En este momento, aunque se gasta una cierta cantidad de tiempo de cálculo calculando hashes, comparando objetos de matriz, etc., esto permitirá que los subprocesos concurrentes encuentren sus propios objetos lo antes posible, y no más tarde Volverá a haber competencia (compartir el cuchillo y no cortar madera por error, prestar especial atención a la solución correspondiente en el campo wasUncontended)

Si cree que este artículo es útil para usted, puede reenviarlo y seguirlo para obtener ayuda.

Supongo que te gusta

Origin blog.csdn.net/weixin_48182198/article/details/109332883
Recomendado
Clasificación