Understanding and Using Java’s CopyOnWriteArrayList

Dealing with concurrent modifications to collections in Java can be a source of tricky bugs. While Collections.synchronizedList() and ConcurrentHashMap offer solutions, sometimes you need a different approach, especially when read operations far outnumber write operations. This is where Java’s CopyOnWriteArrayList shines.

In this post, we’ll dive deep into CopyOnWriteArrayList, exploring its mechanics, use cases, and how it compares to other concurrent collection strategies.

What is CopyOnWriteArrayList?

CopyOnWriteArrayList is a thread-safe variant of ArrayList from the java.util.concurrent package. Its key characteristic, as the name suggests, is that all modifying operations (add, set, remove, etc.) create a fresh, new copy of the underlying array. The original array remains untouched during modification.

This “copy-on-write” strategy ensures that iterators traversing the list will always see a consistent, immutable snapshot of the list at the time the iterator was created. They are never exposed to concurrent modification exceptions (ConcurrentModificationException).


Key Characteristics:

  • Thread-Safe: It’s inherently thread-safe for both reads and writes.
  • Immutable Snapshots for Iterators: Iterators operate on a snapshot of the list when they were created, guaranteeing consistency and avoiding ConcurrentModificationException.
  • Performance Trade-offs:
    • Excellent Read Performance: Reads are very fast as they don’t require any locking.
    • Costly Write Performance: Write operations are expensive because they involve copying the entire underlying array. This cost increases with the size of the list.
  • Minimal Locking for Reads: Read operations usually don’t need to acquire any locks, leading to high concurrency for readers.
  • Can Contain Duplicates: Like ArrayList, it allows duplicate elements.
  • Insertion Order Preserved: Elements maintain their insertion order.

How CopyOnWriteArrayList Works Internally

Let’s look at a simplified view of how operations work:

1. Read Operations (e.g., get(index), iterator())

Read operations directly access the internal array without any synchronization. This is because the array they are reading from is effectively immutable during the read operation. If a write operation is happening simultaneously, it’s operating on a *new* copy, leaving the current array for readers undisturbed.

2. Write Operations (e.g., add(element), remove(element), set(index, element))

When a write operation occurs:

  1. A global reentrant lock is acquired to prevent multiple writes from happening simultaneously.
  2. A new, fresh array is created.
  3. Elements from the old array are copied to the new array.
  4. The new element (for add) is inserted, or an existing element is removed/modified (for remove/set).
  5. The internal reference to the array is updated to point to the new array.
  6. The global lock is released.

This entire process ensures that any iterators that were created *before* the write operation will continue to iterate over the *old* array, seeing the state of the list as it was when they were created. Iterators created after the write operation will see the changes.


When to Use CopyOnWriteArrayList

CopyOnWriteArrayList is an excellent choice when:

  1. Reads Significantly Outnumber Writes: This is the primary use case. If you have a collection that is read frequently but updated very rarely, CopyOnWriteArrayList will offer superior read performance compared to other synchronized collections.
  2. Iterators Need to Be Snapshot-Consistent: If you need to iterate over a collection and guarantee that the collection won’t change mid-iteration (preventing ConcurrentModificationException), this is your go-to. Event listener lists are a classic example.
  3. List Sizes Are Relatively Small: Due to the array copying on writes, large lists can incur significant performance overhead during modifications.

When NOT to Use CopyOnWriteArrayList

Avoid CopyOnWriteArrayList if:

  1. Writes Are Frequent: If your list is modified often, the constant copying will severely impact performance and potentially cause high memory consumption.
  2. List Sizes Are Very Large: Copying large arrays is expensive in terms of both CPU cycles and memory.
  3. You Need Immediate Visibility of Changes: While threads eventually see changes, the “snapshot” nature means an iterator created *before* a write operation won’t see that specific write. If real-time, immediate visibility of all changes to *all* concurrent operations is paramount, other concurrent structures might be better.

Example: Using CopyOnWriteArrayList

Let’s illustrate its use with a simple example where multiple threads read and one thread occasionally writes.


import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class CopyOnWriteArrayListDemo {

    public static void main(String[] args) throws InterruptedException {

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

        // Thread to read elements
        Runnable reader = () -> {
            System.out.println(Thread.currentThread().getName() + " - Start Reading...");
            Iterator it = safeList.iterator(); // Iterator gets a snapshot
            while (it.hasNext()) {
                String fruit = it.next();
                System.out.println(Thread.currentThread().getName() + " - Read: " + fruit);
                try {
                    Thread.sleep(50); // Simulate some processing time
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
            System.out.println(Thread.currentThread().getName() + " - Finished Reading.");
        };

        // Thread to add elements
        Runnable writer = () -> {
            System.out.println(Thread.currentThread().getName() + " - Adding elements...");
            safeList.add("Date");
            safeList.add("Elderberry");
            System.out.println(Thread.currentThread().getName() + " - Added elements.");
        };

        ExecutorService executor = Executors.newFixedThreadPool(4);

        // Start multiple readers
        executor.submit(reader);
        executor.submit(reader);

        // Let readers start, then add new elements
        Thread.sleep(100);

        executor.submit(writer); // Writer will create a new copy

        // Start more readers AFTER the writer for comparison
        Thread.sleep(50);
        executor.submit(reader);

        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);

        System.out.println("Final List: " + safeList);
    }
}
    

Output

pool-1-thread-1 - Start Reading...
pool-1-thread-2 - Start Reading...
pool-1-thread-1 - Read: Apple
pool-1-thread-2 - Read: Apple
pool-1-thread-1 - Read: Banana
pool-1-thread-2 - Read: Banana
pool-1-thread-1 - Read: Cherry
pool-1-thread-2 - Read: Cherry
pool-1-thread-1 - Finished Reading.
pool-1-thread-2 - Finished Reading.

pool-1-thread-3 - Adding elements...
pool-1-thread-3 - Added elements.

pool-1-thread-4 - Start Reading...
pool-1-thread-4 - Read: Apple
pool-1-thread-4 - Read: Banana
pool-1-thread-4 - Read: Cherry
pool-1-thread-4 - Read: Date
pool-1-thread-4 - Read: Elderberry
pool-1-thread-4 - Finished Reading.

Final List: [Apple, Banana, Cherry, Date, Elderberry]

Observe the Output:

  • The readers that started before the writer will print “Apple”, “Banana”, “Cherry”. They are iterating over the original snapshot.
  • The reader that starts after the writer will print “Apple”, “Banana”, “Cherry”, “Date”, “Elderberry”, reflecting the changes.
  • No ConcurrentModificationException occurs, regardless of when writer modifies the list.

Comparison with Other Concurrent Collections

1. CopyOnWriteArrayList vs. Collections.synchronizedList(new ArrayList())

  • CopyOnWriteArrayList:
    • Read Performance: Very high (no locking).
    • Write Performance: Low (array copy).
    • Iterator Safety: Immune to ConcurrentModificationException; iterators see original snapshot.
    • Locking: Fine-grained locking only for writes.
  • Collections.synchronizedList():
    • Read Performance: Moderate (requires acquiring a lock for each operation, including reads).
    • Write Performance: Moderate (acquires lock, no array copy for typical operations).
    • Iterator Safety: Throws ConcurrentModificationException if modified during iteration. You must manually synchronize externally around iterations.
    • Locking: A single mutex protecting all operations (including reads), leading to contention.

Conclusion: If reads vastly outnumber writes and iterator consistency is critical, CopyOnWriteArrayList is superior. If reads and writes are more balanced, or if you need external synchronization for bulk operations, synchronizedList might be simpler, but you must handle ConcurrentModificationException carefully.

2. CopyOnWriteArrayList vs. ConcurrentLinkedQueue (or other Concurrent collections)

  • CopyOnWriteArrayList: Backed by an array, good for random access, preserves insertion order. Expensive writes.
  • ConcurrentLinkedQueue: A non-blocking, thread-safe queue. Excellent for producer-consumer patterns where elements are added to one end and removed from the other. Not suitable for random access lists.
  • Other Concurrent collections (e.g., ConcurrentHashMap, ConcurrentSkipListSet): Each is designed for specific data structures and use cases (maps, sorted sets, etc.) with different performance characteristics. They generally use more sophisticated non-blocking algorithms or fine-grained locking.

Important Considerations

1. Memory Consumption

Since every write operation creates a new copy of the internal array, CopyOnWriteArrayList can have higher memory consumption, especially if you have a very large list and frequent writes. The garbage collector will eventually reclaim the old arrays, but there will be a brief period where two copies of the list exist in memory.

2. State Stalemates (Not for Immediate Visibility)

Remember that iterators work on a snapshot. If you have a scenario where a consumer thread absolutely *must* see the very latest state of the list immediately after a modification, CopyOnWriteArrayList might lead to a “stale” view for iterators created just before the update. For such strict real-time consistency requirements, you might need a different synchronization mechanism or strategy.


Conclusion

CopyOnWriteArrayList is a powerful tool in the java.util.concurrent arsenal, particularly well-suited for scenarios with high read-to-write ratios and where immutable iterator snapshots are beneficial. By understanding its internal workings and performance characteristics, you can make an informed decision on when and where to leverage its strengths in your concurrent Java applications. Choose wisely based on your specific access patterns and performance needs!

Leave a Reply

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