📦 Section 4 · Question #28

How to properly remove elements during iteration

The most common bug encountered by Java developers when modifying collections is invoking collection.remove() inside an enhanced for-each loop.


🟢 Junior Level

1. The Core Trap: Mutating While Iterating

The most common bug encountered by Java developers when modifying collections is invoking collection.remove() inside an enhanced for-each loop.

List<String> names = new ArrayList<>(List.of("Alice", "Bob", "Charlie", "Bob"));

// ❌ ANTI-PATTERN: Throws ConcurrentModificationException!
for (String name : names) {
    if ("Bob".equals(name)) {
        names.remove(name); // Throws ConcurrentModificationException!
    }
}

Why Does This Throw ConcurrentModificationException?

The enhanced for-each loop is syntactic sugar compiled down to an explicit Iterator:

// What the compiler actually generates:
Iterator<String> it = names.iterator();
while (it.hasNext()) {
    String name = it.next(); // Checks: if (modCount != expectedModCount) throw CME
    if ("Bob".equals(name)) {
        names.remove(name);  // Modifies list: modCount++ (expectedModCount remains untouched)
    }
}

When names.remove() executes, the list increments its internal structural modification counter (modCount++). On the subsequent iteration, it.next() compares its cached expectedModCount against names.modCount. Because they no longer match, it immediately throws ConcurrentModificationException (CME).


2. The Two Production-Grade Solutions

Approach 1: Collection.removeIf() (Modern Java 8+ Standard)

The cleanest, fastest, and most expressive approach:

// ✅ RECOMMENDED: In-place, linear-time filter
names.removeIf(name -> "Bob".equals(name));

Approach 2: Explicit Iterator.remove() (Legacy & Imperative Control)

Use this approach when you need side effects, step-by-step logging, or need to pass the iterator across helper methods:

// ✅ SAFE CLASSICAL APPROACH:
Iterator<String> it = names.iterator();
while (it.hasNext()) {
    String name = it.next();
    if ("Bob".equals(name)) {
        it.remove(); // Safely removes current element and synchronizes expectedModCount!
    }
}

[!NOTE] Iterator.remove() is safe because it synchronizes the iterator state: after calling list.remove(lastRet), the iterator updates its internal field expectedModCount = modCount.


🟡 Middle Level

1. Removal Strategies Comparison Matrix

Strategy ArrayList Time LinkedList Time Space Overhead Thread-Safe? Avoids CME? Primary Use Case
Collection.removeIf() $O(N)$ $O(N)$ $O(1)$ / BitSet ❌ No ✅ Yes Standard production code
Iterator.remove() $O(N^2)$ $O(N)$ $O(1)$ ❌ No ✅ Yes Complex multi-step traversal logic
Reverse Indexed for (i--) $O(N^2)$ $O(N^2)$ $O(1)$ ❌ No ✅ Yes Interview quiz; primitive array loops
Stream.filter().toList() $O(N)$ $O(N)$ $O(N)$ ❌ No ✅ Yes Functional pipelines, immutability
Temporary removeAll() $O(N^2)$ $O(N \cdot M)$ $O(M)$ ❌ No ✅ Yes Anti-pattern; avoid in large datasets

2. The $O(N^2)$ Degradation of Iterator.remove() on ArrayList

While Iterator.remove() prevents ConcurrentModificationException, executing it repeatedly on an ArrayList causes an asymptotic performance collapse:

ArrayList Memory Layout during Iterator.remove():
Index:      [ 0 ] [ 1 ] [ 2 ] [ 3 ] ... [ 999,998 ] [ 999,999 ]
Iteration:  Remove item at index 0
Action:     System.arraycopy(elementData, 1, elementData, 0, 999,999) -> shifts 999,999 elements!
Iteration:  Remove item at next match...
Action:     System.arraycopy(...) shifts remaining elements again!
  • Each call to ArrayList.Itr.remove() delegates to ArrayList.this.remove(lastRet).
  • ArrayList.remove(index) calls System.arraycopy() to shift all subsequent elements one slot to the left.
  • If you have $N = 1{,}000{,}000$ elements and remove $500{,}000$ items: \(\text{Total Copies} \approx \sum_{k=1}^{500{,}000} (1{,}000{,}000 - k) \approx 375{,}000{,}000{,}000 \text{ references moved}\)
  • Runtime Impact: This takes tens of seconds to minutes, thrashing CPU L1/L2 caches and memory buses.

