You cancel inactive orders from a bag that might be an ArrayList today and a HashSet tomorrow. The loop, the remove, and the bulk filter do not care which class you new. They care that the bag is a Collection you can iterate and mutate under one contract.

Collection is that contract. Iterator is how you walk it without breaking the walk.

The Collections Roadmap owns the glossary this series will not re-teach: Collection vs Map, optional operations, fail-fast vs weakly consistent vs snapshot, views vs copies. This post is the methods every List and Set already inherited — and the iterator rules that make a remove during a walk legal or a ConcurrentModificationException.

Program to Collection when any bag of elements will do. Walk it with Iterator when the walk itself must mutate.

Iterable is not enough

for-each needs Iterable. That is only iterator(). Collection adds size, membership, optional mutation, bulk methods, and a copy out to an array.

Iterable<Order> iterable = orders;
for (Order order : iterable) {
    System.out.println(order.id());
}

Collection<Order> bag = orders;
bag.size();
bag.contains(order);
bag.add(order);

You can for-each a list or a set because both are collections, and a collection is iterable. You cannot for-each a Map; you iterate a view. That split lives on the roadmap. This post does not re-draw it.

Collection is the shared surface of List and Set. The class you new is how the JDK delivers it.

The contract

Trimmed to the methods this post uses:

public interface Collection<E> extends Iterable<E> {
    int size();
    boolean isEmpty();
    boolean contains(Object o);

    boolean add(E e);
    boolean remove(Object o);
    void clear();

    boolean containsAll(Collection<?> c);
    boolean addAll(Collection<? extends E> c);
    boolean removeAll(Collection<?> c);
    boolean retainAll(Collection<?> c);

    Iterator<E> iterator();
    Object[] toArray();
    <T> T[] toArray(T[] a);

    boolean removeIf(Predicate<? super E> filter);
    Stream<E> stream();
    Stream<E> parallelStream();
}

add returns true when the collection changed. A List that accepts the element almost always returns true. A Set returns false on a duplicate. That boolean is part of the contract, not a HashSet quirk.

MethodJob
size() / isEmpty()How many elements, or none
contains(o)Membership by equals
add(e) / remove(o) / clear()Optional mutation
iterator()Fail-fast walk for java.util types
toArraySnapshot into an array (a copy)
containsAll / addAll / removeAll / retainAllBulk membership and set-algebra
removeIf(filter)Drop matching elements through the iterator
stream() / parallelStream()On-ramp to a pipeline

contains uses equals, not ==. Two Order records with the same components are the same member even if they are different objects.

Collection<Order> open = new ArrayList<>();
Order placed = new Order("o-1", "a@shop.test", List.of(), BigDecimal.ZERO, true);
open.add(placed);

boolean found = open.contains(
        new Order("o-1", "a@shop.test", List.of(), BigDecimal.ZERO, true));
// found == true — record equality, not identity

Optional operations are already in your way

Collection.add is allowed to throw UnsupportedOperationException. That is the optional-operations rule the roadmap named. You hit it the first time you treat List.of like an ArrayList.

Collection<Order> frozen = List.of(placed);
frozen.add(placed);   // UnsupportedOperationException
frozen.clear();       // UnsupportedOperationException

The interface is wider than any one implementation. Catching UnsupportedOperationException as control flow is a bug — pick a type that supports the mutation you need. new ArrayList<>(List.of(placed)) is a mutable copy. List.of is not.

size, isEmpty, contains, and iterator are the methods you can rely on. Mutation is the part that is optional.

Walk it: for-each, Iterator, remove

Enhanced-for is iterator sugar. The compiler calls iterator(), then hasNext / next until the walk ends.

for (Order order : orders) {
    System.out.println(order.id());
}

That is the same walk as this, except you do not hold the Iterator:

Iterator<Order> it = orders.iterator();
while (it.hasNext()) {
    Order order = it.next();
    System.out.println(order.id());
}

You need the Iterator when the walk itself must remove. Iterator.remove deletes the last element next returned and updates the iterator’s expected modification count so the walk stays legal.

Iterator<Order> it = orders.iterator();
while (it.hasNext()) {
    if (!it.next().active()) {
        it.remove();
    }
}

Enhanced-for cannot do that. There is no it in scope. Calling orders.remove(order) inside the loop is a structural change the iterator did not make:

for (Order order : orders) {
    if (!order.active()) {
        orders.remove(order); // ConcurrentModificationException
    }
}

