Using Collections.synchronizedList in Java
java collections synchronizedlist: Learn how Collections.synchronizedList works, when it guarantees thread safety, and where manual synchronization is still required.
java collections synchronizedlist requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
When multiple threads need to share a List in Java, Collections.synchronizedList() is often the first utility that comes to mind. It wraps an existing list so that every individual method call is protected by a single lock. The method is simple to use, but its guarantees are narrower than many developers expect. Understanding exactly what the wrapper does — and what it does not do — prevents subtle concurrency bugs that only appear under load.
The primary keyword java collections synchronizedlist refers to this factory method in the java.util.Collections class. You pass it any List implementation, and it returns a thread-safe view of that list. The wrapper synchronizes on a mutex, which is the wrapper object itself unless you supply a different one.
List<String> baseList = new ArrayList<>(); List<String> syncList = Collections.synchronizedList(baseList);
Every read and write operation on syncList — get, set, add, remove, contains, size — is atomic with respect to the same mutex. That means two threads cannot corrupt the internal structure of the underlying ArrayList by calling add at the same time. The wrapper is a straightforward way to make a list safe for concurrent access without rewriting the underlying data structure.
What synchronizedList Actually Protects
The wrapper serializes access to the list's methods. If two threads call add simultaneously, the operations are ordered by the lock. The same applies to remove, get, and contains. This prevents the classic ConcurrentModificationException that can occur when one thread modifies a list while another iterates over it, but only if the iteration itself is synchronized.
What the wrapper does not do is make compound operations atomic. A check-then-act sequence like this is not safe:
if (!syncList.contains("key")) { syncList.add("key"); }
Between the contains call and the add call, another thread can modify the list. The two operations are individually synchronized, but the sequence as a whole is not. To make the check-then-act atomic, you must hold the lock yourself:
synchronized (syncList) { if (!syncList.contains("key")) { syncList.add("key"); } }
This pattern is documented in the Collections.synchronizedList API, but it is easy to overlook. The same manual synchronization is required for any operation that depends on the state of the list between two calls.
Iteration Requires Manual Synchronization
The most common source of bugs with synchronizedList is iteration. The iterator returned by syncList.iterator() is not fail-fast in the traditional sense, but it is also not thread-safe on its own. If one thread iterates while another modifies the list, you can still get a ConcurrentModificationException or undefined behavior. The wrapper does not synchronize the entire iteration loop.
You must wrap the iteration in a synchronized block on the same mutex:
synchronized (syncList) { for (String item : syncList) { System.out.println(item); } }
This holds the lock for the entire loop, preventing other threads from modifying the list during iteration. The enhanced for-each loop compiles to an iterator call, so the lock is held from the first hasNext() to the last next(). If you use a stream pipeline, the same rule applies:
synchronized (syncList) { syncList.stream() .filter(s -> s.startsWith("a")) .forEach(System.out::println); }
Without the synchronized block, the stream pipeline can encounter concurrent modification. The lock must cover the entire traversal, not just the creation of the stream.
Performance Characteristics and Lock Contention
synchronizedList uses a single lock for all operations. Under low contention, the overhead is modest. When many threads frequently read and write the same list, the lock becomes a bottleneck. Every operation, including read-only ones like size() and contains(), must acquire the same mutex. There is no read-write lock optimization, so concurrent reads are serialized even though they do not modify the list.
For workloads with heavy read traffic and infrequent writes, CopyOnWriteArrayList is often a better fit. It allows concurrent reads without locking and copies the entire backing array on each write. That makes writes expensive, but reads are lock-free and extremely fast. The tradeoff is memory usage: each write allocates a new array, so frequent writes create garbage.
The choice depends on the access pattern. Use synchronizedList when writes are frequent and reads are less common, or when the list is small and contention is low. Use CopyOnWriteArrayList when reads dominate and writes are rare, such as in a listener registry or a configuration cache.
Choosing the Right List for Concurrent Access
synchronizedList is not the only way to share a list between threads. The decision should be based on the actual concurrency pattern of your application.
| Approach | Locking | Best for | Tradeoff |
|---|---|---|---|
Collections.synchronizedList | Single lock per operation | General-purpose thread-safe list | Manual synchronization for iteration and compound actions |
CopyOnWriteArrayList | Lock on write, lock-free reads | Read-heavy, write-rare scenarios | High write cost, memory overhead |
ConcurrentLinkedDeque | Lock-free | Queue-like access patterns | Not a List interface; no indexed access |
For indexed access with moderate contention, synchronizedList is usually sufficient. For high-throughput read workloads, CopyOnWriteArrayList avoids lock contention entirely. If you need a queue rather than a list, ConcurrentLinkedDeque provides lock-free operations but does not implement List.
Common Pitfalls and How to Avoid Them
One frequent mistake is synchronizing on the wrong object. If you create the wrapper without an explicit mutex, the wrapper itself is the lock. Synchronizing on the original list instead of the wrapper does nothing:
List<String> base = new ArrayList<>(); List<String> sync = Collections.synchronizedList(base); // Wrong: locks on base, but sync locks on sync synchronized (base) { for (String s : sync) { // unsafe } }
The correct approach is to synchronize on the wrapper object, or to pass the same mutex to the factory method and synchronize on that:
Object mutex = new Object(); List<String> sync = Collections.synchronizedList(base, mutex); synchronized (mutex) { for (String s : sync) { // safe } }
Another pitfall is mixing the wrapper with an unsynchronized reference. If you keep a reference to the original ArrayList and modify it directly, the wrapper cannot protect against concurrent access. The original reference must not escape the scope where the wrapper is created.
When synchronizedList Is Not the Answer
If your application needs atomic compound operations frequently, consider restructuring the data flow instead of adding more locks. A ConcurrentHashMap with a compute method provides atomic check-then-act semantics for key-value pairs. For a list that is mostly read and rarely modified, an immutable list built once and shared freely avoids synchronization entirely.
synchronizedList is a pragmatic tool for small, shared collections where the concurrency level is low. It is not a replacement for a concurrent data structure designed for high contention. Recognizing the boundary between these cases prevents both over-engineering and subtle race conditions.
Debugging Concurrency Issues with synchronizedList
When a race condition appears in code that uses synchronizedList, the cause is almost always an unsynchronized iteration or a compound action outside the lock. The stack trace may point to a ConcurrentModificationException inside the list's iterator, but the real problem is the missing synchronized block around the loop.
A useful debugging step is to search for every occurrence of the wrapper variable and check whether each access is protected. If the list is passed to another method, that method must also synchronize on the same mutex. The lock is not inherited by method calls; it must be applied at every point where the list is traversed or modified in a non-atomic sequence.
Consider using CopyOnWriteArrayList as a temporary fix during debugging if the race is hard to reproduce. It eliminates iteration races entirely because its iterators operate on an immutable snapshot. If the application works correctly with that substitution, the issue is confirmed to be missing synchronization rather than a deeper data structure problem.