Skip to main content

CopyOnWriteArrayList in Java: When It Wins (JMH) and When It Hurts

CopyOnWriteArrayList trades an expensive, array-copying write for a lock-free read and an iterator that can never throw ConcurrentModificationException. This rewrite backs that trade-off with real JMH read/write throughput benchmarks, two deterministic concurrency demos built on CountDownLatch barriers instead of Thread.sleep guesses, a genuine concurrent-writer race condition caught and fixed mid-benchmark, and a javap-verified correction: the internal lock is a plain Object monitor, not a ReentrantLock.

Iterate a plain ArrayList on one thread while another thread calls add() on it, and sooner or later you get a ConcurrentModificationException. Wrap it in Collections.synchronizedList() and the exception doesn’t go away — it just gets harder to reproduce, because now it only fires when the add() and the iteration genuinely overlap in time, and most test runs don’t happen to overlap. CopyOnWriteArrayList is the class built to make that failure structurally impossible rather than just unlikely, by giving every iterator a private, unchanging snapshot of the list to walk, no matter what anyone else does to the list while that iterator is still running.

What’s usually missing from an explanation of it — including an earlier version of this page — is real numbers: how much read speed you actually get for that guarantee, how much write speed you actually give up, and what the lock inside it actually is (it is not what most articles, this one included until now, say it is). This rewrite adds all three, plus two demos that force the exact race they describe with CountDownLatch barriers instead of hoping a Thread.sleep guess lands right, and a real benchmark bug — a genuine crash, not a hypothetical one — that turned up while measuring the write side and is worth knowing about on its own. The companion module is asmhatre/java-core-examples/cow, where a 7-test suite backs every claim below and output/ holds the captured transcripts every quoted line is taken from verbatim.

Versions this was tested against. JDK 25.0.4.1+1 (Temurin, LTS). CopyOnWriteArrayList itself shipped in Java 5 (2004) as part of the original java.util.concurrent, and its contract hasn’t changed since — though as of Java 21 (JEP 431) it also implements SequencedCollection, so getLast() and reversed() work on it like any other list. JMH 1.37, JUnit Jupiter 5.11.0, Maven 3.9.11. Internal field names below are quoted directly from javap -p against the compiled JDK, not from documentation prose. The benchmark numbers come from a shared, multi-tenant sandbox; several of the write-throughput rows carry an error margin larger than the mean itself, and that’s called out explicitly where it happens rather than quoted as if it weren’t there.

What CopyOnWriteArrayList actually does

CopyOnWriteArrayList is a thread-safe variant of ArrayList. Its defining trait is in the name: every modifying operation — add, set, remove, and so on — builds a fresh copy of the entire backing array, makes its change on the copy, and only then swaps the list’s internal reference over to point at it. The array any reader is currently looking at is never touched. That one design choice is where every property below comes from, good and bad:

  • Thread-safe for both reads and writes, with no extra synchronization required from the caller.
  • Iterators are immutable snapshots. An iterator taken before a write keeps looking at the array that existed when it was created; it is structurally incapable of seeing a ConcurrentModificationException, because the array it’s walking can never change out from under it — nothing else holds a reference to that exact array anymore after the next write.
  • Reads need no lock at all. A read just dereferences whichever array reference is currently published, which is why concurrent reads scale essentially for free (numbers below).
  • Writes are expensive, and get more expensive as the list grows, because each one copies the whole array. A synchronizedList write doesn’t have this cost.
  • Duplicates allowed, insertion order preserved — same contract as ArrayList on both counts.
  • Since Java 21, it’s also a SequencedCollection: getFirst(), getLast(), and reversed() all work on it exactly as they do on ArrayList, pinned by this module’s own test suite. If you haven’t run into SequencedCollection yet, this site covers it separately.

Everything else on this page is one of two things: proving those claims with a demo that can’t fudge the timing, or measuring what “expensive” and “essentially for free” actually mean in ops/ms.

The snapshot guarantee, proven deterministically

