Synchronized Collections
Synchronized collections wrap standard Java collections to make every individual method call thread-safe via an intrinsic monitor lock. The Collections utility class provides Collections.synchronizedList(), Collections.synchronizedSet(), Collections.synchronizedMap(), and related methods, each returning a wrapper that synchronizes on a single lock object before delegating to the backing collection. While synchronized collections make individual operations atomic, they do not make compound operations atomic and do not protect iteration — callers must externally synchronize iteration and any check-then-act sequences. This entry covers every synchronized wrapper method, the synchronization contract, why iteration still requires external locking, compound operations and the TOCTOU problem, comparison with concurrent collections (ConcurrentHashMap, CopyOnWriteArrayList), the legacy Vector and Hashtable, and when synchronized wrappers are the right tool.
Synchronized Wrapper Methods and the Locking Contract
// ── Creating synchronized wrappers ────────────────────────────────────
List<String> synList = Collections.synchronizedList(new ArrayList<>());
Set<String> synSet = Collections.synchronizedSet(new HashSet<>());
Map<String,Int> synMap = Collections.synchronizedMap(new HashMap<>());
SortedSet<String> synSortedSet = Collections.synchronizedSortedSet(new TreeSet<>());
SortedMap<String,Integer> synSortedMap = Collections.synchronizedSortedMap(new TreeMap<>());
// ── Individual methods are atomic ─────────────────────────────────────
// Two threads calling add() concurrently — both calls are atomic, no data corruption:
synList.add("thread-safe-add"); // acquires lock, adds, releases
synList.size(); // acquires lock, reads size, releases
// ── Custom mutex — coordinate with external synchronized block ────────
Object sharedMutex = new Object();
List<String> synListCustomMutex = Collections.synchronizedList(
new ArrayList<>(), sharedMutex);
// Both of these lock on sharedMutex:
synchronized (sharedMutex) {
synListCustomMutex.add("x");
}
synchronized (sharedMutex) {
System.out.println(synListCustomMutex.size());
}
// ── Demonstrating thread safety of individual methods ─────────────────
List<Integer> shared = Collections.synchronizedList(new ArrayList<>());
ExecutorService executor = Executors.newFixedThreadPool(10);
// 10 threads each add 1000 elements — no data corruption:
for (int i = 0; i < 10; i++) {
final int threadId = i;
executor.submit(() -> {
for (int j = 0; j < 1000; j++) {
shared.add(threadId * 1000 + j);
}
});
}
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
System.out.println(shared.size()); // always 10000 — no races on individual add()
// ── Comparison: unprotected ArrayList would give wrong result ─────────
List<Integer> unsafe = new ArrayList<>();
ExecutorService ex2 = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
ex2.submit(() -> {
for (int j = 0; j < 1000; j++) unsafe.add(j);
});
}
ex2.shutdown();
ex2.awaitTermination(5, TimeUnit.SECONDS);
System.out.println(unsafe.size()); // likely != 10000 — data races, corruptionIteration Requires External Locking — Compound Operations
// ── Iteration MUST be externally synchronized ────────────────────────
List<String> synList = Collections.synchronizedList(
new ArrayList<>(List.of("a", "b", "c", "d")));
// WRONG — another thread can modify synList mid-iteration → CME:
for (String s : synList) { // UNSAFE
System.out.println(s);
}
// CORRECT — lock on the wrapper for the full iteration:
synchronized (synList) {
for (String s : synList) { // SAFE
System.out.println(s);
}
}
// CORRECT — iterator form, same requirement:
synchronized (synList) {
Iterator<String> it = synList.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}
}
// CORRECT — streams require the same lock:
synchronized (synList) {
synList.stream()
.filter(s -> s.compareTo("b") > 0)
.forEach(System.out::println);
}
// ── Compound operations — check-then-act is NOT atomic ────────────────
Map<String, Integer> synMap = Collections.synchronizedMap(new HashMap<>());
synMap.put("counter", 0);
// WRONG — two separate atomic operations, not one compound atomic one:
// Thread 1 and Thread 2 both read 0, both write 1 → lost update:
if (synMap.containsKey("counter")) { // atomic read
synMap.put("counter",
synMap.get("counter") + 1); // atomic read + atomic write
} // NOT compound-atomic!
// CORRECT — synchronize the compound check-then-act externally:
synchronized (synMap) {
if (synMap.containsKey("counter")) {
synMap.put("counter", synMap.get("counter") + 1);
}
}
// BETTER — use ConcurrentHashMap with atomic compound method:
ConcurrentHashMap<String, Integer> chm = new ConcurrentHashMap<>();
chm.put("counter", 0);
chm.compute("counter", (k, v) -> v == null ? 1 : v + 1); // atomic compound op
// ── putIfAbsent race condition with synchronized wrappers ─────────────
Map<String, List<String>> index = Collections.synchronizedMap(new HashMap<>());
String key = "java";
// WRONG — race between containsKey and put:
if (!index.containsKey(key)) { // thread A sees false
index.put(key, new ArrayList<>()); // thread B also puts — one list lost
}
// CORRECT — external synchronization for compound op:
synchronized (index) {
index.computeIfAbsent(key, k -> new ArrayList<>());
}
// BEST — ConcurrentHashMap.computeIfAbsent is inherently atomic:
ConcurrentHashMap<String, List<String>> concIndex = new ConcurrentHashMap<>();
concIndex.computeIfAbsent(key, k -> new ArrayList<>());Legacy Synchronized Classes, and Choosing the Right Tool
// ── Legacy Vector vs ArrayList + synchronizedList ────────────────────
// Vector — synchronized by default, legacy:
Vector<String> vector = new Vector<>();
vector.add("legacy");
// Every method locks on 'this' — same limitations as synchronizedList
// Modern equivalent:
List<String> modernSynced = Collections.synchronizedList(new ArrayList<>());
// Prefer this if you need a synchronized ArrayList
// ── Stack (legacy) vs Deque ───────────────────────────────────────────
Stack<Integer> legacyStack = new Stack<>();
legacyStack.push(1);
legacyStack.push(2);
System.out.println(legacyStack.pop()); // 2
// Modern equivalent — ArrayDeque is faster, not synchronized:
Deque<Integer> deque = new ArrayDeque<>();
deque.push(1);
deque.push(2);
System.out.println(deque.pop()); // 2
// Thread-safe stack — ConcurrentLinkedDeque:
Deque<Integer> safeDeque = new ConcurrentLinkedDeque<>();
safeDeque.push(1);
// ── When to use synchronized wrappers vs concurrent collections ────────
//
// synchronized wrappers:
// ✔ Wrapping a specific implementation (e.g., LinkedList, TreeSet)
// ✔ Low-contention, infrequent concurrent access
// ✔ Need to use Collections.sort(), binarySearch() — requires List
// ✘ High contention (one lock = serialized access = poor throughput)
// ✘ Compound atomic operations (need external sync)
// ✘ Safe iteration without blocking all writers
// ConcurrentHashMap:
// ✔ High-throughput concurrent reads and writes
// ✔ Atomic compound ops: putIfAbsent, computeIfAbsent, merge, compute
// ✔ Weakly consistent iteration without external locking
// ✘ Cannot lock the whole map for an atomic multi-step operation easily
// CopyOnWriteArrayList:
// ✔ Read-heavy: iterations never block, no locking needed
// ✔ Snapshot iteration — always consistent view
// ✘ Write-heavy: O(n) copy per write
// ── ConcurrentHashMap atomic compound operations ──────────────────────
ConcurrentHashMap<String, Integer> wordCount = new ConcurrentHashMap<>();
// putIfAbsent — atomic:
wordCount.putIfAbsent("java", 0);
// compute — atomic read-modify-write:
String[] words = {"java", "python", "java", "java", "python"};
for (String w : words) {
wordCount.merge(w, 1, Integer::sum); // atomic increment
}
System.out.println(wordCount); // {java=3, python=2}
// computeIfAbsent — atomic lazy initialization:
ConcurrentHashMap<String, List<String>> groups = new ConcurrentHashMap<>();
groups.computeIfAbsent("even", k -> new CopyOnWriteArrayList<>()).add("2");
groups.computeIfAbsent("even", k -> new CopyOnWriteArrayList<>()).add("4");
System.out.println(groups); // {even=[2, 4]}