Implementazione di HashMap Java 8


92

Secondo il seguente documento di collegamento: Implementazione Java HashMap

Sono confuso con l'implementazione di HashMap(o meglio, un miglioramento in HashMap). Le mie domande sono:

In primo luogo

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

Perché e come vengono utilizzate queste costanti? Voglio alcuni esempi chiari per questo. Come stanno ottenendo un miglioramento delle prestazioni con questo?

In secondo luogo

Se vedi il codice sorgente di HashMapin JDK, troverai la seguente classe interna statica:

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;
        }
    }
    //...
}

Come si usa? Voglio solo una spiegazione dell'algoritmo .

Risposte:


225

HashMapcontiene un certo numero di bucket. Viene utilizzato hashCodeper determinare in quale bucket metterli. Per semplicità immaginalo come un modulo.

Se il nostro codice hash è 123456 e abbiamo 4 bucket, 123456 % 4 = 0l'articolo va nel primo bucket, Bucket 1.

HashMap

Se la nostra funzione hashcode è buona, dovrebbe fornire una distribuzione uniforme in modo che tutti i bucket vengano utilizzati in qualche modo allo stesso modo. In questo caso, il bucket utilizza un elenco collegato per memorizzare i valori.

Bucket collegati

Ma non puoi fare affidamento sulle persone per implementare buone funzioni hash. Le persone spesso scriveranno funzioni hash scadenti che risulteranno in una distribuzione non uniforme. È anche possibile che potremmo essere sfortunati con i nostri input.

Hashmap errato

Meno questa distribuzione è uniforme, più ci spostiamo dalle operazioni O (1) e più ci avviciniamo alle operazioni O (n).

L'implementazione di Hashmap cerca di mitigare questo problema organizzando alcuni bucket in alberi anziché in elenchi collegati se i bucket diventano troppo grandi. Questo è ciò che TREEIFY_THRESHOLD = 8serve. Se un secchio contiene più di otto elementi, dovrebbe diventare un albero.

Benna per alberi

Questo albero è un albero rosso-nero. Viene prima ordinato per codice hash. Se i codici hash sono gli stessi, utilizza il compareTometodo Comparablese gli oggetti implementano quell'interfaccia, altrimenti il ​​codice hash dell'identità.

Se le voci vengono rimosse dalla mappa, il numero di voci nel bucket potrebbe ridursi in modo tale che questa struttura ad albero non sia più necessaria. Ecco a cosa UNTREEIFY_THRESHOLD = 6serve. Se il numero di elementi in un bucket scende al di sotto di sei, potremmo anche tornare a utilizzare un elenco collegato.

Infine, c'è il MIN_TREEIFY_CAPACITY = 64.

Quando una mappa hash aumenta di dimensioni, si ridimensiona automaticamente per avere più bucket. Se abbiamo una piccola mappa hash, la probabilità che otteniamo bucket molto pieni è piuttosto alta, perché non abbiamo così tanti bucket diversi in cui inserire le cose. È molto meglio avere una mappa hash più grande, con più bucket meno pieni. Questa costante in pratica dice di non iniziare a trasformare i secchi in alberi se la nostra mappa hash è molto piccola - dovrebbe invece ridimensionarsi per essere più grande.


Per rispondere alla tua domanda sul miglioramento delle prestazioni, queste ottimizzazioni sono state aggiunte per migliorare il caso peggiore . Sto solo speculando, ma probabilmente vedresti un notevole miglioramento delle prestazioni a causa di queste ottimizzazioni se la tua hashCodefunzione non fosse molto buona.


3
Una distribuzione non uniforme non è sempre un segno di funzioni hash scadenti. Alcuni tipi di dati, ad esempio String, hanno uno spazio dei valori molto più grande del intcodice hash, quindi le collisioni sono inevitabili. Ora dipende dai valori effettivi, come quelli effettivi String, inseriti nella mappa, indipendentemente dal fatto che si ottenga una distribuzione uniforme o meno. Una cattiva distribuzione può essere il risultato solo di sfortuna.
Holger

3
+1, vorrei aggiungere che uno scenario specifico che questo approccio ad albero mitiga è un attacco DOS di collisione hash . java.lang.Stringha un deterministico, non crittografico hashCode, quindi gli aggressori possono creare banalmente stringhe distinte con codici hash in conflitto. Prima di questa ottimizzazione, questo poteva degradare le operazioni di HashMap a O (n) -time, ora le degrada semplicemente a O (log (n)).
MikeFHay

1
+1, if the objects implement that interface, else the identity hash code.stavo cercando quest'altra parte.
Numero 945

1
@NateGlenn il codice hash predefinito se non lo sovrascrivi
Michael

Non ho capito "Questa costante fondamentalmente dice di non iniziare a trasformare i secchi in alberi se la nostra mappa hash è molto piccola, dovrebbe invece ridimensionarsi per essere più grande". per MIN_TREEIFY_CAPACITY. Significa "Una volta inserita una chiave da sottoporre ad hashing nel bucket contenente già 8 ( TREEIFY_THRESHOLD) chiavi e se ci sono già 64 ( MIN_TREEIFY_CAPACITY) chiavi HashMap, l'elenco collegato di quel bucket viene convertito in un albero bilanciato."
anir

16

Per dirla in modo più semplice (per quanto potrei più semplice) + alcuni dettagli in più.

Queste proprietà dipendono da molte cose interne che sarebbe molto interessante capire, prima di passare direttamente ad esse.

TREEIFY_THRESHOLD -> quando un singolo bucket raggiunge questo (e il numero totale supera MIN_TREEIFY_CAPACITY), viene trasformato in un nodo dell'albero rosso / nero perfettamente bilanciato . Perché? A causa della velocità di ricerca. Pensaci in un modo diverso:

sarebbero necessari al massimo 32 passaggi per cercare una voce in un bucket / bin con voci Integer.MAX_VALUE .

Qualche introduzione per il prossimo argomento. Perché il numero di bidoni / secchi è sempre una potenza di due ? Almeno due ragioni: più veloce dell'operazione modulo e modulo su numeri negativi sarà negativo. E non puoi inserire una voce in un bucket "negativo":

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

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

Invece c'è un bel trucco usato al posto del modulo:

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

Questo è semanticamente lo stesso dell'operazione modulo. Manterrà i bit inferiori. Ciò ha una conseguenza interessante quando fai:

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

Nel caso precedente, la decisione su dove va una voce viene presa in base agli ultimi 4 bit solo del tuo codice hash.

È qui che entra in gioco la moltiplicazione dei secchi. In determinate condizioni (occorrerebbe molto tempo per spiegarlo nei dettagli esatti ), le dimensioni dei secchi sono raddoppiate. Perché? Quando le dimensioni dei secchi sono raddoppiate, entra in gioco un altro bit .

Quindi hai 16 bucket: gli ultimi 4 bit del codice hash decidono dove va una voce. Raddoppi i bucket: 32 bucket: gli ultimi 5 bit decidono dove andrà a finire l'ingresso.

In quanto tale, questo processo è chiamato re-hashing. Questo potrebbe rallentare. Cioè (per le persone a cui importa) come HashMap è "scherzato" come: veloce, veloce, veloce, slooow . Ci sono altre implementazioni: cerca hashmap senza pausa ...

Ora UNTREEIFY_THRESHOLD entra in gioco dopo il nuovo hashing. A quel punto, alcune voci potrebbero spostarsi da questi contenitori ad altri (aggiungono un bit in più al (n-1)&hashcalcolo - e come tali potrebbero spostarsi su altri contenitori) e potrebbe raggiungere questo UNTREEIFY_THRESHOLD. A questo punto non conviene tenere il cestino come red-black tree node, ma come LinkedListinvece, come

 entry.next.next....

MIN_TREEIFY_CAPACITY è il numero minimo di bucket prima che un determinato bucket venga trasformato in un albero.


10

TreeNodeè un modo alternativo per memorizzare le voci che appartengono a un singolo contenitore del file HashMap. Nelle implementazioni precedenti le voci di un contenitore erano memorizzate in un elenco collegato. In Java 8, se il numero di voci in un contenitore supera una soglia ( TREEIFY_THRESHOLD), vengono memorizzate in una struttura ad albero invece che nell'elenco collegato originale. Questa è un'ottimizzazione.

Dall'implementazione:

/*
 * 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.

non esattamente vero. Se passano TREEIFY_THRESHOLD E il numero totale di contenitori è almeno MIN_TREEIFY_CAPACITY. Ho provato a coprirlo nella mia risposta ...
Eugene

3

Dovresti visualizzarlo: diciamo che c'è una chiave di classe con solo la funzione hashCode () sovrascritta per restituire sempre lo stesso valore

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 
  }

}

e poi da qualche altra parte, inserisco 9 voci in una HashMap con tutte le chiavi che sono istanze di questa classe. per esempio

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
              /   \   /  \

L'attraversamento dell'albero è più veloce {O (log n)} di LinkedList {O (n)} e man mano che n cresce, la differenza diventa più significativa.


Non è possibile costruire un albero efficiente perché non ha modo di confrontare chiavi diverse dai loro codici hash, che sono tutti uguali, e dal loro metodo uguale, che non aiuta con l'ordinamento.
user253751

@immibis I loro codici hash non sono necessariamente gli stessi. Sono molto probabilmente diversi. Se le classi lo implementano, utilizzerà anche compareToda Comparable. identityHashCodeè un altro meccanismo che utilizza.
Michael,

@Michael In questo esempio tutti gli hashcode sono necessariamente gli stessi e la classe non implementa Comparable. identityHashCode sarà inutile nel trovare il nodo corretto.
user253751

@immibis Ah si, l'ho solo sfogliato ma hai ragione. Quindi, come Keynon implementato Comparable, identityHashCodeverrà utilizzato :)
Michael

@EmonMishra sfortunatamente, il semplice aspetto visivo non sarà sufficiente, ho cercato di coprirlo nella mia risposta.
Eugene

2

La modifica nell'implementazione di HashMap è stata aggiunta con JEP-180 . Lo scopo era:

Migliora le prestazioni di java.util.HashMap in condizioni di forte collisione hash utilizzando alberi bilanciati anziché elenchi collegati per memorizzare le voci della mappa. Implementa lo stesso miglioramento nella classe LinkedHashMap

Tuttavia le prestazioni pure non sono l'unico guadagno. Sarà anche evitare HashDoS attaccare , nel caso in cui una mappa di hash viene utilizzato per inserire archivio utenti, perché l' albero rosso-nero che viene utilizzato per memorizzare i dati nel secchio è peggiore complessità inserimento caso in O (log n). L'albero viene utilizzato dopo che è stato soddisfatto un determinato criterio - vedere la risposta di Eugene .


-1

Per comprendere l'implementazione interna di hashmap, è necessario comprendere l'hashing. L'hashing nella sua forma più semplice, è un modo per assegnare un codice univoco a qualsiasi variabile / oggetto dopo aver applicato qualsiasi formula / algoritmo alle sue proprietà.

Una vera funzione hash deve seguire questa regola:

“La funzione hash dovrebbe restituire lo stesso codice hash ogni volta che la funzione viene applicata su oggetti uguali o uguali. In altre parole, due oggetti uguali devono produrre lo stesso codice hash in modo coerente. "


Questo non risponde alla domanda.
Stephen C
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.