The claim “an iterator sees the list exactly as it was when the iterator was created” is easy to state and easy to get a false sense of confidence about from a demo that relies on Thread.sleep(50) calls to get the timing roughly right. This demo doesn’t rely on timing at all — it uses two CountDownLatch barriers to force the exact ordering every single run: an iterator is taken, a writer thread adds two elements from another thread, and only after that write is confirmed done does the demo open a second iterator and drain the first one.

CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("Apple");
list.add("Banana");
list.add("Cherry");

Iterator<String> beforeWrite = list.iterator(); // snapshot taken HERE, right now
System.out.println("beforeWrite.next() = " + beforeWrite.next()); // consumes "Apple"

CountDownLatch readerReady = new CountDownLatch(1);
CountDownLatch writeDone = new CountDownLatch(1);

Thread writer = new Thread(() -> {
    readerReady.await();
    list.add("Date");
    list.add("Elderberry");
    writeDone.countDown();
}, "writer");
writer.start();

readerReady.countDown();
writeDone.await();
writer.join();

// beforeWrite was created BEFORE the write - drain the rest of it now
while (beforeWrite.hasNext()) System.out.println("beforeWrite.next() = " + beforeWrite.next());

// afterWrite is created AFTER the write - it sees the current array
Iterator<String> afterWrite = list.iterator();
while (afterWrite.hasNext()) System.out.println("afterWrite.next() = " + afterWrite.next());

Full source, latches included: IteratorSnapshotDemo.java.

list before any iterator is taken = [Apple, Banana, Cherry]
beforeWrite.next() = Apple
[writer] added Date and Elderberry - list is now [Apple, Banana, Cherry, Date, Elderberry]

=== beforeWrite iterator was created BEFORE the write - draining the rest of it ===
beforeWrite.next() = Banana
beforeWrite.next() = Cherry
beforeWrite never saw Date or Elderberry, and threw no exception.

=== afterWrite iterator is created AFTER the write - it sees the current array ===
afterWrite.next() = Apple
afterWrite.next() = Banana
afterWrite.next() = Cherry
afterWrite.next() = Date
afterWrite.next() = Elderberry

Final list = [Apple, Banana, Cherry, Date, Elderberry]

Captured run: output/01-iterator-snapshot-demo.txt.

One write, two iterators, two different answers beforeWrite taken writer.add x2 completes afterWrite taken beforeWrite drains to: Apple Banana Cherry ← never sees Date or Elderberry, never throws afterWrite drains to: Apple Banana Cherry Date Elderberry ← sees the current array, all five elements

Going deeper on this section:

synchronizedList’s iterator still throws CME — and the fix for it

Collections.synchronizedList() synchronizes each individual method call on the list’s own monitor — but an iteration is a sequence of separate hasNext()/next() calls, and nothing stops another thread’s add() from landing in the gap between two of them. The class’s own Javadoc says exactly this and tells you to wrap the whole iteration in a synchronized block yourself if you need it to be safe. This demo forces that exact race with latches so the exception shows up on every run, then shows the fix.

List<String> syncList = Collections.synchronizedList(new ArrayList<>(List.of("Apple", "Banana", "Cherry")));
CountDownLatch readerTookFirstElement = new CountDownLatch(1);
CountDownLatch writerAdded = new CountDownLatch(1);

Thread writer = new Thread(() -> {
    readerTookFirstElement.await();
    syncList.add("Date"); // structural change while the reader is mid-iteration
    writerAdded.countDown();
});

Thread reader = new Thread(() -> {
    // Deliberately NOT wrapped in synchronized(syncList) - this is the mistake being reproduced.
    Iterator<String> it = syncList.iterator();
    it.next(); // "Apple" - fine, no structural change yet
    readerTookFirstElement.countDown();
    writerAdded.await();
    it.next(); // the writer's add() already happened - this call throws
});

reader.start(); writer.start();
reader.join(); writer.join();

// The fix: synchronize the WHOLE iteration yourself, as the Javadoc instructs.
List<String> syncList2 = Collections.synchronizedList(new ArrayList<>(List.of("Apple", "Banana", "Cherry")));
Thread safeWriter = new Thread(() -> syncList2.add("Date"));
synchronized (syncList2) {
    Iterator<String> it = syncList2.iterator();
    safeWriter.start(); // cannot acquire the same monitor until this block exits
    while (it.hasNext()) it.next();
}
safeWriter.join();

