If you’ve done LeetCode’s Design HashMap, you’ve implemented put, get, and remove. What that exercise usually skips is the part that actually breaks in production: what happens when someone mutates the map while another piece of code is iterating over it.
I ran into this directly while building MyHashMap, a from-scratch single-threaded HashMap (separate chaining, resize on load factor). Getting put/get/remove right was the easy 80%. Getting entrySet().iterator() to correctly detect concurrent mutation — including the case where a second, completely separate iterator is the one that should notice — took three wrong turns before landing on the pattern the JDK actually uses.
The problem, concretely
Iterator<Entry<K,V>> it = map.entrySet().iterator();
it.next();
map.put(someNewKey, someValue); // structural change, mid-iteration
it.next(); // ??? — undefined behavior if we don't guard against this
Enter fullscreen mode Exit fullscreen mode
Without a guard, next() might return a stale entry, skip entries entirely, or throw an unrelated exception depending on internal bucket-array state. Java’s real collections handle this with ConcurrentModificationException (CME) — but the interesting part isn’t the exception, it’s the mechanism that detects when to throw it.
First idea: a boolean “dirty” flag
Obvious first attempt: a boolean modified field on the map, flipped to true on any put/remove, checked by the iterator. This works for exactly one iterator. It falls apart the moment two iterators are alive at once:
- Iterator A calls
next(), seesmodified == false, proceeds. - Something else mutates the map.
modifiedflips totrue. - Iterator B — created after that mutation — checks the same shared
modifiedflag, seestrue, and incorrectly throws, even though nothing has changed since B was created.
A single shared boolean can’t represent “changed since this specific iterator was created” for more than one iterator at a time. Resetting it on read doesn’t help either — now the other iterator stops seeing the change it legitimately needed to see.
Second idea: a timestamp
Next instinct: give the map a lastModified timestamp instead, and have each iterator capture the current time on creation. Compare timestamps instead of a shared flag — now each iterator has its own baseline.
This closes the multi-iterator gap, but introduces a different bug: resolution. Two mutations in a tight loop, or a mutation immediately followed by iterator creation, can land in the same millisecond (System.currentTimeMillis()) — or even the same tick of System.nanoTime() on some platforms. If a real modification and an iterator’s baseline capture ever produce the same timestamp value, the comparison can’t tell who happened first. Worse, currentTimeMillis() isn’t even guaranteed monotonic — it can jump backward on a clock adjustment.
The deeper issue: this isn’t a timing problem. It’s a “did anything change since I looked” problem, and wall-clock time is the wrong tool for a question that has nothing to do with elapsed time.
What actually works: a monotonic counter (modCount)
Every mutation increments a plain long counter. Every iterator, at creation, snapshots the counter’s current value. Every next() call compares its snapshot against the live value:
final class MyEntryIterator implements Iterator<Entry<K, V>> {
private long expectedVersion;
// ...
public MyEntryIterator() {
this.expectedVersion = version; // snapshot at creation
}
@Override
public Entry<K, V> next() {
if (expectedVersion != version) {
throw new ConcurrentModificationException();
}
// ... advance and return
}
}
Enter fullscreen mode Exit fullscreen mode
No shared mutable flag, no clock. Each iterator carries its own independent baseline (expectedVersion), so the multi-iterator case that broke the boolean flag now works automatically — iterator B’s snapshot is whatever the counter was when B was created, completely independent of A’s. And since it’s a plain increment, not a physical measurement, there’s zero resolution/collision risk: every mutation gets a value strictly different from every other, no matter how fast they happen. This is exactly the pattern java.util.HashMap, ArrayList, and friends use internally — go read AbstractList‘s modCount field and the ConcurrentModificationException javadoc directly; it’s short.
The subtlety that actually caught me: no “free” first call
Here’s the case that exposed a real bug in my own test suite, not just the implementation. Two iterators, A and B, created back to back — before either has called next() even once:
sequenceDiagram
participant Map
participant IteratorA
participant IteratorB
Note over Map: version = 3 (after 3 puts)
IteratorA->>Map: create (snapshot version=3)
IteratorB->>Map: create (snapshot version=3)
IteratorA->>Map: next()
IteratorA->>Map: remove() → version = 4
IteratorB->>Map: next()
Map-->>IteratorB: ConcurrentModificationException
Note over IteratorB: B never called next() before A's<br/>change — still invalidated on its<br/>very first call
I originally wrote a test that let iterator B succeed on its first next() call before asserting a second call would throw. That’s wrong: B’s snapshot predates A’s remove(), so B is already stale the instant A mutates — there’s no “one free call” grace period. The check only cares whether a modification happened since the snapshot was taken, not whether the iterator has navigated yet. Fixing that test (not the implementation) was the actual bug.
The self-invalidation trap
One more sharp edge: an iterator’s own remove() legitimately bumps the shared counter — so if the iterator doesn’t resync its own expectedVersion immediately afterward, it trips its own check on the very next call:
@Override
public void remove() {
if (!nextCalled) {
throw new IllegalStateException();
}
MyEntry entryToDelete = currentEntry;
this.next();
MyHashMap.this.remove(entryToDelete.getKey());
this.expectedVersion = version; // resync — or self-CME on the next call
this.nextCalled = false;
}
Enter fullscreen mode Exit fullscreen mode
One more nuance most write-ups skip: not every write is “structural”
The counter shouldn’t bump on every put() — only on ones that actually change the map’s shape. Overwriting the value of an already-present key isn’t structural (same key, same position, same size) and doesn’t need to invalidate a live iterator; inserting a genuinely new key is, and does:
if (entry == null) {
entries[bucket] = new MyEntry(key, value);
++count;
++version; // new key: structural
} else {
// walk the chain...
if (matchFound) {
entry.setValue(value); // existing key, value-only: NOT structural, no bump
}
}
Enter fullscreen mode Exit fullscreen mode
This matches java.util.HashMap‘s real behavior, and it’s the kind of detail that only shows up once you’ve actually built the thing rather than read about it.
Takeaway
Fail-fast iteration looks like a one-line trick (modCount) until you actually have to defend it against a second iterator, a same-millisecond race, or your own iterator’s legal mutation. Building it from scratch — and writing tests that actually exercise the multi-iterator case — surfaced three separate designs before landing on the one that’s actually in the JDK, plus a bug in the test for the final design, not the code.
Further reading
- Fail-Safe Iterator vs Fail-Fast Iterator — Baeldung
-
ConcurrentModificationExceptionjavadoc — the “best-effort, not a guarantee” language is worth reading verbatim - java-iterator-fail-types — GitHub