Le Java HashMap sous le capot

1. Vue d’ensemble

Dans cet article, nous allons explorer l’implémentation la plus populaire de l’interface Map à partir de Java Collections Framework.

Avant de commencer l’implémentation, il est important de souligner que les interfaces primaires List et Set de collecte étendent Collection mais pas Map .

Autrement dit, HashMap stocke les valeurs par clé et fournit des API permettant d’ajouter, de récupérer et de manipuler des données stockées de différentes manières. L’implémentation est basée sur les principes d’une table de hachage , ce qui peut sembler un peu complexe au début, mais est en réalité très facile à comprendre.

Les paires clé-valeur sont stockées dans ce que nous appelons des compartiments qui forment ensemble ce qu’on appelle une table, qui est en réalité un tableau interne.

Une fois que nous connaissons la clé sous laquelle un objet est stocké ou doit être stocké, les opérations de stockage et de récupération ont lieu à temps constant , O (1) dans une carte de hachage bien dimensionnée.

Pour comprendre comment les cartes de hachage fonctionnent sous le capot, il est nécessaire de comprendre le mécanisme de stockage et de récupération utilisé par le HashMap.

Enfin, les questions relatives à HashMap sont assez courantes dans les entretiens . Il s’agit donc d’un moyen solide de préparer un entretien ou de s’y préparer.

2. L’API put ()

Pour stocker une valeur dans une carte de hachage, nous appelons l’API put qui prend deux paramètres; une clé et la valeur correspondante:

V put(K key, V value);

Lorsqu’une valeur est ajoutée à la carte sous une clé, l’API hashCode () de l’objet clé est appelée pour extraire ce que l’on appelle la valeur de hachage initiale.

Pour voir cela en action, créons un objet qui agira comme une clé.

Nous allons créer un seul attribut à utiliser comme code de hachage pour simuler la première phase du hachage:

public class MyKey {
    private int id;

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

   //constructor, setters and getters
}

Nous pouvons maintenant utiliser cet objet pour mapper une valeur dans la carte de hachage:

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

Il ne se passe pas grand chose dans le code ci-dessus, mais faites attention à la sortie de la console. En effet, la méthode hashCode est invoquée:

Calling hashCode()

Ensuite, l’API hash () de la carte de hachage est appelée en interne pour calculer la valeur de hachage finale à l’aide de la valeur de hachage initiale.

Cette valeur de hachage finale se réduit finalement à un index dans le tableau interne ou à ce que nous appelons un emplacement de compartiment.

La fonction hash de HashMap ressemble à ceci:

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

Ce que nous devrions noter ici, c’est uniquement l’utilisation du code de hachage de l’objet clé pour calculer une valeur de hachage finale.

Dans la fonction put , la valeur de hachage finale est utilisée comme ceci:

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

Notez qu’une fonction interne putVal est appelée et reçoit la valeur de hachage finale comme premier paramètre.

On peut se demander pourquoi la clé est à nouveau utilisée dans cette fonction puisque nous l’avons déjà utilisée pour calculer la valeur de hachage.

La raison en est que les cartes de hachage stockent à la fois la clé et la valeur dans l’emplacement du compartiment en tant qu’objet Map.Entry .

Comme indiqué précédemment, toutes les interfaces du cadre de collections Java étendent l’interface Collection , contrairement à Map . Comparez la déclaration de l’interface Map que nous avons vue précédemment avec celle de l’interface Set :

public interface Set<E> extends Collection<E>

La raison en est que les cartes ne stockent pas exactement des éléments uniques comme le font d’autres collections, mais plutôt une collection de paires clé-valeur.

Ainsi, les méthodes génériques de l’interface Collection telles que add , toArray n’ont pas de sens quand il s’agit de Map .

Le concept que nous avons présenté dans les trois derniers paragraphes constitue l’une des questions d’entretien les plus populaires de Java Collections Framework.

Donc, ça vaut la peine de comprendre.

Un attribut spécial avec la carte de hachage est qu’il accepte les valeurs null et les clés NULL:

@Test
public void givenNullKeyAndVal__whenAccepts__thenCorrect(){
    Map<String, String> map = new HashMap<>();
    map.put(null, null);
}
  • Lorsqu’une clé nulle est rencontrée lors d’une opération put , une valeur de hachage finale de 0 ** lui est automatiquement attribuée, ce qui signifie qu’elle devient le premier élément du tableau sous-jacent.

Cela signifie également que lorsque la clé est null, il n’y a pas d’opération de hachage et, par conséquent, l’API hashCode de la clé n’est pas appelée, ce qui évite finalement une exception de pointeur null.

Lors d’une opération put , lorsque nous utilisons une clé déjà utilisée précédemment pour stocker une valeur, la valeur précédente associée à la clé est renvoyée:

@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);
}

sinon, il retourne null:

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

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

    assertNull(rtnVal);
}

Lorsque put renvoie null, cela peut également signifier que la valeur précédente associée à la clé est null, mais pas nécessairement qu’il s’agit d’un nouveau mappage clé-valeur:

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

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

    assertNull(rtnVal);
}

L’API containsKey peut être utilisée pour distinguer différents scénarios, comme nous le verrons dans la sous-section suivante.

3. L’API get

Pour récupérer un objet déjà stocké dans la carte de hachage, nous devons connaître la clé sous laquelle il a été stocké. Nous appelons l’API get et lui passons l’objet clé:

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

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

    assertEquals("val", val);
}

En interne, le même principe de hachage est utilisé. Le hashCode () API de l’objet clé est appelé pour obtenir la valeur de hachage initiale:

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

Cette fois, l’API hashCode de MyKey est appelée deux fois; une fois pour put et une fois pour get :

Calling hashCode()
Calling hashCode()

Cette valeur est ensuite réorganisée en appelant l’API hash () interne pour obtenir la valeur de hachage finale.

Comme nous l’avons vu dans la section précédente, cette valeur de hachage finale se réduit finalement à un emplacement de compartiment ou à un index du tableau interne.

L’objet de valeur stocké à cet emplacement est ensuite récupéré et renvoyé à la fonction appelante.

Lorsque la valeur renvoyée est null, cela peut signifier que l’objet clé n’est associé à aucune valeur de la carte de hachage:

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

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

    assertNull(rtnVal);
}

Ou cela pourrait simplement signifier que la clé était explicitement mappée sur une instance nulle:

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

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

    assertNull(val);
}

Pour distinguer les deux scénarios, nous pouvons utiliser l’API containsKey , à laquelle nous passons la clé. Elle renvoie true si et seulement si un mappage a été créé pour la clé spécifiée dans la table de hachage:

@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);
}

Dans les deux cas du test ci-dessus, la valeur de retour de l’appel de l’API get est null, mais nous pouvons distinguer lequel est lequel.

4. Vues de collection dans HashMap

HashMap offre trois vues qui nous permettent de traiter ses clés et ses valeurs comme une autre collection. Nous pouvons obtenir un ensemble de toutes les clés de la carte :

@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"));
}

L’ensemble est soutenu par la carte elle-même. Donc toute modification apportée à l’ensemble est reflétée dans la carte :

@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());
}

Nous pouvons également obtenir une vue de collection des valeurs :

@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"));
}

Tout comme l’ensemble des clés, les modifications apportées à cette collection seront reflétées dans la carte sous-jacente .

Enfin, nous pouvons obtenir une vue d’ensemble de toutes les entrées de la carte:

@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"));
    }
}

N’oubliez pas qu’une carte de hachage contient spécifiquement des éléments non ordonnés. Par conséquent, nous supposons que tout ordre soit testé lors du test des clés et des valeurs des entrées de la boucle for each .

Plusieurs fois, vous utiliserez les vues de collection dans une boucle comme dans le dernier exemple, et plus spécifiquement en utilisant leurs itérateurs.

Rappelez-vous simplement que les itérateurs de toutes les vues ci-dessus sont fail-fast .

Si une modification structurelle est effectuée sur la carte, une exception de modification simultanée sera émise après la création de l’itérateur:

@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();
    }
}

La seule modification structurelle autorisée est une opération remove effectuée via l’itérateur lui-même:

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());
}

La dernière chose à retenir de ces vues de collection est la performance des itérations. C’est là qu’une carte de hachage fonctionne assez mal par rapport à la carte de hachage et à la carte arborescente associées.

L’itération sur une carte de hachage se produit au pire des cas O (n) où n est la somme de sa capacité et du nombre d’entrées.

5. HashMap Performance

Les performances d’une carte de hachage sont affectées par deux paramètres: Capacité initiale et Facteur de charge . La capacité correspond au nombre de compartiments ou à la longueur du tableau sous-jacent et la capacité initiale correspond simplement à la capacité lors de la création.

En bref, le facteur de charge ou LF est une mesure du niveau de remplissage de la carte de hachage après l’ajout de certaines valeurs avant son redimensionnement.

La capacité initiale par défaut est 16 et le facteur de charge par défaut est 0,75 .

Nous pouvons créer une carte de hachage avec des valeurs personnalisées pour la capacité initiale et le LF:

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

Les valeurs par défaut définies par l’équipe Java sont bien optimisées dans la plupart des cas. Cependant, si vous devez utiliser vos propres valeurs, ce qui est très bien, vous devez comprendre les implications en termes de performances pour que vous sachiez ce que vous faites.

Lorsque le nombre d’entrées de la table de hachage dépasse le produit de la bande passante et de la capacité, alors rehashing se produit, c’est-à-dire un autre tableau interne est créé avec une taille deux fois supérieure à celle du groupe initial et toutes les entrées sont déplacées vers de nouveaux emplacements de compartiment dans le nouveau tableau .

