Serguey Asael Shinder: ConcurrentModificationException is a best-effort bug detector, not a guarantee
ConcurrentModificationException has a name that suggests two threads and a guarantee. The javadoc offers neither.
It is usually one thread. The ConcurrentModificationException javadoc says the exception "does not always indicate that an object has been concurrently modified by a different thread". Its own example is a single thread that "modifies a collection directly while it is iterating over the collection with a fail-fast iterator". That is the shape most of us have written at least once:
for (String name : names) {
if (name.isBlank()) {
names.remove(name); // structural change outside the iterator
}
}
It is not guaranteed to happen. The ArrayList javadoc is explicit: "the fail-fast behavior of an iterator cannot be guaranteed", and fail-fast iterators throw "on a best-effort basis". It goes on: "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."
So a loop like the one above can finish without an exception on some inputs and throw on others. A passing test proves only that this particular input did not trip the check, not that the loop is correct. With real concurrent access the situation is worse, because the javadoc makes no promise at all about what you will see.

What to write instead. Make the structural change through the one path the contract allows, or avoid iterating while you change the list:
names.removeIf(String::isBlank);
// or, when the decision needs more than a predicate:
for (Iterator<String> it = names.iterator(); it.hasNext(); ) {
if (it.next().isBlank()) {
it.remove();
}
}
ArrayList's javadoc names remove and add on the iterator itself as the modifications that do not trigger the check. For data shared between threads, use a collection designed for it, or synchronize, and do not treat the absence of this exception as evidence that sharing is safe.
The rule I take from it. Treat ConcurrentModificationException like an assertion that sometimes fires: useful when it appears, meaningless when it does not. Fix the loop, never the catch.
No JDK runs on the machine this note was written on; the code is written to compile against Java 21 and the behaviour described is the one stated in the linked javadoc.