Full source: SynchronizedListCmeDemo.java.

[reader] it.next() = Apple
[writer] syncList.add("Date") completed - list is now [Apple, Banana, Cherry, Date]
[reader] it.next() threw ConcurrentModificationException, as expected

Exception actually thrown: java.util.ConcurrentModificationException

=== The fix: synchronize the WHOLE iteration yourself, as the Javadoc instructs ===
holding the lock for the whole iteration - starting safeWriter now
iterated 3 elements with no exception, because safeWriter
cannot acquire the same monitor until this synchronized block exits
after the block: syncList2 = [Apple, Banana, Cherry, Date]

Captured run: output/02-synchronizedlist-cme-demo.txt.

CopyOnWriteArrayList has no equivalent failure mode: there is no synchronized block to remember to add, because the iterator was never looking at anything another thread could reach in the first place. That asymmetry — one class needs discipline from every caller, the other needs none — is the real headline of this whole page, and the test suite pins it directly: copyOnWriteIteratorNeverThrowsConcurrentModificationEvenUnderConcurrentWrites runs 1,000 concurrent adds against a live iteration and never once throws.

The fingerprint of this bug in real code. It almost never shows up in a unit test, because a unit test’s reader and writer rarely truly overlap. It shows up in production, intermittently, on a synchronizedList that a listener registry or a cache invalidation path iterates without its own synchronized block — exactly the kind of list CopyOnWriteArrayList was built for.

How it works internally: unsynchronized reads, locked writes — just not the lock you’d guess

A read — get(index), iterator() — touches the internal array directly with no synchronization at all. That’s safe because the array a reader is looking at is never mutated in place; if a write is happening concurrently, it’s building a brand-new array and will only publish it once finished, leaving the reader’s array completely undisturbed.

A write — add, remove, set — does this, in order: acquire a lock so concurrent writers serialize against each other; allocate a new array; copy the old elements into it; apply the change; publish the new array by swapping the internal reference; release the lock. Iterators created before that swap keep the old array reference and never see it happen; iterators created after get the new one.

The lock in that first step is worth a direct correction, because it’s commonly described — including in an earlier version of this very page — as “a global ReentrantLock.” Checked directly against the compiled class rather than against prose:

$ javap -p java.util.concurrent.CopyOnWriteArrayList | grep -i lock
  final transient java.lang.Object lock;
  private void resetLock();

Captured run: output/06-javap-lock-field.txt.

It’s a plain Object used purely as a monitor for an ordinary synchronized block — not a ReentrantLock, which is a separate, heavier class with its own API (lock()/unlock()/tryLock()) that CopyOnWriteArrayList doesn’t use anywhere. The practical behavior most people care about — writers serialize against each other, one at a time — is the same either way, which is probably why the mislabel spreads so easily. But if you’re the kind of reader who wants to know whether you could, say, call tryLock() on it or check getHoldCount(), the honest answer is: there is no such API here, because there is no ReentrantLock here. Verify against the jar you’re actually running before repeating the older description — this is JDK 25, and nothing in the class’s changelog suggests it was ever otherwise.

What add()/remove()/set() actually do lock monitor new array copy elements apply change swap reference unlock Meanwhile, any iterator opened before “swap reference” keeps its own reference to the OLD array: reader holds old array – never blocked, never torn

Going deeper on this section:

  • Full javap -p output for the class, not just the lock field: run javap -p java.util.concurrent.CopyOnWriteArrayList against your own JDK to reproduce this exactly — it’s a primary source, not something worth copy-pasting stale.
  • Oracle’s CopyOnWriteArrayList Javadoc, which correctly avoids naming the lock implementation at all — the mislabel is a secondary-source problem, not a Javadoc one.

What it costs to read: four threads, with and without a concurrent writer

