Die Java HashMap unter der Haube

1. Überblick

In diesem Artikel werden wir die populärste Implementierung der Map -Schnittstelle aus dem Java Collections Framework untersuchen.

Bevor wir mit der Implementierung beginnen, ist es wichtig, darauf hinzuweisen, dass die Primärschnittstellen List und Set Collection Collection erweitern, Map jedoch nicht.

Einfach ausgedrückt speichert HashMap Werte nach Schlüssel und bietet APIs zum Hinzufügen, Abrufen und Bearbeiten gespeicherter Daten auf verschiedene Arten. Die Implementierung basiert auf den Prinzipien einer Hashtabelle , die auf den ersten Blick etwas komplex klingt, aber eigentlich sehr einfach zu verstehen ist.

Schlüssel-Wert-Paare werden in sogenannten Buckets gespeichert, die zusammen eine sogenannte Tabelle bilden, die eigentlich ein internes Array ist.

Sobald wir wissen, unter welchem ​​Schlüssel ein Objekt gespeichert wird oder gespeichert werden soll, erfolgen Speicher- und Abrufvorgänge in konstanter Zeit , O (1) in einer gut dimensionierten Hash-Map.

Um zu verstehen, wie Hash-Karten unter der Haube funktionieren, muss man den von __HashMap verwendeten Mechanismus zum Speichern und Abrufen verstehen.

Schließlich sind HashMap -bezogene Fragen in Interviews ziemlich häufig , daher ist dies eine gute Möglichkeit, ein Interview vorzubereiten oder sich darauf vorzubereiten.

2. Die put () API

Um einen Wert in einer Hash-Map zu speichern, rufen wir die API put auf, die zwei Parameter benötigt. einen Schlüssel und den entsprechenden Wert:

V put(K key, V value);

Wenn der Karte unter einem Schlüssel ein Wert hinzugefügt wird, wird die hashCode () -API des Schlüsselobjekts aufgerufen, um den sogenannten Hashwert abzurufen.

Um dies in Aktion zu sehen, lassen Sie uns ein Objekt erstellen, das als Schlüssel fungiert.

Wir erstellen nur ein einzelnes Attribut, das als Hash-Code zur Simulation der ersten Hash-Phase verwendet wird:

public class MyKey {
    private int id;

    @Override
    public int hashCode() {
        System.out.println("Calling hashCode()");
        return id;
    }

   //constructor, setters and getters
}

Wir können dieses Objekt jetzt verwenden, um einen Wert in der Hash-Map abzubilden:

@Test
public void whenHashCodeIsCalledOnPut__thenCorrect() {
    MyKey key = new MyKey(1);
    Map<MyKey, String> map = new HashMap<>();
    map.put(key, "val");
}

Im obigen Code passiert nicht viel, aber achten Sie auf die Konsolenausgabe. Tatsächlich wird die hashCode -Methode aufgerufen:

Calling hashCode()

Als Nächstes wird die hash () -API der Hash-Map intern aufgerufen, um den endgültigen Hashwert unter Verwendung des anfänglichen Hashwerts zu berechnen.

Dieser endgültige Hash-Wert läuft letztendlich auf einen Index im internen Array oder auf einen so genannten Bucket-Standort zurück.

Die hash -Funktion von HashMap sieht folgendermaßen aus:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

Was wir hier beachten sollten, ist nur die Verwendung des Hash-Codes aus dem Schlüsselobjekt, um einen endgültigen Hash-Wert zu berechnen.

Innerhalb der put -Funktion wird der endgültige Hashwert folgendermaßen verwendet:

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

Beachten Sie, dass eine interne putVal -Funktion aufgerufen wird und der letzte Hashwert als ersten Parameter angegeben wird.

Man kann sich fragen, warum der Schlüssel wieder in dieser Funktion verwendet wird, da wir ihn bereits zur Berechnung des Hashwerts verwendet haben.

Der Grund ist, dass Hash-Maps sowohl Schlüssel als auch Wert in der Bucket-Position als Map.Entry -Objekt speichern.

Wie bereits erwähnt, erweitern alle Java Collections-Framework-Schnittstellen die Collection -Schnittstelle, Map nicht. Vergleichen Sie die Deklaration der Map-Schnittstelle, die wir zuvor gesehen haben, mit der der Set -Schnittstelle:

public interface Set<E> extends Collection<E>

Der Grund ist, dass ** Maps nicht genau wie einzelne Sammlungen einzelne Elemente speichern, sondern eine Sammlung von Schlüssel-Wert-Paaren.