Contrast with LinkedList

In LinkedList, Iterator.remove() executes in strict $O(1)$ per removal:

// LinkedList.Node pointer rewiring:
Node<E> next = node.next;
Node<E> prev = node.prev;
if (prev == null) first = next; else prev.next = next;
if (next == null) last = prev;  else next.prev = prev;

Because the iterator maintains direct references to the current node, unlinking requires no element shifts. Total removal time for $N$ items in LinkedList is strictly $O(N)$.


3. Loop Pitfalls: Forward vs Reverse Indexing

Forward Loop Index Skipping Trap

// ❌ CRITICAL BUG: Skips consecutive matching elements!
for (int i = 0; i < list.size(); i++) {
    if (list.get(i).equals("Bob")) {
        list.remove(i); // Elements shift left; index i now points to the NEXT element!
                        // Next loop iteration increments i++, skipping that element!
    }
}

If "Bob" appears at index 2 and index 3, removing index 2 causes index 3 to shift down to index 2. The loop then increments i to 3, completely skipping evaluation of the second "Bob".

Reverse Indexed Loop Fallacy

// ⚠️ AVOIDS CME & SKIPPING, BUT ASYMPTOTICALLY SLOW:
for (int i = list.size() - 1; i >= 0; i--) {
    if (list.get(i).equals("Bob")) {
        list.remove(i);
    }
}
  1. Why it avoids CME: The loop uses raw index operations (list.get(i), list.remove(i)) without creating an Iterator instance. Although modCount increments, no iterator exists to check it.
  2. Why it avoids skipping: Removing an element only shifts elements to its right. Because reverse iteration moves to the left (i--), unprocessed elements are unaffected.
  3. Why it is an anti-pattern:
    • On ArrayList: Removing elements near the beginning still incurs massive $O(N)$ memory copies via System.arraycopy(), resulting in overall $O(N^2)$ time.
    • On LinkedList: list.get(i) traverses from the nearest end on every iteration, turning the loop into an $O(N^2)$ disaster even before removals are considered!

4. The Temporary Collection Trap (list.removeAll(toRemove))

// ❌ POOR PATTERN:
List<String> toRemove = new ArrayList<>();
for (String s : list) {
    if (predicate(s)) toRemove.add(s);
}
list.removeAll(toRemove);
  1. Heap Allocation: Allocates an unnecessary intermediate ArrayList, increasing GC pressure.
  2. Algorithmic Collapse: ArrayList.removeAll(Collection<?> c) iterates over its own elements and invokes c.contains(e) for every element. If toRemove is a List, contains() performs an $O(M)$ linear scan: \(\text{Complexity} = O(N \times M) \approx O(N^2)\)
  3. If toRemove is converted to a HashSet, lookups drop to $O(1)$, but at the expense of heavy object allocation (hashing nodes, wrappers, array table).

🔴 Senior Level

1. Deep Dive: ArrayList.removeIf() Under the Hood

In OpenJDK, ArrayList overrides Collection.removeIf() with a highly optimized two-phase compaction algorithm that executes in strictly $O(N)$ time with zero memory copies unless modifications are necessary.