The read side of the read/write-ratio question: with four threads reading the same 1,000-element list by index at once, does the implementation matter, and does a concurrent background writer change the answer? writer=BACKGROUND runs a fifth thread continuously doing an add-then-remove pair for the whole measurement window, with no delay between iterations — about as hostile a background writer as a benchmark can realistically model.

Benchmark                        (container)    (writer)   Mode  Cnt     Score     Error   Units
ReadThroughputBenchmark.readSum          COW        NONE  thrpt    3  1890.457 ± 486.709  ops/ms
ReadThroughputBenchmark.readSum          COW  BACKGROUND  thrpt    3   911.480 ±  70.801  ops/ms
ReadThroughputBenchmark.readSum         SYNC        NONE  thrpt    3    10.311 ±  23.312  ops/ms
ReadThroughputBenchmark.readSum         SYNC  BACKGROUND  thrpt    3     9.428 ±  12.790  ops/ms

Full benchmark source: ReadThroughputBenchmark.java. Captured run: output/04-read-throughput-jmh.txt.

With no writer, CopyOnWriteArrayList reads about 183× faster than synchronizedList under four concurrent readers (1890 vs 10.3 ops/ms); with a hostile background writer running continuously, it’s still roughly 97× faster (911 vs 9.4). The two SYNC rows carry an error margin bigger than the mean itself (±23.3 on a mean of 10.3, ±12.8 on a mean of 9.4) — that’s a single shared mutex getting fought over by four reader threads, and contention on a single lock is exactly the kind of thing that produces wildly uneven measurement-to-measurement throughput. Read that pair as “tens of ops/ms, not hundreds,” not as precise to three decimal places. The COW rows, by contrast, have single-digit-percent error — lock-free reads are also predictable reads, which is its own kind of evidence for how this class behaves under contention.

Read throughput, 4 concurrent readers (ops/ms, log-scaled bars) COW, no writer: 1890.457 COW, bg writer: 911.480 SYNC, no writer: 10.311 ± 23.312 SYNC, bg writer: 9.428 ± 12.790 Bars are proportionally scaled down (actual ratio is roughly 183x and 97x) so the SYNC bars stay visible at all. SYNC’s error bars are not drawn to scale here – they would roughly double the bar’s own height.

Going deeper on this section:

  • Why get(index) and not an Iterator for these reads: a synchronizedList iterator racing a concurrent structural change throws exactly the ConcurrentModificationException reproduced above, which would make the SYNC/BACKGROUND row fail to complete rather than measure anything. Indexed get has no such problem on either implementation, documented inline in ReadThroughputBenchmark.java.

What it costs to write — and the race that two “thread-safe” calls can still lose

The write side measures the cost of one add-then-remove pair, at two list sizes and two writer-thread counts. CopyOnWriteArrayList’s write cost is expected to scale with list size, since every write copies the entire backing array; synchronizedList’s shouldn’t, since it just acquires a mutex and mutates in place.

Getting an honest number here took a detour worth describing, because it’s a real bug and not a contrived one. The first version of this benchmark wrote each iteration as list.add(-1); list.remove(list.size() - 1); — two separate calls, each individually thread-safe, but not atomic as a pair. Under four concurrent writer threads, the size() read in one thread can go stale before its own remove(index) executes, because three other threads might add or remove in between. That’s not a theoretical race. It crashed, every time, with a real stack trace on both containers under test:

java.lang.ArrayIndexOutOfBoundsException: Index 12 out of bounds for length 12
	at java.base/java.util.concurrent.CopyOnWriteArrayList.elementAt(CopyOnWriteArrayList.java:397)
	at java.base/java.util.concurrent.CopyOnWriteArrayList.remove(CopyOnWriteArrayList.java:538)

java.lang.IndexOutOfBoundsException: Index 1002 out of bounds for length 1002
	at java.base/jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:100)
	at java.base/java.util.ArrayList.remove(ArrayList.java:552)
	at java.base/java.util.Collections$SynchronizedList.remove(Collections.java:2753)

