【2023】 Análisis e interpretación detallados del código fuente de HashMap

prefacio

Antes de comprender HashMap, primero introduzcamos la estructura de datos utilizada. Después de jdk1.8, se agregó la estructura de datos de árbol rojo-negro a HashMap para optimizar la eficiencia.

Árbol

En informática, un árbol es un tipo de datos abstractos (ADT) o una estructura de datos que implementa este tipo de datos abstractos , utilizado para simular una colección de datos con propiedades de estructura tipo árbol. Es un conjunto de relaciones jerárquicas compuestas por n (n>0) nodos limitados . Se llama "árbol" porque parece un árbol al revés, lo que significa que tiene las raíces hacia arriba y las hojas hacia abajo. Tiene las siguientes características:

  • Cada nodo tiene nodos secundarios limitados o ningún nodo;
  • Un nodo sin un nodo principal se denomina nodo raíz;
  • Cada nodo no raíz tiene exactamente un nodo padre;
  • Excepto el nodo raíz, cada nodo secundario se puede dividir en múltiples subárboles separados;
  • No hay ciclo en el árbol.
    Insertar descripción de la imagen aquí

La clasificación incluye árboles binarios, árboles de búsqueda binaria, árboles rojo-negro, árboles B, árboles B+, etc.

1. árbol binario

  • Cada nodo contiene como máximo dos nodos secundarios, a saber, el nodo secundario izquierdo y el nodo secundario derecho.
  • No es necesario que cada nodo tenga dos nodos secundarios. Algunos solo tienen hijos izquierdos y otros solo tienen hijos derechos.
  • El subárbol izquierdo y el subárbol derecho de cada nodo del árbol binario también satisfacen las dos primeras definiciones respectivamente.
    Insertar descripción de la imagen aquí

2. Árbol de búsqueda binaria

  • En cualquier nodo del árbol, el valor de cada nodo en su subárbol izquierdo debe ser menor que el valor de este nodo, y el valor del nodo del subárbol derecho debe ser mayor que el valor de este nodo.
  • No habrá nodos con valores clave iguales.
  • Normalmente, la complejidad temporal de la búsqueda de árbol binario es O (log n)
    Insertar descripción de la imagen aquí
    Como no puede girar, ocurrirá el peor de los casos, donde los subárboles izquierdo y derecho estarán extremadamente desequilibrados.
    Insertar descripción de la imagen aquí

3. Árboles rojos y negros

  • Los nodos son rojos o negros.
  • Sigue el nodo que es negro.
  • Los nodos de hoja son todos nodos vacíos negros.
  • Los nodos secundarios de un nodo rojo en un árbol rojo-negro son todos negros.
  • Todas las rutas desde cualquier nodo a un nodo hoja contienen la misma cantidad de nodos negros.
  • Después de agregar o eliminar, si no se cumplen las cinco definiciones anteriores, se producirá una operación de ajuste de rotación.
  • La complejidad temporal de las operaciones de búsqueda, eliminación y adición es O (log n)
    Insertar descripción de la imagen aquí

tabla de picadillo

Una tabla hash, también conocida como tabla hash , es una estructura de datos que accede directamente al valor (valor) en una ubicación de almacenamiento de memoria en función de una clave. Se desarrolla a partir de una matriz y utiliza las características de una matriz

para admitir el acceso aleatorio . a datos basados ​​en subíndices. La función que asigna claves a subíndices de matriz se llama función hash . Se puede expresar como: hashValue = hash(key)
Requisitos básicos de la función hash:

  • El valor hash calculado por la función hash debe ser un número entero positivo mayor o igual a 0, porque hashValue debe usarse como subíndice de la matriz.
  • Si clave1 = clave2, entonces el valor hash obtenido después del hash también debe ser el mismo: hash(clave1) = hash(clave2)
  • Si clave1! = Clave2, entonces el valor hash obtenido después del hash también debe ser el mismo: hash(clave1)!= hash(clave2)

colisión de hash

