Why this matters
ConcurrentModificationExceptionis 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
equalsquietly breaks sorted collections, which is a subtle and genuinely hard bug.
How iteration works
The enhanced for loop is syntax over an iterator.
// 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.
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)orentry.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
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(...):
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.
| Aspect | Fail-fast | Fail-safe |
|---|---|---|
| Collections | ArrayList, HashMap, HashSet, TreeMap | CopyOnWriteArrayList, ConcurrentHashMap |
| On modification during iteration | Throws ConcurrentModificationException | Continues without error |
| What you see | The live collection | A snapshot, or a weakly consistent view |
| Memory cost | None | A full copy for copy-on-write |
| Freshness | Always current | May miss very recent changes |
Collections
Fail-fastArrayList, HashMap, HashSet, TreeMapFail-safeCopyOnWriteArrayList, ConcurrentHashMapOn modification during iteration
Fail-fastThrows ConcurrentModificationExceptionFail-safeContinues without errorWhat you see
Fail-fastThe live collectionFail-safeA snapshot, or a weakly consistent viewMemory cost
Fail-fastNoneFail-safeA full copy for copy-on-writeFreshness
Fail-fastAlways currentFail-safeMay miss very recent changes
Fail-safe trades absolute freshness for the guarantee that iteration completes.
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.
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.
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'sComparableimplementation.
Comparable or Comparator
| Aspect | Comparable | Comparator |
|---|---|---|
| Lives | Inside the class | Outside, as a separate object |
| Method | compareTo(T other) | compare(T a, T b) |
| How many orderings | One — the natural one | As many as you like |
| Can order a type you do not own | No | Yes |
| Used by default | sort(), TreeMap, TreeSet | Must be passed explicitly |
Lives
ComparableInside the classComparatorOutside, as a separate objectMethod
ComparablecompareTo(T other)Comparatorcompare(T a, T b)How many orderings
ComparableOne — the natural oneComparatorAs many as you likeCan order a type you do not own
ComparableNoComparatorYesUsed by default
Comparablesort(), TreeMap, TreeSetComparatorMust 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.
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:
Set<Person> correct = new TreeSet<>(
Comparator.comparingInt(Person::age).thenComparing(Person::name));
Common misreadings
- "
ConcurrentModificationExceptionmeans 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 - bis a finecompareTo." It overflows. UseInteger.compare. - "
reversed()only affects the last field." It reverses the entire chained comparator. - "A
TreeSetusesequalsto detect duplicates." It uses the comparison result, which is why consistency withequalsmatters.
Quick recall
Everything you need if you only revisit this box.
- The enhanced
forloop is anIterator;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
Comparablefor a single natural order; useComparatorfor everything else, including types you do not own. - Build comparators fluently with
comparing,comparingInt,thenComparing,reversed,nullsFirst. Note thatreversed()flips the whole chain. - Never use subtraction in
compareTo— it overflows. - A comparator inconsistent with
equalsmakesTreeSetandTreeMapsilently drop entries. Add a tie-breaker.
Test yourself
Answer these before moving on — recall is what makes it stick.