Full captured crash, both stack traces in full: output/07-write-throughput-race.txt — preserved deliberately, not regenerated by the module’s own run-all.sh, because it’s a more convincing demonstration of “thread-safe per call does not mean safe as a sequence of calls” than a paragraph describing the same idea would be. It happened on both containers — synchronizedList’s per-call locking protects each individual remove from corrupting the list, but it does nothing to stop the index that call was given from going stale first.

The fix: replace the two-call pair with addLast()/removeFirst() — the JEP 431 sequenced-collection methods, verified via javap to be genuine dedicated overrides on both ArrayList and Collections$SynchronizedList, not a default-method composition that would still be two calls underneath. Each is a single call with no externally-fetched index to go stale, so the benchmark that actually ran is this:

@Benchmark
@Threads(4)
public void addRemove_fourConcurrentWriters(ListState s) {
    // addLast()/removeFirst() instead of add(-1); remove(size()-1): the latter races under
    // concurrent writers - the crash is in output/07-write-throughput-race.txt.
    s.list.addLast(-1);
    s.list.removeFirst();
}

Full source: WriteThroughputBenchmark.java.

Benchmark                                                 (container)  (size)   Mode  Cnt      Score       Error   Units
WriteThroughputBenchmark.addRemove_fourConcurrentWriters          COW      10  thrpt    3   2442.460 ± 10099.500  ops/ms
WriteThroughputBenchmark.addRemove_fourConcurrentWriters          COW    1000  thrpt    3    404.113 ±    92.677  ops/ms
WriteThroughputBenchmark.addRemove_fourConcurrentWriters         SYNC      10  thrpt    3   4256.370 ± 11472.184  ops/ms
WriteThroughputBenchmark.addRemove_fourConcurrentWriters         SYNC    1000  thrpt    3   2431.519 ± 10671.130  ops/ms
WriteThroughputBenchmark.addRemove_oneWriter                      COW      10  thrpt    3   8590.710 ±  3673.510  ops/ms
WriteThroughputBenchmark.addRemove_oneWriter                      COW    1000  thrpt    3    692.991 ±  1173.810  ops/ms
WriteThroughputBenchmark.addRemove_oneWriter                     SYNC      10  thrpt    3  17149.052 ± 26734.246  ops/ms
WriteThroughputBenchmark.addRemove_oneWriter                     SYNC    1000  thrpt    3  11819.476 ±   794.382  ops/ms

Captured run: output/05-write-throughput-jmh.txt.

Most of these rows carry an error margin as large as, or larger than, the mean — this sandbox is shared and multi-tenant, and four contending writer threads make that worse, not better. Don’t read any single figure above to three significant digits. The shape is still clear and worth stating in ratio terms rather than absolute ones:

  • With one writer, CopyOnWriteArrayList’s cost scales down hard with size — roughly 12× slower going from a 10-element list to a 1,000-element one (8590 → 693 ops/ms), exactly the O(n) copy cost the mental model predicts. synchronizedList barely moves by comparison (17149 → 11819, about 1.45×), because it never copies anything. At 1,000 elements, that gap is roughly 17× in synchronizedList’s favor for pure write throughput.
  • With four concurrent writers, both containers get slower — synchronizedList’s own mutex now serializes all four writers too, which is why its own numbers drop (17149 → 4256 at size 10, roughly 4×) even though it isn’t copying anything. CopyOnWriteArrayList drops by a similar proportion (8590 → 2442, roughly 3.5×) on top of its existing per-write copy cost. The relative gap at 1,000 elements narrows from ≈17× to roughly 6× (2431 vs 404) — both are now paying a contention tax, not just a copy-cost one.

The headline for this section isn’t any single number — it’s that the write cost is real, it scales with size exactly as the mental model says, and getting a trustworthy measurement of it surfaced a genuine concurrency bug that a less careful benchmark would have silently let crash-and-skip (JMH caught the exception, logged it, and quietly excluded that row from its own summary table rather than failing the run — worth knowing on its own, independent of this class).

Going deeper on this section:

  • Full test suite pinning the fix, 7/7 passing: output/03-tests.txt.
  • javap is the way to check whether a sequenced-collection method is a real override or a default-method composition on any type you depend on — don’t assume either way without checking, the same lesson as the lock field above.

