Ein tiefer Blick in Java’s HashMap-Fallen – visuell demonstriert mit Vaadin Flow
Die stille Gefahr in der Standardbibliothek
Die Verwendung von HashMap und HashSet gehört zum täglich Brot in der Java-Entwicklung. Diese Datenstrukturen bieten exzellente Performance für Lookup- und Einfügeoperationen – solange ihre fundamentalen Annahmen erfüllt bleiben. Eine davon lautet: Der hashCode() eines Schlüssels bleibt stabil. Was aber, wenn das nicht der Fall ist?
Genau hier lauert eine der subtilsten und zugleich gefährlichsten Fallen der Java-Standardbibliothek: mutable Schlüsselobjekte. In diesem Artikel zeigen wir nicht nur, warum diese Konstellation problematisch ist, sondern führen das Phänomen interaktiv mit Vaadin Flow vor. Leserinnen und Leser erfahren, wie die HashMap intern funktioniert, warum equals() allein nicht genügt, und wie man mit modernen Sprachmitteln wie record robuste, unveränderliche Schlüssel erzeugt.
Table of Contents
- Die stille Gefahr in der Standardbibliothek
- Das Grundproblem: Identität, Hashcodes und Lookup
- Der klassische Fehler: hashCode() hängt von veränderlichen Attributen ab
- Sicherheitskritische Folgen: Wenn Konsistenzverlust zur Angriffsfläche wird
- Interaktive Demo mit Vaadin Flow
- HashSet
- Strategien zur Vermeidung
- Warum andere Map-Implementierungen nicht betroffen sind
- Fazit: Der Preis von Bequemlichkeit
- Demo-Quelltext zum selber ausprobieren
Das Grundproblem: Identität, Hashcodes und Lookup
Intern basiert jede HashMap auf einem Array von Buckets, wobei die Position eines Eintrags durch den Hashcode des Schlüssels bestimmt wird. Der Ablauf beim Einfügen (put(K key, V value)) sieht folgendermaßen aus: Zuerst ruft die Map key.hashCode() auf und berechnet aus diesem Wert – unter Anwendung eines internen Spreadings – den Index im Bucket-Array. Falls an dieser Position bereits Einträge vorhanden sind (etwa durch Hash-Kollisionen), erfolgt innerhalb des Buckets eine lineare Suche oder – bei entsprechend vielen Kollisionen – also wenn mehr als acht Einträge in einem Bucket liegen und das zugrunde liegende Array eine gewisse Größe überschreitet (Standardwert: 64) – erfolgt intern eine Transformation des Buckets von einer einfachen verketteten Liste in eine balancierte Binärbaumstruktur (genauer: ein Rot-Schwarz-Baum). Diese Umwandlung verbessert die Lookup-Performance von linearer Zeit O(n) auf logarithmische Zeit O(log n). Die Entscheidung für diese Schwellenwerte basiert auf der Beobachtung, dass Kollisionen in gut gewählten Hashfunktionen selten sind und der Overhead einer Baumstruktur nur bei hoher Eintragsdichte lohnt. wobei equals() verwendet wird, um den passenden Schlüssel zu identifizieren oder einen neuen Eintrag anzulegen.
Beim Zugriff (get(Object key)) geschieht derselbe Ablauf: Der Hashcode des übergebenen Schlüssels wird erneut berechnet, der entsprechende Bucket bestimmt und dort nach einem übereinstimmenden Schlüssel per equals() gesucht. Sollte sich der Hashcode des Objekts jedoch nach dem ursprünglichen put() verändert haben, wird ein anderer Bucket adressiert – der ursprüngliche Eintrag bleibt dann unsichtbar.
Das Entfernen (remove(Object key)) unterliegt denselben Mechanismen: Die Map sucht per hashCode() den korrekten Bucket und vergleicht dann über equals() die Schlüssel. Eine Konsistenz der hash-relevanten Daten über die gesamte Lebenszeit des Schlüssels ist daher essenziell. Nur wenn diese garantiert ist, kann die HashMap ihre Effizienz und Korrektheit gewährleisten.
Das bedeutet: Schon der Zugriff auf den richtigen Bucket hängt ausschließlich vom Ergebnis der Methode hashCode() ab. Dieser Wert wird unmittelbar beim Zugriff berechnet, mit einer internen Transformation kombiniert (etwa durch bitweises Verschieben und XOR-Verknüpfungen, um eine gleichmäßigere Verteilung über das Bucket-Array zu erreichen) und dient dann zur Indexierung des Bucket-Arrays. Ändert sich der Rückgabewert von hashCode() nach dem Einfügen eines Objekts in die Map – beispielsweise durch eine Mutation eines Attributs, das in die Berechnung einfließt – so zeigt der neu berechnete Index auf einen anderen Bucket. Dort existiert das gesuchte Objekt jedoch nicht, weshalb die Map den Eintrag nicht mehr finden kann. Dieses Verhalten ist keine Fehlfunktion der HashMap, sondern die direkte Folge eines Verstoßes gegen den fundamentalen Kontrakt: hashCode() muss bei gleichem objektinternem Zustand konsistent bleiben. Wer diesen Kontrakt verletzt, missbraucht die HashMap auf eine Weise, für die sie nicht ausgelegt ist – mit potenziell schwerwiegenden Konsequenzen für Datenintegrität und Programmlogik.
Der klassische Fehler: hashCode() hängt von veränderlichen Attributen ab
Nehmen wir folgendes Beispiel: Eine Instanz der Klasse Person mit den Werten name = “Alice” und id = 42 wird erstellt und als Schlüssel in eine HashMap eingefügt. Zum Zeitpunkt des Einfügens berechnet die Map den hashCode() basierend auf dem aktuellen Zustand des Objekts – also Objects.hash(“Alice”, 42) – und speichert den Eintrag im entsprechenden Bucket. Danach wird das Feld name des Objekts verändert, z. B. auf den Wert “Bob” gesetzt. Dadurch ändert sich der Rückgabewert von hashCode(), etwa zu Objects.hash(“Bob”, 42), was einen anderen Bucket adressiert.
Wird nun versucht, mit demselben Objekt erneut auf die Map zuzugreifen – map.get(originalPerson) – schlägt die Operation fehl. Denn die Map berechnet aus dem aktuellen Hashcode einen neuen Index, schaut in den entsprechenden Bucket und findet dort keinen passenden Eintrag. Das ursprüngliche Objekt befindet sich technisch weiterhin in der Map, ist jedoch unauffindbar.
Noch irreführender wird die Situation, wenn man über entrySet() iteriert: Dort ist der Eintrag sichtbar, da die Iteration unabhängig vom Hashcode direkt über die internen Verkettungen erfolgt. Auch ein Vergleich per equals() mit einem neu erstellten, identischen Objekt funktioniert – schließlich basiert equals() typischerweise auf inhaltlicher Gleichheit und nicht auf Speicheradresse oder Hashcode.
Die HashMap wird dadurch de facto inkonsistent: Das Element ist physisch noch im internen Datenarray gespeichert, aber über reguläre Zugriffswege wie get(key) nicht mehr auffindbar. Um diesen Effekt reproduzierbar zu demonstrieren, betrachten wir folgendes Codebeispiel:
import java.util.*;
class Person {
String name;
int id;
Person(String name, int id) {
this.name = name;
this.id = id;
}
@Override
public boolean equals(Object o) {
return o instanceof Person p
&& Objects.equals(name, p.name) && id == p.id;
}
@Override
public int hashCode() {
return Objects.hash(name, id);
}
}
public class MutableHashDemo {
public static void main(String[] args) {
Person p = new Person("Alice", 42);
Map<Person, String> map = new HashMap<>();
map.put(p, "Wert");
System.out.println("Vor Änderung:");
System.out.println("map.get(p): " + map.get(p));
// Mutation des Schlüssels
p.name = "Bob";
System.out.println("Nach Änderung:");
System.out.println("map.get(p): " + map.get(p));
System.out.println("Enthält key via entrySet: "
+ map.entrySet().stream()
.anyMatch(e -> e.getKey().equals(p)));
}
}
Der Demoquelltext befindet sich auf github unter https://github.com/svenruppert/Blog—Core-Java—Mutable-HashMap-Keys-in-Java/blob/main/src/test/java/junit/com/svenruppert/MutableHashCodeDemoTest.java
Dieses Beispiel zeigt, dass map.get(p) nach der Änderung null zurückliefert, obwohl entrySet() den Eintrag noch enthält. Der Grund: hashCode() liefert nach der Mutation einen anderen Wert, sodass der originale Bucket nicht mehr adressiert wird. equals() allein hilft in diesem Fall nicht weiter, da der Lookup schon am falschen Index scheitert.
Sicherheitskritische Folgen: Wenn Konsistenzverlust zur Angriffsfläche wird
Während der Verlust der Referenzierbarkeit in einer HashMap auf den ersten Blick lediglich als technisches Problem erscheint, offenbaren sich bei näherer Betrachtung sicherheitsrelevante Implikationen – insbesondere in Systemen mit Zustands-Caching, Authentifizierung oder Zugriffskontrolle. Wenn ein Objekt als Schlüssel in sicherheitskritischen Maps oder Sets dient – z. B. zur Erkennung aktiver Sessions, Auth-Tokens oder Nutzerrechte – und sich sein Hashcode nachträglich ändert, entsteht ein logischer Fehlerzustand. Das System „sieht“ das Objekt nicht mehr, obwohl es weiterhin vorhanden ist. Dadurch entstehen unbeabsichtigte Zugriffslücken oder, schlimmer noch, unbeaufsichtigte Persistenz von Zuständen.
Ein besonders gefährliches Szenario ergibt sich, wenn mutierbare Schlüssel von außen beeinflusst werden können. Denkbar ist etwa eine Situation, in der ein Angreifer kontrollierte Werte in ein Objekt einschleusen kann, das dann als Schlüssel dient. Sobald dieser Schlüssel verändert wird – etwa durch eine manipulierte API-Nutzung oder eine fehlerhafte Deserialisierung – verschwindet er aus der Zugriffskontrolle, obwohl er technisch noch vorhanden ist. Das Ergebnis: Logischer Zugriff ohne gültige Autorisierung.
In Extremfällen kann dies sogar zu typischen sicherheitskritischen Mustern führen, wie etwa:
- Authorization Bypass: Ein Objekt wird vor der Prüfung verändert, das contains() schlägt fehl, der Zugriff wird fälschlicherweise gewährt.
- Resource Lock Hijack: Ein Lock-Objekt wird per HashSet oder Map verwaltet, kann aber nach Mutation nicht mehr entfernt werden – Deadlock oder Race Condition droht.
- Denial-of-Service durch Hash-Kollisionen: Wenn ein System viele mutierbare Objekte mit kontrolliertem Hashcode speichert (oder mutiert), können gezielt Hash-Kollisionen ausgelöst und Performance-Probleme erzeugt werden.
Ein klassischer BufferOverflow tritt in Java durch die Memory-Sicherheit des JVM-Modells zwar nicht direkt auf – doch die strukturelle Wirkung einer inkonsistenten HashMap ähnelt einem Overflow auf logischer Ebene: Ein Zugriff landet im „falschen Speicherbereich“ (Bucket), das Objekt ist zwar vorhanden, aber funktional unsichtbar. Für Sicherheitsprüfer und Architekten ist dies ein zentraler Punkt: Konsistenzverlust in Hash-basierten Strukturen kann nicht nur zu Fehlverhalten führen, sondern ein potenzieller Einfallspunkt für komplexe Angriffe werden.
Interaktive Demo mit Vaadin Flow
Um das beschriebene Problem greifbar zu machen, empfiehlt sich eine minimalistische Demonstrationsanwendung z.B. mit Vaadin Flow. Ziel der Demo ist es, dem Benutzer zu erlauben, live zu beobachten, wie ein Objekt in einer HashMap nach einer Mutation „unsichtbar“ wird.
Die Applikation besteht aus einer einfachen Vaadin View mit folgenden UI-Elementen:
- Eingabefelder für Name und ID
- Ein Button zum Einfügen eines Objekts in eine HashMap
- Ein Button zum Modifizieren des Namens (und damit des HashCodes)
- Ein Button zum Ausführen von map.get()
- Ein Button zur Iteration über entrySet()
Im Konstruktor der View wird zunächst die grafische Oberfläche aufgebaut. Zwei Eingabefelder – eines für den Namen, eines für die ID – dienen der Interaktion mit dem Person-Objekt.
private final TextField nameField = new TextField("Name");
private final TextField idField = new TextField("ID");
private final TextArea output = new TextArea("Ausgabe");
private final Person mutableKey = new Person("Alice", 42);
private final Map<Person, String> map = new HashMap<>();
Über mehrere Buttons lassen sich gezielt Operationen auf der Map durchführen. Der Button “put(key, value)” fügt das aktuelle mutableKey-Objekt als Schlüssel mit dem Wert “Gespeichert” in die Map ein. Dabei wird genau jenes Objekt verwendet, dessen name und id zu Beginn festgelegt wurden – hier “Alice” und 42.
Button putButton = new Button("put(key, value)", _ -> {
map.put(mutableKey, "Gespeichert");
});
Über den Button “Name ändern” kann der Name des mutableKey-Objekts zur Laufzeit verändert werden. Dies führt zu einem Zustand, bei dem sich der Inhalt des Objekts – und damit auch sein Hashcode – nach dem Einfügen in die Map verändert wird.
Button mutateButton = new Button("Name ändern", _ -> {
mutableKey.name = nameField.getValue();
});
Der “get(key)”-Button demonstriert diesen Effekt: Die Methode map.get(mutableKey) versucht, den Wert unter Verwendung des (veränderten) Objekts als Schlüssel abzurufen.
Button getButton = new Button("get(key)", e -> {
String result = map.get(mutableKey);
output.setValue("Resultat von get(): " + result);
});
Um diesen Effekt zu illustrieren und gleichzeitig einen alternativen Zugriff zu zeigen, wurde der Button “entrySet() durchsuchen” hinzugefügt. Er durchläuft alle Schlüssel-Wert-Paare der Map manuell mittels Stream-API und vergleicht die Schlüssel per equals() – unabhängig von der internen Bucket-Struktur. Dadurch lässt sich ein Eintrag mit dem veränderten Schlüsselobjekt unter Umständen doch noch finden, sofern equals() korrekt implementiert ist und unabhängig vom hashCode funktioniert.
Button iterateButton = new Button("entrySet() durchsuchen", e -> {
String result = map.entrySet().stream()
.filter(entry -> entry.getKey().equals(mutableKey))
.map(Map.Entry::getValue)
.findFirst()
.orElse("Nicht gefunden via equals()");
output.setValue("entrySet(): " + result);
});
Hier nun der vollständige Quelltext der View.
@Route(value = PATH, layout = MainLayout.class)
public class VersionOneView
extends VerticalLayout
implements HasLogger {
public static final String PATH = "versionone";
private final TextField nameField = new TextField("Name");
private final TextField idField = new TextField("ID");
private final TextArea output = new TextArea("Ausgabe");
private final Person mutableKey = new Person("Alice", 42);
private final Map<Person, String> map = new HashMap<>();
public VersionOneView() {
logger().info("Initializing VersionOneView");
nameField.setValue(mutableKey.getName());
idField.setValue(String.valueOf(mutableKey.getId()));
output.setWidth("600px");
Button putButton = new Button("put(key, value)", _ -> {
logger().info("Putting value into map with key: {}", mutableKey);
map.put(mutableKey, "Gespeichert");
output.setValue("Eingefügt: " + mutableKey);
});
Button mutateButton = new Button("Name ändern", _ -> {
logger().info("Changing name from {} to {}",
mutableKey.getName(), nameField.getValue());
mutableKey.setName(nameField.getValue());
output.setValue("Name geändert zu: " + mutableKey.getName());
});
Button getButton = new Button("get(key)", e -> {
logger().info("Getting value for key: {}", mutableKey);
String result = map.get(mutableKey);
logger().info("Get result: {}", result);
output.setValue("Resultat von get(): " + result);
});
Button iterateButton = new Button("entrySet() durchsuchen", e -> {
logger().info("Searching through entrySet for key: {}", mutableKey);
String result = map.entrySet().stream()
.filter(entry -> entry.getKey().equals(mutableKey))
.map(Map.Entry::getValue)
.findFirst()
.orElse("Nicht gefunden via equals()");
logger().info("EntrySet search result: {}", result);
output.setValue("entrySet(): " + result);
});
add(nameField, idField,
putButton, mutateButton,
getButton, iterateButton, output);
logger().info("VersionOneView initialization completed");
}
public class Person {
String name;
int id;
public Person(String name, int id) {
this.name = name;
this.id = id;
}
//SNIPP getter setter
@Override
public boolean equals(Object o) {
return o instanceof Person p
&& Objects.equals(name, p.name)
&& id == p.id;
}
@Override
public int hashCode() {
return Objects.hash(name, id);
}
@Override
public String toString() {
return name + " (" + id + ")";
}
}
Diese kleine, aber wirkungsvolle View erlaubt es jedem Leser, die Auswirkungen veränderlicher Schlüssel direkt zu beobachten.
Der Demoquelltext befindet sich auf github unter https://github.com/svenruppert/Blog—Core-Java—Mutable-HashMap-Keys-in-Java/blob/main/src/main/java/com/svenruppert/flow/views/version01/VersionOneView.java
Nun, verändern wir die View ein wenig und zeigen alle Einträge die aus dem EntrySet kommen an. Dabei werden die HashCodes verglichen und das Ergebnis mit ausgegeben.
Button iterateButton = new Button("entrySet() durchsuchen", _ -> {
logger().info("Searching through entrySet for key: {}", mutableKey);
StringBuilder result = new StringBuilder();
map.forEach((key, value) -> {
boolean isMatch = key.equals(mutableKey);
result.append(String.format("""
Key: %s, Value: %s, HashCode %s Match: %s""",
key,
value,
key.hashCode(),
isMatch ? "Ja" : "Nein"));
result.append("\n");
});
logger().info("EntrySet search result: {}", result);
output.setValue("entrySet():\n"
+ (!result.isEmpty() ? result.toString()
: "Map ist leer"));
});
Nun kann man sehr schön sehen, wie eine Instanz nun mehrfach in der selben HashMap vorhanden ist. Wenn man nun ein wenig darüber nachdenkt, fallen einem einige sehr unschöne Einsatzgebiete ein, mit denen man ein System sogar gezielt angreifen kann. Aber dazu in einem weiteren Blogpost mehr.
HashSet
Da ein HashSet intern nichts anderes ist als eine HashMap, bei der die Schlüssel die tatsächlichen Set-Elemente und die Werte ein konstanter Dummy (wie z. B. PRESENT = new Object()) sind, gelten sämtliche hier beschriebenen Probleme in identischer Weise. Wenn ein Objekt nach dem Einfügen in das Set verändert wird – und diese Veränderung betrifft eines der Attribute, das in die Berechnung von hashCode() eingeht – so wird das Objekt im falschen Bucket gesucht. Die Methode contains() gibt dann false zurück, obwohl das Objekt im Set gespeichert ist. Auch remove() schlägt fehl, da dieselbe Logik wie bei get() greift: die HashMap findet den Bucket anhand des aktuellen Hashcodes und erkennt dort den Schlüssel nicht wieder.
Dieses Verhalten ist besonders kritisch, wenn HashSet zur Zwischenspeicherung sicherheitsrelevanter Informationen verwendet wird, etwa um bereits authentifizierte Benutzer, gültige Token oder temporäre Rechte zu verwalten. Ein ungewollt mutierter Schlüssel kann dazu führen, dass Zugriffe fälschlicherweise abgelehnt oder sogar Sicherheitsprüfungen umgangen werden – ein klassischer Fall von logischer Sicherheitslücke, die kaum auffällt und sich nur schwer debuggen lässt.
In sicherheitskritischen Modulen sollten Set-Elemente daher stets immutable und defensiv konstruiert sein. Das bedeutet konkret: keine veränderbaren Felder, keine öffentliche Modifizierbarkeit und – idealerweise – Konstruktion über record oder Builder mit finalen Attributen.
Set<Person> people = new HashSet<>();
people.add(p); // p.hashCode() ist X
p.name = "Malicious";
System.out.println(people.contains(p)); // false
Strategien zur Vermeidung
Die einfachste und zugleich wirkungsvollste Strategie: Verwende ausschließlich immutable Objekte als Schlüssel. Seit Java 16 sind records dafür das idiomatisch richtige Werkzeug. Sie erzeugen automatisch equals() und hashCode() auf Basis finaler Felder.
record PersonKey(String name, int id) {}
Alternativ – wenn Mutable-Zustand unvermeidlich ist – muss das Objekt vor einer Modifikation aus der Map entfernt und anschließend mit aktualisiertem Hashcode wieder eingefügt werden. Ein gefährlicher Workaround, der in der Praxis selten korrekt implementiert wird.
Warum andere Map-Implementierungen nicht betroffen sind
Das hier beschriebene Problem tritt spezifisch bei HashMap und darauf aufbauenden Strukturen wie HashSet auf, weil der Zugriffspfad über einen berechneten Hashcode verläuft. Andere Map-Implementierungen im JDK – etwa TreeMap, LinkedHashMap oder EnumMap – sind in dieser Hinsicht nicht betroffen oder nur in abgeschwächter Form.
Die TreeMap etwa basiert nicht auf einem Hashcode-basierten Indexing, sondern auf einer sortierten Baumstruktur (Red-Black-Tree). Sie verwendet entweder die natürliche Ordnung der Schlüssel (via Comparable) oder einen bereitgestellten Comparator. Dadurch hängt der Zugriff nicht vom Ergebnis von hashCode() ab, sondern von der stabilen Vergleichbarkeit durch compareTo() oder compare(K1, K2). Eine Veränderung eines Attributs, das in die Vergleichslogik eingeht, kann zwar ebenfalls zu inkonsistentem Verhalten führen, etwa bei get() oder remove() – jedoch ist der Mechanismus transparent und kontrollierbarer, weil die Sortierung über eine klar definierte Vergleichsfunktion erfolgt.
Auch LinkedHashMap, obwohl intern eine HashMap erweitert, ist nicht immun gegen das mutable-Hashcode-Problem, da sie dieselbe Bucket-Logik verwendet. Sie bietet jedoch zusätzlich eine deterministische Iterationsreihenfolge, was bei der Fehlersuche helfen kann, verändert aber nichts am Grundproblem.
Die EnumMap schließlich ist per Design gegen dieses Problem geschützt, da sie ausschließlich enum-Typen als Schlüssel zulässt. Diese sind per Sprachdefinition unveränderlich und besitzen stabile, final vergebene Hashcodes. Somit ist eine Mutation ausgeschlossen und die Map bleibt robust gegenüber dieser Fehlerklasse.
Du solltest daher bewusst abwägen, welche Map-Implementierung im jeweiligen Kontext zum Einsatz kommt – und ob ein stabiler Schlüsselzustand garantiert werden kann oder ob auf alternative Ordnungsmechanismen ausgewichen werden sollte.
Fazit: Der Preis von Bequemlichkeit
In der täglichen Entwicklung wird das Problem oft übersehen, weil es sich nur in spezifischen Situationen manifestiert – etwa nach mehreren Operationen, im Multi-Threading oder bei Integrationen über API-Grenzen hinweg. Doch die Folgen sind gravierend: verlorene Einträge, unerklärliches null, inkonsistenter Zustand.
Wer HashMaps effizient und korrekt einsetzen möchte, sollte ihre internen Regeln respektieren – und insbesondere sicherstellen, dass Schlüsselobjekte sich nicht nachträglich verändern.
Demo-Quelltext zum selber ausprobieren
Der vollständige Quelltext zur Demo-View mit Vaadin Flow ist öffentlich auf GitHub verfügbar. Die Anwendung lässt sich mit einem einfachen mvn jetty:run lokal starten. Die Webanwendung ist dann unter der Adresse http://localhost:8080/ erreichbar.
GitHub: https://github.com/svenruppert/Blog—Core-Java—Mutable-HashMap-Keys-in-Java
Happy Coding
Sven