Hash Collisions — Bad hashCode() Killed Response Times
A single bad hashCode() flooded one bucket with 500K entries, causing 400x latency.
20+ years shipping production systems from the metal up. Everything here is grounded in real deployments.
- ✓Python 3.8+, basic understanding of hash functions, familiarity with dictionaries and sets, knowledge of time complexity (Big O notation)
- Hash collisions occur when different keys produce the same array index
- Two main resolution strategies: chaining (linked lists) and open addressing (probing)
- Chaining is simpler, tolerates high load, but pointer-chasing hurts cache
- Open addressing is cache-friendly, requires tombstones for deletion, degrades fast >0.7 load
- Java's HashMap uses chaining with treeification (≥8 chain length), default load factor 0.75
- Worst mistake: deleting with null in open addressing — breaks all subsequent lookups
A hash function takes a key — any key, a string, a number, an object — and converts it into an integer index that points to a slot in an array. The array is called a hash table or hash map.
Here's the core tension: there are infinite possible keys (every word, every URL, every username in existence), but your array has a fixed, finite number of slots. By the Pigeonhole Principle — a fancy name for a simple idea — if you have more pigeons than holes, at least two pigeons must share a hole. Same logic here. Two different keys will eventually produce the same index.
A typical hash function looks like this: take the key, do some arithmetic on it (often involving the key's characters and a prime number), then use the modulo operator (%) to force the result into the valid range of your array. Modulo is just the remainder after division. So if your array has 10 slots and your hash arithmetic produces 47, then 47 % 10 = 7, and your item goes into slot 7.
The quality of your hash function matters a lot. A bad hash function clusters everything into a few slots, causing frequent collisions. A good one spreads keys evenly so collisions are rare. But even the best function cannot eliminate them entirely — it can only minimize them.
Imagine a school with 30 lockers but 40 students. Two students get assigned the same locker — that's a hash collision. A hash function is the locker-assignment system, and the locker number is the 'hash'. When two different keys end up at the same locker, the program has to figure out how to share the space without losing anyone's stuff. Everything in hash tables revolves around solving this exact problem elegantly.
Every time you look something up in a Python dictionary, a Java HashMap, or a JavaScript object, a hash table is working behind the scenes. Hash tables are one of the fastest data structures ever invented — lookups that would take thousands of steps in a plain list take just one step in a well-built hash table. That speed is why they power databases, caches, compilers, and almost every performance-critical system you'll ever touch.
But there's a catch. The speed comes from a clever trick: a hash function converts your key (like a name or a URL) into a number, and that number points directly to a memory slot. The problem? Different keys can produce the same number. Just like two people sharing a locker, two keys sharing the same slot is called a collision — and if you don't handle it, data gets overwritten and silently lost. Collisions aren't bugs; they're mathematically inevitable the moment your data grows large enough.
By the end of this article you'll understand exactly why collisions happen, how to visualize them clearly, and how the two dominant strategies — chaining and open addressing — resolve them. You'll also write working Java code for both approaches, understand their trade-offs, and walk into any interview ready to discuss hash tables with genuine confidence.
What Is a Hash Function and Why Do Collisions Happen?
A hash function takes a key — any key, a string, a number, an object — and converts it into an integer index that points to a slot in an array. The array is called a hash table or hash map.
Here's the core tension: there are infinite possible keys (every word, every URL, every username in existence), but your array has a fixed, finite number of slots. By the Pigeonhole Principle — a fancy name for a simple idea — if you have more pigeons than holes, at least two pigeons must share a hole. Same logic here. Two different keys will eventually produce the same index.
A typical hash function looks like this: take the key, do some arithmetic on it (often involving the key's characters and a prime number), then use the modulo operator (%) to force the result into the valid range of your array. Modulo is just the remainder after division. So if your array has 10 slots and your hash arithmetic produces 47, then 47 % 10 = 7, and your item goes into slot 7.
The quality of your hash function matters a lot. A bad hash function clusters everything into a few slots, causing frequent collisions. A good one spreads keys evenly so collisions are rare. But even the best function cannot eliminate them entirely — it can only minimize them.
public class HashFunctionDemo { // A simple hash function: sum the ASCII values of all characters, // multiply by a prime to reduce clustering, then fit into tableSize slots. static int computeHash(String key, int tableSize) { int hashValue = 0; for (int i = 0; i < key.length(); i++) { // charAt gives the character's ASCII integer value hashValue = (hashValue * 31 + key.charAt(i)) % tableSize; } return hashValue; } public static void main(String[] args) { int tableSize = 7; // small table so collisions happen quickly String[] keys = {"cat", "dog", "act", "god", "tac"}; System.out.println("Key -> Hash Index (table size = " + tableSize + ")"); System.out.println("-------------------------------"); for (String key : keys) { int index = computeHash(key, tableSize); System.out.printf("%-6s -> slot %d%n", key, index); } } }
String.hashCode() uses 31 for exactly this reason — it's fast (computable with bit shifts) and distributes characters well across hash buckets.String.hashCode() — it's well-distributed and constant-time for typical sizes.Chaining: Give Each Slot Its Own Waiting Room
Chaining is the most intuitive collision resolution strategy. Instead of storing one value per slot, each slot holds a linked list (or any list structure) of all the key-value pairs that hashed to that index. When a collision happens, you just append the new item to the list at that slot. No drama.
Think back to the school lockers analogy. With chaining, each locker gets a hook rail instead of a single hook. Multiple students can hang their bags — their items form a chain. Finding your bag means going to your locker and scanning the rail for your name tag.
Insertion is always fast — O(1) — because you just add to the front of the list. Lookup requires scanning the chain at the target slot, which is O(k) where k is the number of items in that chain. If your hash function distributes keys evenly and your table isn't too full, k stays tiny (often just 1 or 2), so lookups remain near O(1) on average.
The main cost of chaining is memory — every node in the linked list needs an extra pointer. For small tables or high collision rates, those pointers add up. But chaining is forgiving: your table can hold more items than it has slots, and performance degrades gracefully rather than catastrophically.
import java.util.LinkedList; public class ChainingHashMap { // Each entry in the table holds a linked list of KeyValuePair objects static class KeyValuePair { String key; String value; KeyValuePair(String key, String value) { this.key = key; this.value = value; } } private static final int TABLE_SIZE = 7; // The hash table: an array where each slot is a linked list (the "chain") @SuppressWarnings("unchecked") private LinkedList<KeyValuePair>[] table = new LinkedList[TABLE_SIZE]; ChainingHashMap() { // Pre-fill every slot with an empty list so we never hit a null pointer for (int i = 0; i < TABLE_SIZE; i++) { table[i] = new LinkedList<>(); } } private int getSlot(String key) { int hashValue = 0; for (int i = 0; i < key.length(); i++) { hashValue = (hashValue * 31 + key.charAt(i)) % TABLE_SIZE; } return hashValue; } // INSERT: compute the slot, then append to that slot's linked list public void put(String key, String value) { int slot = getSlot(key); LinkedList<KeyValuePair> chain = table[slot]; // If the key already exists, update its value instead of duplicating it for (KeyValuePair pair : chain) { if (pair.key.equals(key)) { pair.value = value; // update in place System.out.println("Updated '" + key + "' at slot " + slot); return; } } // Key not found in the chain — add a new entry chain.add(new KeyValuePair(key, value)); System.out.println("Inserted '" + key + "' at slot " + slot + (chain.size() > 1 ? " [COLLISION — chain length now " + chain.size() + "]" : "")); } // LOOKUP: go to the slot, walk the chain looking for a matching key public String get(String key) { int slot = getSlot(key); for (KeyValuePair pair : table[slot]) { if (pair.key.equals(key)) { return pair.value; // found it } } return null; // key doesn't exist in the map } // Print every slot and its chain so we can visualize the structure public void printTable() { System.out.println("\n--- Hash Table State ---"); for (int i = 0; i < TABLE_SIZE; i++) { System.out.print("Slot " + i + ": "); if (table[i].isEmpty()) { System.out.println("[empty]"); } else { StringBuilder sb = new StringBuilder(); for (KeyValuePair pair : table[i]) { sb.append("[").append(pair.key).append("=>").append(pair.value).append("] -> "); } System.out.println(sb.toString().replaceAll(" -> $", "")); } } } public static void main(String[] args) { ChainingHashMap map = new ChainingHashMap(); map.put("cat", "feline"); map.put("dog", "canine"); map.put("act", "perform"); // 'act' collides with 'cat' at slot 3 map.put("god", "deity"); // 'god' collides with 'dog' at slot 6 map.put("tac", "fastener"); map.put("cat", "kitty"); // updating an existing key map.printTable(); System.out.println("\nLookup 'act' -> " + map.get("act")); System.out.println("Lookup 'god' -> " + map.get("god")); System.out.println("Lookup 'xyz' -> " + map.get("xyz")); } }
Open Addressing: Find the Next Available Locker
Open addressing takes a completely different approach. There are no linked lists at all — every value lives directly inside the main array. When a collision occurs, the algorithm probes (searches) for the next empty slot according to a fixed rule and puts the item there instead.
Going back to the locker analogy: if locker 3 is taken, you check locker 4, then 5, then 6, and so on until you find an empty one. This is the simplest variant, called linear probing.
There are three common probing strategies. Linear probing checks the next slot sequentially (index + 1, + 2, etc.). Quadratic probing jumps by squared distances (index + 1, + 4, + 9) to reduce clustering. Double hashing applies a second hash function to determine the jump size, giving the most uniform distribution.
The critical difference from chaining: the load factor (number of items divided by table size) must stay below 1.0, because you literally can't fit more items than slots. In practice you resize the table when the load factor exceeds 0.7 to keep performance healthy. Open addressing is more cache-friendly than chaining because everything lives in one contiguous array — no pointer chasing. This makes it faster in practice on modern hardware even if the theoretical complexity looks similar.
public class LinearProbingHashMap { private static final int TABLE_SIZE = 11; // prime number reduces clustering private static final String TOMBSTONE = "__DELETED__"; // marker for deleted slots private String[] keys = new String[TABLE_SIZE]; private String[] values = new String[TABLE_SIZE]; private int computeHash(String key) { int hashValue = 0; for (int i = 0; i < key.length(); i++) { hashValue = (hashValue * 31 + key.charAt(i)) % TABLE_SIZE; } return hashValue; } // INSERT: find the target slot; if it's taken, probe forward one step at a time public void put(String key, String value) { int startSlot = computeHash(key); int currentSlot = startSlot; int probeCount = 0; while (probeCount < TABLE_SIZE) { // A null slot or a tombstone (deleted) slot is available for writing if (keys[currentSlot] == null || keys[currentSlot].equals(TOMBSTONE)) { keys[currentSlot] = key; values[currentSlot] = value; System.out.printf("Inserted '%s' at slot %d (probed %d time(s))%n", key, currentSlot, probeCount); return; } // If the same key already exists, overwrite its value if (keys[currentSlot].equals(key)) { values[currentSlot] = value; System.out.printf("Updated '%s' at slot %d%n", key, currentSlot); return; } // Slot is occupied by a different key — move to the next slot (linear probe) currentSlot = (currentSlot + 1) % TABLE_SIZE; // wrap around with modulo probeCount++; } System.out.println("Table is full! Could not insert '" + key + "'"); } // LOOKUP: start at the hash slot and probe forward until we find the key or an empty slot public String get(String key) { int startSlot = computeHash(key); int currentSlot = startSlot; int probeCount = 0; while (probeCount < TABLE_SIZE) { if (keys[currentSlot] == null) { return null; // genuinely empty — key was never inserted } if (keys[currentSlot].equals(key)) { return values[currentSlot]; // found the key } // Keep probing past tombstones and other occupied slots currentSlot = (currentSlot + 1) % TABLE_SIZE; probeCount++; } return null; } // DELETE: we can't just set to null (would break probe chains), so place a tombstone public void delete(String key) { int startSlot = computeHash(key); int currentSlot = startSlot; int probeCount = 0; while (probeCount < TABLE_SIZE) { if (keys[currentSlot] == null) return; // key doesn't exist if (keys[currentSlot].equals(key)) { keys[currentSlot] = TOMBSTONE; // mark as deleted, don't break the chain values[currentSlot] = null; System.out.println("Deleted '" + key + "' at slot " + currentSlot); return; } currentSlot = (currentSlot + 1) % TABLE_SIZE; probeCount++; } } public void printTable() { System.out.println("\n--- Hash Table State ---"); for (int i = 0; i < TABLE_SIZE; i++) { if (keys[i] == null) { System.out.printf("Slot %2d: [empty]%n", i); } else if (keys[i].equals(TOMBSTONE)) { System.out.printf("Slot %2d: [tombstone]%n", i); } else { System.out.printf("Slot %2d: %s => %s%n", i, keys[i], values[i]); } } } public static void main(String[] args) { LinearProbingHashMap map = new LinearProbingHashMap(); map.put("apple", "fruit"); map.put("grape", "vine fruit"); // may collide depending on hash map.put("mango", "tropical"); map.put("lemon", "citrus"); map.put("apple", "red fruit"); // updating existing key map.printTable(); System.out.println("\nLookup 'mango' -> " + map.get("mango")); System.out.println("Lookup 'kiwi' -> " + map.get("kiwi")); map.delete("grape"); System.out.println("After deleting 'grape':"); System.out.println("Lookup 'mango' -> " + map.get("mango")); // still works! } }
Chaining vs Open Addressing — When to Use Which
Both strategies solve collisions correctly. The choice depends on your data size, memory constraints, and the hardware you're running on.
Chaining shines when you don't know in advance how many items you'll insert. Your table can exceed 100% capacity (load factor > 1) and still function — it just gets slower as chains grow. Chaining is also simpler to implement correctly, especially when deletions are frequent, because you just remove a node from a linked list with no tombstone complications.
Open addressing wins on modern CPUs because all data sits in one contiguous block of memory. Modern CPUs prefetch sequential memory aggressively — this is called cache locality. Pointer chasing through linked list nodes (as chaining requires) defeats this optimization. Benchmarks consistently show open addressing is faster in practice for small-to-medium tables even when the theoretical complexity is identical.
The load factor is your key tuning knob. For chaining, load factors up to 1.0 are acceptable; beyond that, rebuild the table. For open addressing, keep the load factor below 0.7. Java's HashMap uses 0.75 as its default threshold — a carefully chosen balance between memory use and speed.
In interviews, either approach is acceptable unless the question specifies one. The depth of your explanation — mentioning load factors, tombstones, and cache locality — is what separates a good answer from a great one.
public class LoadFactorDemo { // This demo shows WHY we care about the load factor: // as it rises, the average probe count in a linear-probing table climbs fast. static int simulateInserts(int tableSize, int itemsToInsert) { String[] table = new String[tableSize]; int totalProbes = 0; for (int item = 0; item < itemsToInsert; item++) { // Simulate a key with a spread-out hash using item * prime int slot = (item * 37) % tableSize; int probes = 0; // Linear probe until we find an empty slot while (table[slot] != null) { slot = (slot + 1) % tableSize; probes++; } table[slot] = "item" + item; totalProbes += probes; } return totalProbes; } public static void main(String[] args) { int tableSize = 100; System.out.println("Load Factor | Items | Total Extra Probes | Avg Probes/Insert"); System.out.println("------------------------------------------------------------"); int[] itemCounts = {10, 30, 50, 70, 80, 90, 95}; for (int items : itemCounts) { double loadFactor = (double) items / tableSize; int totalProbes = simulateInserts(tableSize, items); double avgProbes = (double) totalProbes / items; System.out.printf(" %.2f | %3d | %5d | %.2f%n", loadFactor, items, totalProbes, avgProbes); } } }
How Java's HashMap Handles Collisions in Production
Java's HashMap is one of the most heavily optimised data structures in existence. It uses chaining, but with two critical improvements: treeification and resize threshold.
From Java 8 onwards, when a chain reaches length 8, HashMap converts it from a linked list into a balanced binary tree (a Red-Black tree). This ensures worst-case lookup time stays at O(log n) even under pathological collisions — like a denial-of-service attack on the hash function. The tree backconverts to a list when chain length shrinks below 6, avoiding memory overhead for small tables.
The second safeguard is the load factor. By default, when the number of entries exceeds 75% of the table size, HashMap doubles the array and rehashes every entry. This rehashing is expensive — O(n) — but happens rarely because the threshold is tuned so that average chain length stays near 1.
A third, less-known detail: HashMap uses hash perturbation. It applies a secondary mixing function (the method) that spreads bits from the key's hashCode more evenly across the table. This reduces collisions even when keys have poorly distributed hashCodes.hash()
These mechanisms make Java's HashMap robust for production use. But nothing saves you from a truly broken hashCode — if all keys return the same code, you'll get a single chain and O(n) performance regardless.
public class HashMapInternalsDemo { // Simulate Java's treeification threshold logic // In real HashMap, when TREEIFY_THRESHOLD = 8 and chain length >= 8, // the chain is converted to a TreeNode (Red-Black tree). public static void main(String[] args) { java.util.HashMap<String, String> map = new java.util.HashMap<>(); // Insert 10 keys all with same hash (simulate bad hashCode) String base = "badHash"; for (int i = 0; i < 10; i++) { map.put(base + i, "value" + i); } // Lookup time stays fast because Java's treeification handles the long chain long start = System.nanoTime(); String val = map.get("badHash9"); long end = System.nanoTime(); System.out.println("Lookup took " + (end - start) + " ns — still fast due to treeification"); // Show table size after resizing try { java.lang.reflect.Field tableField = java.util.HashMap.class.getDeclaredField("table"); tableField.setAccessible(true); Object[] table = (Object[]) tableField.get(map); System.out.println("Table length: " + table.length); } catch (Exception e) { System.out.println("Cannot reflect table size"); } } }
Common Mistakes and How to Debug Collision Issues
Even seasoned engineers fall into hash collision traps. Here are the most frequent mistakes and how to spot them in production.
Mistake 1: Bad hashCode implementation. The classic sin. If hashCode() returns a constant or uses only partial fields, collisions skyrocket. Symptom: benchmark shows O(n) lookup time. Debug: print the distribution of hash values over your key population. A uniform distribution should have roughly equal counts in each bucket. Fix: use Objects.hash(field1, field2, ...) or a prime-multiplier pattern.
Mistake 2: Forgetting to override equals() when overriding hashCode(). Java's contract demands that equal keys produce equal hash codes. If you override only equals() or only hashCode(), your map will store duplicate keys or fail to find entries. Symptom: containsKey() returns false for a key you just inserted. Debug: check that both methods are consistent. Fix: always override both, or use a record class (Java 16+) which does it automatically.
Mistake 3: Using mutable keys. If you insert a key into a HashMap and then mutate the key object (change a field used by hashCode), the map will not rehash. The key will be lost forever in the wrong bucket. Symptom: a key you can still reference as a variable returns null from the map. Debug: ensure keys are immutable (final fields, no setters). Fix: use String, Integer, or other immutable types as keys.
Mistake 4: ignoring the load factor in open addressing. Letting the load factor exceed 0.7 causes probe counts to explode. At 0.95, average probe count hits 10+ (see the simulation). Symptom: hash table inserts and lookups become very slow at high occupancy. Debug: monitor fill percentage. Fix: resize proactively, or use a chaining-based hash table that degrades linearly.
Mistake 5: Deleting with null in open addressing. This breaks the probe chain — subsequent lookups for keys that were inserted after the deleted key will incorrectly return null. Debug: if after deleting one key you can't find many others, check for null deletions. Fix: always use a tombstone sentinel.
import java.util.HashMap; public class MutableKeyTrap { static class MutableKey { int id; String name; MutableKey(int id, String name) { this.id = id; this.name = name; } @Override public int hashCode() { return id * 31 + name.hashCode(); } @Override public boolean equals(Object o) { if (!(o instanceof MutableKey)) return false; MutableKey other = (MutableKey) o; return this.id == other.id && this.name.equals(other.name); } } public static void main(String[] args) { HashMap<MutableKey, String> map = new HashMap<>(); MutableKey key = new MutableKey(1, "Alice"); map.put(key, "value1"); System.out.println("Before mutation: " + map.get(key)); // "value1" key.id = 2; // mutate! hashCode changes but bucket remains old System.out.println("After mutation: " + map.get(key)); // null — key lost! // The old key is still in the map but in the wrong bucket // Create a new key with original state to find it: MutableKey originalKey = new MutableKey(1, "Alice"); System.out.println("With original state: " + map.get(originalKey)); // still "value1" } }
Double Hashing: The Escape Hatch from Clustering
Linear probing creates primary clustering. Quadratic probing creates secondary clustering. Both degrade search to O(n) under load. Double hashing fixes this by using a second hash function to compute the probe step. Instead of stepping by 1 or a quadratic sequence, you step by h2(key). This spreads probe sequences uniformly across the table. The result: near-constant performance even at 70% load. The catch is that your second hash must never produce 0, and must be coprime to table size. Production systems like Redis and Python's dict use variants of this for reliability. If you see performance degrade slowly instead of falling off a cliff, you likely have clustering — not the hash function itself.
// io.thecodeforge.hash.DoubleHashTable public class DoubleHashTable<K, V> { private Entry<K, V>[] table; private int capacity; public DoubleHashTable(int capacity) { this.capacity = capacity; this.table = new Entry[capacity]; } private int hash1(K key) { return Math.abs(key.hashCode() % capacity); } // Must be odd, less than capacity, and never 0 private int hash2(K key) { return 1 + (Math.abs(key.hashCode() % (capacity - 1))); } public void put(K key, V value) { int idx = hash1(key); int step = hash2(key); while (table[idx] != null && !table[idx].deleted) { if (table[idx].key.equals(key)) { table[idx].value = value; return; } idx = (idx + step) % capacity; } table[idx] = new Entry<>(key, value); } }
Robin Hood Hashing: Steal From the Rich, Give to the Poor
Standard open addressing punishes keys that landed far from their hash. Robin Hood hashing tracks how far each key drifted from its ideal slot. When inserting, if the current key has drifted less than the key in the slot you're probing, you swap them. This equalizes probe lengths across all keys. Worst-case lookup becomes bounded and you get cache-friendly linear scans. The trade-off: insertions become slightly slower because of the swaps. But for read-heavy workloads — cache lookups, database indexes — this is a win. CPython's dictionary uses a variant. If you're building a high-throughput cache, consider Robin Hood over plain linear probing.
// io.thecodeforge.hash.RobinHoodHash public class RobinHoodHash<K, V> { private static class Entry<K, V> { K key; V value; int dist; // distance from ideal slot } private Entry<K, V>[] table; private int capacity; public RobinHoodHash(int capacity) { this.capacity = capacity; this.table = new Entry[capacity]; } public void put(K key, V value) { int idx = Math.abs(key.hashCode() % capacity); int dist = 0; Entry<K, V> newEntry = new Entry<>(key, value, dist); while (true) { Entry<K, V> cur = table[idx]; if (cur == null) { table[idx] = newEntry; return; } if (cur.key.equals(key)) { cur.value = value; return; } // Swap if new entry has drifted further if (newEntry.dist > cur.dist) { table[idx] = newEntry; newEntry = cur; } idx = (idx + 1) % capacity; newEntry.dist++; } } }
When a Bad Hash Function Killed Response Times
hashCode() on the session key object returned 0 for certain fields. Every key with that field hashed to the same slot, creating a chain of 500K+ entries. Lookups became O(n) where n = total sessions.Objects.hash(field1, field2) and let Java's built-in distribution spread the keys. Then forced a rehash by calling put() on a fresh HashMap.- Never trust a custom hashCode without verifying real-world bucket distribution.
- Instrument your maps — log chain length distribution periodically or at least during load tests.
- Always keep the load factor at or below 0.75; Java's default is tested for a reason.
((HashMap)map).table reflection or a JMX bean to expose bucket sizes. If any chain > 20, your hash function is failing.equals() and hashCode() are implemented consistently. If two equal keys produce different hash codes, they'll live in different buckets.Print bucket distribution: `java -cp perf-utils.jar io.thecodeforge.HashBucketDistribution <pid>` (simulate)Dump map: `jstack -l <pid> | grep -A5 "hash"` (not perfect — better to use Java Mission Control)Collectors.toMap(k → k.newHash(), v → v) and swap the reference atomically.Check load factor: `System.out.println((double)size / table.length);`Temporarily enlarge the table: `table = Arrays.copyOf(table, table.length * 2);` then rehashPrint all slots: `System.out.println(Arrays.toString(keys));`Count tombstones: `System.out.println(Arrays.stream(keys).filter(k -> k.equals(TOMBSTONE)).count());`| Feature / Aspect | Chaining | Open Addressing (Linear Probe) |
|---|---|---|
| Data storage | Linked lists hang off each array slot | All data lives directly inside the array |
| Memory overhead | Extra pointer per node (higher overhead) | No extra pointers (lower overhead) |
| Max load factor | Can exceed 1.0 (more items than slots) | Must stay below 1.0 (can't exceed slot count) |
| Recommended load factor threshold | ≤ 1.0 before resizing | ≤ 0.7 before resizing |
| Deletion complexity | Simple — remove node from linked list | Tricky — requires tombstone markers |
| Cache performance | Poor (pointer chasing through memory) | Excellent (contiguous array, CPU prefetches) |
| Performance at high load | Degrades linearly — chains get longer | Degrades exponentially — probes multiply fast |
| Best for | Unpredictable insert volume, frequent deletes | Known data size, read-heavy, performance-critical |
| Used in Java | HashMap (with tree upgrade at chain length 8) | Not the default, but used in some specialized maps |
| File | Command / Code | Purpose |
|---|---|---|
| HashFunctionDemo.java | public class HashFunctionDemo { | What Is a Hash Function and Why Do Collisions Happen? |
| ChainingHashMap.java | public class ChainingHashMap { | Chaining |
| LinearProbingHashMap.java | public class LinearProbingHashMap { | Open Addressing |
| LoadFactorDemo.java | public class LoadFactorDemo { | Chaining vs Open Addressing |
| HashMapInternalsDemo.java | public class HashMapInternalsDemo { | How Java's HashMap Handles Collisions in Production |
| MutableKeyTrap.java | public class MutableKeyTrap { | Common Mistakes and How to Debug Collision Issues |
| DoubleHashTable.java | public class DoubleHashTable | Double Hashing |
| RobinHoodHash.java | public class RobinHoodHash | Robin Hood Hashing |
Key takeaways
Interview Questions on This Topic
What is a hash collision and what are the two main strategies to resolve it? Walk me through how each one works.
Why can't you simply set a slot to null when deleting a key in a hash table that uses linear probing? What problem does that cause, and how do you fix it?
Java's HashMap resizes when the load factor hits 0.75. Why 0.75 specifically — what happens to performance if you let the load factor reach 0.95?
Frequently Asked Questions
A hash collision happens when two different keys produce the same index after the hash function runs. Because your hash table has a fixed number of slots but an unlimited number of possible keys, the Pigeonhole Principle guarantees that eventually two keys will map to the same slot. A good hash function minimizes this but cannot eliminate it.
Open addressing is typically faster in practice on modern hardware, even though both are O(1) average in theory. Because open addressing stores everything in a single contiguous array, the CPU's cache prefetcher can load data before it's even requested. Chaining's linked list nodes scatter across memory, causing cache misses on every pointer follow.
A tombstone is a special marker value placed in a slot when a key is deleted from an open-addressing hash table. Instead of setting the slot to null (which would falsely signal 'no item was ever here' and break probe chains), a tombstone signals 'this slot had an item but it was deleted — keep probing'. Without tombstones, lookups for keys inserted after the deleted key would incorrectly return null.
Starting from Java 8, when a bucket chain exceeds 8 entries (TREEIFY_THRESHOLD), HashMap converts that linked list into a balanced Red-Black tree. This ensures worst-case lookup time remains O(log n) even under pathological collisions. The tree backconverts to a list when entries drop below 6 (UNTREEIFY_THRESHOLD).
Technically yes, but it's highly dangerous. If you mutate any field that is used in the hashCode() or equals() methods after inserting the key, the map will not rehash the key into the correct bucket. The key will be effectively lost — lookups using the same object reference will return null because the hash code changed but the bucket didn't. Always use immutable keys like String, Integer, or record classes.
20+ years shipping production systems from the metal up. Everything here is grounded in real deployments.
That's Software Engineering. Mark it forged?
7 min read · try the examples if you haven't