🔴 The Error You're Seeing

Confirm this matches your console output. If it does, you're in the right place.

ERROR LOGjava.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)

Solution 1✓ Most common cause

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("-"); }
Solution 2

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);
Solution 3

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);
Solution 4

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); }
Solution 5

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

Java 8

Protocol identical across List iterators; removeIf available as bulk alternative.

Java 17+

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.