En situaciones reales, es casi imposible encontrar una función hash que pueda calcular diferentes valores hash para diferentes claves. Esto provocará un fenómeno en el que se asignan varias claves a la misma posición de subíndice de matriz después de ser convertidas mediante una operación hash. Esta situación se denomina conflicto hash (o conflicto hash o colisión hash).
Insertar descripción de la imagen aquí

método de cremallera

Para resolver conflictos de hash, generalmente se utiliza un método llamado método zip.
En una tabla hash, cada posición de subíndice de la matriz se puede llamar un depósito. Cada depósito corresponde a una lista vinculada. Todos los elementos con el mismo valor hash se colocan en la lista vinculada correspondiente a la misma ranura. Este método se llama método de cremallera.

  • En la operación de inserción, la ranura hash correspondiente se calcula a través de la función hash y se inserta en la lista vinculada correspondiente. La complejidad temporal de la inserción es O (1)
  • Al buscar o eliminar un elemento, también calculamos la ranura correspondiente a través de la función hash y luego recorremos la lista vinculada para encontrarlo o eliminarlo.
    • En promedio, la complejidad temporal de la consulta al resolver conflictos según el método de lista vinculada es O (1)
    • La tabla hash puede degenerar en una lista vinculada y la complejidad temporal de la consulta degenerará de O (1) a O (n).
    • Transforme la lista vinculada en el método de lista vinculada en otras estructuras de datos dinámicas eficientes, como árboles rojo-negro. La complejidad temporal de la consulta es O (log n)
      Insertar descripción de la imagen aquí
      Insertar descripción de la imagen aquí

Y el uso de árboles rojo-negros puede prevenir eficazmente los ataques DDos.

1. Introducción

HashMapEs una clase de implementación importante en Map: es una tabla hash y el contenido almacenado es un mapeo de pares clave-valor (clave => valor). HashMap no es seguro para subprocesos. HashMap permite el almacenamiento de claves y valores nulos, y las claves son únicas.

Antes de JDK1.8, HashMapla estructura de datos subyacente era una estructura pura de matriz + lista vinculada. Dado que las matrices tienen las características de lectura rápida y adición y eliminación lentas, mientras que las listas vinculadas tienen las características de lectura lenta y adición y eliminación rápidas, HashMap combina los dos sin utilizar bloqueos de sincronización para la modificación, por lo que su rendimiento es mejor. La matriz es HashMapel cuerpo principal y se introduce una lista vinculada para resolver conflictos de hash. El método específico para resolver conflictos de hash aquí es: método cremallera

2. Análisis del código fuente

1. poner método

1.1 Atributos comunes

Umbral de expansión = capacidad del arreglo * factor de carga

    //默认的初始容量
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16  
    //默认的加载因子     
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    //存储数据的数组
    transient Node<K,V>[] table;
    //容量
    transient int size;    
    

1.2 Constructor

    //默认无参构造
    public HashMap() {
    
    
        this.loadFactor = DEFAULT_LOAD_FACTOR; // 指定加载因子为默认加载因子 0.75
    }
  • HashMap crea matrices de forma perezosa y no inicializa la matriz al crear el objeto.
  • En el constructor sin parámetros, se establece el factor de carga predeterminado