// OpenJDK ArrayList.java implementation:
@Override
public boolean removeIf(Predicate<? super E> filter) {
    Objects.requireNonNull(filter);
    int removeCount = 0;
    final BitSet removeSet = new BitSet(size);
    final int expectedModCount = modCount;
    final int size = this.size;

    // Phase 1: Scan and record matching indices in a compact BitSet
    for (int i = 0; modCount == expectedModCount && i < size; i++) {
        @SuppressWarnings("unchecked")
        final E element = (E) elementData[i];
        if (filter.test(element)) {
            removeSet.set(i);
            removeCount++;
        }
    }
    // Fail-fast detection during predicate evaluation
    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }

    final boolean anyToRemove = removeCount > 0;
    if (anyToRemove) {
        final int newSize = size - removeCount;
        // Phase 2: In-place two-pointer compaction
        for (int i = 0, j = 0; (i < size) && (j < newSize); i++, j++) {
            i = removeSet.nextClearBit(i);
            elementData[j] = elementData[i];
        }
        // Phase 3: Prevent memory loitering (null out tail references for GC)
        for (int k = newSize; k < size; k++) {
            elementData[k] = null;
        }
        this.size = newSize;
        if (modCount != expectedModCount) {
            throw new ConcurrentModificationException();
        }
        modCount++;
    }
    return anyToRemove;
}

Architectural Key Points:

  1. Zero-Write Optimization: If removeCount == 0, the method terminates without performing a single write to elementData.
  2. Predicate Tamper Detection: If filter.test(element) modifies the list concurrently or reentrantly, modCount != expectedModCount triggers an immediate CME before corrupting data structures.
  3. BitSet Compaction: Uses removeSet.nextClearBit(i) to jump directly to surviving elements, copying each surviving reference exactly once: elementData[j] = elementData[i].
  4. Memory Loitering Prevention:
    for (int k = newSize; k < size; k++) {
        elementData[k] = null; // Let GC reclaim orphaned objects!
    }
    

    If elements were merely shifted and size decremented, slots between newSize and old size would retain strong references to deleted objects in heap memory. This would cause a silent memory leak known as memory loitering.

  5. Single Structural Update: modCount++ is executed exactly once at the end of the batch modification, rather than $K$ times.

2. Removals in Concurrent Collections

Concurrent Collection Removal Mechanism Behavior During Concurrent Iteration
CopyOnWriteArrayList Snapshot Array iterator().remove() throws UnsupportedOperationException. removeIf() acquires the internal synchronization lock and atomically swaps the backing array.
ConcurrentHashMap CAS + Striped Bins it.remove() is fully thread-safe and weakly consistent. Calls ConcurrentHashMap.this.remove(key, value) without throwing CME.
ConcurrentLinkedQueue Lock-Free CAS Unlinking it.remove() safely unlinks nodes using CAS operations (item.compareAndSet(x, null)), leaving phantom unlinked nodes for background GC.
Collections.synchronizedList() Mutex Wrapper Client must manually synchronize the entire iteration block on the list mutex; otherwise, external removals cause CME.
// Manual synchronization required for synchronized collections:
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
synchronized (syncList) {
    Iterator<String> it = syncList.iterator();
    while (it.hasNext()) {
        if ("target".equals(it.next())) {
            it.remove(); // Must be inside synchronized block!
        }
    }
}

4 Tricky Questions

1. Why does a reverse for-i loop avoid ConcurrentModificationException, and when does it become an antipattern?

Answer: A reverse index loop for (int i = list.size() - 1; i >= 0; i--) operates directly on list indices via list.get(i) and list.remove(i). It does not create an Iterator instance on the heap. While list.remove(i) increments the internal modCount, there is no iterator checking expectedModCount == modCount, so no CME is ever thrown.

It becomes an antipattern in two major scenarios:

  1. On LinkedList: Calling list.get(i) and list.remove(i) requires traversing linked nodes from the ends on every iteration. This degrades overall complexity to $O(N^2)$.
  2. On large ArrayLists: Removing items from the first half of the list still invokes System.arraycopy() to shift surviving elements to the left, degrading runtime to $O(N^2)$. Collection.removeIf() should always be preferred.

2. What happens if you call collection.remove() inside a for-each loop but immediately break?

for (String s : list) {
    if ("target".equals(s)) {
        list.remove(s);
        break; // Does this throw CME?
    }
}

Answer: It will not throw ConcurrentModificationException.

The enhanced for-each loop checks modCount inside it.hasNext() and it.next(). When list.remove(s) executes, modCount is incremented. However, because break immediately jumps execution out of the loop block, neither it.hasNext() nor it.next() is ever called again.

