How does HashMap work internally?

Asked constantlyintermediate1–8 yrs9 min readJava 2 (1.2)Java 8LTS

A lazily-created array of buckets indexed by a spread hash; collisions chain into a linked list that becomes a red-black tree only when the bin holds 8+ nodes AND the table is at least 64 slots.

The Answer

Say this in the room. 45 seconds.

  • HashMap stores entries in an array of buckets, Node<K,V>[] table. The array is not allocated in the constructor — it's created on the first put(), at size 16.
  • On put(), the key's hashCode() is spreadh ^ (h >>> 16) — to mix high bits down into the low bits, then the bucket index is (n - 1) & hash. That bitmask works only because capacity is always a power of two.
  • Two keys landing in the same bucket collide, and chain into a linked list.
  • When a bucket gets long, it converts to a red-black tree, dropping worst-case lookup from O(n) to O(log n).
  • When size exceeds capacity × 0.75, the table doubles and every entry is redistributed.
  • Lookup is O(1) average, O(log n) worst case. HashMap is not thread-safe.

Understand It

The table is lazy

The constructor allocates nothing. It only records the load factor. The array appears on first insert:

Compiled and run on this build
Map<String, Integer> m = new HashMap<>();
// reflection on the private `table` field
System.out.println("before put: " + tableLength(m));
m.put("a", 1);
System.out.println("after put:  " + tableLength(m));
Output
before put: 0
after put:  16

Why: maps get declared and never used all the time — as fields, in DTOs, in config objects. Deferring a 16-slot array costs nothing and saves real memory across a large heap.

Why the hash gets "spread"

The index is computed with a bitmask, not a modulo:

int index = (n - 1) & hash;   // n = table length, always a power of 2

For n = 16, n - 1 is 0b1111 — so only the bottom 4 bits of the hash decide the bucket. Any hashCode() that varies mostly in its high bits would pile every entry into one slot.

So HashMap folds the high half down first:

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

One XOR, one shift. Cheap insurance against a badly-distributed hashCode().

Note the key == null branch: a null key hashes to 0, so it always lives in bucket 0. That's why HashMap permits exactly one null key — and any number of null values.

Collisions: list first, tree later

Same bucket → the new node appends to a linked list on that bin. Lookup walks the chain calling equals().

The treeify rule — where nearly every article is wrong

The claim you'll read everywhere is "at 8 nodes the list becomes a red-black tree." That is incomplete, and it's the follow-up interviewers use to separate readers from people who've actually looked.

Two constants govern this:

ConstantValueMeaning
TREEIFY_THRESHOLD8bin length that attempts treeify
MIN_TREEIFY_CAPACITY64table must be at least this big
UNTREEIFY_THRESHOLD6shrink back to a list on resize

If the bin hits 8 but the table is smaller than 64, HashMap resizes instead of treeifying. Reasoning: in a small table, a long bin usually means too few buckets, not genuinely pathological hashing. Spreading entries out is cheaper than building a tree.

Forcing every key into one bucket on JDK 21 shows exactly that:

entries= 8  table= 16  bin = Node      ← hit 8, did NOT treeify
entries= 9  table= 32  bin = Node      ← resized instead
entries=10  table= 64  bin = Node      ← resized again
entries=11  table= 64  bin = TreeNode  ← table >= 64, NOW it treeifies

The real rule: bin ≥ 8 AND table ≥ 64.

Tree bins also need ordering. TreeNode compares by hash first, then by Comparable if the key type implements it, and finally falls back to an arbitrary but consistent tiebreak. That's why a key type that is Comparable degrades more gracefully under heavy collision.

Resize

threshold = capacity × loadFactor → 16 × 0.75 = 12. The table doubles when size exceeds it, so the 13th entry triggers it:

size=12  table=16
size=13  table=32

The clever part of the JDK 8+ rewrite: because capacity doubles, an existing entry either stays at index i or moves to i + oldCap. Which one is decided by a single bit test, (e.hash & oldCap) == 0 — no rehashing required. Each bin splits into a "lo" list and a "hi" list, preserving relative order.

That order preservation is not cosmetic. In Java 7 the transfer reversed each chain, and two threads resizing concurrently could weave a circular linked list — a later get() would then spin at 100% CPU forever. The JDK 8 rewrite eliminated that specific livelock.

But that fix removed one symptom, not the thread-safety problem. Concurrent put() on JDK 21 still loses updates and corrupts size, because nothing here is synchronized. Use ConcurrentHashMap.

The contract you inherit

HashMap is only correct if your keys honor the hashCode()/equals() contract:

  • Equal objects must return equal hash codes.
  • A key's hash must not change while it's in the map.

Break the second and the entry becomes unreachable — the map looks in a bucket the key no longer hashes to. It's still in memory, still counted by size(), and you cannot get() or remove() it. That's the Challenge exercise below.


Interviewer's Next Move

The question after the question. This is what the round is actually testing.

1. "You said it treeifies at 8. Always?" No — the table must also be ≥ 64 slots, else it resizes instead. Below 64, a long bin is treated as too-few-buckets rather than bad hashing.

2. "Why is capacity always a power of two?" So the index can be (n-1) & hash instead of hash % n. A bitmask is far cheaper than modulo, and it makes the resize split a single bit test. Pass a non-power-of-two to the constructor and tableSizeFor() silently rounds it up.

