CopyOnWriteArrayList and CopyOnWriteArraySet in Java

  • Last Updated: October 22, 2026
  • By: javahandson
  • Series
img

CopyOnWriteArrayList and CopyOnWriteArraySet in Java

CopyOnWriteArrayList keeps reads lock-free by copying its array on every write. See how it and CopyOnWriteArraySet work, what they cost, and when to use them.

If you have ever shared a plain ArrayList between two threads, you already know the pain. One thread reads while another writes, and suddenly you get a ConcurrentModificationException or, worse, silently wrong data. CopyOnWriteArrayList is Java’s clean answer for the case where reads happen far more often than writes. It lives in the java.util.concurrent package, and its sibling CopyOnWriteArraySet is built right on top of it.

In this guide we will go slow and build the idea from the ground up. No jargon gets dumped on you without meaning. By the end you will understand how both classes work inside, when they shine, when they hurt, and how to answer the tricky interview questions that come up around them.

Diagram showing copy-on-write: readers use the old array while a writer copies it, edits the copy, and swaps the reference.

1. The Problem: Sharing a List Across Threads

Let us start with why this class even exists. A normal ArrayList is fast, but it is not thread-safe. That single fact explains a lot of production bugs.

Imagine a list of event listeners. Many threads read this list all the time to fire events. Once in a while, someone adds or removes a listener. The moment two threads touch the same ArrayList together, things fall apart.

What actually breaks? A couple of things:

  • A reader looping over the list can hit a ConcurrentModificationException when a writer changes it mid-loop.
  • Two writers can overwrite each other’s work, so an element quietly goes missing.
  • The internal size and array can fall out of sync, giving you a corrupt list.

You have a few ways to make it safe. Each one comes with a catch:

  • Wrap it with Collections.synchronizedList(). Every read and write now locks. Simple, but every single reader has to wait in line.
  • Use explicit locks yourself. You get more control, but also more code and more ways to get it wrong.
  • Use CopyOnWriteArrayList, which takes a completely different route.

The first two options slow down reads. And reads are the thing we do most. That mismatch is exactly the gap CopyOnWriteArrayList was built to fill.

2. What Does “Copy-On-Write” Actually Mean?

The name sounds fancy, but the idea is plain. The list keeps your data inside a private array. Reads touch that array directly, with no lock at all.

Writes are where the magic sits. The list does not change the existing array. Instead it does three things in order:

  • First, it takes a fresh copy of the whole array.
  • Next, it makes the change on that new copy.
  • Finally, it swaps the old array out and points to the new one.

So every write creates a brand-new array. Readers who were already looping over the old array simply keep going. They never see the change mid-loop, and they never crash. That is the whole trick, and it is surprisingly powerful once it clicks.

2.1 A Real-World Picture

Think of a printed notice board in an office. People read the current notice freely. When the admin wants to update it, they do not scribble over the old sheet while everyone is still reading. They print a fresh sheet, pin it up, and remove the old one. Anyone mid-read finishes the old sheet in peace. That printed-sheet swap is copy-on-write in everyday terms.

💡 Interview Insight
Interviewers love this line: reads are lock-free, writes copy the entire array. If you can explain why that trade-off makes reads fast and writes expensive, you have basically answered half the questions on this topic.

3. CopyOnWriteArrayList Explained

CopyOnWriteArrayList implements the List interface. So from the outside it feels just like an ArrayList. You can add, get, remove, and loop over it the same way. All the differences hide under the hood.

3.1 The Internal Pieces

Inside, the class is simpler than people expect. It keeps just two things:

  • A single array that holds the elements. It is marked volatile, so every thread always sees the latest reference.
  • A lock (a ReentrantLock in modern versions) that only writers use.

That volatile keyword matters more than it looks. When a writer swaps in a new array, the volatile write makes that new array visible to all other threads at once. No reader is left pointing at a stale reference. So you get clean memory visibility for free.

3.2 A Simple Example

Here is a small program. One thread keeps reading while another adds items. With a plain ArrayList this would often blow up. Here it runs clean.

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
 