CopyOnWriteArrayList vs the rest of java.util.concurrent

vs. Collections.synchronizedList(new ArrayList<>()). The numbers above make this comparison concrete rather than qualitative: synchronizedList acquires its single mutex for every operation, reads included, which is exactly why its read throughput collapsed under four concurrent readers (measured above at roughly 1/180th of CopyOnWriteArrayList’s). CopyOnWriteArrayList needs no lock at all for reads and pays for that with a write that gets more expensive as the list grows — also measured above, and real. Iterator safety is the other half of the trade: synchronizedList throws ConcurrentModificationException on an unguarded concurrent structural change (reproduced deterministically earlier on this page) and requires the caller to remember a synchronized block around every iteration; CopyOnWriteArrayList never throws it and requires nothing extra from the caller, because there’s no shared mutable array for a concurrent write to corrupt out from under an iterator.

vs. ConcurrentLinkedQueue and the rest of java.util.concurrent. This comparison stays conceptual rather than benchmarked, because the two types don’t actually compete for the same job. CopyOnWriteArrayList is backed by an array: it gives you random access by index and preserves insertion order, at the cost of an expensive write. ConcurrentLinkedQueue is a non-blocking linked queue built for producer-consumer patterns — elements go in one end and come out the other — and it was never meant to support random access or a stable iteration snapshot in the first place, so comparing its throughput to CopyOnWriteArrayList’s would be comparing answers to two different questions. The rest of the package — ConcurrentHashMap, ConcurrentSkipListSet, and so on — is the same story: each is shaped for a specific data structure and access pattern, generally via finer-grained locking or non-blocking algorithms suited to that shape, not via the copy-on-write strategy this page is about.

The actual decision, in numbers rather than adjectives: if reads vastly outnumber writes and you want snapshot-consistent iteration with zero caller discipline, CopyOnWriteArrayList’s read advantage (measured above at roughly two orders of magnitude under contention) dwarfs its write disadvantage for almost any read-heavy workload. If writes are frequent or the list is large, that write cost (measured above scaling down by roughly 12× between 10 and 1,000 elements under one writer) stops being a rounding error and synchronizedList, with its externally-managed iteration safety, is the better-costed choice — you just have to actually remember the synchronized block.

What the defaults don’t promise you

Memory, briefly. Every write allocates a whole new array, so for a brief window — between the copy being built and the old array becoming unreachable — two copies of the list’s backing storage exist in memory simultaneously. The garbage collector reclaims the old one once nothing references it anymore, typically fast, but a list that is both large and frequently written will carry a real, repeated memory cost that a synchronizedList (which mutates in place) does not.

Staleness is a real design trade-off, not a bug to route around. Because iterators are snapshots, an iterator created just before a write will never see that write, no matter how long the iteration runs afterward — demonstrated directly earlier on this page. If some part of your system genuinely needs every reader to observe a write immediately, this class is the wrong tool for that specific requirement; it was built to make the opposite trade-off on purpose.

One thing worth knowing that the original design obviously couldn’t have anticipated: as of Java 21, CopyOnWriteArrayList also implements SequencedCollection, so getFirst()/getLast()/reversed() all work on it the same way they do on ArrayList — pinned by this module’s test suite (copyOnWriteArrayListImplementsSequencedCollectionLikeAnyOtherList). It changes nothing about the staleness behavior above; reversed() is still a live view over the same snapshot semantics, not an escape hatch from them.

Should you reach for this?

Reach for it when reads dominate, the list stays modest in size, and you want iterator safety with zero caller discipline. Event-listener lists and small, rarely-changed caches are the textbook case, and the read numbers above explain why: roughly two orders of magnitude faster reads than a synchronized alternative under real contention, with no risk of ConcurrentModificationException regardless of what any other thread does mid-iteration. Reach for something else — synchronizedList with disciplined synchronized blocks, or a different concurrent structure entirely — when writes are frequent, the list is large, or any reader needs to see a write the instant it happens; the write numbers above show exactly what you’d be paying, and it is not a rounding error at 1,000 elements and four writers.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.