Class 36: Thread-Safe Collections in Java
Part 6 — Locks and Concurrent Collections
In the previous class, we learned about ReadWriteLock. We saw how multiple threads can read shared data at the same time while a writer gets exclusive access.
Now let's consider another common situation.
Imagine that we have an ArrayList containing customer names. Multiple threads need to add names, remove names, and read information from this list.
Can we simply share the same ArrayList between all these threads?
Not safely. Most of Java's traditional collection classes, such as ArrayList and HashMap, are not designed to handle concurrent modifications automatically.
Today, we will understand how Java provides thread-safe collections to help us solve this problem.
1. What Is a Thread-Safe Collection?
Let's first understand the problem with a simple example.
Suppose two threads are adding elements to the same ArrayList.
import java.util.ArrayList;
import java.util.List;
public class CollectionExample {
public static void main(String[] args) {
List<Integer> numbers = new ArrayList<>();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
numbers.add(i);
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
}
}
Both threads are modifying the same ArrayList.
ArrayList does not provide automatic synchronization for concurrent modifications.
This means we should not assume that the collection will remain safe when multiple threads modify it.
A thread-safe collection is designed to handle access from multiple threads safely according to its documented behavior.
Java provides several ways to work with collections in multithreaded applications.
2. Making a Collection Synchronized
One simple option is to use the synchronized collection wrappers provided by the Collections utility class.
For example, we can create a synchronized version of an ArrayList:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class SynchronizedListExample {
public static void main(String[] args) {
List<Integer> numbers =
Collections.synchronizedList(new ArrayList<>());
numbers.add(10);
numbers.add(20);
numbers.add(30);
System.out.println(numbers);
}
}
Here, we are using:
Collections.synchronizedList()
This method returns a synchronized List.
Its individual operations, such as add(), remove(), and get(), are protected against concurrent access using synchronization.
We can use similar wrappers for other collection types:
Collections.synchronizedList(new ArrayList<>());
Collections.synchronizedSet(new HashSet<>());
Collections.synchronizedMap(new HashMap<>());
These are useful when we want to make an existing collection available through synchronized operations.
3. An Important Point About Iterating
There is something important we need to understand when working with synchronized collections.
Consider this code:
List<String> names =
Collections.synchronizedList(new ArrayList<>());
names.add("Ankit");
names.add("Rahul");
names.add("Priya");
synchronized (names) {
for (String name : names) {
System.out.println(name);
}
}
Why are we using synchronized(names) while iterating?
Because synchronizing individual collection operations does not automatically make a complete iteration safe against another thread modifying the collection.
We synchronize on the collection while iterating so that other threads using the same synchronized wrapper cannot modify it during that iteration.
Remember: when iterating over a synchronized collection, synchronize on the collection for the entire iteration.
4. ConcurrentHashMap
Now let's explore a collection that is designed specifically for concurrent access.
We already know about HashMap.
HashMap stores data as key-value pairs. For example, we can store an employee ID and employee name.
But a normal HashMap is not designed for concurrent modifications by multiple threads.
Java provides ConcurrentHashMap for this purpose.
import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;
public class ConcurrentMapExample {
public static void main(String[] args) {
Map<Integer, String> employees =
new ConcurrentHashMap<>();
employees.put(101, "Ankit");
employees.put(102, "Rahul");
employees.put(103, "Priya");
System.out.println(employees.get(101));
employees.remove(102);
System.out.println(employees);
}
}
ConcurrentHashMap allows multiple threads to access and update the map safely.
It is designed to support concurrent operations without using one single lock for the entire map for every operation.
This allows better concurrency than simply synchronizing the entire map in many common situations.
We should remember that ConcurrentHashMap does not allow null keys or null values.
5. CopyOnWriteArrayList
Let's explore another useful collection.
Imagine a notification system where many threads frequently read a list of registered listeners.
New listeners may occasionally be added or existing listeners removed, but reading happens much more often.
Java provides CopyOnWriteArrayList for this kind of situation.
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.List;
public class CopyOnWriteExample {
public static void main(String[] args) {
List<String> names = new CopyOnWriteArrayList<>();
names.add("Ankit");
names.add("Rahul");
names.add("Priya");
for (String name : names) {
System.out.println(name);
}
names.add("Amit");
System.out.println(names);
}
}
The interesting part is how CopyOnWriteArrayList handles modifications.
When an element is added, removed, or changed, it creates a new copy of the underlying array containing the updated data.
Readers can continue using the previous array while the updated copy is prepared.
This makes reading convenient because readers do not need to wait for ordinary modifications in the same way they would with a single shared synchronized lock.
Iterators work with a snapshot of the collection as it existed when the iterator was created. They do not show changes made afterward.
The trade-off is that modifying the collection can be expensive because a new copy is created.
Therefore, CopyOnWriteArrayList is useful when reads are frequent and modifications are relatively rare.
6. BlockingQueue
We have already learned about BlockingQueue in Class 22 while studying the Producer-Consumer problem.
Let's quickly connect that concept with thread-safe collections.
BlockingQueue is a thread-safe queue designed to support communication between producer and consumer threads.
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class BlockingQueueExample {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> queue =
new ArrayBlockingQueue<>(2);
queue.put("Task 1");
queue.put("Task 2");
System.out.println(queue.take());
System.out.println(queue.take());
}
}
Remember these two methods:
- put(): Adds an element and waits if the queue is full.
- take(): Removes an element and waits if the queue is empty.
BlockingQueue handles the required coordination internally. We do not have to write our own wait() and notify() logic for this basic producer-consumer arrangement.
7. Which Collection Should We Use?
Let's quickly compare the collections we discussed.
| Collection | Purpose |
|---|---|
| Collections.synchronizedList() | A synchronized wrapper around a List. |
| Collections.synchronizedMap() | A synchronized wrapper around a Map. |
| ConcurrentHashMap | A map designed for concurrent access. |
| CopyOnWriteArrayList | A list suited to frequent reads and rare modifications. |
| BlockingQueue | A queue that supports waiting when full or empty. |
There is no single best collection for every situation. We should choose based on how the application reads, writes, and shares data.
Test Your Understanding
Let's see how well you understood this class!
Question 1: Why should we be careful when sharing an ArrayList between multiple threads?
Question 2: Which method creates a synchronized List wrapper?
Question 3: Which collection is designed for concurrent key-value operations?
Question 4: When is CopyOnWriteArrayList particularly useful?
Question 5: What happens when put() is called on a full BlockingQueue?
Class Summary
Let's quickly revise what we learned today.
- Traditional collections such as ArrayList and HashMap are not automatically safe for concurrent modifications.
- Collections.synchronizedList() creates a synchronized List wrapper.
- Collections.synchronizedMap() creates a synchronized Map wrapper.
- Iteration over synchronized collections requires additional synchronization.
- ConcurrentHashMap is designed for concurrent map operations.
- CopyOnWriteArrayList is useful when reads are frequent and modifications are rare.
- BlockingQueue supports thread-safe producer-consumer communication.
Remember: Choose a collection based on how your application uses shared data. Thread-safe does not mean every collection is equally suitable for every situation.
You have completed 36 out of 46 classes.