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
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.
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.
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.
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.
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.
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.
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.
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.
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.| 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.
Frequently Asked Questions
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