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).CopyOnWriteArrayListitself shipped in Java 5 (2004) as part of the originaljava.util.concurrent, and its contract hasn’t changed since — though as of Java 21 (JEP 431) it also implementsSequencedCollection, sogetLast()andreversed()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 fromjavap -pagainst 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
synchronizedListwrite doesn’t have this cost. - Duplicates allowed, insertion order preserved — same contract as
ArrayListon both counts. - Since Java 21, it’s also a
SequencedCollection:getFirst(),getLast(), andreversed()all work on it exactly as they do onArrayList, pinned by this module’s own test suite. If you haven’t run intoSequencedCollectionyet, 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.
Going deeper on this section:
- Test pinning both outcomes as assertions: CowClaimsTest.java (
iteratorTakenBeforeAWriteNeverSeesThatWrite,iteratorTakenAfterAWriteSeesIt), 7/7 passing: output/03-tests.txt.
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 asynchronizedListthat a listener registry or a cache invalidation path iterates without its ownsynchronizedblock — exactly the kind of listCopyOnWriteArrayListwas 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.
Going deeper on this section:
- Full
javap -poutput for the class, not just the lock field: runjavap -p java.util.concurrent.CopyOnWriteArrayListagainst 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.
Going deeper on this section:
- Why
get(index)and not anIteratorfor these reads: asynchronizedListiterator racing a concurrent structural change throws exactly theConcurrentModificationExceptionreproduced above, which would make theSYNC/BACKGROUNDrow fail to complete rather than measure anything. Indexedgethas 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.synchronizedListbarely moves by comparison (17149 → 11819, about 1.45×), because it never copies anything. At 1,000 elements, that gap is roughly 17× insynchronizedList’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.CopyOnWriteArrayListdrops 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.
javapis 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 ofConcurrentModificationExceptionregardless of what any other thread does mid-iteration. Reach for something else —synchronizedListwith disciplinedsynchronizedblocks, 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
- Companion repository,
cowmodule: full source, demos, JMH benchmarks and captured output. - Sequenced Collections in Java 21+: getFirst, getLast and reversed() — the interface this class picked up for free in Java 21.
- Java HashMap vs ConcurrentHashMap: Complete Interview Guide — the map-side equivalent of the locking-vs-lock-free trade-off on this page.
- ArrayList vs LinkedList in 2026: JMH Benchmarks and Why LinkedList Rarely Wins — the single-threaded version of “don’t guess, benchmark it.”
- Oracle Javadoc: CopyOnWriteArrayList.
No Comments yet!