PrepZone Logo
PrepZone

Iteration and Ordering

Fail-fast versus fail-safe, safe removal during a loop, and Comparable versus Comparator.

Read these first

Why this matters

  • ConcurrentModificationException is among the most common run-time failures in Java, and the fix depends on understanding why the check exists.
  • Comparators compose, and the fluent form has almost entirely replaced hand-written comparison logic.
  • A comparator inconsistent with equals quietly breaks sorted collections, which is a subtle and genuinely hard bug.

How iteration works

The enhanced for loop is syntax over an iterator.

Java
// What you write
for (String name : names) {
    System.out.println(name);
}

// What the compiler generates
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
    String name = iterator.next();
    System.out.println(name);
}

Iterator has three methods: hasNext(), next(), and remove() — the last being the only safe way to delete during traversal.

Fail-fast

Most java.util collections track a modification count. The iterator records it at creation and re-checks it on every next(). A mismatch means the collection changed structurally, and the iterator throws rather than returning unpredictable results.

Fail-fast — ArrayList, HashMap
Iterator startsRecords modCount
ConcurrentModificationException
Fail-safe — CopyOnWriteArrayList
Iterator startsTakes a snapshot
Loop finishes cleanlyChange not seen
Standard collections throw the moment the structure changes mid-loop. Concurrent collections iterate over a snapshot instead, so they never throw but can miss recent writes.
Java
List<String> names = new ArrayList<>(List.of("ana", "bob", "carl"));

for (String name : names) {
    if (name.equals("bob")) {
        names.remove(name);          // modCount changes
    }
}                                     // ConcurrentModificationException on the next iteration

What counts as structural

  • Adding or removing an element is structural, and invalidates every active iterator.
  • Replacing a value with set(index, value) or entry.setValue(...) is not structural and is safe.
  • iterator.remove() updates the count the iterator is tracking, so it stays valid.
  • The check is best-effort, not a guarantee. Under concurrency it may miss a modification.

Removing safely

Java
List<String> names = new ArrayList<>(List.of("ana", "bob", "carl", "dee"));

// 1. removeIf — the right default
names.removeIf(name -> name.startsWith("b"));

// 2. Iterator.remove — when the condition needs state or several steps
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
    String name = iterator.next();
    if (shouldDrop(name)) {
        iterator.remove();
    }
}

// 3. Collect, then remove — when you must not touch it mid-pass
List<String> doomed = names.stream().filter(this::shouldDrop).toList();
names.removeAll(doomed);

For maps, remove through the entrySet iterator or use values().removeIf(...):

Java
map.entrySet().removeIf(entry -> entry.getValue() == 0);

Fail-safe iteration

The concurrent collections iterate over a snapshot or a weakly consistent view, so they never throw.

AspectFail-fastFail-safe
CollectionsArrayList, HashMap, HashSet, TreeMapCopyOnWriteArrayList, ConcurrentHashMap
On modification during iterationThrows ConcurrentModificationExceptionContinues without error
What you seeThe live collectionA snapshot, or a weakly consistent view
Memory costNoneA full copy for copy-on-write
FreshnessAlways currentMay miss very recent changes
  • Collections

    Fail-fastArrayList, HashMap, HashSet, TreeMap
    Fail-safeCopyOnWriteArrayList, ConcurrentHashMap
  • On modification during iteration

    Fail-fastThrows ConcurrentModificationException
    Fail-safeContinues without error
  • What you see

    Fail-fastThe live collection
    Fail-safeA snapshot, or a weakly consistent view
  • Memory cost

    Fail-fastNone
    Fail-safeA full copy for copy-on-write
  • Freshness

    Fail-fastAlways current
    Fail-safeMay miss very recent changes

Fail-safe trades absolute freshness for the guarantee that iteration completes.

Java
List<String> safe = new CopyOnWriteArrayList<>(List.of("a", "b"));
for (String value : safe) {
    safe.add("c");          // no exception — the iterator holds the original snapshot
    break;                   // (without the break this would loop forever adding elements)
}

ConcurrentHashMap is weakly consistent rather than snapshot-based: its iterator reflects the state at creation and may or may not show subsequent changes, but it never throws and never shows an element twice.

Comparable: the type's natural order

Implement Comparable when a type has one obvious ordering.

Java
public class Version implements Comparable<Version> {
    private final int major, minor;

    @Override
    public int compareTo(Version other) {
        int result = Integer.compare(major, other.major);
        return result != 0 ? result : Integer.compare(minor, other.minor);
    }
}