1.3 método de colocación

  • diagrama de flujo
    Insertar descripción de la imagen aquí
  • Código fuente específico
    
	public V put(K key, V value) {
    
    
        return putVal(hash(key), key, value, false, true);
    }
    
	/** 
	*  计算hash值的方法
	*/
		static final int hash(Object key) {
    
    
	        int h;
	        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
	    }

	/** 
	*  具体执行put添加方法
	*/
	final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    
    
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //判断数组是否初始化(数组初始化是在第一次put的时候)
        if ((tab = table) == null || (n = tab.length) == 0)
        	//如果未初始化,调用resize()进行初始化
            n = (tab = resize()).length;
        //通过 & 运算符计算求出该数据(key)的数组下标并且判断该下标位置是否有数据
        if ((p = tab[i = (n - 1) & hash]) == null)
        	//如果没有,直接将数据放在该下标位置
            tab[i] = newNode(hash, key, value, null);
        else {
    
      //该下标位置有数据的情况
            Node<K,V> e; K k;
            //判断该下标位置的数据是否和当前新put的数据一样
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
				//如果一样,则直接覆盖value
                e = p;
            //判断是不是红黑树
            else if (p instanceof TreeNode)  
            	//如果是红黑树的话,进行红黑树的具体添加操作
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //如果都不是代表是链表
            else {
    
      
            	//遍历链表
                for (int binCount = 0; ; ++binCount) {
    
    
                	//判断next节点是否为null,是null代表遍历到链表尾部了
                    if ((e = p.next) == null) {
    
    
                    	//把新值插入到尾部
                        p.next = newNode(hash, key, value, null);
                        //插入数据后,判断链表长度有大于等于8了没
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        	//如果是则进行红黑树转换
                            treeifyBin(tab, hash);
                        break; //退出
                    }
                    //如果在链表中找到相同数据的值,则进行修改操作
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    //把下一个节点赋值为当前节点
                    p = e;
                }
            }
            //判断e是否为null(e值为前面修改操作存放原数据的变量)
            if (e != null) {
    
     // existing mapping for key
            	//不为null的话证明是修改操作,取出老值
                V oldValue = e.value;
               
                if (!onlyIfAbsent || oldValue == null)
                	//把新值赋值给当前节点
                    e.value = value;
                afterNodeAccess(e);
                //返回老值
                return oldValue;
            }
        }
        //计算当前节点的修改次数
        ++modCount;
        //判断当前数组中的数据量是否大于扩容阈值
        if (++size > threshold)
        	//进行扩容
            resize();
        afterNodeInsertion(evict);
        return null;
    }
  • proceso específico
  1. Determine si la tabla de matriz clave-valor es nula y realice una ejecución compleja de resize() para expansión (inicialización)
  2. Calcule el valor hash según el par clave-valor para obtener el índice de la matriz
  3. Juzgar tabla[i]==valor hash para obtener el índice de matriz
  4. Si taale[i] == null, la condición está establecida, cree directamente un nuevo nodo y agregue
    i. Determine si el primer elemento de la tabla [i] es el mismo que la clave. Si es el mismo, sobrescriba directamente el valor
    ii. Determine si la tabla [i] es un nodo de árbol, es decir, la tabla ¿Es [i] un árbol rojo-negro? Si es un árbol rojo-negro, inserte directamente el par clave-valor en el número iii. Atraviese la tabla [i],
    inserte datos al final de la lista vinculada y luego determine si la longitud de la lista vinculada es mayor que 8. Si es así, convierta la lista vinculada Es una operación de árbol rojo-negro. Si se descubre que la clave ya existe durante Durante el proceso transversal, el valor se sobrescribirá.