Note: remove() without a preceding next() throws IllegalStateException. Calling remove() twice in a row without another next() does too. The iterator removes the last returned element, not “whatever is next.”

Fail-fast is not thread-safety

Most java.util collections use a fail-fast iterator: a structural change during iteration, except Iterator.remove, throws ConcurrentModificationException. The roadmap owns fail-fast vs weakly consistent vs snapshot. The fact that changes a decision here is simpler:

orders.add(placed);
Iterator<Order> it = orders.iterator();
orders.add(another); // structural change
it.next();           // ConcurrentModificationException

You do not need a second thread. A single loop that mutates the collection through anything other than iterator.remove is enough. Fail-fast is a bug detector for that mistake. It is not a memory barrier, a lock, or a promise that two threads may share the list.

The Javadoc calls fail-fast best-effort. Do not write catch (ConcurrentModificationException) as business logic. Concurrent types (ConcurrentHashMap, CopyOnWriteArrayList) use weakly consistent or snapshot iterators and will not throw CME. Those contracts stay on the roadmap.

removeIf is the JDK’s bulk form of the iterator-remove loop:

orders.removeIf(order -> !order.active());

Same idea: the collection removes through its own iterator (or an equivalent internal walk). You do not hold a stale cursor.

Bulk methods

containsAll, addAll, removeAll, and retainAll are set algebra on any Collection. They are not Set-only.

Collection<String> basket = new ArrayList<>();
for (LineItem item : order.items()) {
    basket.add(item.sku());
}

Collection<String> inStock = Set.of("SKU-A", "SKU-C");
basket.retainAll(inStock);  // keep SKUs we can fulfill
basket.removeAll(Set.of("SKU-X"));
boolean allKnown = basket.containsAll(List.of("SKU-A"));

addAll is “union into this.” removeAll is “difference.” retainAll is “intersection, in place.” Each returns true if this changed.

Note: retainAll / removeAll call contains on the argument for each of your elements (or the reverse, depending on the implementation). If the argument is an ArrayList, each contains is a scan. If it is a HashSet, each contains is a hash lookup. The bulk method’s cost follows the argument’s membership cost — pass a Set when the other side is large.

ListIterator is the list-only extra

Iterator walks forward and can remove. ListIterator is the List-only extra: backward, set, and add at the cursor.

public interface ListIterator<E> extends Iterator<E> {
    boolean hasPrevious();
    E previous();
    int nextIndex();
    int previousIndex();
    void set(E e);
    void add(E e);
}

Use it when you are already on a List and the walk must rewrite or insert, not just drop.

List<LineItem> items = new ArrayList<>(order.items());
ListIterator<LineItem> it = items.listIterator();
while (it.hasNext()) {
    LineItem item = it.next();
    if (item.sku().equals("SKU-OLD")) {
        it.set(new LineItem("SKU-NEW", item.quantity(), item.unitPrice()));
    }
    if (item.quantity() > 10) {
        it.add(new LineItem("SKU-GIFT", 1, BigDecimal.ZERO));
    }
}

set replaces the last element next or previous returned. add inserts at the cursor (before the element that next would return). A Set has no ListIterator because a Set has no index and no “here, between these two.” The List post owns the rest of indexing.

toArray, stream, equals

toArray copies. The iterator does not. If you need a snapshot the next add cannot disturb, copy out:

Order[] snapshot = orders.toArray(Order[]::new);     // Java 11
Order[] older = orders.toArray(new Order[0]);

Collection.stream() and parallelStream() are the on-ramp to a pipeline. The pipeline itself is Streams. Do not treat stream() as a second iterator API in this post — and do not reach for parallelStream() because it looks faster. The streams post owns map / filter; here the method exists so a Collection can become a Stream without you writing an adapter.

Collection itself does not specify equals. List and Set do, and they disagree on purpose.

List<String> listA = List.of("a", "b");
List<String> listB = new ArrayList<>(List.of("a", "b"));
listA.equals(listB);                    // true — same elements, same order

Set<String> setA = Set.of("a", "b");
Set<String> setB = new LinkedHashSet<>(List.of("b", "a"));
setA.equals(setB);                      // true — same members, order ignored

List.of("a", "b").equals(List.of("b", "a")); // false
Set.of("a", "b").equals(Set.of("b", "a"));   // true
List.of("a").equals(Set.of("a"));            // false — List vs Set

List equality is sequence. Set equality is membership. Two collections with the same elements are not equal across those contracts. hashCode follows the same split: a list hashes the ordered sequence; a set hashes the members.

Internals that change a decision

