HashMap, line by line
hash spreading, (n-1) & hash, the resize split, and treeification.
- chapter
- chapter 17.01
- reading time
- 14 min read
a HashMap is a Node[] whose length is always a power of two. a key's bin is (n - 1) & hash, where hash = h ^ (h >>> 16) folds the high bits of hashCode() into the low ones. when it gets 75% full the array doubles, and every chain splits by one bit. a bin that collects 9 nodes becomes a red-black tree, but only once the table has at least 64 bins.
every number in this chapter is a constant in java/util/HashMap.java (jdk 25), or something we ran.
the picture
the HashMap chapter has the friendly version: buckets, collisions, a load factor. this one reads the source. if you've ever been asked "why is the capacity a power of two?" in an interview, everything you need is in about 40 lines of it.
think of a coat check with numbered hooks (the table). your ticket number decides the hook (the hash picks the bin). two coats on one hook get stacked (a chain). when the room gets busy, the staff build a second row of hooks and move each coat at most once, to a hook they can work out from the ticket alone (resize).
next arrow is the whole collision strategy.- table
Node<K,V>[]. null until the firstput(it's allocated lazily, insideresize()). length is always a power of two, default 16.- Node
- four fields:
final int hash,final K key,V value,Node next. 32 bytes each on jdk 25 with default flags (we measured it with a class histogram). - threshold
capacity × loadFactor. default 16 × 0.75 = 12. the put that makessizeexceed it triggers a resize.- modCount
- bumped on every structural change. iterators compare it to fail fast (see fail-fast).
notice the node keeps its hash. it's computed once, on the way in, and never again. that detail pays off twice later: lookups compare the cached int before ever calling equals, and resizing never calls hashCode().
hash(): folding the top half down
the whole method is one line:
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);why bother? because of how the bin is picked. a 16-bin table looks at the low 4 bits of the hash and nothing else. if your keys only differ above bit 4 (and plenty do: whole-number Float keys, ids that are multiples of 65,536, a lazy hashCode()), they all land in the same bin. h >>> 16 slides the top half down, and XOR mixes it into the bits the mask will actually read.
Integer keys that only differ above bit 16: without spreading they all pile into bin 0.same thing, in code you can run:
void main() {
for (int i = 0; i < 4; i++) {
int h = Integer.valueOf(i << 16).hashCode();
int spread = h ^ (h >>> 16);
IO.println(h + ": bin " + (h & 15) + " without spreading, bin " + (spread & 15) + " with");
}
}0: bin 0 without spreading, bin 0 with 65536: bin 0 without spreading, bin 1 with 131072: bin 0 without spreading, bin 2 with 196608: bin 0 without spreading, bin 3 with
the comment above hash() is honest about the tradeoff: it's the cheapest mix that fixes the common systematic cases, and anything nastier is left for the trees (below) to absorb. one shift, one XOR. it runs on every get and put, so cheap wins.
(n - 1) & hash, and why the length is a power of two
when n is a power of two, n - 1 is a run of ones: 16 → 1111, 64 → 111111. so hash & (n - 1) keeps exactly the low bits, which is the same as hash % n for a non-negative hash, without a division. and unlike %, it never goes negative (-7 % 16 is -7; -7 & 15 is 9), so there's no Math.abs bug waiting to happen.
that's the deal: a power-of-two length makes indexing a single AND, and (we'll see in a second) it makes resizing almost free. the price is that only the low bits matter, which is why hash() exists.
so what if you ask for a capacity that isn't a power of two? tableSizeFor rounds up, with a bit trick instead of a loop:
every bit below the highest set bit of cap - 1 becomes a 1, then + 1 carries it up to the next power of two.
-1 >>> numberOfLeadingZeros(cap - 1) is all ones up to the highest bit of cap - 1; adding one carries into the next power of two. the cards below show what that means for 100 entries.var map = new HashMap<Integer, Integer>(100);
for (int i = 0; i < 100; i++) map.put(i, i);
// how long is map's table now?put, one branch at a time
putVal is about 40 lines and every one of them shows up in an interview somewhere. here's the path a key takes:
hover a stage.
null keys are allowed, and they really do go to bin 0 (unlike ConcurrentHashMap, which bans them):
void main() {
var m = new HashMap<String, Integer>();
m.put(null, 1);
m.put(null, 2);
IO.println(m + " " + m.get(null) + " " + m.size());
IO.println("Aa".hashCode() + " " + "BB".hashCode());
}{null=2} 2 1
2112 2112resize: every chain splits by one bit
here's the payoff of power-of-two lengths. going from 16 to 32 bins, the mask grows by exactly one bit, bit 4 (value 16, the old capacity). a node's new index is its old index plus either 0 or 16, depending on that one bit. so resize() doesn't rehash anything. it walks each old chain once and deals the nodes into two lists: (e.hash & oldCap) == 0 stays at j (lo), the rest go to j + oldCap (hi).
13 entries in 16 bins. the threshold is 12 (16 × 0.75), so the 13th put (key 16, into bin 0) pushed size past it and called resize().
this is also why HashMap iteration order is "whatever the bins say", and why it changes when the map grows:
var m = new HashMap<Integer, String>();
m.put(17, "a");
m.put(1, "b");
IO.println(m.keySet());
for (int i = 100; i < 111; i++) m.put(i, "x"); // 13 entries: resize to 32
m.keySet().removeIf(k -> k >= 100);
IO.println(m.keySet());the java 7 resize loop, and why it's gone+
java 7's resize moved each node to the head of its new chain, which reversed the chain. if two threads resized the same map at once, one could see the other's half-reversed links and stitch a.next = b, b.next = a. the next get on that bin spun forever at 100% CPU. java 8's lo/hi lists keep the original order (the source literally says "preserve order"), so that particular cycle can't form. a HashMap shared between threads is still broken: lost puts, a size that lies, entries that vanish. the fix isn't a better HashMap, it's ConcurrentHashMap.
treeification: when a chain turns into a tree
three constants control it, all in the source:
| constant | value | what it does |
|---|---|---|
TREEIFY_THRESHOLD | 8 | appending to a bin that already has 8 nodes calls treeifyBin |
MIN_TREEIFY_CAPACITY | 64 | treeifyBin on a table smaller than this resizes instead |
UNTREEIFY_THRESHOLD | 6 | when a resize splits a tree bin, a half with 6 or fewer nodes goes back to a list |
the middle one is the bit people miss. a crowded bin in a small table is more likely a "the table is too small" problem than a "these keys really collide" problem, so HashMap tries growing first. drag through it with keys that all return the same hashCode():
8 nodes, which is TREEIFY_THRESHOLD. the next append is the one that checks it.
next, then left/right pointers.we didn't make those numbers up. reflection into a live map (this needs --add-opens, so it's a terminal run rather than a run button):
import java.lang.reflect.Field;
import java.util.HashMap;
public class Treeify {
record Key(int id) implements Comparable<Key> {
public int hashCode() { return 42; } // every key collides
public int compareTo(Key o) { return Integer.compare(id, o.id); }
}
public static void main(String[] args) throws Exception {
Field table = HashMap.class.getDeclaredField("table");
table.setAccessible(true);
var map = new HashMap<Key, Integer>();
for (int i = 1; i <= 11; i++) {
map.put(new Key(i), i);
Object[] t = (Object[]) table.get(map);
Object bin = t[42 & (t.length - 1)];
System.out.println(i + " keys: Node[" + t.length + "], bin is a " + bin.getClass().getSimpleName());
}
}
}$ java --add-opens java.base/java.util=ALL-UNNAMED Treeify.java
1 keys: Node[16], bin is a Node
...
8 keys: Node[16], bin is a Node
9 keys: Node[32], bin is a Node
10 keys: Node[64], bin is a Node
11 keys: Node[64], bin is a TreeNodea TreeNode is 56 bytes against a Node's 32 (it extends LinkedHashMap.Entry and adds parent, left, right, prev and a red flag). that's why trees are the fallback, not the default. the comment in HashMap.java works out the odds: with random hashes and the default load factor, bin sizes follow a Poisson distribution with a mean of about 0.5, and the chance of a bin reaching 8 is about 6 in 100 million. if you see tree bins in a heap dump, some hashCode() is broken or someone is attacking you on purpose (hash flooding, where an attacker sends keys chosen to collide). the trees arrived in java 8 (JEP 180) so that a pile of colliding keys degrades to O(log n) instead of a list walk.
why 8 to treeify but 6 to untreeify? hysteresis. if both were 8, a bin hovering around 8 entries would flip between list and tree on every other operation. the gap of 2 means it has to shrink for real before converting back. (removing from a tree bin uses a shape check instead of a count: when the root has no left or right child, or the left child has no left child, the bin goes back to a list. that's somewhere between 2 and 6 nodes.)
hash = h ^ (h >>> 16). the mask only reads low bits, so the high bits get folded into them.- the bin is
(n - 1) & hash. powers of two make that one AND, never negative. new HashMap<>(n)is a capacity. for "n entries without resizing" useHashMap.newHashMap(n).- resize deals every chain into two: stay at
jor move toj + oldCap. one bit decides, no rehash. - a 9th node in a bin treeifies only if the table has 64+ bins. otherwise the table doubles.
- tree bins (56-byte nodes) are an emergency exit for bad hashes, not something healthy maps use.