Implementación de HashMap Java 8

92

Según el siguiente documento de enlace: Implementación de Java HashMap

Estoy confundido con la implementación de HashMap(o más bien, una mejora en HashMap). Mis consultas son:

en primer lugar

static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;

¿Por qué y cómo se utilizan estas constantes? Quiero algunos ejemplos claros de esto. ¿Cómo están logrando una ganancia de rendimiento con esto?

En segundo lugar

Si ve el código fuente de HashMapen JDK, encontrará la siguiente clase interna estática:

static final class TreeNode<K, V> extends java.util.LinkedHashMap.Entry<K, V> {
    HashMap.TreeNode<K, V> parent;
    HashMap.TreeNode<K, V> left;
    HashMap.TreeNode<K, V> right;
    HashMap.TreeNode<K, V> prev;
    boolean red;

    TreeNode(int arg0, K arg1, V arg2, HashMap.Node<K, V> arg3) {
        super(arg0, arg1, arg2, arg3);
    }

    final HashMap.TreeNode<K, V> root() {
        HashMap.TreeNode arg0 = this;

        while (true) {
            HashMap.TreeNode arg1 = arg0.parent;
            if (arg0.parent == null) {
                return arg0;
            }

            arg0 = arg1;
        }
    }
    //...
}

¿Cómo se usa? Solo quiero una explicación del algoritmo .

Hasnain Ali Bohra
fuente

Respuestas:

225

HashMapcontiene una cierta cantidad de cubos. Se utiliza hashCodepara determinar en qué cubo colocarlos. En aras de la simplicidad, imagínelo como un módulo.

Si nuestro código hash es 123456 y tenemos 4 cubos, 123456 % 4 = 0entonces el artículo va en el primer contenedor, Cubo 1.

HashMap

Si nuestra función de código hash es buena, debería proporcionar una distribución uniforme para que todos los depósitos se utilicen de forma un tanto igual. En este caso, el depósito usa una lista vinculada para almacenar los valores.

Cubos vinculados

Pero no puede confiar en que las personas implementen buenas funciones hash. La gente a menudo escribirá funciones hash deficientes que darán como resultado una distribución desigual. También es posible que tengamos mala suerte con nuestras aportaciones.

Mapa de hash incorrecto

Cuanto menos uniforme sea esta distribución, más nos alejamos de las operaciones O (1) y más nos acercamos a las operaciones O (n).

La implementación de Hashmap intenta mitigar esto organizando algunos depósitos en árboles en lugar de listas vinculadas si los depósitos se vuelven demasiado grandes. Para eso TREEIFY_THRESHOLD = 8es. Si un cubo contiene más de ocho elementos, debería convertirse en un árbol.

Cubo de árbol

Este árbol es un árbol rojo-negro. Primero se ordena por código hash. Si los códigos hash son los mismos, utiliza el compareTométodo de Comparablesi los objetos implementan esa interfaz, de lo contrario, el código hash de identidad.

Si las entradas se eliminan del mapa, la cantidad de entradas en el depósito podría reducirse de modo que esta estructura de árbol ya no sea necesaria. Para eso UNTREEIFY_THRESHOLD = 6es. Si la cantidad de elementos en un depósito cae por debajo de seis, también podríamos volver a usar una lista vinculada.

Finalmente, está el MIN_TREEIFY_CAPACITY = 64.

Cuando un mapa hash aumenta de tamaño, se redimensiona automáticamente para tener más depósitos. Si tenemos un pequeño mapa hash, la probabilidad de que obtengamos depósitos muy llenos es bastante alta, porque no tenemos tantos depósitos diferentes para colocar cosas. Es mucho mejor tener un mapa hash más grande, con más depósitos y menos llenos. Esta constante básicamente dice que no debemos comenzar a convertir cubos en árboles si nuestro mapa hash es muy pequeño; primero debería cambiar el tamaño para ser más grande.


Para responder a su pregunta sobre la ganancia de rendimiento, estas optimizaciones se agregaron para mejorar el peor de los casos. Solo estoy especulando, pero probablemente solo vería una mejora notable en el rendimiento debido a estas optimizaciones si su hashCodefunción no fuera muy buena.