Une faible capacité initiale réduit les coûts d’espace mais augmente la fréquence de redistribution . Rehase est évidemment un processus très coûteux. Ainsi, en règle générale, si vous prévoyez de nombreuses entrées, vous devez définir une capacité initiale considérablement élevée.

D’un autre côté, si vous définissez une capacité initiale trop élevée, vous devrez payer le coût en temps d’itération. Comme nous l’avons vu dans la section précédente.

Ainsi, une capacité initiale élevée est bonne pour un grand nombre d’entrées couplées avec peu ou pas d’itération .

Une faible capacité initiale convient à quelques entrées avec beaucoup d’itérations .

6. Collisions dans le HashMap

Une collision, ou plus précisément une collision de code de hachage dans un HashMap , est une situation où deux objets clés ou plus produisent la même valeur de hachage finale et par conséquent pointent vers le même emplacement de compartiment ou le même indice de tableau.

Ce scénario peut se produire car, selon les contrats equals et hashCode , deux objets inégaux en Java peuvent avoir le même code de hachage .

Cela peut également arriver à cause de la taille finie du tableau sous-jacent, c’est-à-dire avant le redimensionnement. Plus ce tableau est petit, plus les risques de collision sont élevés.

Cela dit, il convient de mentionner que Java implémente une technique de résolution de collision de code de hachage que nous verrons à l’aide d’un exemple.

  • N’oubliez pas que c’est la valeur de hachage de la clé qui détermine le compartiment dans lequel l’objet sera stocké. Ainsi, si les codes de hachage de deux clés se rencontrent, leurs entrées seront toujours stockées dans le même compartiment. **

Et par défaut, l’implémentation utilise une liste liée en tant qu’implémentation de compartiment.

Les opérations initialement constantes O (1) put et get auront lieu en temps linéaire O (n) en cas de collision. En effet, après avoir trouvé l’emplacement du compartiment avec la valeur de hachage finale, chacune des clés de cet emplacement sera comparée à l’objet clé fourni à l’aide de l’API equals__.

Pour simuler cette technique de résolution de collision, modifions un peu notre objet clé précédent:

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
    }

}

Notez que nous retournons simplement l’attribut id en tant que code de hachage - et forçons ainsi une collision.

Notez également que nous avons ajouté des instructions de log dans nos implémentations equals et hashCode - afin de savoir exactement quand la logique est appelée.

Continuons maintenant pour stocker et récupérer des objets qui entrent en collision:

@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);
}

Dans le test ci-dessus, nous créons trois clés différentes: une avec un id unique et les deux autres avec le même id. Puisque nous utilisons id comme valeur de hachage initiale , il y aura certainement une collision à la fois pendant le stockage et la récupération des données avec ces clés.

De plus, grâce à la technique de résolution de collision que nous avons vue précédemment, nous nous attendons à ce que chacune de nos valeurs stockées soit correctement récupérée, d’où les assertions des trois dernières lignes.

Lorsque nous exécutons le test, il devrait passer, indiquant que les collisions ont été résolues et nous utiliserons la journalisation produite pour confirmer que les collisions ont bien eu lieu:

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]----

Notez que lors des opérations de stockage, __k1__ et __k2__ ont été mappés avec succès sur leurs valeurs à l'aide du code de hachage uniquement.

Cependant, le stockage de __k3__ n’était pas si simple, le système a détecté que son emplacement dans le compartiment contenait déjà un mappage pour __k2__. Par conséquent, la comparaison __equals__ a été utilisée pour les distinguer et une liste chaînée a été créée pour contenir les deux mappages.

Tout autre mappage ultérieur dont les clés sont au même emplacement de compartiment suivra le même itinéraire et remplacera l'un des nœuds de la liste liée ou sera ajouté à l'en-tête de la liste si __equals__ Comparison renvoie false pour tous les nœuds existants.

De même, lors de la récupération, __k3__ et __k2__ ont été comparés à __equals__ pour identifier la clé correcte dont la valeur doit être récupérée.

Pour terminer, à partir de Java 8, les listes chaînées sont remplacées de manière dynamique par des arbres de recherche binaires équilibrés dans la résolution des collisions après que le nombre de collisions dans un emplacement de compartiment donné a dépassé un certain seuil.

Cette modification améliore les performances car, en cas de collision, le stockage et la récupération ont lieu dans __O (log n) .__

Cette section est **  très courante dans les entretiens techniques ** , surtout après les questions de base sur le stockage et la récupération

===  **  7. Conclusion**

Dans cet article, nous avons exploré l'implémentation __HashMap__ de l'interface Java __Map__.

Le code source complet de tous les exemples utilisés dans cet article se trouve dans le projet https://github.com/eugenp/tutorials/tree/master/java-collections-maps[GitHub].