🔴 The Error You're Seeing
Confirm this matches your console output. If it does, you're in the right place.
java.lang.IllegalStateException
at java.base/java.util.ArrayList$Itr.remove(ArrayList.java:1063)⚡ Quick Fix Works 80% of the time
Call next() once immediately before each remove()/set() call.
while (it.hasNext()) {
String s = it.next(); // licenses one mutation
if (isJunk(s)) {
it.remove(); // consumes the license
}
}🧠 Why this Happens
Tap to expand the deep technical explanation
ArrayList$Itr tracks lastRet - the index returned by the most recent next(). remove() demands lastRet >= 0, deletes that slot, then resets lastRet to -1. So remove-before-next, double remove, and post-add mutation all hit the guard and throw IllegalStateException. The list is untouched; the CURSOR state refused the request. ConcurrentModificationException is the sibling error for when the LIST changed behind the iterator - different culprit, similar symptom.
The HITEC City Parking Spot Analogy:
Hotel checkout: the desk refuses if you never checked in - or if you already checked out this morning.
🔁 How to Reproduce Confirm this is your error
List<String> l = new ArrayList<>(List.of("x")); ListIterator<String> li = l.listIterator(); li.next(); li.remove(); li.remove(); // IllegalStateException - no next() since last removal
🛠️ Solutions (5 Ways to Fix)
One next() licenses exactly one mutation
👉 Use this if filtering inside an explicit iterator loop.
Keep next() immediately adjacent to its remove()/set() so the pairing is visible and each license is used once.
for (ListIterator<String> it = words.listIterator(); it.hasNext(); ) {
String w = it.next();
if (w.isBlank()) it.set("-");
}Replace the idiom with removeIf(predicate)
👉 Use this if the goal is simply deleting matching elements (Java 8+).
removeIf implements the advance-then-delete protocol internally with bitset compaction; hand-written cursor bugs disappear entirely.
orders.removeIf(o -> o.total() == 0);Collect victims, then removeAll
👉 Use this if removal rules are complex, asynchronous, or the list is shared.
Iterate read-only collecting keys to delete, mutate afterwards in one call; no iterator mutation protocol involved at all.
Set<Key> dead = live.stream()
.filter(this::expired)
.map(Entity::key).collect(Collectors.toSet());
live.removeAll(dead);Backwards index loop removing by index
👉 Use this if avoiding iterators entirely in an ArrayList.
Walking indices from size()-1 down to 0 keeps removals from shifting not-yet-visited positions, sidestepping both ISE and ConcurrentModificationException.
for (int i = list.size() - 1; i >= 0; i--) {
if (drop(list.get(i))) list.remove(i);
}CopyOnWriteArrayList for listener lists mutated during iteration
👉 Use this if threads or the loop itself modify the list while readers iterate - listener registries, observers, read-heavy shared config.
Iterators walk an immutable snapshot, so removing ONE listener mid-notification loop is safe and never throws; the trade-off is an O(n) array copy per write, which is why it fits read-mostly registries rather than write-hot paths.
List<Listener> listeners = new CopyOnWriteArrayList<>();
void broadcast(Event event) {
for (Listener l : listeners) {
if (l.isStale()) {
listeners.remove(l); // safe: iterator holds a snapshot
} else {
l.onEvent(event);
}
}
}📋 Version Notes
Protocol identical across List iterators; removeIf available as bulk alternative.
Unchanged. LinkedList and ListIterator enforce the same lastRet discipline.
🛡️ How to Prevent This Next Time
Remember the rule: one successful next() buys exactly one remove() or set(). Prefer bulk APIs (removeIf, removeAll) that encapsulate the protocol.