Daher sind die generischen Methoden der Collection -Schnittstelle, wie add , toArray , für Map nicht sinnvoll.

Das Konzept, das wir in den letzten drei Absätzen behandelt haben, führt zu einer der beliebtesten Java Collections Framework-Interviewfragen .

Es lohnt sich also zu verstehen.

Ein besonderes Attribut der Hash-Map ist, dass null -Werte und Null-Schlüssel akzeptiert werden:

@Test
public void givenNullKeyAndVal__whenAccepts__thenCorrect(){
    Map<String, String> map = new HashMap<>();
    map.put(null, null);
}

Wenn ein Nullschlüssel während einer put -Operation gefunden wird, wird ihm automatisch der letzte Hashwert 0 ** zugewiesen. Dies bedeutet, dass er das erste Element des zugrunde liegenden Arrays wird.

Dies bedeutet auch, dass, wenn der Schlüssel null ist, keine Hashing-Operation ausgeführt wird. Daher wird die hashCode -API des Schlüssels nicht aufgerufen, wodurch letztendlich eine Nullzeigerausnahme vermieden wird.

Wenn wir während einer put -Operation einen Schlüssel verwenden, der bereits zuvor zum Speichern eines Werts verwendet wurde, wird der vorherige mit dem Schlüssel verknüpfte Wert zurückgegeben:

@Test
public void givenExistingKey__whenPutReturnsPrevValue__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("key1", "val1");

    String rtnVal = map.put("key1", "val2");

    assertEquals("val1", rtnVal);
}

Andernfalls wird null zurückgegeben:

@Test
public void givenNewKey__whenPutReturnsNull__thenCorrect() {
    Map<String, String> map = new HashMap<>();

    String rtnVal = map.put("key1", "val1");

    assertNull(rtnVal);
}

Wenn put null zurückgibt, kann dies auch bedeuten, dass der dem Schlüssel zugeordnete vorherige Wert null ist, und nicht unbedingt, dass es sich um eine neue Schlüsselwertzuordnung handelt:

@Test
public void givenNullVal__whenPutReturnsNull__thenCorrect() {
    Map<String, String> map = new HashMap<>();

    String rtnVal = map.put("key1", null);

    assertNull(rtnVal);
}

Die containsKey -API kann verwendet werden, um solche Szenarien zu unterscheiden, wie wir im nächsten Unterabschnitt sehen werden.

3. Die get -API

Um ein Objekt abzurufen, das bereits in der Hash-Map gespeichert ist, müssen wir den Schlüssel kennen, unter dem es gespeichert wurde. Wir rufen die get -API auf und übergeben das Schlüsselobjekt daran:

@Test
public void whenGetWorks__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("key", "val");

    String val = map.get("key");

    assertEquals("val", val);
}

Intern wird das gleiche Hash-Prinzip verwendet. The hashCode () API des Schlüsselobjekts wird aufgerufen, um den ursprünglichen Hashwert zu erhalten:

@Test
public void whenHashCodeIsCalledOnGet__thenCorrect() {
    MyKey key = new MyKey(1);
    Map<MyKey, String> map = new HashMap<>();
    map.put(key, "val");
    map.get(key);
}

Dieses Mal wird die hashCode -API von MyKey zweimal aufgerufen. einmal für put und einmal für get :

Calling hashCode()
Calling hashCode()

Dieser Wert wird dann durch Aufrufen der internen hash () -API erneut aufgerufen, um den endgültigen Hashwert zu erhalten.

Wie wir im vorherigen Abschnitt gesehen haben, läuft dieser endgültige Hashwert letztendlich auf einen Bucket-Ort oder einen Index des internen Arrays hinaus.

Das an diesem Ort gespeicherte Wertobjekt wird dann abgerufen und an die aufrufende Funktion zurückgegeben.

Wenn der zurückgegebene Wert null ist, kann dies bedeuten, dass das Schlüsselobjekt keinem Wert in der Hash-Map zugeordnet ist:

@Test
public void givenUnmappedKey__whenGetReturnsNull__thenCorrect() {
    Map<String, String> map = new HashMap<>();

    String rtnVal = map.get("key1");

    assertNull(rtnVal);
}

Oder es könnte einfach bedeuten, dass der Schlüssel explizit einer Nullinstanz zugeordnet wurde:

@Test
public void givenNullVal__whenRetrieves__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("key", null);

    String val=map.get("key");

    assertNull(val);
}

Um zwischen den beiden Szenarien zu unterscheiden, können wir die containsKey -API verwenden, an die wir den Schlüssel übergeben. Diese gibt true zurück, wenn und nur dann, wenn für den angegebenen Schlüssel in der Hash-Map eine Zuordnung erstellt wurde:

@Test
public void whenContainsDistinguishesNullValues__thenCorrect() {
    Map<String, String> map = new HashMap<>();

    String val1 = map.get("key");
    boolean valPresent = map.containsKey("key");

    assertNull(val1);
    assertFalse(valPresent);

    map.put("key", null);
    String val = map.get("key");
    valPresent = map.containsKey("key");

    assertNull(val);
    assertTrue(valPresent);
}

In beiden Fällen im obigen Test ist der Rückgabewert des get -API-Aufrufs null, aber wir können unterscheiden, welcher der beiden ist.

4. Sammlungsansichten in HashMap

HashMap bietet drei Ansichten, mit denen wir die Schlüssel und Werte als eine andere Sammlung behandeln können. Wir können einen Satz aller Schlüssel der Karte erhalten:

@Test
public void givenHashMap__whenRetrievesKeyset__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("name", "baeldung");
    map.put("type", "blog");

    Set<String> keys = map.keySet();

    assertEquals(2, keys.size());
    assertTrue(keys.contains("name"));
    assertTrue(keys.contains("type"));
}

Der Satz wird durch die Karte selbst gesichert. Also jede Änderung, die am Set vorgenommen wurde, spiegelt sich in der Karte wider :

@Test
public void givenKeySet__whenChangeReflectsInMap__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("name", "baeldung");
    map.put("type", "blog");

    assertEquals(2, map.size());

    Set<String> keys = map.keySet();
    keys.remove("name");

    assertEquals(1, map.size());
}

Wir können auch eine Sammlungsansicht der Werte erhalten:

@Test
public void givenHashMap__whenRetrievesValues__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("name", "baeldung");
    map.put("type", "blog");

    Collection<String> values = map.values();

    assertEquals(2, values.size());
    assertTrue(values.contains("baeldung"));
    assertTrue(values.contains("blog"));
}

Genau wie beim Schlüsselsatz werden alle in dieser Sammlung vorgenommenen Änderungen in der zugrunde liegenden Karte wiedergegeben.

Zum Schluss erhalten Sie eine Setansicht aller Einträge in der Karte:

@Test
public void givenHashMap__whenRetrievesEntries__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("name", "baeldung");
    map.put("type", "blog");

    Set<Entry<String, String>> entries = map.entrySet();

    assertEquals(2, entries.size());
    for (Entry<String, String> e : entries) {
        String key = e.getKey();
        String val = e.getValue();
        assertTrue(key.equals("name") || key.equals("type"));
        assertTrue(val.equals("baeldung") || val.equals("blog"));
    }
}

Denken Sie daran, dass eine Hash-Map speziell ungeordnete Elemente enthält. Daher wird beim Testen der Schlüssel und Werte der Einträge in der for Each -Schleife eine beliebige Reihenfolge angenommen.

Oft werden Sie die Auflistungsansichten wie im letzten Beispiel in einer Schleife und insbesondere mit ihren Iteratoren verwenden.

Denken Sie daran, dass die Iteratoren für alle obigen Ansichten fail-fast sind.

Wenn eine strukturelle Änderung an der Karte vorgenommen wird, wird nach dem Erstellen des Iterators eine Ausnahme für gleichzeitige Änderungen ausgelöst:

@Test(expected = ConcurrentModificationException.class)
public void givenIterator__whenFailsFastOnModification__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("name", "baeldung");
    map.put("type", "blog");

    Set<String> keys = map.keySet();
    Iterator<String> it = keys.iterator();
    map.remove("type");
    while (it.hasNext()) {
        String key = it.next();
    }
}

Die einzige erlaubte strukturelle Änderung ist eine remove -Operation, die vom Iterator selbst ausgeführt wird:

public void givenIterator__whenRemoveWorks__thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("name", "baeldung");
    map.put("type", "blog");

    Set<String> keys = map.keySet();
    Iterator<String> it = keys.iterator();

    while (it.hasNext()) {
        it.next();
        it.remove();
    }

    assertEquals(0, map.size());
}

Das Letzte, was Sie sich über diese Sammlungsansichten merken sollten, ist die Leistung von Iterationen. Dies ist der Punkt, an dem eine Hash-Map im Vergleich zu den mit ihren Gegenstücken verknüpften Hash-Maps und Tree-Maps sehr schlecht ist.

Iteration über eine Hash-Map geschieht im schlimmsten Fall O (n) , wobei n die Summe seiner Kapazität und der Anzahl der Einträge ist.