3. "What breaks if hashCode() returns a constant?" Nothing breaks — it stays correct, but every entry collides into one bin. You get O(n) until treeify, then O(log n). A hash map degraded into a sorted list.

4. "Is HashMap thread-safe on Java 21? The infinite loop was fixed." The fix removed the circular-list livelock. It did not add synchronization. Concurrent writes still lose updates and corrupt size. Use ConcurrentHashMap — not Collections.synchronizedMap(), which locks the whole map.

5. "Difference between HashMap and ConcurrentHashMap iteration?" HashMap iterators are fail-fast: structural modification during iteration throws ConcurrentModificationException. ConcurrentHashMap is weakly consistent: it never throws, but may or may not reflect concurrent updates.

Code traps

Trap A — predict before you run:

Map<String, Integer> m = new HashMap<>();
m.put("a", 1);
m.put("b", 2);
m.put("c", 3);
System.out.println(m);
Answer

{a=1, b=2, c=3} — and this is a trap. It looks like insertion order, but that's coincidence: "a", "b", "c" have ascending hashes landing in ascending buckets. Change the keys to "x", "y", "z" or add more and the illusion breaks. HashMap guarantees no order. Use LinkedHashMap for insertion order, TreeMap for sorted.

Trap B:

Map<Integer, String> m = new HashMap<>();
m.put(1, "one");
System.out.println(m.get(1));
System.out.println(m.get(1L));
Answer

one then null. The key is boxed to Integer, and Integer.equals(Long) is always false regardless of numeric value. Silent, and a genuine production bug source.

Common wrong answers

Said in interviewsReality
"It treeifies at 8 nodes."Only if the table is also ≥ 64.
"Java 8 made HashMap thread-safe."It fixed one livelock. Still not thread-safe.
"It uses hash % capacity."(n-1) & hash. Bitmask, not modulo.
"Load factor 0.75 means it resizes at 75% and that's tunable for speed."Correct on the trigger; lowering it costs memory for marginal gain — measure first.
"Iteration follows insertion order."No guarantee at all.

Check Yourself

Q1. A HashMap with default settings. How many put() calls with distinct keys before the table becomes 32?

Answer13. Threshold is 16 × 0.75 = 12, and resize fires when size exceeds it.

Q2. Keys all return hashCode() == 7. After 8 inserts, what's in the bin?

AnswerStill a linked list. The table is only 16, below MIN_TREEIFY_CAPACITY of 64, so it resizes instead of treeifying.

Q3. Why does mutating a key's hash-bearing field make the entry unreachable?

AnswerThe entry stays physically in the bucket chosen by the old hash. get() computes the new hash, looks in a different bucket, finds nothing. Still occupies memory, still counted by size(), unreachable by key.


Practice

TierExerciseTime
Warm-upIteration order surprise5 min
ChallengeBreak the map with a mutable key20 min
ProductionCache losing updates under load45 min
InterviewFull round replay10 min

What changed, and when

Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.

  1. Java 2 (1.2)

    HashMap arrives in the collections framework, unsynchronized, permitting one null key and any number of null values.

    Before Java 2 (1.2): Hashtable was the only choice: every method synchronized whether you needed it or not, and it throws NullPointerException on a null key or value.

  2. Java 8LTS

    A collision bin converts from a linked list to a red-black tree once it holds 8 nodes AND the table is at least 64 slots, capping worst-case lookup at O(log n).

    Before Java 8: A bin was always a linked list, so keys with a colliding hashCode() degraded lookup to O(n) with no ceiling — the shape behind the 2011 hash-collision denial-of-service disclosures.

  3. Java 8LTS

    Resize splits each bin into a lo/hi pair with the single bit test (e.hash & oldCap) == 0, preserving relative order and rehashing nothing.

    Before Java 8 (now gone): transfer() recomputed each index and prepended, reversing every chain. Two threads resizing at once could weave a circular list, and a later get() would then spin at 100% CPU forever.

  4. Java 8LTS

    Hash spreading reduced to one operation: h ^ (h >>> 16).

    Before Java 8 (now gone): Java 7 applied four shifts and XORs, plus an optional hashSeed and a separate string-hashing path, to defend against weak hashCode() distributions.

Practice ladder

Reading this page is not knowing it. Four tiers, ending in a real incident.

Where this question goes next

  • concurrenthashmap internals — not written yet
  • hashmap thread safety — not written yet
  • mutable key problem — not written yet
  • hashmap vs hashtable — not written yet
  • load factor tuning — not written yet

Questions that lead here

  • What is the contract between hashCode() and equals()?

    Equal objects must return the same hash code. Unequal objects are free to collide — that is legal and normal, not a bug. Break the first rule and hash-based collections lose your data silently.

    Asked constantlyintermediate1–8 yrs8 min readOop
  • When would you use LinkedList instead of ArrayList?

    Almost never. LinkedList only wins at the ends of the list, and when you need that you want ArrayDeque instead. Its famous advantage — cheap mid-list insertion — is 23x slower than ArrayList on real hardware, because you must walk to the index first.

    Asked constantlyintermediate0–8 yrs9 min readCollections

Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-24.