Inside HashMap, and why iterators fail fast
Buckets, hash spreading, resizing and treeified bins — then the ConcurrentModificationException every Java developer meets once.
After this lesson you can
- Describe how HashMap turns a hash code into a bucket and when it resizes
- Explain why a bad hashCode costs speed but never correctness
- Remove elements while iterating without a ConcurrentModificationException
- Say why fail-fast is a best-effort check, not a guarantee
"How does a HashMap work?" is one of the most common Java interview
questions. The description below is OpenJDK's implementation — the
HashMap contract promises the behaviour, not these numbers, but this
is the answer interviewers expect.
Buckets
A HashMap is an array of bins (buckets). To place a key it:
- calls
key.hashCode()and spreads it:h ^ (h >>> 16), mixing the high 16 bits into the low ones; - picks the bin
(capacity - 1) & spread. Capacity is always a power of two, so this keeps the low bits — which is why the spreading step exists: without it, hash codes that differ only in their high bits would all collide; - walks the bin, comparing the stored hash first and calling
equalsonly when the hashes match. A bin is a linked list. When one bin would hold more than 8 entries, it is converted to a red-black tree if the table already has at least 64 bins; a smaller table is resized instead. A tree bin finds a key in O(log n) when the keys differ in hash code or implementComparable; keys with identical hashes and no ordering can still need a scan of the bin.
The default capacity is 16 and the load factor 0.75, so the threshold
is 12. When an insertion takes the size above the threshold, the
table doubles and every entry is redistributed — which is also why
iteration order can change after a few more puts.
A terrible hashCode therefore never breaks a map; it only makes it
slow:
import java.util.*; public class Solution { record SameHash(int id) { @Override public int hashCode() { return 42; } // legal, and awful } public static String describe() { Map<SameHash, String> map = new HashMap<>(); for (int i = 0; i < 2000; i++) map.put(new SameHash(i), "v" + i); map.put(new SameHash(7), "replaced"); return "size=" + map.size() + " get(1234)=" + map.get(new SameHash(1234)) + " get(7)=" + map.get(new SameHash(7)) + " get(5000)=" + map.get(new SameHash(5000)); }}Fail-fast iterators
Every structural change — adding or removing an element, but not set
on a list position or a put that only replaces an existing key's
value — increments the collection's modCount. An iterator records
that count when it is created and checks it on each next(); if the
collection was changed by anyone other than the iterator itself, it
throws ConcurrentModificationException. Despite the name, this needs
no second thread: removing from a list inside a for-each over it is
enough.
import java.util.*; public class Solution { public static List<String> describe() { List<String> out = new ArrayList<>(); List<String> four = new ArrayList<>(List.of("a", "b", "c", "d")); try { for (String s : four) { if (s.equals("b")) four.remove(s); } out.add("no exception"); } catch (ConcurrentModificationException e) { out.add("remove b from [a, b, c, d]: ConcurrentModificationException"); } List<String> three = new ArrayList<>(List.of("a", "b", "c")); List<String> visited = new ArrayList<>(); for (String s : three) { visited.add(s); if (s.equals("b")) three.remove(s); // next-to-last element } out.add("remove b from [a, b, c]: no exception, visited=" + visited + " left=" + three); List<String> safe = new ArrayList<>(List.of("a", "b", "c", "d")); safe.removeIf(s -> s.equals("b") || s.equals("c")); out.add("removeIf: " + safe); return out; }}Fail-fast is best effort. In the second case above, removing the
next-to-last element makes hasNext() return false before next()
ever checks the count, so the loop simply stops early and skips an
element without any exception. Never rely on the exception to detect a
bug. The safe ways to remove while iterating are Iterator.remove(),
removeIf, or collecting what to remove and removing it afterwards.
For concurrent access, ConcurrentHashMap never throws this exception:
its iterators are weakly consistent — they may or may not reflect
changes made after they were created. It also rejects null keys and
values, because get returning null must unambiguously mean "absent".
Common mistakes
list.remove(x)insidefor (T x : list)— aConcurrentModificationException, or, worse, a silently skipped element.- Treating
HashMapiteration order as stable. It depends on the hash codes, the capacity and the insertion history, and a resize reshuffles it. - Sizing a map that will hold a known 1,000 entries with
new HashMap<>(1000): the argument is a capacity, not an entry count, so it still resizes once. Java 19 addedHashMap.newHashMap(1000), which sizes for that many entries.
Exercise · Pro
Simulate where HashMap files each key
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade