Order paid. You need to notify every listener that registered: fraud, email, warehouse. Listeners iterate constantly. A listener is added maybe once at startup, maybe when a plugin loads. An ArrayList of listeners is a race. Collections.synchronizedList makes every get take a lock, and the iterator still needs a lock you remember to hold.

CopyOnWriteArrayList (and CopyOnWriteArraySet) are the JDK answer when iteration is the hot path and mutation is rare.

The series hub owns snapshot vs weakly consistent vs fail-fast. This post applies snapshot: the iterator walks the array that existed when it was created. Later add calls do not appear in that walk, and they do not throw ConcurrentModificationException.

The lab is still checkout. The map of orders stays a ConcurrentHashMap. The listener list is the copy-on-write type.

public record LineItem(String sku, int quantity, BigDecimal unitPrice) {}

public record Order(
        String id,
        String customerEmail,
        List<LineItem> items,
        BigDecimal total,
        boolean active) {}

@FunctionalInterface
interface OrderListener {
    void onPaid(Order order);
}

Order order = new Order(
        "o-100",
        "ada@ex.com",
        List.of(new LineItem("SKU-1", 1, new BigDecimal("9.99"))),
        new BigDecimal("9.99"),
        true);

Mutation copies the entire array

A CopyOnWriteArrayList is a List backed by a snapshot array. Every add, set, and remove allocates a new array, copies the live elements, and publishes the new array.

List<OrderListener> listeners = new CopyOnWriteArrayList<>();
listeners.add(order -> System.out.println("receipt " + order.id()));
listeners.add(order -> System.out.println("fraud " + order.id()));

Write cost is O(n) plus a full array allocation. Two adds in a row copy twice. This is not an ArrayList with a lock. It is a list that treats writes as “replace the array.”

Reads and iterators hit the current array with no lock on the read path. That is the trade: cheap, stable iteration; expensive writes; extra garbage on every mutation.

void paid(Order order) {
    for (OrderListener listener : listeners) {
        listener.onPaid(order);
    }
}

A plugin thread can listeners.add(...) while paid is in the loop. The loop still walks the old array. The new listener runs on the next paid. That is the snapshot contract, not a bug.

Snapshot iterators: no CME, no later adds

The iterator is a snapshot. It does not throw ConcurrentModificationException. It does not see elements added after it was created. Iterator.remove is unsupported — mutation goes through the list, which copies again.

Iterator<OrderListener> it = listeners.iterator();
listeners.add(order -> System.out.println("audit " + order.id())); // copies; does not change `it`
while (it.hasNext()) {
    it.next().onPaid(order); // audit is not in this walk
}

it.remove() throws UnsupportedOperationException. Snapshot iterators do not write back through to the list. Unregister with listeners.remove(listener), which copies.

try {
    it.remove();
} catch (UnsupportedOperationException expected) {
    listeners.remove(0); // copies; `it` still walks the old array
}

No extra synchronized (listeners) around the for-each. Contrast Collections.synchronizedList: skip the lock on iteration and you are back in a race.

listIterator is the same snapshot idea. Indexed get(i) reads the current array; a concurrent write may replace the array between two get calls. If you need a stable view for more than one index, iterate (snapshot) or copy yourself.

The listener list this type is for

A warehouse paid event is the usual picture: many threads iterate, almost none mutate.

final class EmailReceipt implements OrderListener {
    @Override
    public void onPaid(Order order) {
        System.out.println("receipt " + order.id());
    }
}

final class OrderEvents {
    private final List<OrderListener> listeners = new CopyOnWriteArrayList<>();

    void addListener(OrderListener listener) {
        listeners.addIfAbsent(listener);
    }

    void removeListener(OrderListener listener) {
        listeners.remove(listener);
    }

    void paid(Order order) {
        for (OrderListener listener : listeners) {
            listener.onPaid(order);
        }
    }
}