public class CowListDemo {
    public static void main(String[] args) throws InterruptedException {
        List<String> names = new CopyOnWriteArrayList<>();
        names.add("Asha");
        names.add("Ravi");
 
        // Reader thread: loops many times
        Thread reader = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                for (String name : names) {
                    System.out.println("Read: " + name);
                }
            }
        });
 
        // Writer thread: adds while reader is looping
        Thread writer = new Thread(() -> {
            names.add("Meera");
            names.add("John");
        });
 
        reader.start();
        writer.start();
        reader.join();
        writer.join();
 
        System.out.println("Final list: " + names);
    }
}

The reader never throws a ConcurrentModificationException. Even though the writer adds names mid-loop, each loop runs over a stable snapshot. The new names show up in a later loop, not the current one.

3.3 What Happens Inside on a Write

Let us trace a single add call. Suppose the list holds three items and you add a fourth. Here is the sequence the class runs through:

  • The list grabs its lock so two writers do not clash.
  • A new array of four slots gets created from the current three.
  • Your new item goes into the last slot of that new array.
  • The volatile reference now points to this fresh array.
  • The lock is released, and the write is done.

Notice the lock is only for writers. Readers never ask for it. A reader just grabs whatever array the reference currently points to and walks through it. That is why ten reader threads can run side by side with zero slowdown.

💡 Interview Insight
A common follow-up: does a reader ever block a writer, or a writer block a reader? The answer is no on both counts. Readers stay fully lock-free. Only writers compete, and they compete only with other writers.

3.4 The Snapshot Iterator

This part trips up a lot of people, so let us be very clear. When you call iterator() on a CopyOnWriteArrayList, you get an iterator tied to the array as it looked at that exact moment. That frozen view is called a snapshot.

Two rules follow from this design:

  • The iterator never throws ConcurrentModificationException. These iterators are fail-safe, not fail-fast.
  • The iterator does not support remove, set, or add. Calling them throws UnsupportedOperationException.

Why block those methods? Because changing a snapshot would be pointless. The live list has already moved on to a newer array. So the designers simply disabled them rather than let you edit a frozen copy.

CopyOnWriteArrayList<Integer> nums =
        new CopyOnWriteArrayList<>(List.of(1, 2, 3));
 
for (Integer n : nums) {
    // Safe: this add touches the live list, not the snapshot
    if (n == 2) {
        nums.add(99);
    }
    System.out.println(n);   // prints 1, 2, 3 only
}
 
System.out.println(nums);    // [1, 2, 3, 99]

The loop prints 1, 2, 3. That 99 lands in the list, but the running iterator never sees it. Its snapshot was fixed before the add happened.

3.5 Handy Extra Methods

CopyOnWriteArrayList adds a couple of methods you will not find on a plain ArrayList. They exist to cut down wasted copies.

  • addIfAbsent(e): adds the element only if it is not already present. One atomic step, one possible copy.
  • addAllAbsent(collection): adds only the elements that are missing. Useful for merging without duplicates.

Both methods do the check and the copy under a single lock. So you avoid the race of checking first and adding later in two separate calls.

3.6 What a Read Looks Like

A read path is almost boring, and that is the point. When you call get(index), the list reads the current array reference and returns the element at that index. There is no lock to grab and no other thread to wait for.

Because the array reference is volatile, your read always sees the most recent array that any writer published. So you never read a half-updated state. You either see the array before a write or the array after it, never something in between.

4. CopyOnWriteArraySet Explained

Now the sibling. CopyOnWriteArraySet is a Set, so it holds unique elements with no duplicates. It gives you the same thread-safety style: lock-free reads and copy-based writes.

4.1 How It Is Built

Here is the neat part. CopyOnWriteArraySet does not reinvent anything. Inside, it simply holds a CopyOnWriteArrayList and leans on it to store elements. When you add something, the set first checks whether that element already exists. If it does, nothing happens. If not, it appends.

That duplicate check is why a set write can cost even more than a list write. Before copying the array, the set scans the whole thing to look for a match. For a big set, that scan really adds up.

import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
 
public class CowSetDemo {
    public static void main(String[] args) {
        Set<String> tags = new CopyOnWriteArraySet<>();
        tags.add("java");
        tags.add("concurrency");
        tags.add("java");          // duplicate, ignored
 
        for (String tag : tags) {
            System.out.println(tag);
        }
        System.out.println("Size: " + tags.size());  // 2
    }
}

The second “java” gets dropped. The set keeps only unique values, exactly like any other Set. Its size stays at two.

4.2 How It Differs From HashSet