The contract is a negative number if this sorts first, zero if they tie, positive otherwise.

Comparator: ordering from outside

A comparator is a separate object, so one type can be sorted many ways.

Java
record Employee(String name, String department, int salary, LocalDate joined) { }

List<Employee> staff = new ArrayList<>(load());

// Single field
staff.sort(Comparator.comparing(Employee::name));

// Descending
staff.sort(Comparator.comparingInt(Employee::salary).reversed());

// Several fields, in order of precedence
staff.sort(Comparator.comparing(Employee::department)
                     .thenComparing(Comparator.comparingInt(Employee::salary).reversed())
                     .thenComparing(Employee::name));

// Null-tolerant
staff.sort(Comparator.comparing(Employee::department,
                                Comparator.nullsFirst(Comparator.naturalOrder())));

// The type's own order, and its reverse
staff.sort(Comparator.naturalOrder());
staff.sort(Comparator.reverseOrder());

The factory methods worth memorising

  • comparing(keyExtractor) — order by an extracted value.
  • comparingInt / comparingLong / comparingDouble — the same, without boxing.
  • thenComparing(...) — a tie-breaker, chainable as often as needed.
  • reversed() — flips the comparator it is called on, which matters when chaining.
  • nullsFirst / nullsLast — wrap another comparator to tolerate nulls.
  • naturalOrder / reverseOrder — use the type's Comparable implementation.

Comparable or Comparator

AspectComparableComparator
LivesInside the classOutside, as a separate object
MethodcompareTo(T other)compare(T a, T b)
How many orderingsOne — the natural oneAs many as you like
Can order a type you do not ownNoYes
Used by defaultsort(), TreeMap, TreeSetMust be passed explicitly
  • Lives

    ComparableInside the class
    ComparatorOutside, as a separate object
  • Method

    ComparablecompareTo(T other)
    Comparatorcompare(T a, T b)
  • How many orderings

    ComparableOne — the natural one
    ComparatorAs many as you like
  • Can order a type you do not own

    ComparableNo
    ComparatorYes
  • Used by default

    Comparablesort(), TreeMap, TreeSet
    ComparatorMust be passed explicitly

Implement Comparable for the one obvious order; use Comparators for everything situational.

Consistency with equals

The contract asks that compareTo return 0 exactly when equals returns true. Sorted collections depend on it.

Java
record Person(String name, int age) { }

// Comparing by age only — inconsistent with equals
Set<Person> byAge = new TreeSet<>(Comparator.comparingInt(Person::age));
byAge.add(new Person("Ana", 30));
byAge.add(new Person("Bob", 30));        // compares as 0, so treated as a duplicate

System.out.println(byAge.size());         // 1 — Bob was silently dropped

TreeSet and TreeMap use the comparison, not equals, to decide identity. The fix is to add a tie-breaker that makes distinct objects compare as non-zero:

Java
Set<Person> correct = new TreeSet<>(
        Comparator.comparingInt(Person::age).thenComparing(Person::name));

Common misreadings

  • "ConcurrentModificationException means two threads collided." Usually one thread modified a collection it was iterating.
  • "set() during iteration is unsafe." Replacing a value is not structural and is fine. Adding or removing is not.
  • "Fail-safe iterators always show current data." They show a snapshot or a weakly consistent view, and may lag.
  • "a - b is a fine compareTo." It overflows. Use Integer.compare.
  • "reversed() only affects the last field." It reverses the entire chained comparator.
  • "A TreeSet uses equals to detect duplicates." It uses the comparison result, which is why consistency with equals matters.

Quick recall

Everything you need if you only revisit this box.

  • The enhanced for loop is an Iterator; iterator.remove() is the only safe removal during traversal.
  • Fail-fast collections track a modification count and throw on structural change. set() is not structural.
  • Remove safely with removeIf, an explicit iterator, or by collecting first.
  • Fail-safe collections — CopyOnWriteArrayList, ConcurrentHashMap — never throw but may show stale data.
  • Implement Comparable for a single natural order; use Comparator for everything else, including types you do not own.
  • Build comparators fluently with comparing, comparingInt, thenComparing, reversed, nullsFirst. Note that reversed() flips the whole chain.
  • Never use subtraction in compareTo — it overflows.
  • A comparator inconsistent with equals makes TreeSet and TreeMap silently drop entries. Add a tie-breaker.

Test yourself

Answer these before moving on — recall is what makes it stick.