OrderEvents events = new OrderEvents();
OrderListener email = new EmailReceipt();
events.addListener(email);
events.paid(new Order(
        "o-100",
        "ada@ex.com",
        List.of(new LineItem("SKU-1", 1, new BigDecimal("9.99"))),
        new BigDecimal("9.99"),
        true));
events.removeListener(email);

addIfAbsent still copies on a successful add. contains / remove scan the current array — O(n), which is the point of keeping the list tiny. Named listener objects (not a fresh this::emailReceipt each call) are what remove can find.

set(i, listener) and addAll also copy. addAll copies once for the whole batch, not once per element, but the new array is still oldLength + added.

This is a bad ArrayList replacement for a list that grows on every request:

List<String> packed = new CopyOnWriteArrayList<>();
for (LineItem item : order.items()) {
    packed.add(item.sku()); // copies the array per SKU — do not
}

That loop belongs on an ArrayList. Copy-on-write is not “the thread-safe ArrayList.” Frequent writes thrash memory and burn CPU on copies.

CopyOnWriteArraySet is a COW list used as a set

CopyOnWriteArraySet is a Set whose uniqueness is “scan the array.” Internally it is a CopyOnWriteArrayList. contains, add, and remove are linear in the current size.

Set<OrderListener> unique = new CopyOnWriteArraySet<>();
OrderListener email = new EmailReceipt();
unique.add(email);
unique.add(email); // no-op; already present
boolean known = unique.contains(email); // O(n) scan of a handful of listeners

A lambda or method reference is a new object every time you write it. add(this::emailReceipt) twice registers two listeners. Keep the instance if you will contains / remove.

Use the set when the collection is tiny and you want “at most once” — a handful of listeners, a few registered modules. Do not use it as a concurrent HashSet. A growing set of SKUs or order ids belongs in ConcurrentHashMap.newKeySet() (a concurrent hash set) or, if you needed a snapshot list, in a structure you copy on purpose.

Set<String> skus = ConcurrentHashMap.newKeySet();
skus.add("SKU-1"); // hash set, not a COW array
for (LineItem item : order.items()) {
    skus.add(item.sku());
}
boolean inCatalog = skus.contains("SKU-1"); // expected O(1), not a scan

CopyOnWriteArraySet allows null (one), like the list. Concurrent maps and queues in this wave do not. That is not a reason to pick COW for a map-shaped job.

vs synchronizedList vs ConcurrentHashMap

TypeWrite costIteratorNullsJob
ArrayListamortized O(1) addfail-fastyesone thread
Collections.synchronizedListlock per callyou must lock the listyesrare; easy to get iteration wrong
CopyOnWriteArrayListO(n) copysnapshot, no CMEyesmany reads, rare writes, list
CopyOnWriteArraySetO(n) copy + scansnapshotone nulltiny unique listeners
ConcurrentHashMapper-binweakly consistentnoshared map

synchronizedList serializes every get and add. Iteration is only safe inside synchronized (list):

List<OrderListener> locked = Collections.synchronizedList(new ArrayList<>());
locked.add(email);
synchronized (locked) {
    for (OrderListener listener : locked) {
        listener.onPaid(order);
    }
}

Skip that synchronized (locked) and the iterator is a race — fail-fast CME or a torn read. COW pays on writes so the notify loop can skip that protocol.

ConcurrentHashMap is the shared catalog of orders. It is not a listener list. Weakly consistent map iterators may see a later put; a COW list iterator will not see a later add. Pick the iterator contract that matches the event: “notify whoever was registered when I started” is snapshot. “scan whatever mappings exist, more or less” is CHM.

A synchronized ArrayList of listeners plus a forgotten lock on the notify loop is how production loses a listener or throws CME. COW makes the notify loop the simple path.

The snapshot is a volatile array publish. Readers see a fully built array, not a half-copied one. That is why a write is “allocate, copy, publish,” not “mutate in place under a lock.” It is also why a write is visible without you locking the list — and why a write that copies ten thousand listeners is a pause you will feel.

