Java Tutorial
🔍

Java CopyOnWriteArrayList

Java CopyOnWriteArrayList

java.util.concurrent.CopyOnWriteArrayList<E> is the thread-safe List for workloads where reads vastly outnumber writes. Every write operation — add(), remove(), set() — copies the entire backing array, performs the modification on the copy, then atomically swaps the reference. Readers always see a stable, immutable snapshot and never block, never throw ConcurrentModificationException, and never race with a writer. The cost is that every write is O(n). Choose it when reads are frequent and writes are rare.

What Is Java CopyOnWriteArrayList?

CopyOnWriteArrayList<E> is a concrete class in java.util.concurrent that implements List<E>. It is the concurrent sibling of ArrayList — same interface, same positional access, same ordering — but with full thread safety achieved through a copy-on-write strategy rather than locking on reads.

The diagram below shows where it sits in the Collections hierarchy and how it relates to alternatives.

java.lang.Iterable<E>
    └── java.util.Collection<E>
            └── java.util.List<E>
                    ├── java.util.ArrayList               ← NOT thread-safe
                    ├── java.util.Vector                  ← global lock (legacy)
                    └── java.util.concurrent
                            └── CopyOnWriteArrayList<E>   ← THIS CLASS
                                                          ← snapshot array, no lock on read

KEY FACTS:
  Package    : java.util.concurrent
  Since      : Java 1.5
  Implements : List<E>, RandomAccess, Cloneable, Serializable
  Backing    : volatile Object[] array reference
  Null       : allowed — null elements are permitted
  Duplicates : allowed
  Ordering   : insertion order preserved (same as ArrayList)
  Thread-safe: YES — reads need no lock, writes copy the array
  Iterator   : snapshot-based — NEVER throws ConcurrentModificationException
  Write cost : O(n) per write — full array copy every time

Basic Overview — The Copy-On-Write Mechanism

REGULAR ArrayList — NOT thread-safe:

  Internal: Object[] elementData  (direct, no volatile)

  Thread 1 iterates:  reads elementData[0], [1], [2]...
  Thread 2 adds:      writes elementData[3] = "newItem"
  Thread 3 removes:   shifts elementData[0..2] left

  Result:
    Thread 1 may see partially written state mid-iteration
    Thread 2 and Thread 3 may corrupt each other's indices
    Any structural change fires ConcurrentModificationException on Thread 1's iterator

CopyOnWriteArrayList — thread-safe:

  Internal: volatile Object[] array  ← volatile ensures all threads see latest reference

  WRITE OPERATION (add, remove, set):
    1. Acquire ReentrantLock         ← only ONE writer at a time
    2. Object[] snapshot = array     ← read current array
    3. Object[] newArray = Arrays.copyOf(snapshot, newLength)
    4. Modify newArray               ← add/remove/set on the COPY
    5. array = newArray              ← atomic volatile write — swap reference
    6. Release ReentrantLock

  READ OPERATION (get, contains, size, iteration):
    1. Object[] snapshot = array     ← read volatile — ONE instruction, no lock
    2. Read from snapshot            ← snapshot is immutable from this thread's view
    No lock. Never blocks. Never sees partial writes.

  ITERATOR:
    Created by capturing: Object[] snapshot = array (at iterator creation time)
    Iterator reads ONLY from this snapshot — never follows array reference updates
    Writer swaps to a new array → iterator's snapshot is unchanged
    Result: iterator NEVER throws CME, NEVER sees modifications made after creation

When to Use CopyOnWriteArrayList

The decision is about the read-to-write ratio and the collection size.

USE CopyOnWriteArrayList WHEN:
  1. Reads are extremely frequent, writes are rare
     — event listener registries (add listener once, fire thousands of times)
     — configuration snapshots (load once at startup, read on every request)
     — observer lists (few subscribers registered, many events dispatched)
     — allow-list or deny-list (occasional updates, checked on every request)

  2. Iteration must never throw ConcurrentModificationException under concurrency
     — a thread dispatching events while another thread (de)registers listeners
     — background scan threads that must complete even if the list changes

  3. Snapshot iteration is semantically correct
     — events fired to the set of listeners known AT dispatch time
     — a listener registered during dispatch may intentionally miss this event

CHOOSE ArrayList + external lock WHEN:
  - Writes are as frequent as reads
  - The list is very large (millions of elements) — O(n) copy per write is too expensive
  - Fine-grained iteration with concurrent adds/removes is not needed

CHOOSE Collections.synchronizedList WHEN:
  - Legacy code that must be a drop-in replacement
  - Beware: iteration still requires external synchronized block
  - No snapshot semantics — concurrent writes throw CME during iteration

CHOOSE ConcurrentLinkedDeque WHEN:
  - Queue or deque semantics with concurrent access — head/tail operations only
  - No random-access by index needed

DO NOT USE CopyOnWriteArrayList WHEN:
  - Write frequency is moderate or high — O(n) copy per write kills throughput
  - The list has millions of elements — copying gigabytes on every write
  - Memory is constrained — during write, both old and new arrays live in heap

How CopyOnWriteArrayList Works Internally

The volatile Array Reference

The entire thread-safety guarantee rests on one field: private transient volatile Object[] array. The volatile keyword ensures:

  • Every write to array is immediately visible to every thread — no CPU cache staleness.
  • Every read of array sees the most recently written value — no stale snapshot from before the last write.
VOLATILE ARRAY REFERENCE — WHY IT WORKS:

  Thread 1 (writer): completes add("X")
    Step 5: array = newArray    ← volatile write

  Thread 2 (reader): calls get(0) one nanosecond later
    Step 1: Object[] snap = array   ← volatile read

  Java Memory Model guarantee:
    The volatile write in Thread 1 happens-before the volatile read in Thread 2.
    Thread 2 ALWAYS sees newArray — never the pre-write version.

  This is why get(), size(), and contains() need NO lock:
    They only need to read array once (volatile read)
    and then safely operate on the immutable snapshot.

Write Path — Lock, Copy, Modify, Swap

add("New-Listener") on list ["A", "B", "C"]:

  BEFORE:
    volatile array → ["A", "B", "C"]

  Step 1: lock.lock()              ← ReentrantLock acquired (blocks other writers)
  Step 2: Object[] current = array ← read volatile array
  Step 3: int len = current.length = 3
  Step 4: Object[] newArr = Arrays.copyOf(current, len + 1)
           newArr = ["A", "B", "C", null]
  Step 5: newArr[len] = "New-Listener"
           newArr = ["A", "B", "C", "New-Listener"]
  Step 6: array = newArr           ← volatile write: all readers instantly see new array
  Step 7: lock.unlock()

  AFTER:
    volatile array → ["A", "B", "C", "New-Listener"]
    Old array ["A","B","C"] is unreferenced → eligible for GC
    Any iterators created BEFORE step 6 still hold the old snapshot ["A","B","C"]

SIMULTANEOUS READER during above write:

  Thread R: Object[] snap = array  ← gets either old or new array (volatile read)
  Whichever it gets, snap is a complete, consistent array — never a partially written state
  Thread R reads snap[0], snap[1], snap[2] — no lock, no contention with writer

Iterator Snapshot — Why CME Never Fires

ITERATOR LIFECYCLE:

  Time 0: listIterator = list.iterator()
           → captures: Object[] snapshot = array (the current array)
           → cursor = 0, size = snapshot.length

  Time 1: Thread W calls list.add("X")
           → Writer creates new array, assigns array = newArray (volatile)
           → listIterator's snapshot field is UNCHANGED — it still points to old array

  Time 2: listIterator.next() is called
           → reads snapshot[cursor] — never reads the live array field
           → sees OLD values — will never see "X" that was just added

  Time 3: listIterator.remove() is called
           → throws UnsupportedOperationException
           → CopyOnWriteArrayList iterator is READ-ONLY
           → modification during iteration must use list.remove() directly (safe)
           → or use list.removeIf() which manages its own lock internally

CONTRAST WITH ArrayList's fail-fast iterator:
    ArrayList iterator:
      hasNext() reads list.size() (live field)
      next() checks modCount == expectedModCount → CME if writer changed list

    CopyOnWriteArrayList iterator:
      hasNext() reads snapshot.length (fixed at iterator creation)
      next() reads snapshot[cursor] (immutable array)
      No modCount. No CME. No lock. Completely decoupled from live list.

Core Operations with Examples

add(), get(), contains() — Basic Usage

Java
1// File: CopyOnWriteArrayListBasicsDemo.java 2 3import java.util.Iterator; 4import java.util.List; 5import java.util.concurrent.CopyOnWriteArrayList; 6import java.util.concurrent.ExecutorService; 7import java.util.concurrent.Executors; 8import java.util.concurrent.TimeUnit; 9 10public class CopyOnWriteArrayListBasicsDemo { 11 12 public static void main(String[] args) throws InterruptedException { 13 14 CopyOnWriteArrayList<String> eventListeners = new CopyOnWriteArrayList<>(); 15 16 // add() — copies array, adds at end: O(n) 17 eventListeners.add("EmailService"); 18 eventListeners.add("SMSService"); 19 eventListeners.add("PushService"); 20 eventListeners.add("AuditLogger"); 21 System.out.println("Listeners: " + eventListeners); 22 System.out.println("Size: " + eventListeners.size()); 23 24 // get() — O(1), no lock 25 System.out.println("get(0): " + eventListeners.get(0)); 26 27 // contains() — O(n) scan of snapshot, no lock 28 System.out.println("contains(SMSService): " + eventListeners.contains("SMSService")); 29 System.out.println("contains(SlackBot) : " + eventListeners.contains("SlackBot")); 30 31 System.out.println(); 32 33 // Concurrent read during write — no CME, no blocking 34 System.out.println("=== Concurrent read + write — no CME ==="); 35 ExecutorService pool = Executors.newFixedThreadPool(2); 36 37 // Reader thread iterates while writer adds 38 pool.submit(() -> { 39 System.out.print(" Reader sees: "); 40 for (String listener : eventListeners) { 41 System.out.print(listener + " "); 42 try { Thread.sleep(10); } catch (InterruptedException ignored) {} 43 } 44 System.out.println("(reader done)"); 45 }); 46 47 // Writer thread adds during iteration — never interrupts the reader 48 pool.submit(() -> { 49 try { Thread.sleep(15); } catch (InterruptedException ignored) {} 50 eventListeners.add("SlackNotifier"); // O(n) copy — reader is unaffected 51 System.out.println(" Writer added: SlackNotifier"); 52 }); 53 54 pool.shutdown(); 55 pool.awaitTermination(5, TimeUnit.SECONDS); 56 57 System.out.println("Final listeners: " + eventListeners); 58 59 System.out.println(); 60 61 // removeIf() — safe concurrent removal 62 System.out.println("=== removeIf() — thread-safe removal ==="); 63 eventListeners.removeIf(l -> l.contains("Audit")); 64 System.out.println("After removing Audit*: " + eventListeners); 65 } 66}
Output:
Listeners: [EmailService, SMSService, PushService, AuditLogger]
Size: 4
get(0): EmailService
contains(SMSService): true
contains(SlackBot)  : false

=== Concurrent read + write — no CME ===
  Reader sees: EmailService SMSService PushService AuditLogger (reader done)
  Writer added: SlackNotifier
Final listeners: [EmailService, SMSService, PushService, AuditLogger, SlackNotifier]

=== removeIf() — thread-safe removal ===
After removing Audit*: [EmailService, SMSService, PushService, SlackNotifier]

Iterator Snapshot — What Readers See

Java
1// File: CopyOnWriteIteratorSnapshotDemo.java 2 3import java.util.Iterator; 4import java.util.concurrent.CopyOnWriteArrayList; 5 6public class CopyOnWriteIteratorSnapshotDemo { 7 8 public static void main(String[] args) { 9 10 CopyOnWriteArrayList<String> tasks = new CopyOnWriteArrayList<>(); 11 tasks.add("Task-A"); tasks.add("Task-B"); tasks.add("Task-C"); 12 13 // Capture snapshot iterator BEFORE modification 14 Iterator<String> snapshotIt = tasks.iterator(); 15 16 // Modify the list — adds go to a NEW array 17 tasks.add("Task-D"); 18 tasks.remove("Task-B"); 19 20 System.out.println("Live list after modifications: " + tasks); 21 System.out.println("Iterator was created BEFORE modifications."); 22 System.out.println(); 23 24 // Iterator still sees the OLD snapshot — Task-D absent, Task-B present 25 System.out.print("Iterator sees: "); 26 while (snapshotIt.hasNext()) { 27 System.out.print(snapshotIt.next() + " "); 28 } 29 System.out.println(" ← snapshot from creation time"); 30 31 System.out.println(); 32 33 // Iterator does NOT support remove() — read-only snapshot 34 Iterator<String> readOnly = tasks.iterator(); 35 readOnly.next(); 36 try { 37 readOnly.remove(); // throws UnsupportedOperationException 38 } catch (UnsupportedOperationException e) { 39 System.out.println("iterator.remove() throws UnsupportedOperationException"); 40 System.out.println("Use list.remove() or list.removeIf() instead."); 41 } 42 43 System.out.println(); 44 45 // Safe removal via the list reference directly — creates a new copy atomically 46 tasks.remove("Task-A"); 47 System.out.println("After list.remove(Task-A): " + tasks); 48 } 49}
Output:
Live list after modifications: [Task-A, Task-C, Task-D]
Iterator was created BEFORE modifications.

Iterator sees: Task-A Task-B Task-C   ← snapshot from creation time

iterator.remove() throws UnsupportedOperationException
Use list.remove() or list.removeIf() instead.

After list.remove(Task-A): [Task-C, Task-D]

CopyOnWriteArrayList as Event Dispatcher

Java
1// File: EventDispatcherDemo.java 2 3import java.util.concurrent.CopyOnWriteArrayList; 4import java.util.concurrent.Executors; 5import java.util.concurrent.ScheduledExecutorService; 6import java.util.concurrent.TimeUnit; 7 8public class EventDispatcherDemo { 9 10 @FunctionalInterface 11 interface OrderEventListener { 12 void onEvent(String eventType, String orderId, String detail); 13 } 14 15 static class OrderEventBus { 16 17 // Listener list — rarely updated, frequently iterated 18 private final CopyOnWriteArrayList<OrderEventListener> listeners = 19 new CopyOnWriteArrayList<>(); 20 21 public void subscribe(OrderEventListener listener) { 22 listeners.add(listener); 23 } 24 25 public boolean unsubscribe(OrderEventListener listener) { 26 return listeners.remove(listener); 27 } 28 29 // Dispatch — iterates snapshot; new/removed listeners affect NEXT dispatch 30 public void dispatch(String eventType, String orderId, String detail) { 31 System.out.printf(" DISPATCH [%s] %s — %s%n", eventType, orderId, detail); 32 // Iterating CopyOnWriteArrayList — no CME even if subscribe/unsubscribe 33 // is called by another thread during this loop 34 for (OrderEventListener listener : listeners) { 35 listener.onEvent(eventType, orderId, detail); 36 } 37 } 38 39 public int subscriberCount() { return listeners.size(); } 40 } 41 42 public static void main(String[] args) throws InterruptedException { 43 44 OrderEventBus bus = new OrderEventBus(); 45 46 // Register listeners 47 OrderEventListener emailAlert = (type, order, detail) -> 48 System.out.printf(" EMAIL : [%s] %s — %s%n", type, order, detail); 49 OrderEventListener smsAlert = (type, order, detail) -> 50 System.out.printf(" SMS : [%s] %s — %s%n", type, order, detail); 51 OrderEventListener auditLog = (type, order, detail) -> 52 System.out.printf(" AUDIT : [%s] %s — %s%n", type, order, detail); 53 OrderEventListener dashboard = (type, order, detail) -> 54 System.out.printf(" DASHBOARD: [%s] %s — %s%n", type, order, detail); 55 56 bus.subscribe(emailAlert); 57 bus.subscribe(smsAlert); 58 bus.subscribe(auditLog); 59 bus.subscribe(dashboard); 60 61 System.out.println("Subscribers: " + bus.subscriberCount()); 62 System.out.println(); 63 64 // Dispatch events from one thread while another unregisters a listener 65 ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); 66 67 // Dispatcher thread — fires events periodically 68 scheduler.scheduleAtFixedRate(() -> 69 bus.dispatch("ORDER_PLACED", "ORD-" + System.nanoTime() % 1000, 70 "Zomato order Rs.349"), 71 0, 100, TimeUnit.MILLISECONDS); 72 73 // Management thread — unregisters SMS after 150ms 74 scheduler.schedule(() -> { 75 bus.unsubscribe(smsAlert); 76 System.out.println("\n [Management] SMS listener unsubscribed\n"); 77 }, 150, TimeUnit.MILLISECONDS); 78 79 Thread.sleep(350); 80 scheduler.shutdownNow(); 81 82 System.out.println("\nFinal subscriber count: " + bus.subscriberCount()); 83 System.out.println("(SMS was removed — remaining: email, audit, dashboard)"); 84 } 85}
Output:
Subscribers: 4

  DISPATCH [ORDER_PLACED] ORD-123 — Zomato order Rs.349
    EMAIL   : [ORDER_PLACED] ORD-123 — Zomato order Rs.349
    SMS     : [ORDER_PLACED] ORD-123 — Zomato order Rs.349
    AUDIT   : [ORDER_PLACED] ORD-123 — Zomato order Rs.349
    DASHBOARD: [ORDER_PLACED] ORD-123 — Zomato order Rs.349
  DISPATCH [ORDER_PLACED] ORD-456 — Zomato order Rs.349
    EMAIL   : [ORDER_PLACED] ORD-456 — Zomato order Rs.349
    SMS     : [ORDER_PLACED] ORD-456 — Zomato order Rs.349
    AUDIT   : [ORDER_PLACED] ORD-456 — Zomato order Rs.349
    DASHBOARD: [ORDER_PLACED] ORD-456 — Zomato order Rs.349

  [Management] SMS listener unsubscribed

  DISPATCH [ORDER_PLACED] ORD-789 — Zomato order Rs.349
    EMAIL   : [ORDER_PLACED] ORD-789 — Zomato order Rs.349
    AUDIT   : [ORDER_PLACED] ORD-789 — Zomato order Rs.349
    DASHBOARD: [ORDER_PLACED] ORD-789 — Zomato order Rs.349

Final subscriber count: 3
(SMS was removed — remaining: email, audit, dashboard)

Real-World Example — Razorpay Webhook Notification Registry

A payment gateway like Razorpay maintains a list of webhook endpoints registered by merchant integrations. The gateway fires payment events to every registered endpoint. Endpoint registrations and deregistrations are infrequent — they happen when merchants configure their integration. But webhook dispatch fires on every payment — hundreds per second. CopyOnWriteArrayList is exactly right: writes are rare (registration), reads are constant (dispatch).

Java
1// File: WebhookEndpoint.java 2 3public record WebhookEndpoint( 4 String merchantId, 5 String url, 6 String[] events, 7 boolean active) { 8 9 public boolean accepts(String eventType) { 10 for (String event : events) { 11 if (event.equals("*") || event.equals(eventType)) return true; 12 } 13 return false; 14 } 15 16 @Override 17 public String toString() { 18 return String.format("[%s] %s (%s)", merchantId, url, active ? "active" : "disabled"); 19 } 20}
Java
1// File: WebhookDispatcher.java 2 3import java.util.List; 4import java.util.concurrent.CopyOnWriteArrayList; 5import java.util.concurrent.atomic.AtomicLong; 6 7public class WebhookDispatcher { 8 9 // Read-heavy, write-rare → CopyOnWriteArrayList is the correct choice 10 private final CopyOnWriteArrayList<WebhookEndpoint> registry = 11 new CopyOnWriteArrayList<>(); 12 13 private final AtomicLong totalDispatched = new AtomicLong(); 14 private final AtomicLong totalDeliveries = new AtomicLong(); 15 16 public void register(WebhookEndpoint endpoint) { 17 registry.add(endpoint); 18 System.out.println(" REGISTERED : " + endpoint); 19 } 20 21 public boolean deregister(String merchantId) { 22 // removeIf handles the lock and array copy internally — thread-safe 23 boolean removed = registry.removeIf(ep -> ep.merchantId().equals(merchantId)); 24 if (removed) System.out.println(" DEREGISTERED : merchant=" + merchantId); 25 return removed; 26 } 27 28 public void updateStatus(String merchantId, boolean active) { 29 // Replace in-place — requires remove + re-add for immutable records 30 List<WebhookEndpoint> toReplace = registry.stream() 31 .filter(ep -> ep.merchantId().equals(merchantId)) 32 .toList(); 33 for (WebhookEndpoint old : toReplace) { 34 // replaceAll uses lock internally for all replacements 35 registry.replaceAll(ep -> ep.merchantId().equals(merchantId) 36 ? new WebhookEndpoint(ep.merchantId(), ep.url(), ep.events(), active) 37 : ep); 38 } 39 System.out.printf(" STATUS UPDATE: merchant=%s active=%b%n", merchantId, active); 40 } 41 42 // High-frequency dispatch — iterates snapshot, no lock, no CME 43 public void dispatchEvent(String eventType, String paymentId, double amount) { 44 totalDispatched.incrementAndGet(); 45 // Snapshot taken once at loop start — deregistrations during dispatch 46 // affect the NEXT dispatch, not this one 47 int deliveries = 0; 48 for (WebhookEndpoint endpoint : registry) { 49 if (endpoint.active() && endpoint.accepts(eventType)) { 50 deliverWebhook(endpoint, eventType, paymentId, amount); 51 deliveries++; 52 } 53 } 54 totalDeliveries.addAndGet(deliveries); 55 } 56 57 private void deliverWebhook(WebhookEndpoint endpoint, String eventType, 58 String paymentId, double amount) { 59 System.out.printf(" WEBHOOK → %s | event=%s | txn=%s | Rs.%.2f%n", 60 endpoint.url(), eventType, paymentId, amount); 61 } 62 63 public void printStats() { 64 System.out.println("=".repeat(62)); 65 System.out.printf(" WEBHOOK STATS%n"); 66 System.out.printf(" Registered endpoints : %d%n", registry.size()); 67 System.out.printf(" Total dispatches : %d%n", totalDispatched.get()); 68 System.out.printf(" Total deliveries : %d%n", totalDeliveries.get()); 69 System.out.println("=".repeat(62)); 70 System.out.println(" Active endpoints:"); 71 registry.stream().filter(WebhookEndpoint::active) 72 .forEach(ep -> System.out.println(" " + ep)); 73 System.out.println("=".repeat(62)); 74 } 75 76 public static void main(String[] args) { 77 78 WebhookDispatcher dispatcher = new WebhookDispatcher(); 79 80 System.out.println("--- Registering merchant webhook endpoints ---"); 81 dispatcher.register(new WebhookEndpoint( 82 "M001", "https://shopifystore.in/webhooks", 83 new String[]{"payment.success", "payment.failed"}, true)); 84 dispatcher.register(new WebhookEndpoint( 85 "M002", "https://woocommerce.in/api/payment", 86 new String[]{"*"}, true)); 87 dispatcher.register(new WebhookEndpoint( 88 "M003", "https://saasapp.in/hooks/razorpay", 89 new String[]{"payment.success"}, true)); 90 dispatcher.register(new WebhookEndpoint( 91 "M004", "https://enterprise.co.in/payments", 92 new String[]{"payment.success", "refund.processed"}, false)); // disabled 93 94 System.out.println("\n--- Payment events ---"); 95 dispatcher.dispatchEvent("payment.success", "pay_ABC123", 2499.0); 96 System.out.println(); 97 dispatcher.dispatchEvent("payment.failed", "pay_DEF456", 999.0); 98 99 System.out.println("\n--- Configuration changes ---"); 100 dispatcher.deregister("M003"); 101 dispatcher.updateStatus("M004", true); 102 103 System.out.println("\n--- Next payment event (updated registry) ---"); 104 dispatcher.dispatchEvent("payment.success", "pay_GHI789", 4999.0); 105 106 System.out.println(); 107 dispatcher.printStats(); 108 } 109}
Output:
--- Registering merchant webhook endpoints ---
  REGISTERED   : [M001] https://shopifystore.in/webhooks (active)
  REGISTERED   : [M002] https://woocommerce.in/api/payment (active)
  REGISTERED   : [M003] https://saasapp.in/hooks/razorpay (active)
  REGISTERED   : [M004] https://enterprise.co.in/payments (disabled)

--- Payment events ---
    WEBHOOK → https://shopifystore.in/webhooks | event=payment.success | txn=pay_ABC123 | Rs.2499.00
    WEBHOOK → https://woocommerce.in/api/payment | event=payment.success | txn=pay_ABC123 | Rs.2499.00
    WEBHOOK → https://saasapp.in/hooks/razorpay | event=payment.success | txn=pay_ABC123 | Rs.2499.00

    WEBHOOK → https://shopifystore.in/webhooks | event=payment.failed | txn=pay_DEF456 | Rs.999.00
    WEBHOOK → https://woocommerce.in/api/payment | event=payment.failed | txn=pay_DEF456 | Rs.999.00

--- Configuration changes ---
  DEREGISTERED : merchant=M003
  STATUS UPDATE: merchant=M004 active=true

--- Next payment event (updated registry) ---
    WEBHOOK → https://shopifystore.in/webhooks | event=payment.success | txn=pay_GHI789 | Rs.4999.00
    WEBHOOK → https://woocommerce.in/api/payment | event=payment.success | txn=pay_GHI789 | Rs.4999.00
    WEBHOOK → https://enterprise.co.in/payments | event=payment.success | txn=pay_GHI789 | Rs.4999.00

==============================================================
  WEBHOOK STATS
  Registered endpoints : 3
  Total dispatches     : 3
  Total deliveries     : 8
==============================================================
  Active endpoints:
    [M001] https://shopifystore.in/webhooks (active)
    [M002] https://woocommerce.in/api/payment (active)
    [M004] https://enterprise.co.in/payments (active)
==============================================================

Performance Considerations

OperationCopyOnWriteArrayListArrayListsynchronizedListConcurrentLinkedDeque
get(index)O(1), no lockO(1)O(1) + lockN/A
add(element)O(n) — full copyO(1) amortisedO(1) + lockO(1)
remove(object)O(n) — full copyO(n) shiftO(n) + lockO(n)
contains(e)O(n), no lockO(n)O(n) + lockO(n)
size()O(1), no lockO(1)O(1) + lockApproximate
Concurrent readsFully parallelData raceSerialisedFully parallel
Iterator CME?Never — snapshotYesYesNever
Iteration costO(n), no lockO(n)O(n) + lockO(n)
Memory during write2× array sizeNormalNormalPer-node

The O(n) write trade-off: For a list of 10,000 elements, every add() copies 10,000 references. At 100 writes per second, that is one million reference copies per second just for this list. For lists larger than a few thousand elements with moderate write frequency, CopyOnWriteArrayList creates serious GC pressure — two live copies of the array exist simultaneously during every write.

Read parallelism is unlimited: A thousand threads can call get(), size(), contains(), and iterator() simultaneously without any contention. No lock is ever acquired. This is the defining advantage for heavily read-biased workloads.

Thread safety: CopyOnWriteArrayList is fully thread-safe without any external synchronisation for individual operations. Unlike Collections.synchronizedList(), iteration also requires no external synchronized block — the snapshot iterator is inherently safe.

Best Practices

Declare the variable as List<E> rather than CopyOnWriteArrayList<E>. List<String> listeners = new CopyOnWriteArrayList<>() lets callers work with the standard List interface and allows you to swap to a synchronizedList wrapper or another concurrent implementation without changing callers. The snapshot iterator behaviour is invisible through the List interface — document it in Javadoc if callers need to know.

Use removeIf() for conditional bulk removal instead of iterating and calling remove() inside a loop. removeIf(predicate) acquires the lock once, evaluates all elements, creates a single new array, and swaps. Calling list.remove(element) inside a for-each loop creates O(n) new arrays — one per removed element. A single removeIf() creates exactly one copy regardless of how many elements match.

Prefer immutable or effectively immutable elements. Since iterators return references to the snapshot array, a mutable element can still be modified by one thread while another thread's iterator has a reference to it. If elements are mutable, protect their fields with volatile or synchronised accessors. record classes (Java 16+) are ideal element types — immutable by design.

Use addIfAbsent() for set-like uniqueness checks. CopyOnWriteArrayList.addIfAbsent(element) checks whether the element is already present and adds it only if absent — atomically in a single write operation. This is cleaner than if (!list.contains(element)) list.add(element), which is two separate operations with a race window between them.

Common Mistakes

Mistake 1 — Using CopyOnWriteArrayList for Write-Heavy Workloads

Java
1// WRONG — high write frequency creates enormous GC pressure 2// This list holds 50,000 entries and is updated 500 times per second 3CopyOnWriteArrayList<OrderEvent> orderStream = new CopyOnWriteArrayList<>(); 4 5// Every add() copies 50,000 references — 25,000,000 copies per second! 6// JVM spends more time GC-collecting old arrays than doing useful work 7 8// CORRECT — use ArrayList with external synchronisation for write-heavy lists 9List<OrderEvent> orderStream2 = 10 java.util.Collections.synchronizedList(new java.util.ArrayList<>()); 11 12// OR for queue-style processing: 13java.util.concurrent.LinkedBlockingQueue<OrderEvent> orderQueue = 14 new java.util.concurrent.LinkedBlockingQueue<>();

Mistake 2 — Calling iterator.remove() — It Always Throws

Java
1CopyOnWriteArrayList<String> items = new CopyOnWriteArrayList<>(); 2items.add("keep-me"); items.add("remove-me"); items.add("keep-me-too"); 3 4// WRONG — CopyOnWriteArrayList iterator is read-only 5Iterator<String> it = items.iterator(); 6while (it.hasNext()) { 7 if ("remove-me".equals(it.next())) { 8 it.remove(); // throws UnsupportedOperationException — ALWAYS 9 } 10} 11 12// CORRECT — use removeIf() on the list itself 13items.removeIf("remove-me"::equals); // single atomic copy-and-swap 14System.out.println(items); // [keep-me, keep-me-too]
Output:
[keep-me, keep-me-too]

Mistake 3 — Assuming Iterator Sees the Latest State

Java
1CopyOnWriteArrayList<String> config = new CopyOnWriteArrayList<>(); 2config.add("feature.dark-mode=true"); 3config.add("feature.beta-search=false"); 4 5Iterator<String> it = config.iterator(); // snapshot captured here 6 7// Another thread (or same thread) updates config 8config.add("feature.new-checkout=true"); 9config.set(1, "feature.beta-search=true"); // replaces second element 10 11// WRONG assumption: iterator sees "feature.beta-search=true" and "feature.new-checkout=true" 12System.out.print("Iterator sees: "); 13while (it.hasNext()) { 14 System.out.print(it.next() + " "); 15} 16System.out.println(); 17// Iterator sees the SNAPSHOT from creation: beta-search=false, no new-checkout 18System.out.println("Current live list: " + config);
Output:
Iterator sees: feature.dark-mode=true  feature.beta-search=false
Current live list: [feature.dark-mode=true, feature.beta-search=true, feature.new-checkout=true]

Mistake 4 — Treating Separate Operations as Atomic

Java
1CopyOnWriteArrayList<String> activeUsers = new CopyOnWriteArrayList<>(); 2 3// WRONG — contains() and add() are two separate operations — race window exists 4if (!activeUsers.contains("user-001")) { 5 activeUsers.add("user-001"); // another thread may add "user-001" between these two lines 6} 7 8// CORRECT — use addIfAbsent() which is a single atomic operation 9boolean wasAdded = activeUsers.addIfAbsent("user-001"); 10System.out.println("Added: " + wasAdded); // true only if user-001 was not already present

Interview Questions

Q1. What is CopyOnWriteArrayList and why is it used?

CopyOnWriteArrayList is a thread-safe List in java.util.concurrent where every write operation — add(), set(), remove() — creates a full copy of the backing array, applies the modification, and atomically replaces the volatile array reference with the new copy. Reads (get(), size(), contains(), iteration) need no lock — they operate on the immutable snapshot they see when reading the volatile field. It is used when reads are extremely frequent and writes are rare: event listener registries, allow/deny lists, configuration snapshots, and observer patterns in multi-threaded environments.

Q2. How does CopyOnWriteArrayList achieve thread-safe iteration without ConcurrentModificationException?

The iterator captures the volatile array reference once at creation time as a local snapshot variable. Every next() and hasNext() call reads from this captured snapshot — it never follows the live array field again. A concurrent add() in another thread creates a new array and replaces the live array reference, but the iterator's local snapshot variable is immutable from the iterator's perspective. Since the snapshot never changes under the iterator, there is no modCount to check and no ConcurrentModificationException is ever possible. The iterator is read-only — iterator.remove() throws UnsupportedOperationException.

Q3. What is the time complexity of write operations in CopyOnWriteArrayList and why?

Every write operation (add(), remove(), set(), addAll()) is O(n) where n is the current list size. The operation acquires a ReentrantLock, reads the current array, calls Arrays.copyOf() to create a copy of length n (or n+1 for add()), modifies the copy, and performs a volatile write to swap the array reference. The O(n) copy is unavoidable — it is the mechanism that ensures readers always see a consistent, immutable snapshot. This is the fundamental tradeoff: zero contention on reads, O(n) cost on writes.

Q4. When should you choose CopyOnWriteArrayList over Collections.synchronizedList?

Use CopyOnWriteArrayList when reads are far more frequent than writes. Collections.synchronizedList(new ArrayList<>()) holds a global lock on every individual method — reads and writes both serialise through the same mutex. At 100 concurrent reader threads, 99 wait at any moment. CopyOnWriteArrayList reads without any lock — all 100 readers proceed simultaneously. Additionally, synchronizedList requires explicit synchronized(list) wrapping around iteration to avoid ConcurrentModificationException, while CopyOnWriteArrayList iteration is inherently safe without external synchronisation. Choose synchronizedList for write-heavy lists where O(n) copy per write would be too expensive.

Q5. Why does CopyOnWriteArrayList use a ReentrantLock for writes instead of synchronized?

CopyOnWriteArrayList uses final ReentrantLock lock = new ReentrantLock() rather than synchronized(this) for its write operations. The main practical benefit is that ReentrantLock allows tryLock() — a non-blocking attempt to acquire the lock — which enables timeout-based and interruptible lock acquisition. The lock also produces better JVM diagnostics. The deeper reason is historical: ReentrantLock was introduced alongside CopyOnWriteArrayList in Java 5 as the preferred explicit lock mechanism. Only one writer can hold the lock at a time — concurrent add() calls from different threads queue up behind the lock.

Q6. What is the difference between CopyOnWriteArrayList and a ConcurrentHashMap-backed Set?

CopyOnWriteArrayList is a List — it preserves insertion order, allows duplicates, and supports get(index). ConcurrentHashMap.newKeySet() is a Set — it rejects duplicates and has no positional access. For iteration, both are safe: CopyOnWriteArrayList uses snapshot iteration (iterator sees state at creation time), while ConcurrentHashMap's set iterator is weakly consistent (may see some concurrent changes). For write frequency, ConcurrentHashMap.newKeySet() is O(1) average per write; CopyOnWriteArrayList is O(n). For read-biased listener lists with ordered, duplicate-allowing semantics, CopyOnWriteArrayList is the right choice. For membership-check-only sets with moderate writes, ConcurrentHashMap.newKeySet() is better.

FAQs

Does CopyOnWriteArrayList allow null elements?

Yes. Unlike ConcurrentHashMap which prohibits null, CopyOnWriteArrayList allows null elements — multiple nulls in the same list are permitted. This is consistent with ArrayList's behaviour. contains(null) works correctly and returns true if any null element is present.

What happens to existing iterators when a CopyOnWriteArrayList is modified?

Nothing. Existing iterators continue to operate on their captured snapshot and complete normally. They see the list state at the moment they were created, regardless of any subsequent adds, removes, or sets. The snapshot is the array reference — once captured, it is immutable from the iterator's perspective. Only iterators created after the modification see the new state.

Is CopyOnWriteArrayList's iterator thread-safe to share between threads?

The iterator object itself is not designed to be shared across threads — it maintains a cursor integer that is not synchronised. Each thread should call list.iterator() to get its own iterator. The snapshot the iterator reads from is inherently thread-safe (immutable array), but the cursor state is thread-local.

Can I use CopyOnWriteArrayList for an LRU cache or a sliding window?

Not efficiently. LRU and sliding window patterns require frequent writes (evictions on every access or every new element), which would trigger O(n) copies constantly. CopyOnWriteArrayList is designed for write-rare workloads. For LRU caches, use LinkedHashMap with access-order mode and removeEldestEntry(). For concurrent LRU caches, use the Caffeine library.

What is addIfAbsent() and how does it differ from contains() plus add()?

addIfAbsent(element) is an atomic single operation defined on CopyOnWriteArrayList — it checks whether the element is present and adds it only if absent, all within one lock acquisition. if (!list.contains(element)) list.add(element) is two separate operations with a race window between them — another thread can insert the same element between the contains() and add() calls, resulting in a duplicate. addIfAbsent() eliminates this race. There is also addAllAbsent(Collection) for bulk atomic addition of elements not already present.

How does CopyOnWriteArraySet relate to CopyOnWriteArrayList?

CopyOnWriteArraySet<E> is backed by a CopyOnWriteArrayList and uses it for all storage and iteration. It adds uniqueness enforcement by delegating add() to CopyOnWriteArrayList.addIfAbsent() — elements are stored only if not already present. All thread-safety properties are identical: reads are lock-free snapshots, writes copy the full array, and iterators are snapshot-based and never throw ConcurrentModificationException. Use it when you need a thread-safe Set with read-biased access patterns.

Summary

CopyOnWriteArrayList<E> achieves thread-safe iteration by holding a volatile Object[] reference and copying the entire array on every write. Readers — including iterators — capture the volatile field once and work from that immutable snapshot, acquiring no lock and never seeing partial writes. Writers acquire a ReentrantLock, copy, modify, and swap the reference in O(n) time. Iteration never throws ConcurrentModificationException.

The design decision is entirely about read-to-write ratio. For event listener registries, webhook endpoint lists, and configuration snapshots where the list changes infrequently but is iterated thousands of times per second, CopyOnWriteArrayList is correct and efficient. For anything with moderate-to-high write frequency or large element counts, the O(n) copy cost and the temporary memory doubling during writes make it the wrong choice — use Collections.synchronizedList() or a queue structure instead.

For interviews: explain the volatile array reference and why reads need no lock, walk through the lock-copy-modify-swap write sequence, explain why iterators are snapshot-based and therefore never CME, describe the O(n) write tradeoff and when it is acceptable, and know that iterator.remove() throws UnsupportedOperationException. These questions appear consistently from fresher campus rounds at TCS through senior engineering interviews at Razorpay, Flipkart, and Swiggy.

What to Read Next

TopicLink
How ArrayList's resizable array differs from CopyOnWriteArrayList's snapshot modelJava ArrayList →
How ConcurrentHashMap provides thread-safe key-value storage as a complementary toolJava HashMap →
How the Iterator interface and fail-fast behaviour contrast with snapshot iterationJava Iterator →
How the Collections Framework organises thread-safe and non-thread-safe collectionsJava Collections Framework →
How Java Generics make CopyOnWriteArrayList type-safe at compile timeJava Generics →
Java CopyOnWriteArrayList | DevStackFlow