A regular HashSet uses hashing, so lookups land in near O(1) time. CopyOnWriteArraySet does no hashing. It stores items in a plain array and checks duplicates by scanning. So its contains and add run in O(n), not O(1).

This tells you something important. For a small set, that cost is nothing to worry about. For a large set with frequent writes, it gets slow fast. Keep the size in mind before you reach for it.

💡 Interview Insight
If asked how CopyOnWriteArraySet achieves thread safety, do not say “hashing” or “synchronized buckets”. Say it wraps a CopyOnWriteArrayList and inherits its copy-on-write behavior. That one sentence shows you actually know the internals.

4.3 Nulls, Duplicates, and Order

A few small facts round out the picture. They come up in code reviews more often than you would think:

  • Both classes allow null elements. A plain null add works fine, unlike some other concurrent collections.
  • The list allows duplicates, as any List should. The set rejects them, as any Set should.
  • Iteration order follows insertion order. The underlying array keeps elements in the order you added them.

That last point is handy. If you need a thread-safe collection that also remembers insertion order, the copy-on-write classes give you both in one go.

5. Performance: Where the Cost Hides

Nothing comes free. The speed of reads is paid for by the cost of writes. Let us look at both sides honestly.

5.1 Reads Are Very Fast

A read is just an array access through a reference. No lock, no waiting, no contention. Many threads can read at once with no slowdown between them. This is the single biggest reason the class exists.

5.2 Writes Are Expensive

Every write copies the full array. So one add on a list of a million items copies a million references. Do that in a loop and you create a mountain of garbage for the collector to clean up later.

Here is a rough feel for the trade-offs between the safe list and a plain one:

OperationCopyOnWriteArrayListPlain ArrayList
get (read)O(1), lock-freeO(1), not thread-safe
add (write)O(n), copies arrayO(1) amortized
remove (write)O(n), copies arrayO(n)
Thread-safeYesNo

The lesson is simple. Writing a lot to a copy-on-write collection is a bad idea. Use it only when writes stay rare.

5.3 The Memory Spike During a Write

There is one more hidden cost. While a write runs, two copies of the array exist at the same time: the old one and the new one. For a huge list, that brief doubling of memory can matter. Watch your heap if your lists grow very large.

The cost also grows with size. A write on a ten-item list is trivial. A write on a ten-thousand-item list copies ten thousand references each time. So the same class can feel instant in one place and sluggish in another, purely based on how big the collection has grown.

6. When to Use These Collections

Now the practical bit. These classes fit a very specific shape of problem. Match that shape, and they are excellent. Miss it, and they quietly drag your performance down.

6.1 Good Fits

  • Listener or observer lists. You read them on every event but rarely add or remove.
  • Configuration or settings that load once and then get read constantly.
  • Caches of mostly-static reference data that changes a few times a day.
  • Any list where readers vastly outnumber writers.

6.2 The Observer Pattern Example

The observer pattern is the textbook use case. You keep a list of observers and notify each one when something happens. Notifications are frequent, but registering a new observer is rare. That read-heavy, write-rare mix is a perfect match.

public class EventSource {
    private final CopyOnWriteArrayList<Listener> listeners =
            new CopyOnWriteArrayList<>();
 
    public void register(Listener l) { listeners.add(l); }
    public void unregister(Listener l) { listeners.remove(l); }
 
    public void fire(String event) {
        // No lock needed, no risk of ConcurrentModificationException
        for (Listener l : listeners) {
            l.onEvent(event);
        }
    }
}

The fire method runs on a lock-free snapshot. A listener can even register during a notification, and the loop stays safe. That safety with zero locking is why Java’s own event code uses this pattern.

6.3 Poor Fits

  • Queues or buffers with constant adds and removes. The copying will crush you.
  • Large collections that change often. Each write copies everything.
  • Cases where a reader must see writes the instant they happen. Snapshots lag by design.

When writes are frequent, reach for ConcurrentHashMap, ConcurrentLinkedQueue, or a BlockingQueue instead. These handle heavy concurrent writes far better than a copy-on-write list ever could.

7. CopyOnWrite vs Other Thread-Safe Options

It helps to see where these classes sit among the alternatives. Each option solves a slightly different problem, so none of them is a blanket winner.