Miguel
fuente
3
Una distribución desigual no siempre es un signo de funciones hash deficientes. Algunos tipos de datos, por ejemplo String, tienen un espacio de valor mucho mayor que el intcódigo hash, por lo que las colisiones son inevitables. Ahora depende de los valores reales, como los valores reales String, que pones en el mapa, ya sea que obtengas una distribución uniforme o no. Una mala distribución puede ser el resultado de una mala suerte.
Holger
3
+1, me gustaría agregar que un escenario específico que mitiga este enfoque de árbol es un ataque DOS de colisión hash . java.lang.Stringtiene un carácter determinista, no criptográfico hashCode, por lo que los atacantes pueden crear de manera trivial cadenas distintas con códigos hash en colisión. Antes de esta optimización, esto podía degradar las operaciones de HashMap a tiempo O (n), ahora simplemente las degrada a O (log (n)).
MikeFHay
1
+1, if the objects implement that interface, else the identity hash code.estaba buscando esta otra parte.
Number945
1
@NateGlenn el código hash predeterminado si no lo anula
Michael
No recibí "Esta constante básicamente dice que no debemos comenzar a convertir cubos en árboles si nuestro mapa hash es muy pequeño; primero debería cambiar el tamaño para ser más grande". para MIN_TREEIFY_CAPACITY. ¿Significa "Una vez que insertamos una clave que se va a aplicar hash al depósito que ya contiene 8 TREEIFY_THRESHOLDclaves ( ) y si ya hay 64 MIN_TREEIFY_CAPACITYclaves ( ) HashMap, la lista vinculada de ese depósito se convierte en un árbol equilibrado"?
anir
16

Para ponerlo más simple (tanto como pueda más simple) + algunos detalles más.

Estas propiedades dependen de muchas cosas internas que serían muy interesantes de entender, antes de pasar a ellas directamente.

TREEIFY_THRESHOLD -> cuando un solo cubo alcanza esto (y el número total excede MIN_TREEIFY_CAPACITY), se transforma en un nodo de árbol rojo / negro perfectamente equilibrado . ¿Por qué? Debido a la velocidad de búsqueda. Piense en ello de otra manera:

se necesitarían como máximo 32 pasos para buscar una entrada dentro de un depósito / contenedor con entradas Integer.MAX_VALUE .

Alguna introducción para el próximo tema. ¿Por qué la cantidad de contenedores / cubos es siempre una potencia de dos ? Al menos dos razones: más rápido que la operación de módulo y módulo en números negativos será negativo. Y no puede poner una Entrada en un depósito "negativo":

 int arrayIndex = hashCode % buckets; // will be negative

 buckets[arrayIndex] = Entry; // obviously will fail

En cambio, se usa un buen truco en lugar de módulo:

 (n - 1) & hash // n is the number of bins, hash - is the hash function of the key

Eso es semánticamente lo mismo que la operación de módulo. Mantendrá los bits inferiores. Esto tiene una consecuencia interesante cuando lo haces:

Map<String, String> map = new HashMap<>();

En el caso anterior, la decisión de a dónde va una entrada se toma en función de los últimos 4 bits solo de su código hash.

Aquí es donde entra en juego la multiplicación de cubos. Bajo ciertas condiciones (tomaría mucho tiempo explicarlo con detalles exactos ), los cubos se duplican en tamaño. ¿Por qué? Cuando los cubos se duplican en tamaño, entra en juego un bit más .

Entonces tiene 16 cubos: los últimos 4 bits del código hash deciden dónde va una entrada. Doblas los cubos: 32 cubos - 5 últimos bits deciden dónde irá la entrada.

Como tal, este proceso se llama rehacer. Esto podría volverse lento. Eso es (para las personas que se preocupan) como HashMap se "bromea" como: rápido, rápido, rápido, lento . Hay otras implementaciones: buscar hashmap sin pausa ...

Ahora UNTREEIFY_THRESHOLD entra en juego después de volver a aplicar el hash. En ese punto, algunas entradas pueden moverse de estos contenedores a otros (agregan un bit más al (n-1)&hashcálculo y, como tal, pueden moverse a otros contenedores) y puede llegar a esto UNTREEIFY_THRESHOLD. En este punto, no vale la pena mantener la papelera como red-black tree node, sino como un LinkedListlugar, como

 entry.next.next....

MIN_TREEIFY_CAPACITY es la cantidad mínima de depósitos antes de que un determinado depósito se transforme en un árbol.

Eugenio
fuente
10

TreeNodees una forma alternativa de almacenar las entradas que pertenecen a un solo contenedor de HashMap. En implementaciones más antiguas, las entradas de un contenedor se almacenaban en una lista vinculada. En Java 8, si el número de entradas en un contenedor pasa un umbral ( TREEIFY_THRESHOLD), se almacenan en una estructura de árbol en lugar de la lista vinculada original. Esta es una optimización.

Desde la implementación:

/*
 * Implementation notes.
 *
 * This map usually acts as a binned (bucketed) hash table, but
 * when bins get too large, they are transformed into bins of
 * TreeNodes, each structured similarly to those in
 * java.util.TreeMap. Most methods try to use normal bins, but
 * relay to TreeNode methods when applicable (simply by checking
 * instanceof a node).  Bins of TreeNodes may be traversed and
 * used like any others, but additionally support faster lookup
 * when overpopulated. However, since the vast majority of bins in
 * normal use are not overpopulated, checking for existence of
 * tree bins may be delayed in the course of table methods.
Eran
fuente
no es exactamente cierto. Si pasan TREEIFY_THRESHOLD Y el número total de contenedores es al menos MIN_TREEIFY_CAPACITY. Intenté cubrir eso en mi respuesta ...
Eugene
3

Necesitaría visualizarlo: digamos que hay una clave de clase con solo la función hashCode () anulada para devolver siempre el mismo valor

public class Key implements Comparable<Key>{

  private String name;

  public Key (String name){
    this.name = name;
  }

  @Override
  public int hashCode(){
    return 1;
  }

  public String keyName(){
    return this.name;
  }

  public int compareTo(Key key){
    //returns a +ve or -ve integer 
  }

}

y luego en otro lugar, estoy insertando 9 entradas en un HashMap con todas las claves siendo instancias de esta clase. p.ej

Map<Key, String> map = new HashMap<>();

    Key key1 = new Key("key1");
    map.put(key1, "one");

    Key key2 = new Key("key2");
    map.put(key2, "two");
    Key key3 = new Key("key3");
    map.put(key3, "three");
    Key key4 = new Key("key4");
    map.put(key4, "four");
    Key key5 = new Key("key5");
    map.put(key5, "five");
    Key key6 = new Key("key6");
    map.put(key6, "six");
    Key key7 = new Key("key7");
    map.put(key7, "seven");
    Key key8 = new Key("key8");
    map.put(key8, "eight");

//Since hascode is same, all entries will land into same bucket, lets call it bucket 1. upto here all entries in bucket 1 will be arranged in LinkedList structure e.g. key1 -> key2-> key3 -> ...so on. but when I insert one more entry 

    Key key9 = new Key("key9");
    map.put(key9, "nine");

  threshold value of 8 will be reached and it will rearrange bucket1 entires into Tree (red-black) structure, replacing old linked list. e.g.

                  key1
                 /    \
               key2   key3
              /   \   /  \

El recorrido del árbol es más rápido {O (log n)} que LinkedList {O (n)} y, a medida que n crece, la diferencia se vuelve más significativa.

alquilado
fuente
No puede construir un árbol eficiente porque no tiene forma de comparar claves que no sean sus códigos hash, que son todos iguales, y su método de igualdad, que no ayuda a ordenar.
user253751
@immibis Sus códigos hash no son necesariamente los mismos. Es muy probable que sean diferentes. Si las clases lo implementan, usará adicionalmente compareTofrom Comparable. identityHashCodees otro mecanismo que utiliza.
Michael
@Michael En este ejemplo, todos los códigos hash son necesariamente los mismos y la clase no implementa Comparable. identityHashCode no tendrá ningún valor para encontrar el nodo correcto.
user253751
@immibis Ah, sí, solo lo hojeé, pero tienes razón. Entonces, como Keyno implementa Comparable, identityHashCodese usará :)
Michael
@EmonMishra desafortunadamente, simplemente lo visual no será suficiente, he tratado de cubrir eso en mi respuesta.
Eugene
2

El cambio en la implementación de HashMap se agregó con JEP-180 . El propósito era:

Mejore el rendimiento de java.util.HashMap en condiciones de alta colisión de hash mediante el uso de árboles equilibrados en lugar de listas vinculadas para almacenar las entradas del mapa. Implementar la misma mejora en la clase LinkedHashMap

Sin embargo, el rendimiento puro no es la única ventaja. También evitará el ataque HashDoS , en caso de que se use un mapa hash para almacenar la entrada del usuario, porque el árbol rojo-negro que se usa para almacenar datos en el depósito tiene la complejidad de inserción del peor de los casos en O (log n). El árbol se usa después de que se cumplen ciertos criterios; consulte la respuesta de Eugene .

Anton Krosnev
fuente
-1

Para comprender la implementación interna de hashmap, debe comprender el hash. El hash en su forma más simple, es una forma de asignar un código único para cualquier variable / objeto después de aplicar cualquier fórmula / algoritmo en sus propiedades.

Una verdadera función hash debe seguir esta regla:

“La función hash debe devolver el mismo código hash todas y cada una de las veces que se aplica la función en objetos iguales o iguales. En otras palabras, dos objetos iguales deben producir el mismo código hash de forma coherente ".

Avinash
fuente
Esto no responde a la pregunta.
Stephen C