Why this is dangerous in production:

  • It only works if you intend to remove at most one element.
  • If a future developer removes the break or wraps the removal in additional logic, the code immediately crashes with CME.
  • If the collection contains multiple matches, only the first is removed. Use list.remove(item) directly without a loop if you only want to remove the first match!

3. What algorithmic catastrophe occurs when calling list.removeAll(toRemove) where toRemove is a List?

Answer: The default implementation in AbstractCollection.removeAll(Collection<?> c) iterates over the calling collection and tests membership:

public boolean removeAll(Collection<?> c) {
    boolean modified = false;
    Iterator<?> it = iterator();
    while (it.hasNext()) {
        if (c.contains(it.next())) { // Linear search if c is an ArrayList!
            it.remove();
            modified = true;
        }
    }
    return modified;
}

If c (toRemove) is an ArrayList, c.contains(e) performs an $O(M)$ linear scan through toRemove. If the host list has $N$ elements and toRemove has $M$ elements, total comparisons equal $O(N \times M)$. On an ArrayList, each it.remove() also incurs an $O(N)$ shift. In the worst case, this leads to an catastrophic $O(N^2 \times M)$ or $O(N \times M)$ operation that can lock up a production thread.

Remedy: If removeAll() must be used, convert toRemove to a HashSet first ($O(1)$ lookup time) or use list.removeIf(toRemoveSet::contains).


4. What is “memory loitering”, and how does ArrayList.removeIf() prevent it?

Answer: Memory loitering occurs when an internal data structure retains an object reference that the application logic has already discarded, preventing the Garbage Collector from freeing the allocated memory.

In ArrayList, the capacity of the internal elementData[] buffer is frequently larger than size. When $K$ elements are removed and surviving elements are shifted forward, the slots from index newSize to oldSize - 1 would continue pointing to the old objects if left untouched. Even though list.get(index) rejects access beyond newSize, the JVM GC root analysis sees active references in elementData[k], preventing GC reclamation.

ArrayList.removeIf() explicitly prevents this by executing:

for (int k = newSize; k < size; k++) {
    elementData[k] = null; // Explicitly clears reference for Garbage Collector
}

🎯 Interview Cheat Sheet

30-Second Elevator Pitch

“To remove elements during iteration in Java, the golden standard is Collection.removeIf(Predicate) introduced in Java 8. In ArrayList, it runs in $O(N)$ linear time using a two-pointer BitSet compaction algorithm and explicitly nulls out tail references to prevent memory loitering. The legacy safe way is Iterator.remove(), which updates expectedModCount to prevent ConcurrentModificationException, but degrades to $O(N^2)$ on ArrayList due to repeated System.arraycopy() shifts. Never call collection.remove() inside an enhanced for-each loop because the iterator detects structural modifications and throws ConcurrentModificationException.”


Red Flags & Pitfalls (DO NOT Say)

  • ❌ “Calling list.remove() inside a for-each loop is fine as long as you wrap it in a try-catch.” (Catching CME to control flow is atrocious design and corrupts iterator state).
  • ❌ “A reverse index loop is just as fast as removeIf().” (Reverse loops still trigger $O(N^2)$ memory copying on ArrayList and $O(N^2)$ traversal on LinkedList).
  • ❌ “Iterator.remove() works on CopyOnWriteArrayList.” (It throws UnsupportedOperationException because COW iterators operate on immutable snapshots).
  • ❌ “Stream.filter() modifies the list in place.” (Stream.filter() creates a new pipeline and requires .toList() or .collect(), allocating a brand-new collection).

Summary Checklist

Removal Technique Decision Tree:
├─ Need to mutate existing collection in place?
│  ├─ Modern Java 8+? ──────────────> Collection.removeIf(predicate)  [O(N), Best]
│  ├─ Complex imperative steps? ────> Iterator.remove()               [O(N) LinkedList, O(N^2) ArrayList]
│  └─ Concurrent multithreaded? ────> ConcurrentHashMap.keySet().removeIf() or COWAL.removeIf()
└─ Prefer immutable functional flow? ─> list.stream().filter(...).toList()  [Allocates new list]