CopyOnWriteArrayList and Concurrent Collections
CopyOnWriteArrayList makes every write copy the whole array, so readers never need a lock and never see a change mid-iteration. That trade is excellent for lists that are read constantly and changed rarely, such as listener lists, and terrible for anything else. This page shows how it works, measures both sides of the trade, and introduces the other concurrent collections for queues and sorted data.
Contents
How copy-on-write works
CopyOnWriteArrayListjava.util.concurrentvolatileaddsetremove
Two properties follow. An iterator works on the array that existed when it was created, a snapshot, so it can never throw ConcurrentModificationException
| Class | Since | How it is safe | Best for |
|---|---|---|---|
CopyOnWriteArrayListCopyOnWriteArraySet | Java 5 | Copy the array on every write | Listener lists, configuration read by many threads |
ConcurrentHashMap | Java 5 | Lock-free reads, per-bin write locks | Shared maps; see |
ConcurrentLinkedQueueConcurrentLinkedDeque | Java 5, Java 7 | Lock-free linked nodes | Unbounded queues that never block |
LinkedBlockingQueueArrayBlockingQueue | Java 5 | Locks with waiting | Producer and consumer threads |
ConcurrentSkipListMapConcurrentSkipListSet | Java 6 | Lock-free skip list | Sorted data shared between threads |
Collections.synchronizedList | Java 1.2 | One lock on every call | Simple sharing with frequent writes |
The programs ran on JDK 21. The multi-threaded ones give different numbers on each run.
Snapshot iteration
The program adds an element while looping over a list, once on an ArrayListCopyOnWriteArrayList
import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
public class SnapshotIteration {
public static void main(String[] args) {
List<String> plain = new ArrayList<>(List.of("a", "b", "c"));
try {
for (String s : plain) {
if (s.equals("a")) {
plain.add("d");
}
}
} catch (ConcurrentModificationException e) {
System.out.println("ArrayList: ConcurrentModificationException");
}
List<String> cow = new CopyOnWriteArrayList<>(List.of("a", "b", "c"));
for (String s : cow) {
if (s.equals("a")) {
cow.add("d"); // writes go to a NEW array
}
System.out.print(s + " "); // the loop keeps reading the OLD array
}
System.out.println();
System.out.println("after loop: " + cow);
// The iterator is read-only
try {
cow.iterator().remove();
} catch (UnsupportedOperationException e) {
System.out.println("iterator.remove: UnsupportedOperationException");
}
}
}
Output:
ArrayList: ConcurrentModificationException
a b c
after loop: [a, b, c, d]
iterator.remove: UnsupportedOperationException
- The
loop threwArrayList on the next step after the add.ConcurrentModificationException - The copy-on-write loop printed only
: the iterator kept walking the old array, while the list itself now holdsa b c as well.d - Because the iterator's array is a snapshot,
is not supported and throwsiterator.remove() . Remove through the list, or useUnsupportedOperationException .removeIf
Removing elements from ordinary lists while looping is covered on the
The classic use: listener lists
An event source notifies listeners, and listeners may subscribe or unsubscribe while an event is being delivered. The program has a one-shot listener that removes itself and a late listener that subscribes during delivery.
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
public class Listeners {
// The classic use: many events fired, listeners rarely added or removed
static final List<Consumer<String>> listeners = new CopyOnWriteArrayList<>();
static void fire(String event) {
for (Consumer<String> l : listeners) { // no lock, no exception
l.accept(event);
}
}
public static void main(String[] args) throws InterruptedException {
listeners.add(e -> System.out.println("logger got " + e));
// A listener that removes itself while being called
listeners.add(new Consumer<>() {
@Override
public void accept(String e) {
System.out.println("one-shot got " + e + ", unsubscribing");
listeners.remove(this);
}
});
fire("start");
fire("stop");
Thread adder = new Thread(() -> listeners.add(e -> System.out.println("late got " + e)));
adder.start();
adder.join();
fire("restart");
}
}
Output:
logger got start
one-shot got start, unsubscribing
logger got stop
logger got restart
late got restart
- The one-shot listener unsubscribed inside its own callback, and the loop that was calling it continued safely; it did not receive
.stop - The late listener was added during the
event but only receivedstop , because eachrestart iterates the snapshot taken when it started.fire - No locks appear in the code. Listeners change rarely and events are frequent, which is exactly the trade copy-on-write makes. Swing, Android and many event libraries use this pattern.
The cost of writes
To show the other side, the program adds 20,000 and then 40,000 elements one at a time to a synchronized list and to a copy-on-write list, and finally adds 40,000 in a single addAll
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Supplier;
public class WriteCost {
// Best of 3: fill a fresh list from the supplier with n single adds
static double timeAdds(Supplier<List<Integer>> make, int n) {
long best = Long.MAX_VALUE;
for (int run = 0; run < 3; run++) {
List<Integer> list = make.get();
long start = System.nanoTime();
for (int i = 0; i < n; i++) {
list.add(i);
}
best = Math.min(best, System.nanoTime() - start);
}
return best / 1e6;
}
public static void main(String[] args) {
timeAdds(CopyOnWriteArrayList::new, 5_000); // warm-up
List<Integer> source = new ArrayList<>();
for (int i = 0; i < 40_000; i++) {
source.add(i);
}
System.out.println("best of 3 runs:");
for (int n : new int[] {20_000, 40_000}) {
System.out.printf(" %,d adds, synchronizedList: %7.1f ms%n", n,
timeAdds(() -> Collections.synchronizedList(new ArrayList<>()), n));
System.out.printf(" %,d adds, CopyOnWriteArrayList: %7.1f ms%n", n,
timeAdds(CopyOnWriteArrayList::new, n));
}
long best = Long.MAX_VALUE;
for (int run = 0; run < 3; run++) {
long start = System.nanoTime();
List<Integer> cow = new CopyOnWriteArrayList<>();
cow.addAll(source);
best = Math.min(best, System.nanoTime() - start);
}
System.out.printf(" 40,000 elements, one addAll: %7.2f ms%n", best / 1e6);
}
}
Output (JDK 21 on a shared 2-CPU Linux machine; treat the times as rough):
best of 3 runs:
20,000 adds, synchronizedList: 0.5 ms
20,000 adds, CopyOnWriteArrayList: 104.1 ms
40,000 adds, synchronizedList: 1.1 ms
40,000 adds, CopyOnWriteArrayList: 446.1 ms
40,000 elements, one addAll: 0.02 ms
- The synchronized list took about a millisecond either way. The copy-on-write list took over a hundred times as long for 20,000 adds, because the 20,000th add copied 20,000 elements and all of them together copied about 200 million.
- Doubling the adds to 40,000 made the copy-on-write list between about three and four and a half times slower across runs, close to the quadrupling that quadratic copying predicts, while the synchronized list barely changed.
- One
of 40,000 elements took a fraction of a millisecond, because it copies the array once. When a copy-on-write list must be filled, build it in one step withaddAll ornew CopyOnWriteArrayList<>(existingList) .addAll
Where it wins: many readers, rare writes
Three threads iterate a 100-element list in a loop for 300 ms while a fourth adds one element every 10 ms. With synchronizedListCopyOnWriteArrayList
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
public class ReadHeavy {
public static void main(String[] args) throws InterruptedException {
for (int round = 0; round < 2; round++) {
List<Integer> sync = Collections.synchronizedList(new ArrayList<>());
List<Integer> cow = new CopyOnWriteArrayList<>();
for (int i = 0; i < 100; i++) {
sync.add(i);
cow.add(i);
}
long a = run(sync, true);
long b = run(cow, false);
if (round == 1) {
System.out.printf("3 readers iterating a 100-element list, 1 rare writer:%n"
+ " synchronizedList (lock around each loop): %,d iterations%n"
+ " CopyOnWriteArrayList (no lock): %,d iterations%n", a, b);
}
}
}
// Runs 3 readers for 300 ms while a writer adds one element every 10 ms
static long run(List<Integer> list, boolean lockToIterate) throws InterruptedException {
AtomicBoolean stop = new AtomicBoolean();
long[] counts = new long[3];
Thread[] readers = new Thread[3];
for (int r = 0; r < 3; r++) {
int id = r;
readers[r] = new Thread(() -> {
while (!stop.get()) {
long sum = 0;
if (lockToIterate) {
synchronized (list) {
for (int x : list) {
sum += x;
}
}
} else {
for (int x : list) {
sum += x;
}
}
if (sum >= 0) {
counts[id]++;
}
}
});
readers[r].start();
}
Thread writer = new Thread(() -> {
while (!stop.get()) {
list.add(1);
sleep(10);
}
});
writer.start();
sleep(300);
stop.set(true);
for (Thread t : readers) {
t.join();
}
writer.join();
return counts[0] + counts[1] + counts[2];
}
static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Output (counts vary between runs):
3 readers iterating a 100-element list, 1 rare writer:
synchronizedList (lock around each loop): 1,004,717 iterations
CopyOnWriteArrayList (no lock): 4,800,863 iterations
- The copy-on-write list completed several times as many iterations. With the synchronized list, the readers queue for one lock, so only one of them can iterate at a time, even though none of them changes anything.
- The writer's 30 or so additions per run cost little with copy-on-write, because the list is small and writes are rare.
- With more cores, the gap grows: lock-free readers scale with the number of CPUs, a single lock does not.
Other concurrent collections
Copy-on-write fits only one pattern. The program shows the tools for the others: sets of unique listeners, queues filled by several threads, a producer and consumer pair, and a sorted map shared between threads.
import java.util.List;
import java.util.NavigableMap;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.LinkedBlockingQueue;
public class OtherConcurrent {
public static void main(String[] args) throws InterruptedException {
// addIfAbsent and CopyOnWriteArraySet: small sets of listeners or flags
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>(List.of("a"));
System.out.println("addIfAbsent(a): " + list.addIfAbsent("a") + ", addIfAbsent(b): "
+ list.addIfAbsent("b") + " -> " + list);
Set<String> set = new CopyOnWriteArraySet<>(List.of("x", "y", "x"));
System.out.println("CopyOnWriteArraySet keeps insertion order: " + set);
// ConcurrentLinkedQueue: lock-free, unbounded, never blocks
Queue<Integer> queue = new ConcurrentLinkedQueue<>();
Thread[] producers = new Thread[4];
for (int t = 0; t < producers.length; t++) {
producers[t] = new Thread(() -> {
for (int i = 0; i < 10_000; i++) {
queue.offer(i);
}
});
producers[t].start();
}
for (Thread p : producers) {
p.join();
}
System.out.println("ConcurrentLinkedQueue size after 4 x 10,000 offers: " + queue.size());
// BlockingQueue: the consumer waits for work; a bounded queue makes producers wait
BlockingQueue<String> work = new LinkedBlockingQueue<>(2);
Thread consumer = new Thread(() -> {
try {
String job;
while (!(job = work.take()).equals("STOP")) {
System.out.println(" consumed " + job);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
consumer.start();
for (String job : List.of("job1", "job2", "job3", "STOP")) {
work.put(job); // blocks while the queue holds 2 items
}
consumer.join();
// ConcurrentSkipListMap: sorted and thread-safe
NavigableMap<Integer, String> prices = new ConcurrentSkipListMap<>();
prices.put(30, "c");
prices.put(10, "a");
prices.put(20, "b");
System.out.println("skip list map: " + prices + ", ceilingKey(15) = "
+ prices.ceilingKey(15) + ", headMap(20) = " + prices.headMap(20));
}
}
Output:
addIfAbsent(a): false, addIfAbsent(b): true -> [a, b]
CopyOnWriteArraySet keeps insertion order: [x, y]
ConcurrentLinkedQueue size after 4 x 10,000 offers: 40000
consumed job1
consumed job2
consumed job3
skip list map: {10=a, 20=b, 30=c}, ceilingKey(15) = 20, headMap(20) = {10=a}
adds only when the element is missing, atomically.addIfAbsent is built on it, keeps insertion order, and has the same cost profile as the list.CopyOnWriteArraySet accepted 40,000 offers from four threads without losing any and without locks. It is unbounded andConcurrentLinkedQueue returnspoll when empty, so it never blocks.null- A
with capacity 2 made the producer wait inLinkedBlockingQueue until the consumer had taken items, and the consumer waited input until work arrived. A sentinel value,take , ends the consumer. Blocking queues are the basis of thread pools.STOP is the concurrent counterpart ofConcurrentSkipListMap , with the same navigation methods such asTreeMap andceilingKey ; seeheadMapNavigableMap and NavigableSet .
Compound actions and visibility
Thread-safe collections make each single call safe, but code often combines calls, and that combination is not atomic. Three patterns cause most bugs with concurrent lists:
- Check then act:
lets two threads both see "missing" and both add.if (!list.contains(x)) list.add(x) does the check and the add under the list's lock.addIfAbsent - Size then index:
can throwlist.get(list.size() - 1) if another thread removes an element between the two calls. With copy-on-write, iterate a snapshot or useIndexOutOfBoundsException (Java 21), which reads one array.getLast() - Iterate then modify by index: positions found during one iteration may be stale by the time they are used. Use
orremoveIf , which each work on the list in one locked step.replaceAll
The collections in java.util.concurrentBlockingQueuevolatileArrayList
Compound actions, shown running
The three patterns above are easy to agree with and easy to write anyway. This program runs the first two with real threads on a CopyOnWriteArrayList
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
public class CompoundRace {
public static void main(String[] args) throws InterruptedException {
// 1. Check then act: contains followed by add, from 4 threads
int dupRuns = 0;
for (int run = 0; run < 20; run++) {
List<Integer> list = new CopyOnWriteArrayList<>();
runThreads(4, () -> {
for (int i = 0; i < 1_000; i++) {
if (!list.contains(i)) {
list.add(i);
}
}
});
if (list.size() != 1_000) {
dupRuns++;
}
}
System.out.println("contains + add: duplicates in " + dupRuns + " of 20 runs");
int okRuns = 0;
for (int run = 0; run < 20; run++) {
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
runThreads(4, () -> {
for (int i = 0; i < 1_000; i++) {
list.addIfAbsent(i);
}
});
if (list.size() == 1_000) {
okRuns++;
}
}
System.out.println("addIfAbsent: exactly 1,000 elements in " + okRuns + " of 20 runs");
// 2. Size then index: another thread removes between the two calls, 10 rounds
AtomicInteger failures = new AtomicInteger();
int reads = 0;
for (int round = 0; round < 10; round++) {
CopyOnWriteArrayList<Integer> shared = new CopyOnWriteArrayList<>();
for (int i = 0; i < 1_000; i++) {
shared.add(i);
}
Thread remover = Thread.ofPlatform().start(() -> {
while (!shared.isEmpty()) {
try {
shared.remove(shared.size() - 1);
} catch (IndexOutOfBoundsException e) {
failures.incrementAndGet();
}
}
});
while (!shared.isEmpty()) {
try {
shared.get(shared.size() - 1);
reads++;
} catch (IndexOutOfBoundsException e) {
failures.incrementAndGet();
}
}
remover.join();
}
System.out.println("get(size() - 1) while another thread removes, 10 rounds: " + reads
+ " reads, " + failures.get() + " IndexOutOfBoundsException");
// 3. A snapshot of the array answers both questions consistently
CopyOnWriteArrayList<String> names = new CopyOnWriteArrayList<>(List.of("ann", "bo", "cy"));
Object[] snap = names.toArray();
names.remove("cy");
System.out.println("snapshot last element: " + snap[snap.length - 1] + " of " + snap.length
+ ", list now " + names);
}
static void runThreads(int n, Runnable task) throws InterruptedException {
Thread[] ts = new Thread[n];
for (int i = 0; i < n; i++) {
ts[i] = Thread.ofPlatform().start(task);
}
for (Thread t : ts) {
t.join();
}
}
}
contains + add: duplicates in 17 of 20 runs
addIfAbsent: exactly 1,000 elements in 20 of 20 runs
get(size() - 1) while another thread removes, 10 rounds: 9489955 reads, 369 IndexOutOfBoundsException
snapshot last element: cy of 3, list now [ann, bo]
- Four threads each ran
for the numbers 0 to 999. In most of the 20 runs the list ended with duplicates, because two threads both saw a number as missing before either added it.if (!list.contains(i)) list.add(i) - The same work with
left exactly 1,000 elements in all 20 runs. It checks and adds while holding the list's internal lock, so no other write can slip in between.addIfAbsent - Reading
while another thread removed from the end threwget(size() - 1) a few hundred times across 10 rounds, out of millions of reads. A bug that rare passes every test and then fails in production.IndexOutOfBoundsException returned the current array, and its last element stayedtoArray() aftercy was removed from the list. Every question asked of the snapshot gets a consistent answer, at the cost of possibly being out of date.cy
The same rules apply to ConcurrentHashMapgetputmergecompute
Which collection to use
| Situation | Use |
|---|---|
| Read constantly, changed rarely, small | CopyOnWriteArrayListCopyOnWriteArraySet |
| Listeners or observers | CopyOnWriteArrayList |
| Frequent writes to a shared list | Collections.synchronizedList |
| Work handed from producers to consumers | LinkedBlockingQueueArrayBlockingQueue |
| Queue that never blocks | ConcurrentLinkedQueue |
| Shared map | ConcurrentHashMap |
| Shared sorted map or set | ConcurrentSkipListMapConcurrentSkipListSet |
| Data that never changes after creation | List.ofMap.of |
Immutable collections are often the simplest thread-safe choice of all; see