When COW wins, and the memory bill

COW wins when:

  • Iteration (or a get during notify) dwarfs mutation.
  • A missed new listener on the current pass is acceptable — it runs next time.
  • The list stays small. Copying twelve listeners is cheap. Copying twelve thousand on every add is not.

COW loses when:

  • You add / remove on the request path.
  • You wanted a general thread-safe list of orders.
  • You wanted O(1) contains on a set of SKUs.

Every write retires an array. Old arrays live until in-flight iterators finish and the GC runs. A burst of writes means a burst of garbage. That churn is the cost you accepted for snapshot iteration.

Note: equals / contains on the list still scan. Random access is fine (get(i) is an array index). Bulk addAll copies once per call, not once per element — still O(n + m) data movement.

subList is a range view that throws ConcurrentModificationException if another thread copies the parent list underneath it. That is not the snapshot iterator contract. Prefer the full-list iterator, or copy a range into an ArrayList on the thread that owns that work.

OperationCostNotes
get(i)O(1)current array
iterator()O(1) to createwalks a snapshot
add / set / removeO(n) copypublishes a new array
containsO(n) scanlist and set
addIfAbsentO(n) scan, then copylist only

Interview lens

Interviewers want the write cost, the iterator snapshot, and why the set is not a HashSet.

QuestionHonest answer
When does COW win?Many iterators, rare mutations — listener lists, registered callbacks.
What does the iterator see?A snapshot of the array at creation. No CME. No later adds.
Why is CopyOnWriteArraySet.contains linear?The set is a COW list. Uniqueness is a scan, not a hash table.
Memory on writes?Each mutation allocates and copies the whole array. Old arrays wait on iterators + GC.
Default thread-safe ArrayList?No. Frequent writes want confine, a queue, or a different concurrent type.
vs synchronizedList?Wrapper still needs a lock around iteration. COW iteration does not.
vs ConcurrentHashMap?Map vs list; weakly consistent vs snapshot; no nulls vs nulls allowed.

Wrong answer: “CopyOnWriteArrayList is the default thread-safe ArrayList.” It is a snapshot list for read-heavy, write-rare workloads. Using it as a shared order buffer will copy on every insert.

Cheat sheet

Types          CopyOnWriteArrayList, CopyOnWriteArraySet
Write          copy the whole array; O(n); allocates every mutation
Iterator       snapshot; no CME; no later add; Iterator.remove unsupported
Set            COW list as a set; contains / add uniqueness are O(n)
Nulls          list yes; set one null
Wins           listener lists, tiny registered sets, iteration dominates
Loses          request-path adds, large lists, HashSet replacement

Do             notify-loop on CopyOnWriteArrayList
               CopyOnWriteArraySet for a handful of unique listeners
Don't          replace ArrayList under frequent writes
               use CopyOnWriteArraySet as a concurrent HashSet

Do:

  • Put listener and plugin registries on CopyOnWriteArrayList / CopyOnWriteArraySet.
  • Accept that a listener added during this notify runs on the next event.
  • Keep the collection small.

Don’t:

  • Use COW as the thread-safe ArrayList for order lines.
  • Expect CopyOnWriteArraySet.contains to be a hash lookup.
  • Hold a snapshot iterator open for a long time if you are also writing — the old array cannot be collected.

Wrap-up

Copy-on-write lists and sets make iteration the cheap, stable path: every mutation copies the array, and iterators walk a snapshot. That is why they fit listener lists and tiny unique registries. It is also why they are a bad general ArrayList or HashSet. Shared order ids still belong in a ConcurrentHashMap. Work that must wait for the next pick belongs on a blocking queue.

Next optional step in the series Pass the next order id to a worker without spinning. BlockingQueue: Hand Off Work Without a Busy Wait