Command Palette

Search for a command to run...

[Advanced Java] Iterator, ListIterator, and Fail-Fast versus Fail-Safe Iteration

You have almost certainly met ConcurrentModificationException already: you removed an element inside an enhanced for loop, the program blew up, and you learned to reach for Iterator.remove() or removeIf instead. That is the right fix and it is where a beginner course stops. This article is about the machinery underneath it.

The machinery is worth knowing because the exception is not the interesting part. The interesting part is that the check which produces it is documented as best-effort, and there is a small, entirely deterministic, single-threaded case where a broken loop silently produces a wrong answer and throws nothing at all. That case is demonstrated below with real output.

Four cells walked by two iterators: one reading the live list and throwing, one reading a frozen copy and never throwing

Every output line, stack trace, counter value and disassembly below was produced by compiling and running the code on OpenJDK 21.0.6 (arm64). No threads were started anywhere in this article; the section on concurrent collections says explicitly which claims come from the documented contract rather than from an experiment.

What the Iterator contract actually is

java.util.Iterator is four methods, of which everyday code calls two:

public interface Iterator<E> {
    boolean hasNext();
    E next();
    default void remove() { throw new UnsupportedOperationException("remove"); }
    default void forEachRemaining(Consumer<? super E> action) { ... }
}

remove and forEachRemaining became default methods in Java 8, which is why a hand-written iterator that implements only hasNext and next still compiles.

The implementation is two int fields. Here is java.util.ArrayList$Itr from the JDK 21 sources, with the comments the JDK authors wrote:

private class Itr implements Iterator<E> {
    int cursor;       // index of next element to return
    int lastRet = -1; // index of last element returned; -1 if no such
    int expectedModCount = modCount;

    public boolean hasNext() {
        return cursor != size;
    }
    ...
}

cursor is the index of the next element to return, which means the cursor does not sit on an element — it sits in the gap before one. Three elements therefore have four legal cursor positions, 0 through 3, and hasNext() is nothing more than cursor != size.

lastRet is the other half of the contract. It records the index that next() last returned, and it is what remove() deletes. It starts at -1, and remove() resets it to -1 after each successful deletion.

Three list cells with four cursor slots between them, and three state cards showing cursor and lastRet after iterator, next and remove

The three exceptions the contract defines

Those two fields generate all three failures the contract can produce. Calling remove() before any next() leaves lastRet at -1:

import java.util.*;

public class Traces {
    public static void main(String[] args) {
        List<String> l = new ArrayList<>(List.of("ada", "linus"));
        Iterator<String> it = l.iterator();
        it.remove();
    }
}
Exception in thread "main" java.lang.IllegalStateException
	at java.base/java.util.ArrayList$Itr.remove(ArrayList.java:1062)
	at Traces.main(Traces.java:6)

Calling remove() twice in a row fails identically, and for exactly the same reason — the first call put lastRet back to -1:

Iterator<String> it = l.iterator();
it.next();
it.remove();
it.remove();
Exception in thread "main" java.lang.IllegalStateException
	at java.base/java.util.ArrayList$Itr.remove(ArrayList.java:1062)
	at Traces3.main(Traces3.java:8)

Note that IllegalStateException carries no message. There is nothing to read in a log except the frame it came from, so recognise the shape.

The third failure is calling next() when cursor == size:

List<String> l = new ArrayList<>(List.of("ada"));
Iterator<String> it = l.iterator();
it.next();
it.next();
Exception in thread "main" java.util.NoSuchElementException
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1052)
	at Traces2.main(Traces2.java:7)

hasNext() is the only thing standing between a loop and that exception. It is a query, not a guard: nothing forces you to call it, and next() does not call it for you.

One more detail from the source, because it matters later. remove() does this:

public void remove() {
    if (lastRet < 0)
        throw new IllegalStateException();
    checkForComodification();
    try {
        ArrayList.this.remove(lastRet);
        cursor = lastRet;
        lastRet = -1;
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

Two lines are doing quiet, essential work. cursor = lastRet rewinds the cursor to the hole the deletion left, which is why iterator removal never skips the following element. And expectedModCount = modCount re-synchronises the iterator with the list, which is the entire reason this removal is legal and a direct list.remove(...) is not.

The enhanced for loop is an Iterator, and the bytecode proves it

The enhanced for loop over a Collection is not a language feature with its own runtime support. The compiler rewrites it into iterator calls, and javap -c shows the rewrite exactly:

import java.util.List;

public class ForEach {
    static int sum(List<Integer> xs) {
        int total = 0;
        for (int x : xs) {
            total += x;
        }
        return total;
    }
}
javac ForEach.java && javap -c -p ForEach.class
  static int sum(java.util.List<java.lang.Integer>);
    Code:
       0: iconst_0
       1: istore_1
       2: aload_0
       3: invokeinterface #7,  1            // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator;
       8: astore_2
       9: aload_2
      10: invokeinterface #13,  1           // InterfaceMethod java/util/Iterator.hasNext:()Z
      15: ifeq          38
      18: aload_2
      19: invokeinterface #19,  1           // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
      24: checkcast     #23                 // class java/lang/Integer
      27: invokevirtual #25                 // Method java/lang/Integer.intValue:()I
      30: istore_3
      31: iload_1
      32: iload_3
      33: iadd
      34: istore_1
      35: goto          9
      38: iload_1
      39: ireturn

(The default constructor is omitted; everything else is verbatim.)

Read the four highlighted instructions in order. List.iterator() is called once and the result stored in slot 2. Iterator.hasNext() runs at the top of every pass and ifeq jumps out when it returns false. Iterator.next() produces an Object, checkcast casts it to Integer — that is type erasure showing through — and goto 9 closes the loop.

Two consequences fall straight out of that listing:

  • The iterator lives in local slot 2 and your source code has no name for it. That is the whole reason you cannot call remove() safely from inside an enhanced for loop: the object that would let you do it exists, but the language hides it. Writing the for (Iterator<String> it = list.iterator(); it.hasNext(); ) form by hand is not a stylistic downgrade — it is the only way to get a reference to that object.
  • hasNext() is called before every next(), and next() is where the comodification check lives. Hold on to that; the next section turns on it.

Iterating an array compiles to something else entirely

The same syntax over an array produces no iterator at all:

static int sum(int[] xs) {
    int total = 0;
    for (int x : xs) total += x;
    return total;
}
       3: astore_2
       4: aload_2
       5: arraylength
       6: istore_3
       7: iconst_0
       8: istore        4
      10: iload         4
      12: iload_3
      13: if_icmpge     33
      16: aload_2
      17: iload         4
      19: iaload
      20: istore        5
      ...
      27: iinc          4, 1
      30: goto          10

arraylength, an index in slot 4, iaload, iinc. It is an ordinary counted loop. An array cannot be structurally modified, so there is nothing here to detect and no ConcurrentModificationException is possible — which is also why the same syntax over a List and over an array behave so differently when something changes underneath.

ListIterator: the bidirectional, positional iterator only List provides

Iterator is the lowest common denominator: forwards only, no positions, and remove as the single mutation. List offers something larger through listIterator(), and it is the only interface in the collections framework that does — Set and the Map views offer iterator() and no positional variant, because they have no index to talk about.

CapabilityIteratorListIterator
Move forwardhasNext(), next()hasNext(), next()
Move backwardnot availablehasPrevious(), previous()
Ask where the cursor isnot availablenextIndex(), previousIndex()
Delete the last element returnedremove()remove()
Replace the last element returnednot availableset(E)
Insert at the cursornot availableadd(E)
Available onany IterableList only

nextIndex() and previousIndex() are just cursor and cursor - 1 exposed as methods, which makes the between-elements model visible from outside:

List<String> words = new ArrayList<>(List.of("ada", "linus", "grace"));
ListIterator<String> it = words.listIterator();
System.out.println("start            nextIndex=" + it.nextIndex() + " previousIndex=" + it.previousIndex());
System.out.println("next() -> " + it.next() + "   nextIndex=" + it.nextIndex() + " previousIndex=" + it.previousIndex());
System.out.println("next() -> " + it.next() + " nextIndex=" + it.nextIndex() + " previousIndex=" + it.previousIndex());
System.out.println("previous() -> " + it.previous() + " nextIndex=" + it.nextIndex() + " previousIndex=" + it.previousIndex());
start            nextIndex=0 previousIndex=-1
next() -> ada   nextIndex=1 previousIndex=0
next() -> linus nextIndex=2 previousIndex=1
previous() -> linus nextIndex=1 previousIndex=0

Look at the last line. previous() returned linus — the same element next() had just returned. The cursor moved back over it rather than past it, because a cursor sitting in a gap can be crossed in either direction. Alternating next() and previous() returns the same element forever, and that is correct behaviour, not a bug.

To walk a list backwards, start the cursor at the far end:

List<String> back = new ArrayList<>(List.of("a", "b", "c"));
ListIterator<String> r = back.listIterator(back.size());
while (r.hasPrevious()) System.out.print(r.previous() + " ");
c b a

set replaces what next() last returned

set is the method that has no Iterator equivalent and the one people most often need. It overwrites the element at lastRet without touching the size, so it is not a structural modification and it does not disturb any other cursor state:

List<String> up = new ArrayList<>(List.of("ada", "linus", "grace"));
for (ListIterator<String> j = up.listIterator(); j.hasNext(); ) {
    String s = j.next();
    if (s.length() > 3) j.set(s.toUpperCase());
}
System.out.println(up);
[ada, LINUS, GRACE]

The alternative is an indexed loop with list.set(i, ...), which works on an ArrayList and is quadratic on a LinkedList because every set(i, ...) walks the chain again. ListIterator.set is positional for free: it already knows where it is.

set has the same precondition as remove — it needs lastRet >= 0 — so calling it before the first next() throws IllegalStateException.

add inserts before the cursor, and the cursor moves past it

add is the subtle one. The Javadoc says the element is inserted immediately before the implicit cursor, and the JDK source shows what that costs the cursor:

public void add(E e) {
    checkForComodification();
    try {
        int i = cursor;
        ArrayList.this.add(i, e);
        cursor = i + 1;
        lastRet = -1;
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

Three effects, all verifiable. The cursor advances past the new element, so the next next() skips it and the loop cannot insert its way into an infinite loop. lastRet goes back to -1, so set and remove immediately after an add are illegal. And expectedModCount is resynchronised, so the insertion does not invalidate the iterator that performed it.

List<String> ins = new ArrayList<>(List.of("a", "b", "c"));
ListIterator<String> k = ins.listIterator();
while (k.hasNext()) {
    String s = k.next();
    if (s.equals("b")) {
        k.add("B2");
        System.out.println("after add(\"B2\") nextIndex=" + k.nextIndex()
                + " previousIndex=" + k.previousIndex() + " list=" + ins);
    }
}
System.out.println("after the loop " + ins);
after add("B2") nextIndex=3 previousIndex=2 list=[a, b, B2, c]
after the loop [a, b, B2, c]

nextIndex went from 2 to 3 across the insertion: the cursor stayed on the far side of B2, the loop went on to c, and B2 was never returned by next(). If you actually want to see what you just inserted, previous() returns it — and the third state change proves itself too:

List<String> l = new ArrayList<>(List.of("a", "b"));
ListIterator<String> it = l.listIterator();
it.next();
it.add("x");
try { it.set("y"); } catch (IllegalStateException e) {
    System.out.println("set() after add() -> " + e.getClass().getName());
}
try { it.remove(); } catch (IllegalStateException e) {
    System.out.println("remove() after add() -> " + e.getClass().getName());
}
System.out.println("list = " + l);
System.out.println("previous() -> " + it.previous());
set() after add() -> java.lang.IllegalStateException
remove() after add() -> java.lang.IllegalStateException
list = [a, x, b]
previous() -> x

How fail-fast actually works: modCount and expectedModCount

The mechanism is two integers, and both can be read out of a running JVM.

AbstractList declares the first one. The Javadoc in the JDK source defines it precisely: "The number of times this list has been structurally modified. Structural modifications are those that change the size of the list, or otherwise perturb it in such a fashion that iterations in progress may yield incorrect results."

Every iterator copies that value into its own expectedModCount at construction, and checkForComodification compares them:

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

Reflection makes the two counters concrete. modCount is protected on AbstractList and expectedModCount is package-private on ArrayList$Itr, so reading them needs the module opened on the command line:

import java.lang.reflect.Field;
import java.util.*;

public class ModCount {
    static Field mod, expected;

    static String state(List<?> list, Iterator<?> it) throws Exception {
        return String.format("modCount=%d expectedModCount=%d", mod.getInt(list), expected.getInt(it));
    }

    public static void main(String[] args) throws Exception {
        mod = java.util.AbstractList.class.getDeclaredField("modCount");
        mod.setAccessible(true);

        List<String> list = new ArrayList<>(List.of("ada", "linus", "grace"));
        Iterator<String> it = list.iterator();

        expected = it.getClass().getDeclaredField("expectedModCount");
        expected.setAccessible(true);

        System.out.println("iterator class            " + it.getClass().getName());
        System.out.println("after iterator()          " + state(list, it));
        System.out.println("after next() -> " + it.next() + "     " + state(list, it));
        list.add("ken");
        System.out.println("after list.add(\"ken\")     " + state(list, it));
        try {
            it.next();
        } catch (ConcurrentModificationException e) {
            System.out.println("next() threw              " + e.getClass().getName());
        }
    }
}
javac ModCount.java && java --add-opens java.base/java.util=ALL-UNNAMED ModCount
iterator class            java.util.ArrayList$Itr
after iterator()          modCount=0 expectedModCount=0
after next() -> ada     modCount=0 expectedModCount=0
after list.add("ken")     modCount=1 expectedModCount=0
next() threw              java.util.ConcurrentModificationException

The same program run with it.remove() in place of list.add(...) keeps the two in step, because iterator removal writes expectedModCount = modCount on the way out:

after iterator()          modCount=0 expectedModCount=0
after next() -> ada     modCount=0 expectedModCount=0
after it.remove()         modCount=1 expectedModCount=1
after next() -> linus   modCount=1 expectedModCount=1
list = [linus, grace]

That is the whole mechanism. There is no thread, no lock, no monitor. It is one counter compared against a snapshot of that counter.

What counts as a structural modification

The definition says "changes the size, or otherwise perturbs it" — and that second clause is not decoration. Probing each operation with the same reflection shows which ones bump modCount:

Operation on an ArrayListmodCountStructural
list.get(0)0 to 0no
list.set(0, "z")0 to 0no
list.add("z")0 to 1yes
list.remove("a")0 to 1yes
list.addAll(List.of("y"))0 to 1yes
list.clear()0 to 1yes
list.removeIf(s -> false)0 to 0no
list.removeIf(s -> true)0 to 1yes
list.sort(null)0 to 1yes
list.replaceAll(String::trim)0 to 1yes

Two rows deserve attention. sort and replaceAll change no sizes at all and are still structural, because reordering a list under a live cursor would make the traversal return the wrong elements — exactly the "perturb" case in the Javadoc. Sorting a list you are iterating throws:

List<String> l = new ArrayList<>(List.of("b", "a", "c"));
Iterator<String> it = l.iterator();
System.out.println("next() -> " + it.next());
l.sort(null);
System.out.println("size unchanged: " + l.size() + ", list = " + l);
it.next();
next() -> b
size unchanged: 3, list = [a, b, c]
Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1095)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1049)
	at SortDuring.main(SortDuring.java:9)

And removeIf(s -> false) does not bump the counter, because ArrayList.removeIf only touches modCount when it actually deletes something. A removeIf that matches nothing leaves every live iterator valid.

Fail-fast is best-effort, not a guarantee

Here is the paragraph in the ArrayList Javadoc that most tutorials never quote:

Note that the fail-fast behavior of an iterator cannot be guaranteed as it is, generally speaking, impossible to make any hard guarantees in the presence of unsynchronized concurrent modification. Fail-fast iterators throw ConcurrentModificationException on a best-effort basis. Therefore, it would be wrong to write a program that depended on this exception for its correctness: the fail-fast behavior of iterators should be used only to detect bugs.

That is usually read as a hedge about threads. It is not only about threads. The check lives inside next() and inside no other method — in particular, not inside hasNext(), which is return cursor != size; and nothing else. So a structural modification that makes hasNext() return false ends the loop before next() ever gets the chance to complain.

Removing the second-to-last element of a list inside an enhanced for loop does exactly that:

import java.util.*;

public class Quiet {
    public static void main(String[] args) {
        List<String> a = new ArrayList<>(List.of("a", "b", "c", "d"));
        int seen = 0;
        for (String s : a) {
            seen++;
            System.out.println("visited " + s);
            if (s.equals("c")) a.remove(s);
        }
        System.out.println("no exception, list = " + a + ", visited " + seen + " of 4");
    }
}
visited a
visited b
visited c
no exception, list = [a, b, d], visited 3 of 4

Three elements visited out of four. d was never looked at, the loop exited normally, and nothing was thrown. Walk the counters: after next() returned c the cursor was 3 and the size was 4; a.remove("c") dropped the size to 3 and raised modCount to 1; the loop then asked hasNext(), which computed 3 != 3 and returned false. The comparison that would have caught the mismatch was never reached.

hasNext returns cursor != size with no check, while next runs checkForComodification, traced over a loop that ends early without throwing

In that example the surviving list happens to be correct. Change the data by one element and it is not:

List<String> tags = new ArrayList<>(List.of("keep", "keep", "drop", "drop"));
for (String t : tags) {
    if (t.equals("drop")) tags.remove(t);
}
System.out.println("for-each remove  -> " + tags);

List<String> ok = new ArrayList<>(List.of("keep", "keep", "drop", "drop"));
ok.removeIf(t -> t.equals("drop"));
System.out.println("removeIf         -> " + ok);
for-each remove  -> [keep, keep, drop]
removeIf         -> [keep, keep]

A filter that was meant to delete every drop left one behind, silently, with no exception and no warning. That is what "best-effort" costs in practice, and it is the single most important thing in this article: ConcurrentModificationException is a bug detector that sometimes misses. LinkedList behaves identically — its ListItr.hasNext() is nextIndex < size, so the same removal produces [a, b, d] there too.

⚠️ Never treat a clean run as proof that a loop does not modify what it iterates. Fail-fast is a smoke alarm, not a type system.

The same hole exists in HashMap

HashMap's iterator has a different hasNext() — it is next != null, walking the bucket table rather than counting an index — but the structure of the hole is the same. Removing the mapping that is last in iteration order ends the walk without a complaint:

Map<String, Integer> m = new LinkedHashMap<>();
m.put("a", 1); m.put("b", 2); m.put("c", 3);
List<String> order = new ArrayList<>(m.keySet());
System.out.println("iteration order " + order);
for (String k : m.keySet()) {
    if (k.equals(order.get(order.size() - 1))) m.remove(k);
}
System.out.println("removing the last key in iteration order -> no exception, map = " + m);
iteration order [a, b, c]
removing the last key in iteration order -> no exception, map = {a=1, b=2}

Removing any earlier key throws, as expected. LinkedHashMap is used here only so the iteration order is fixed and the demonstration is reproducible; a plain HashMap has the same hole at whatever entry happens to come last.

Note also what does not throw: overwriting the value of a key that already exists is not a structural modification, because the table's shape is unchanged.

Map<String, Integer> hm2 = new HashMap<>();
hm2.put("a", 1); hm2.put("b", 2);
Iterator<String> h2 = hm2.keySet().iterator();
h2.next();
hm2.put("a", 7);
System.out.println("value-only put -> next() = " + h2.next());
value-only put -> next() = b

Fail-safe iterators, and what the JDK actually calls them

"Fail-safe iterator" is interview vocabulary, not JDK vocabulary. The string fail-safe does not appear anywhere in the java.util or java.util.concurrent sources of JDK 21. What the Javadoc actually distinguishes is two documented behaviours: a snapshot iterator and a weakly consistent one. They are not the same thing, and the difference is visible in a single-threaded program.

Three panels showing the same write during a walk: ArrayList throws, CopyOnWriteArrayList never sees the write, ConcurrentHashMap does

The experiment below is the same for all three: build a three-element collection, call next() once, add "d", then finish the walk.

CopyOnWriteArrayList iterates a snapshot

The CopyOnWriteArrayList Javadoc is unusually direct about it: "The 'snapshot' style iterator method uses a reference to the state of the array at the point that the iterator was created. This array never changes during the lifetime of the iterator, so interference is impossible and the iterator is guaranteed not to throw ConcurrentModificationException. The iterator will not reflect additions, removals, or changes to the list since the iterator was created."

CopyOnWriteArrayList<String> cw = new CopyOnWriteArrayList<>(List.of("a", "b", "c"));
Iterator<String> i2 = cw.iterator();
System.out.println("  next() -> " + i2.next());
cw.add("d");
while (i2.hasNext()) System.out.println("  next() -> " + i2.next());
System.out.println("  list = " + cw);
  next() -> a
  next() -> b
  next() -> c
  list = [a, b, c, d]

The list ends up with four elements and the iterator returned three. The write was not missed, delayed or lost — it went to a different array, and the iterator is still holding the old one. Notice that "never throws" is not the same as "sees the truth": this iterator is reliably, permanently out of date the moment anyone writes.

Because the array it holds is a frozen copy that the list no longer owns, the mutating methods cannot work at all:

Iterator<String> it2 = cw.iterator();
it2.next();
it2.remove();
Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.concurrent.CopyOnWriteArrayList$COWIterator.remove(CopyOnWriteArrayList.java:1208)
	at CowRemove.main(CowRemove.java:8)

remove, set and add on a COWIterator all throw it, with a null message. That is a real API restriction, not a detail: code written against Iterator.remove() will not survive being handed a CopyOnWriteArrayList.

What the snapshot costs, counted in copies

The name says what the cost is. Every mutating call allocates a whole new backing array and copies the old contents into it. Reflecting on the private array field and watching its identity change counts the copies exactly (this program needs --add-opens java.base/java.util.concurrent=ALL-UNNAMED alongside the java.base/java.util one):

Field arr = CopyOnWriteArrayList.class.getDeclaredField("array");
arr.setAccessible(true);
CopyOnWriteArrayList<Integer> cow = new CopyOnWriteArrayList<>();
Object previous = arr.get(cow);
int newArrays = 0;
long elementsCopied = 0;
for (int i = 0; i < 1000; i++) {
    int before = ((Object[]) arr.get(cow)).length;
    cow.add(i);
    Object now = arr.get(cow);
    if (now != previous) { newArrays++; elementsCopied += before; previous = now; }
}
CopyOnWriteArrayList: 1000 add() calls
  new backing arrays allocated 1000
  element slots copied         499500
ArrayList: 1000 add() calls
  new backing arrays allocated 13
  element slots copied         2456

One thousand appends, one thousand array allocations, and 499,500 element slots copied — the sum 0 + 1 + ... + 999, which is quadratic in the number of writes. ArrayList allocated 13 arrays over the same thousand appends because it grows geometrically and amortises. The numbers here are allocation counts, not timings; they are exact and reproducible, and they are the reason CopyOnWriteArrayList is documented for collections that are read constantly and written rarely, such as a listener list.

ConcurrentHashMap is weakly consistent, not snapshot-based

A ConcurrentHashMap iterator does not copy anything. It walks the live table, and the guarantee it offers is the one defined in the java.util.concurrent package documentation:

  • they may proceed concurrently with other operations
  • they will never throw ConcurrentModificationException
  • they are guaranteed to traverse elements as they existed upon construction exactly once, and may (but are not guaranteed to) reflect any modifications subsequent to construction.

The third bullet is the one that matters, and "may but are not guaranteed to" means the behaviour is genuinely unspecified. Running the same experiment:

ConcurrentHashMap<String, Integer> ch = new ConcurrentHashMap<>();
ch.put("a", 1); ch.put("b", 2); ch.put("c", 3);
Iterator<Map.Entry<String, Integer>> i3 = ch.entrySet().iterator();
System.out.println("  next() -> " + i3.next());
ch.put("d", 4);
while (i3.hasNext()) System.out.println("  next() -> " + i3.next());
  next() -> a=1
  next() -> b=2
  next() -> c=3
  next() -> d=4

d was added after the iterator was created and the iterator returned it anyway. That is the opposite of the CopyOnWriteArrayList result from the same steps, and both are correct — they implement different contracts. Repeating this run gave the same output every time, but repeatability here is a property of one small map on one JVM, not a guarantee; a different table size, a different key, or a real second thread could each change it, and the specification permits all of those outcomes.

Two more facts, both from the contract rather than from a race. Iterator.remove() works on ConcurrentHashMap views and removes from the map, unlike CopyOnWriteArrayList. And none of this makes a compound operation atomic: if (!map.containsKey(k)) map.put(k, v) is still two operations, which is what putIfAbsent and compute exist for.

Nothing in this section was race-tested. A race is not reproducible on demand, so claiming a concurrency result from a single run would be dishonest. Every statement above about concurrent behaviour comes from the documented contract; the outputs shown are single-threaded programs that mutate a collection between calls on their own iterator, which is enough to expose the difference in semantics.

ArrayListCopyOnWriteArrayListConcurrentHashMap
Common namefail-fastfail-safefail-safe
JDK termfail-fastsnapshotweakly consistent
Readsthe live arraya copy frozen at iterator()the live table
Throws ConcurrentModificationExceptionbest-effortnevernever
Sees writes made after iterator()it throws insteadnevermay or may not
Iterator.remove()supportedUnsupportedOperationExceptionsupported
Cost of a writeamortiseda full array copy each timelocalised to one bin

Writing your own Iterable

The contract is small enough that implementing it is unremarkable. A class that implements Iterable works in an enhanced for loop, because that is all the compiler asked for in the bytecode above:

import java.util.*;

class Countdown implements Iterable<Integer> {
    private final int from;

    Countdown(int from) { this.from = from; }

    @Override
    public Iterator<Integer> iterator() {
        return new Iterator<>() {
            private int n = from;

            @Override
            public boolean hasNext() { return n > 0; }

            @Override
            public Integer next() {
                if (!hasNext()) throw new NoSuchElementException("countdown finished");
                return n--;
            }
        };
    }
}
for (int n : new Countdown(4)) System.out.print(n + " ");
System.out.println();
new Countdown(3).forEach(n -> System.out.print("forEach " + n + "  "));
4 3 2 1
forEach 3  forEach 2  forEach 1

Two methods, no remove, no counters, and it works with both for and Iterable.forEach. Three things are worth doing deliberately:

  • Throw NoSuchElementException from next() past the end, with a message. The contract requires the exception type; the message is yours.
  • Return a new iterator from every call to iterator(). Returning this from an object that also holds the cursor means the second loop over the same object finds it already exhausted.
  • Do not override remove unless you mean it. The inherited default throws UnsupportedOperationException("remove"), which is the honest answer for a computed sequence.
Iterator<Integer> r = new Countdown(3).iterator();
r.next();
r.remove();
Exception in thread "main" java.lang.UnsupportedOperationException: remove
	at java.base/java.util.Iterator.remove(Iterator.java:102)
	at CountdownRemove.main(CountdownRemove.java:33)

If your own class wraps a mutable collection and you want fail-fast behaviour, the recipe is the one AbstractList documents: keep a modCount field, increment it in every structural operation, snapshot it in the iterator, and compare on next().

Practical rules

  • Reach for removeIf first. It is one call, it expresses the intent, it cannot skip an element, and it touches modCount once instead of once per deletion — removeIf on a four-element list took modCount from 0 to 1 where two Iterator.remove() calls took it to 2.
  • If you need the loop, take the iterator explicitly. for (Iterator<String> it = list.iterator(); it.hasNext(); ) is the only form that gives you a name for the object the enhanced for loop hides.
  • Never structurally modify a collection you are iterating except through that iterator. Not add, not remove, not clear, and — less obviously — not sort or replaceAll.
  • Use ListIterator when you need to replace or insert during a walk. set is the right tool for an in-place update and it is not even a structural modification.
  • Do not rely on ConcurrentModificationException to find these bugs. It is best-effort by specification, it does not fire when the removal makes hasNext() false, and the loop that got away with it silently produced a wrong list.
  • Choose a concurrent collection for its read/write shape, not for the exception it avoids. CopyOnWriteArrayList copies the whole array on every write; that is a good trade for a listener list and a terrible one for a queue.
  • Prefer a stream or removeIf to a mutation loop when you can. list.stream().filter(...).toList() produces a new list and never touches the original, which sidesteps the entire question.

FAQ

What is the difference between fail-fast and fail-safe iterators in Java?

A fail-fast iterator reads the live collection and throws ConcurrentModificationException when it detects that the collection was structurally modified behind its back — the java.util collections work this way. A "fail-safe" iterator never throws, because it is not reading the live structure in the same way: CopyOnWriteArrayList iterates a snapshot taken when the iterator was created, and ConcurrentHashMap iterates the live table under a weaker guarantee. "Fail-safe" is a nickname; the JDK Javadoc uses "snapshot" and "weakly consistent", and the string fail-safe appears nowhere in the java.util or java.util.concurrent sources.

Why does ConcurrentModificationException mention concurrency when there is only one thread?

Because "concurrent" here means "at the same time as an iteration in progress", not "on another thread". A single-threaded loop that removes from the list it is walking has two things touching the list at once — the loop's iterator and the loop's body — and that is exactly what the check detects. The stack trace shows it plainly: the throwing frame is ArrayList$Itr.checkForComodification, called from ArrayList$Itr.next.

Can ConcurrentModificationException be missed?

Yes, and this is the important part. The check runs inside next() only. If a structural modification makes hasNext() return false, the loop ends before the check runs. Removing the second-to-last element of an ArrayList inside an enhanced for loop over ["a", "b", "c", "d"] visits three elements, throws nothing, and skips "d" entirely. The Javadoc states that fail-fast behaviour is provided "on a best-effort basis" and should be used "only to detect bugs".

What is a structural modification?

Anything that changes the size of the collection, or otherwise reorders or reshapes it in a way that would make an in-progress iteration return the wrong elements. add, remove, clear and addAll are structural; get and set(index, value) are not. Less obviously, sort and replaceAll are structural on an ArrayList even though the size does not change, and both make a live iterator throw.

What is the difference between Iterator and ListIterator?

Iterator moves forward only and can delete the element it last returned. ListIterator extends it with hasPrevious()/previous() for backward movement, nextIndex()/previousIndex() for position, set(E) to replace the last element returned, and add(E) to insert at the cursor. ListIterator is available only on List, because it is the only collection interface with a meaningful index.

Where does the cursor end up after ListIterator.add?

Immediately after the element you just inserted. The JDK source sets cursor = i + 1, so the next next() returns the element that followed the insertion point, not the new element — which is what stops an insert-during-traversal loop from running forever. The same call sets lastRet = -1, so set and remove right after an add both throw IllegalStateException, and previous() is what returns the element you added.

Is CopyOnWriteArrayList slower than ArrayList?

For writes, structurally so: every mutating call allocates a fresh backing array and copies the existing elements into it. A thousand appends produced a thousand new arrays and 499,500 copied element slots, against 13 arrays and 2,456 copied slots for ArrayList. Reads take no lock and no copy at all. That shape — cheap concurrent reads paid for by an expensive write — is what makes it right for something like a listener list and wrong for anything write-heavy.

Does iterating a ConcurrentHashMap see changes made during the walk?

Maybe, and the specification refuses to promise either way. The documented guarantee is that the iterator never throws ConcurrentModificationException, traverses every element present when it was created exactly once, and "may (but is not guaranteed to) reflect any modifications subsequent to construction". In the single-threaded run above, an entry added after the iterator was created did show up. Do not build logic on that; if you need a consistent view, take one explicitly.

Conclusion

Iteration in Java is a cursor sitting between two elements, plus two int fields that record where it is and what it last returned. Everything else falls out of that: hasNext() is a comparison against size, remove() is legal exactly when lastRet is not -1, and the enhanced for loop is those calls with the iterator hidden in a local slot you cannot name. Fail-fast is one counter compared against a copy of that counter, which makes it cheap, useful, and — as the second-to-last-element case shows in three lines of real output — genuinely fallible. Treat it as a bug detector that sometimes misses, use removeIf or the explicit iterator, and pick a concurrent collection for how it is written to rather than for the exception it declines to throw.

Article 12 turns from walking a collection to ordering one: Comparable versus Comparator, what "natural order" actually obliges a class to promise, why an inconsistent comparator can make sort throw, and how to build custom sorting that composes.

Related Posts

[Advanced Java] Nested, Inner, Local and Anonymous Classes in Java

Static nested, inner, local and anonymous classes in Java on OpenJDK 21: the synthetic this$0 field proved with javap, Outer.this and outer.new Inner(), the memory leak an inner class causes, effectively final capture, the Outer$1 class file, and a concrete comparison of anonymous classes against lambdas.

[Advanced Java] Queue, Deque, Stack and PriorityQueue in Java

Queue, Deque, Stack and PriorityQueue on OpenJDK 21: the two families of Queue methods and exactly what each one does on an empty and a full queue, the full Deque method table and the stack view, why Stack extends Vector is a design mistake with both surprises demonstrated, and proof that a PriorityQueue is a binary heap whose toString and iterator are not in priority order.

[Advanced Java] Comparable vs Comparator in Java: Natural Ordering and Custom Sorting

Comparable and Comparator on OpenJDK 21: the full compareTo contract, the Comparator factory and combinator API, why reversed flips every key composed so far, the int subtraction overflow bug, the TimSort IllegalArgumentException that only fires on large inputs, null keys, and sorting stability measured in comparator invocations.

[Advanced Java] The Java Stream API: map, filter, reduce and collect

The Java Stream API on OpenJDK 21: the source-intermediate-terminal pipeline, laziness proved with an interleaved println trace, map, filter, all three reduce overloads, collect and the Collectors factory, primitive streams and the allocation cost of boxing, and the traps around peek, findAny, stateful lambdas and parallelStream.