Java HashMap Internals: Buckets, Collisions, Treeification, and Resizing
โHow does HashMap work?โ is probably the single most-asked Java interview question โ and for once, the interview obsession is justified. HashMapโs internals explain why equals/hashCode have a contract, why mutating keys corrupts maps, what load factor means, and why Java 8 quietly put red-black trees inside a hash table.
This guide walks the whole path of a put and a get, then covers the failure modes that show up in production.
The Core Idea: Trade Memory for Address Math
A list answers contains by checking every element. A hash map skips the search entirely: it computes where a key must live.
put("cat", 1): "cat".hashCode() โโถ 98262 โspreadโโถ index = hash & (capacity-1) โโถ bucket 6
table (capacity 16): [0] [1] [2] [3] [4] [5] [6]โโโโโถ ("cat" โ 1) [7] ... [15]Storage is a plain array of buckets (default capacity 16). The keyโs hashCode() โ any int, positive or negative โ is squeezed into a valid index. Because capacity is always a power of two, hash & (capacity - 1) does the job with a single AND instruction instead of a modulo.
A get("cat") recomputes the same index, jumps straight to bucket 6, and confirms the key with equals. No scanning โ thatโs the O(1).
One refinement worth knowing because interviewers love it: before indexing, HashMap spreads the hash โ h ^ (h >>> 16) โ XOR-ing the high 16 bits into the low 16. Since indexing only uses the low bits, hashCodes that differ only in high bits (common for e.g. sequential values) would otherwise collide constantly. This one line of bit-twiddling is why mediocre hashCodes still perform tolerably.
Collisions: When Two Keys Want One Bucket
Different keys can produce the same index โ different hashCodes squeezed to the same slot, or genuinely equal hashCodes (perfectly legal: the contract says equal objects โ equal hashes, not unequal objects โ unequal hashes; with ~4 billion ints and unlimited possible keys, collisions are mathematically unavoidable).
HashMapโs answer: each bucket holds a linked list of entries (each storing hash, key, value, next):
[6] โโโถ ("cat" โ 1) โโโถ ("act" โ 9) โโโถ nullBoth put and get walk the chain, comparing hashes first (cheap int compare) and then equals (potentially expensive) only on hash matches. Short chains keep this effectively O(1).
Java 8โs twist: treeification
Long chains degrade toward O(n) โ and before Java 8, attackers exploited exactly this: flood a server with request parameters engineered to share one bucket, and the hash map underlying the parameter parsing becomes a linked list, turning O(n) requests into O(nยฒ) CPU (a real denial-of-service class, hash-flooding).
Since Java 8, when a single bucketโs chain exceeds 8 entries (and the table has at least 64 buckets), the chain converts into a red-black tree, dropping worst-case bucket operations from O(n) to O(log n). It converts back to a list if the bucket shrinks below 6. For this to work well, keys should be Comparable โ String and the numeric wrappers are, which covers most real maps.
Practical translation: HashMapโs worst case is now O(log n) even under adversarial keys โ but you should still never rely on that; a decent hashCode keeps you on the fast path.
Resizing: Load Factor and the Cost of Growth
More entries โ more collisions. HashMap monitors its load factor โ entries รท capacity โ and when it crosses the threshold (default 0.75), the table doubles and every entry is redistributed:
capacity 16, threshold 12 โโถ 13th entry arrives โโถ new table of 32, rehash allWhy 0.75? Itโs the empirically chosen elbow of the memory/speed curve โ beyond it chains lengthen fast; below it youโre buying little speed with a lot of empty slots. Youโll essentially never change it.
What you should change: initial capacity, when you know the size in advance.
// about to insert ~10,000 entries?Map<String, User> users = new HashMap<>(14_000); // โ 10_000 / 0.75, rounded upWithout this, the map resizes ~10 times on the way to 10k entries, rehashing everything each time. HashMap.newHashMap(10_000) (Java 19+) does the load-factor math for you. This is one of the cheapest micro-optimizations thatโs actually worth doing.
A power-of-two detail that makes resizing elegant: doubling capacity adds one bit to the index mask, so every entry either stays at its index or moves to exactly index + oldCapacity โ decided by a single bit of its hash. No full rehash computation per key.
The Contract, Now With Consequences
The rules from the records guide โ equal objects must share a hashCode; override both or neither โ stop being abstract once youโve seen the machinery:
Broken: equals without hashCode. Two equal keys hash to different buckets. map.get(equalKey) looks in the wrong bucket, returns null, and your โcacheโ silently stores duplicates under keys it can never find again.
Legal but catastrophic: constant hashCode. hashCode() { return 42; } satisfies the contract and turns your map into one bucket โ a linked list (or tree) wearing a HashMap costume. Interviewers ask this as โwhat happens if hashCode always returns the same value?โ โ the answer is โit still works, at O(log n) per op after treeification, O(n) before.โ
The mutation trap โ the one that bites in production:
Set<Point> visited = new HashSet<>(); // HashSet is a HashMap in disguisePoint p = new Point(2, 3);visited.add(p); // stored in the bucket for hash(2,3)p.x = 99; // hash is now different...visited.contains(p); // false โ looks in bucket for hash(99,3)visited.size(); // 1 โ the entry still exists, unreachableThe entry is a ghost: present, iterable, but unfindable by lookup, and even remove(p) fails. Nothing throws. This is the argument for immutable keys โ records, Strings, and value types. If a key must be mutable, never mutate hash-relevant fields while itโs in a map.
Null, Order, and Other Behavioral Fine Print
- One null key is allowed (stored in bucket 0) and any number of null values. But
getreturningnullis ambiguous โ missing key, or present-with-null-value? PrefergetOrDefault/containsKey, and better yet, donโt store null values. - Iteration order is unspecified and unstable โ it can change after a resize. Code that accidentally depends on HashMap order breaks on JVM upgrades; tests that assert on it flake. Need order?
LinkedHashMap(insertion) orTreeMap(sorted). - Not thread-safe. Concurrent writes can lose updates, and in old Java versions concurrent resize could even create infinite loops. Under concurrency use
ConcurrentHashMapโ lock-striped, no global lock, and itscompute/mergeare atomic. (More in the concurrency guide.) - HashSet is literally a HashMap whose values are a shared dummy object โ every fact above applies to sets verbatim.
Walking put() End to End
Interview-ready mental replay of map.put("cat", 9) on a map already containing "cat" โ 1:
- Compute
h = spread("cat".hashCode()). - Index:
i = h & (table.length - 1). - Bucket empty? โ new entry, done. Else walk the chain/tree:
- For each node: if
node.hash == h && node.key.equals("cat")โ replace value, return old value1. - No match by chain end โ append new node; if chain length now > 8 (and table โฅ 64) โ treeify.
- If
++size > capacity ร 0.75โ double and redistribute.
get is steps 1โ4 without the writes. If you can narrate this unprompted, youโre ahead of most candidates โ and more importantly, every โweird map behaviorโ you meet now has a mechanical explanation.
Choosing Among the Map Family (Recap With New Eyes)
| Map | Ordering | Ops | Use when |
|---|---|---|---|
HashMap | none, unstable | O(1) | default |
LinkedHashMap | insertion (or access) | O(1) + list upkeep | stable output order; LRU caches |
TreeMap | sorted by key | O(log n) | range queries, floor/ceiling, sorted reports |
ConcurrentHashMap | none | O(1), thread-safe | shared across threads |
EnumMap | enum ordinal | array-fast | enum keys โ smaller & faster than HashMap |
EnumMap is the underused one: keys that are enums get a flat array indexed by ordinal โ no hashing at all.
Writing a Good hashCode (and Recognizing a Bad One)
Since the whole machine turns on hash quality, itโs worth knowing what โgoodโ means: equal objects equal hashes (the contract), unequal objects usually unequal hashes (the quality bar), and all bits of the input influencing the output (the spread).
In practice you almost never hand-roll it:
// records: generated correctly, donepublic record RouteKey(String origin, String destination, LocalDate date) {}
// regular classes: Objects.hash covers nearly every case@Override public int hashCode() { return Objects.hash(origin, destination, date);}Objects.hash uses the classic 31-multiplier polynomial internally โ the same scheme String.hashCode has used for decades (31 being an odd prime whose multiply optimizes to shift-and-subtract). Its one cost is varargs boxing; only hot-path types with millions of lookups justify hand-writing the polynomial to avoid it.
Bad hashCodes to recognize in review: hashing only one field of a multi-field key (โall routes from Delhi collideโ), hashing a mutable field (the ghost-entry trap above), and XOR-ing fields symmetrically (a.hashCode() ^ b.hashCode() makes (x, y) collide with (y, x) โ a real problem for pair-like keys; the 31-polynomialโs asymmetry exists precisely to avoid this).
Frequently Asked Questions
Does HashMap shrink when entries are removed? No โ capacity only grows. A map that once held a million entries keeps its giant table after clear(). In the rare case that matters (a long-lived map with a one-time bulk phase), replace it with a fresh new HashMap<>(map) sized to current contents.
Why is iteration order different between runs or JVMs? Order is a function of capacity and each keyโs spread hash โ both implementation details. String hashes are stable across JVMs, but default Object.hashCode (identity-based) is not, and any resize reshuffles everything. Treat the order as random; if a test asserts on it, the test is wrong.
How does ConcurrentHashMap differ internally? Same bucket-array-plus-treeified-bins design, but writes use fine-grained per-bin synchronization (CAS for empty bins, a tiny lock for occupied ones) and reads are entirely lock-free. Thatโs why it scales where Collections.synchronizedMap โ one global lock โ does not. Full context in the concurrency guide.
Is WeakHashMap related? Structurally similar, but keys are held by weak references: once a key is otherwise unreachable, the entry evaporates at the GCโs convenience. Itโs the canonical fix for the listener-registry and metadata-cache memory leaks โ association without ownership.
Self-Check
- Why must HashMapโs capacity be a power of two?
- A key classโs
hashCodereturns1for every instance. Is the equals/hashCode contract violated? Whatโs the performance consequence? - After
map.put(key, v), someone mutates a field ofkeyused in itshashCode. Describe exactly whatmap.get(key)does now, step by step. - Youโre about to bulk-load 1M entries into a fresh HashMap. What one constructor argument saves roughly 20 rehash-everything passes, and what value should it be?
- Why did Java 8 add trees to buckets rather than just recommending better hashCodes?
(Answers: so hash & (cap-1) replaces modulo and resize moves entries by one bit. No โ the contract permits it; all entries share one bucket, O(log n) after treeify. It spreads the new hash, indexes to a different bucket, finds no entry there, returns null โ while the stale entry sits in the old bucket. Initial capacity โ 1_000_000/0.75 โ 1.34M. Because key quality is in usersโ hands and adversaries exploit collisions deliberately โ the JDK needed a worst-case guarantee it controls.)
Next: the feature that changed how Java code reads more than any other โ Streams and Lambdas, where collections stop being loops and start being pipelines.