5. HashMap-Leistung

Die Leistung einer Hash-Map wird von zwei Parametern beeinflusst: Initial Capacity und Load Factor . Die Kapazität ist die Anzahl der Buckets oder die zugrunde liegende Arraylänge und die Anfangskapazität ist einfach die Kapazität während der Erstellung.

Kurz gesagt, der Lastfaktor oder LF ist ein Maß dafür, wie voll die Hash-Map sein soll, nachdem einige Werte hinzugefügt wurden, bevor ihre Größe geändert wird.

Die anfängliche Standardkapazität ist 16 und der Standardlastfaktor ist 0.75 .

Wir können eine Hash-Map mit benutzerdefinierten Werten für Anfangskapazität und LF erstellen:

Map<String,String> hashMapWithCapacity=new HashMap<>(32);
Map<String,String> hashMapWithCapacityAndLF=new HashMap<>(32, 0.5f);

Die vom Java-Team festgelegten Standardwerte sind für die meisten Fälle gut optimiert. Wenn Sie jedoch Ihre eigenen Werte verwenden müssen, was sehr in Ordnung ist, müssen Sie die Auswirkungen auf die Leistung verstehen, damit Sie wissen, was Sie tun.

Wenn die Anzahl der Hash-Map-Einträge das Produkt aus LF und Kapazität übersteigt, tritt rehashing auf, dh ein anderes internes Array wird mit der doppelten Größe des ursprünglichen Arrays erstellt und alle Einträge werden an neue Speicherorte im neuen Array verschoben. .

Eine geringe Anfangskapazität reduziert die Platzkosten, erhöht jedoch die Häufigkeit des Nachwaschens . Das Nachwaschen ist offensichtlich ein sehr teurer Prozess. Wenn Sie also viele Einträge erwarten, sollten Sie in der Regel eine beträchtlich hohe Anfangskapazität einstellen.

Wenn Sie auf der anderen Seite die anfängliche Kapazität zu hoch einstellen, zahlen Sie die Kosten in Iterationszeit. Wie wir im vorherigen Abschnitt gesehen haben.

Eine hohe Anfangskapazität ist also gut für eine große Anzahl von Einträgen, gekoppelt mit einer geringen bis keiner Iteration ** .

Eine geringe Anfangskapazität eignet sich für wenige Einträge mit viel Wiederholung .

6. Kollisionen in der HashMap

Eine Kollision, oder genauer eine Hashcode-Kollision in einer HashMap , ist eine Situation, in der zwei oder mehr Schlüsselobjekte denselben endgültigen Hashwert erzeugen und daher auf dieselbe Bucket-Position oder denselben Array-Index zeigen.

Dieses Szenario kann auftreten, weil gemäß dem equals - und hashCode -Vertrag zwei ungleiche Objekte in Java denselben Hashcode haben können.

Dies kann auch aufgrund der endlichen Größe des zugrunde liegenden Arrays geschehen, d. H. Vor der Größenänderung. Je kleiner dieses Array ist, desto höher ist die Wahrscheinlichkeit einer Kollision.

Allerdings ist es erwähnenswert, dass Java eine Hash-Code-Kollisionsauflösungstechnik implementiert, die wir anhand eines Beispiels sehen werden.

  • Beachten Sie, dass der Hashwert des Schlüssels bestimmt, in welchem ​​Bucket das Objekt gespeichert wird. Wenn also die Hashcodes von zwei Schlüsseln kollidieren, werden ihre Einträge immer noch in demselben Bucket gespeichert. **

Standardmäßig verwendet die Implementierung eine verknüpfte Liste als Bucket-Implementierung.

Die anfänglich konstante Zeit O (1) put und get wird im Fall einer Kollision in der linearen Zeit O (n) auftreten. Dies liegt daran, dass nach dem Auffinden der Bucket-Position mit dem endgültigen Hashwert jeder Schlüssel an dieser Position mit dem bereitgestellten Schlüsselobjekt mithilfe der equals -API verglichen wird.

Um dieses Kollisionsauflösungsverfahren zu simulieren, ändern wir unser früheres Schlüsselobjekt ein wenig:

public class MyKey {
    private String name;
    private int id;

    public MyKey(int id, String name) {
        this.id = id;
        this.name = name;
    }

   //standard getters and setters

    @Override
    public int hashCode() {
        System.out.println("Calling hashCode()");
        return id;
    }

   //toString override for pretty logging

    @Override
    public boolean equals(Object obj) {
        System.out.println("Calling equals() for key: " + obj);
       //generated implementation
    }

}

Beachten Sie, wie wir das id -Attribut einfach als Hash-Code zurückgeben - und somit eine Kollision erzwingen.

Beachten Sie außerdem, dass wir in unseren equals - und hashCode -Implementierungen Protokollanweisungen hinzugefügt haben, sodass wir genau wissen, wann die Logik aufgerufen wird.

Lassen Sie uns jetzt einige Objekte speichern und abrufen, die irgendwann kollidieren:

@Test
public void whenCallsEqualsOnCollision__thenCorrect() {
    HashMap<MyKey, String> map = new HashMap<>();
    MyKey k1 = new MyKey(1, "firstKey");
    MyKey k2 = new MyKey(2, "secondKey");
    MyKey k3 = new MyKey(2, "thirdKey");

    System.out.println("storing value for k1");
    map.put(k1, "firstValue");
    System.out.println("storing value for k2");
    map.put(k2, "secondValue");
    System.out.println("storing value for k3");
    map.put(k3, "thirdValue");

    System.out.println("retrieving value for k1");
    String v1 = map.get(k1);
    System.out.println("retrieving value for k2");
    String v2 = map.get(k2);
    System.out.println("retrieving value for k3");
    String v3 = map.get(k3);

    assertEquals("firstValue", v1);
    assertEquals("secondValue", v2);
    assertEquals("thirdValue", v3);
}

Im obigen Test erstellen wir drei verschiedene Schlüssel - einer hat eine eindeutige id und die anderen beiden haben dieselbe id . Da wir id als anfänglichen Hashwert verwenden, wird es beim Speichern und Abrufen von Daten mit diesen Schlüsseln definitiv zu einer Kollision kommen.

Darüber hinaus erwarten wir dank der zuvor beschriebenen Kollisionsauflösungsmethode, dass jeder unserer gespeicherten Werte korrekt abgerufen wird, daher die Aussagen in den letzten drei Zeilen.

Wenn der Test ausgeführt wird, sollte er bestanden werden. Dies zeigt an, dass Kollisionen behoben wurden, und wir werden die erstellte Protokollierung verwenden, um zu bestätigen, dass die Kollisionen tatsächlich aufgetreten sind:

storing value for k1
Calling hashCode()
storing value for k2
Calling hashCode()
storing value for k3
Calling hashCode()
Calling equals() for key: MyKey[name=secondKey, id=2]retrieving value for k1
Calling hashCode()
retrieving value for k2
Calling hashCode()
retrieving value for k3
Calling hashCode()
Calling equals() for key: MyKey[name=secondKey, id=2]----

Beachten Sie, dass während Speicheroperationen __k1__ und __k2__ erfolgreich ihren Werten zugeordnet wurden und nur der Hashcode verwendet wurde.

Die Speicherung von __k3__ war jedoch nicht so einfach. Das System erkannte, dass sein Speicherplatz bereits eine Zuordnung für __k2__ enthielt. Daher wurde der __equals__-Vergleich verwendet, um sie zu unterscheiden, und es wurde eine verknüpfte Liste erstellt, die beide Zuordnungen enthält.

Jede andere nachfolgende Zuordnung, deren Schlüssel auf dieselbe Bucket-Position angewendet wird, folgt derselben Route und ersetzt einen der Knoten in der verknüpften Liste oder wird dem Kopf der Liste hinzugefügt, wenn __equals__ Comparison für alle vorhandenen Knoten den Wert false ergibt.

Während des Abrufs wurden __k3__ und __k2__ gleichwertig verglichen, um den richtigen Schlüssel zu identifizieren, dessen Wert abgerufen werden soll.

Abschließend werden in Java 8 die verknüpften Listen dynamisch durch symmetrische binäre Suchbäume in der Kollisionsauflösung ersetzt, nachdem die Anzahl der Kollisionen an einem bestimmten Speicherort einen bestimmten Schwellenwert überschreitet.

Diese Änderung bietet eine Leistungssteigerung, da im Falle einer Kollision das Speichern und Abrufen in __O (log n) erfolgt.

Dieser Abschnitt ist **  in technischen Interviews **  sehr häufig, insbesondere nach den grundlegenden Fragen zum Speichern und Abrufen.

===  **  7. Fazit**

In diesem Artikel haben wir die __HashMap-Implementierung der Java-Schnittstelle __Map__ untersucht.

Den vollständigen Quellcode für alle in diesem Artikel verwendeten Beispiele finden Sie im https://github.com/eugenp/tutorials/tree/master/java-collections-maps[GitHub-Projekt.