The interface does not promise Big-O. contains on an ArrayList scans. contains on a HashSet hashes. Both are legal Collection.contains. If the hot operation is “is this SKU already in the bag?”, the type that compiles (List) is the wrong delivery.

Fail-fast is a modCount on the collection and an expectedModCount on the iterator. Iterator.remove increments both. orders.add increments only the collection. The next next() notices the mismatch and throws. That is why the iterator’s own remove is legal and everyone else’s mutation is not.

toArray allocates. iterator() does not. Do not snapshot “just in case” on a hot path; do snapshot when you must iterate a stable picture while another method mutates the live collection on the same thread.

When Collection is the wrong type

Program to Collection on a parameter when any bag of elements is enough: “here are some orders; count them, filter them, copy them.” Reach past it when the job is more specific.

  • Need get(i), subList, or encounter-by-index → List.
  • Need uniqueness without scanning → Set.
  • Need insert-at-end / take-from-front → Queue / Deque (next after Set).
  • Need lookup by id → Map. A map is not a Collection; its views are.

Do not use Collection as a way to hide that the caller must index. Do not use an ArrayList as a Collection whose only real operation is contains on thousands of SKUs.

Interview lens

Interviewers want the shared contract, not twenty class names. Draw Iterable → Collection → List / Set / Queue. Put Iterator.remove on the walk. Put Map off to the side.

Whiteboard one-liner. Fail-fast means structural change during iteration throws CME, except Iterator.remove. It is not thread-safety. Optional ops may throw UnsupportedOperationException.

OperationArrayListHashSet
size / isEmptyO(1)O(1)
contains / remove(Object)O(n)expected O(1)
addamortized O(1)expected O(1)
iterator stepO(1)O(1)
retainAll(other)follows other.containsfollows other.contains
QuestionHonest answer
What does fail-fast actually mean?Structural change during iteration → ConcurrentModificationException, except Iterator.remove. Best-effort bug detector. Not a concurrency guarantee.
Why is iterator.remove allowed?It is the iterator that tracks expectedModCount. It updates both counts. Any other structural change does not.
Why can’t enhanced-for remove?The compiler holds the Iterator. You don’t. collection.remove inside the loop is a structural change that iterator did not make.
What is an optional operation?add / remove / clear may throw UnsupportedOperationException. List.of does. The interface is wider than the implementation.
Does Collection.equals care about order?Collection itself does not specify equals. List.equals is order + elements. Set.equals is membership. A list never equals a set.
Why does enhanced-for use Iterator?Iterable.iterator() is the only walk for-each has. That is why CME shows up in a loop that never mentions Iterator.

Wrong answer: “fail-fast means the collection is thread-safe.” Fail-fast is a single-threaded iterator contract. Sharing still needs a concurrent type, confinement, or a lock you document.

Cheat sheet

Iterable       iterator() — enough for for-each
Collection     size, contains, optional add/remove/clear, bulk, toArray, stream
add returns    true if this collection changed (Set: false on duplicate)
Optional ops   UOE on List.of / unmodifiable wrappers — pick a mutable type
Iterator       hasNext / next / remove (remove last returned element)
for-each       sugar over iterator(); no remove
Fail-fast      CME on structural change except iterator.remove (java.util)
removeIf       bulk form of the iterator-remove loop
ListIterator   list-only: previous, set, add
toArray        copy; iterator is not
equals         List: order; Set: membership; List never equals Set
stream()       on-ramp; Streams post owns the pipeline

Do             Collection as a parameter when any bag will do
               iterator.remove / removeIf to drop during a walk
Don't          orders.remove inside for-each
               catch CME or UOE as control flow
               ArrayList.contains as uniqueness

Do:

  • Type parameters and fields as Collection when indexing and uniqueness are not the job.
  • Remove during a walk with Iterator.remove or removeIf.
  • Pass a Set into retainAll / removeAll when the other side is large.

Don’t:

  • Call collection.remove inside enhanced-for.
  • Treat fail-fast as thread-safety.
  • Invent a custom Collection to learn the contract — use ArrayList and HashSet.

Wrap-up

Collection is the bag every List and Set already is: size, membership, optional mutation, bulk algebra, a copy out, and an iterator. Enhanced-for is that iterator. iterator.remove and removeIf are the legal ways to drop elements during a walk. Fail-fast catches the illegal ways; it does not make the bag safe to share.

Once you need an index, you are no longer in this post. That extra is List.

Next optional step in the series Indexes, subList views, and why not every List is an ArrayList. List: Index, SubList, and Random Access as a Type