CollectionBest WhenRead CostWrite Cost
CopyOnWriteArrayListMany reads, few writesLock-freeHigh (copy)
Collections.synchronizedListBalanced reads and writesLockedLocked
ConcurrentLinkedQueueHeavy concurrent writesLock-freeLock-free
VectorLegacy code onlyLockedLocked

A synchronized list locks on every operation, reads included. CopyOnWriteArrayList frees your reads entirely. That lock-free read is its one big edge, so use it only to get that edge.

7.1 Why Not Just Use Vector?

People new to Java sometimes ask about Vector, since it is thread-safe too. But Vector locks every method, so reads are slow under load. It is also legacy code from the early Java days. For new work, skip Vector and pick a modern concurrent collection that fits your read-write balance.

8. Common Pitfalls and Best Practices

A few traps catch people again and again. Knowing them upfront saves hours of confused debugging.

8.1 Do Not Write in a Hot Loop

Adding items one by one inside a tight loop is the classic mistake. Each add copies the whole array. A loop of 10,000 adds does 10,000 copies. If you must bulk-load, build a normal list first, then hand it to the constructor in one shot.

// Bad: 10,000 array copies
CopyOnWriteArrayList<Integer> bad = new CopyOnWriteArrayList<>();
for (int i = 0; i < 10_000; i++) {
    bad.add(i);
}
 
// Good: one copy at construction time
List<Integer> source = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
    source.add(i);
}
CopyOnWriteArrayList<Integer> good =
        new CopyOnWriteArrayList<>(source);

8.2 Remember the Snapshot Lag

Your iterator always sees a frozen view. If another thread adds data while you loop, you will not see it until your next loop starts. For most read-heavy work that is perfectly fine. Just never rely on instant visibility of writes.

8.3 Do Not Expect Atomic Compound Actions

Single operations like add or remove are atomic. But a check-then-act across two calls is not. For example, calling contains and then add as two steps can still race. Use addIfAbsent for that exact case, since it does both under one lock.

8.4 Prefer Small, Stable Collections

These classes work best when they stay small and settle down early. A list of twenty listeners that changes once an hour is ideal. A list of fifty thousand rows that mutates every second is a trap. Before you choose a copy-on-write collection, ask one question: do my writes stay rare? If the honest answer is no, pick something else.

💡 Interview Insight
A sharp question to expect: what is the time complexity of add on a CopyOnWriteArrayList? Answer O(n), because every add copies the whole backing array. Candidates who say O(1) out of habit lose the point here.

9. FAQ’s on CopyOnWriteArrayList

Q: What is the difference between CopyOnWriteArrayList and ArrayList?

A: ArrayList is fast but not thread-safe. CopyOnWriteArrayList is thread-safe: reads happen with no lock, and every write copies the whole backing array. Use ArrayList for single-threaded code, and CopyOnWriteArrayList only when many threads read while few write.

Q: Why does CopyOnWriteArrayList never throw ConcurrentModificationException?

A: Its iterator works on a snapshot of the array taken at the moment you call iterator(). Later writes create a new array and do not touch that snapshot, so the loop stays valid. These iterators are fail-safe, not fail-fast.

Q: What is the time complexity of add on a CopyOnWriteArrayList?

A: It is O(n), because each add copies the entire backing array into a new one. Reads stay O(1) and lock-free. This is why you should avoid writing to it inside a tight loop.

Q: How does CopyOnWriteArraySet work internally?

A: It wraps a CopyOnWriteArrayList and reuses its copy-on-write behavior. On add, it scans the array for a duplicate first, then copies. It does no hashing, so contains and add run in O(n), which keeps it best for small sets.

Q: When should I not use CopyOnWriteArrayList?

A: Avoid it when writes are frequent or the collection is large, since every write copies everything. For write-heavy needs, pick ConcurrentHashMap, ConcurrentLinkedQueue, or a BlockingQueue instead.

10. Conclusion

CopyOnWriteArrayList and CopyOnWriteArraySet solve one problem really well. They give you thread-safe collections where reads never lock and never crash, even while another thread writes. The price for all that is the copy on every write.

So the rule of thumb stays short. Pick them when reads crush writes in number, your collection is not huge, and you can live with a slight snapshot lag. Step outside that zone, and a different concurrent collection will serve you better. Choose the right tool, and your concurrent code stays both safe and fast.

11. Further Reading

Leave a Comment