1.4 método de cambio de tamaño (expansión)

  • diagrama de flujo
    Insertar descripción de la imagen aquí
  • Código fuente específico
    final Node<K,V>[] resize() {
    
    
        Node<K,V>[] oldTab = table;
        //如果当前数组为null的时候,把oldCap 老数组容量设置为0
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //老的扩容阈值
        int oldThr = threshold;
        int newCap, newThr = 0;
        //判断数组容量是否大于0,大于0说明数组已经初始化
        if (oldCap > 0) {
    
    
        	//判断当前数组长度是否大于最大数组长度
            if (oldCap >= MAXIMUM_CAPACITY) {
    
    
            	//如果是,将扩容阈值直接设置为int类型的最大数值并且直接返回
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //如果在最大长度访问内,则需要扩容oldCap << 1 == oldCap * 2
            //并且判断是否大于16,
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold  等价于 oldCap * 2
        }
      
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        //数组初始化的情况,将阈值和扩容因子设置为默认值
        else {
    
                   // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //初始化容量小于16的时候,扩容阈值没用阈值的
        if (newThr == 0) {
    
    
        	//创建阈值
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        //计算出来的阈值赋值
        threshold = newThr;
        @SuppressWarnings({
    
    "rawtypes","unchecked"})
        //根据上边计算得出的容量 创建新的数组
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        //扩容操作,判断不为null证明不是初始化数组
        if (oldTab != null) {
    
    
        //	遍历数组
            for (int j = 0; j < oldCap; ++j) {
    
    
                Node<K,V> e;
                //判断当前下标为j的数组如果不为null的话赋值给e
                if ((e = oldTab[j]) != null) {
    
    
                	//将数组的位置设置为null
                    oldTab[j] = null;
                    //判断是否有下一个节点
                    if (e.next == null)
                    	//如果没有,就查询计算在新数组中的下标并放进去
                        newTab[e.hash & (newCap - 1)] = e;
                    //有下个节点的情况,并且判断是否已经树化
                    else if (e instanceof TreeNode)
                    	//进行红黑树的操作
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                   //有下个节点的情况,并且判还没有树化  
                    else {
    
     // preserve order
                        Node<K,V> loHead = null, loTail = null;  //低位数组
                        Node<K,V> hiHead = null, hiTail = null;  //高位数组
                        Node<K,V> next;
                        遍历循环
                        do {
    
    
                        	//取出next节点
                            next = e.next;
                            //通过 & 操作计算出结果为0
                            if ((e.hash & oldCap) == 0) {
    
    
                            	//如果低位为null,则把e值放入低位2头
                                if (loTail == null)
                                    loHead = e;
                                //低位尾不是null,
                                else
                                	//将数据放入next节点
                                    loTail.next = e;
                                loTail = e;
                            }
                            
                            else {
    
    
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        //低位如果记录的有数据,是链表
                        if (loTail != null) {
    
    
                        //将下一个元素置空
                            loTail.next = null;
                            //将低位头放入新数组的
                            newTab[j] = loHead;
                        }
                        //高位尾如果记录有数据,是链表
                        if (hiTail != null) {
    
    
                        	//将下个元素置空
                            hiTail.next = null;
                            //将高位头放入新数组的(原下标+原数组容量)位置
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
  • Principio de ejecución
  1. Al agregar elementos o inicializar, debe llamar al método de cambio de tamaño para la expansión. La primera vez que agrega datos, la longitud inicial de la matriz es 16. Cada expansión posterior alcanzará el umbral de expansión (longitud de la matriz * 0,75).
  2. Cada vez que se amplía la capacidad, la capacidad será el doble de la capacidad anterior a la expansión;
  3. Después de la expansión, se creará una nueva matriz y los datos de la matriz anterior deben moverse a la nueva matriz
    i. Para nodos sin conflictos de hash, use directamente e.hash&(newCap-1) para calcular la posición del índice del nueva matriz
    ii. Si es un árbol rojo-negro, y la adición del árbol rojo-negro
    iii. Si es una lista vinculada, debe recorrer la lista vinculada y es posible que deba dividir la lista vinculada para determinar si (e.hash&oldCap) es 0. La posición del elemento permanecerá en la posición original o se moverá a la posición original + el tamaño de matriz aumentado.

¿Cómo volver a determinar la posición de los elementos en la matriz durante la expansión? Vemos que está determinada por if ((e.hash & oldCap) == 0).

hash HEX(97)  = 0110 0001‬ 
n    HEX(16)  = 0001 0000
--------------------------
         结果  = 0000 0000
# e.hash & oldCap = 0 计算得到位置还是扩容前位置
     hash HEX(17)  = 0001 0001‬ 
     n    HEX(16)  = 0001 0000
--------------------------
         结果  = 0001 0000
#  e.hash & oldCap != 0 计算得到位置是扩容前位置+扩容前容量

obtener método

public V get(Object key) {
    
    
    // 定义一个Node结点
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    
    
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
    
    
        // 数组中元素相等的情况
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // bucket中不止一个结点
        if ((e = first.next) != null) {
    
    
            //判断是否为TreeNode树结点
            if (first instanceof TreeNode)
                //通过树的方法获取结点
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
    
    
                //通过链表遍历获取结点
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    // 找不到就返回null
    return null;
}

problema comun

1. ¿Cómo calcular el índice? Ahora que tenemos hashCode, ¿por qué todavía necesitamos usar el método hash()? ¿Por qué la capacidad del arreglo es igual a la n potencia de 2?

  • Primero calcule el hashCode() de la clave, luego llame Hash()al método y use el método XOR para perturbar la operación para el hash secundario, y finalmente (n - 1) & hashobtenga el índice a través de la operación AND. (Usar este método es equivalente a la operación de módulo hash % n)

  • El propósito del hash secundario () es sintetizar datos binarios de bits altos, hacer que la distribución de hash sea más uniforme y reducir la probabilidad de colisión de hash. La fórmula de cálculo es:(h = key.hashCode()) ^ (h >>> 16)

  • Para calcular el índice, si es la enésima potencia de 2, puedes usar la operación AND bit a bit en lugar de módulo, que es más eficiente; y cuando se expanda la capacidad, los elementos con hash & lodCap == 0 permanecerán en el original. posición, y aquellos con 1 serán devueltos a la posición expandida. Nueva ubicación, nueva ubicación = ubicación anterior + lodCap

    • 计算方式: hash & lengthUtilice el valor hash secundario y la capacidad original para realizar operaciones. Si el resultado es 0, la posición permanecerá sin cambios. Si no es 0, se moverá a una nueva posición.
    • Nueva forma de calcular la ubicación:原始数组容量+原始下标=新的位置
  • El objetivo principal de utilizar la enésima potencia de 2 es coordinar mejor la eficiencia de optimización y hacer que los subíndices se distribuyan de manera más uniforme.

2. ¿Cuál es la diferencia entre 1.7 y 1.8 en el proceso del método de venta de HashMap?

  1. HashMap crea matrices de forma perezosa. La matriz se crea solo después del primer uso.
  2. Calcular índice (subíndice de depósito)
    1. Primero, obtenga el valor hash de la clave y luego calcule el valor hash secundario mediante el método hash (). ** El método de cálculo es: ** (h = key.hashCode()) ^ (h >>> 16)Desplace el valor hash hacia la derecha a través de unsigned y luego realice el cálculo XOR con el valor hash original. Esta función es principalmente para interrumpir los 16 bits inferiores que realmente participan en el cálculo, lo que puede interrumpir efectivamente la operación y reducir la probabilidad de colisión hash.
    2. Luego use todo el valor hash secundario y la capacidad de la matriz para dividir y dejar el método del resto. El resto obtenido es el subíndice final del depósito. El método de cálculo es: tomar la longitud de la matriz -1 y realizar una operación(n-1)&hash AND con el valor hash. Es equivalente a usar el valor hash para hacer que el % % restante tenga la longitud n de la matriz.Operar, usar y realizar operaciones es principalmente para mejorar efectivamente la eficiencia de las operaciones (1.7 no tiene esta optimización)
  3. Si el subíndice del depósito no está ocupado por nadie, cree un nodo Nodo y regrese
  4. Si el subíndice del depósito ya está ocupado: se comparará con cada nodo uno por uno para ver si el valor hash y el igual () son relativos. Si son iguales, significa la misma clave, luego sobrescríbalo y modifíquelo. Si no son iguales, agrégalo.
    1. Agregar o actualizar lógica cuando TreeNode ya sea un árbol rojo-negro
    2. Si es un nodo ordinario, utilizará la lógica de agregar o actualizar la lista vinculada. Si la longitud de la lista vinculada excede el umbral del árbol de 8, utilizará la lógica del árbol y realizará la operación del árbol (el requisito previo es que la longitud de la matriz alcanza 64)
  5. Antes de regresar, también verificará si la capacidad excede el umbral de expansión (longitud del conjunto/factor de carga), una vez superado, la capacidad se ampliará.
  6. diferente:
    1. Al insertar en una lista vinculada, 1.7 usa el método de inserción de encabezado (insertar desde el encabezado de la lista vinculada) y 1.8 usa el método de inserción de cola (insertar desde el final de la lista vinculada).
      1. 1.7 significa expansión cuando es mayor o igual al umbral y no hay espacio (si hay espacio, no se expandirá pero seguirá ubicándose en el índice del cubo calculado), mientras que 1.8 significa expansión cuando es mayor igual o superior al umbral.
      2. 1.8 se optimizará al expandir el nodo Nodo informático.

Supongo que te gusta

Origin blog.csdn.net/weixin_52315708/article/details/131918897
Recomendado
Clasificación