Other concurrent collections in Java
CopyOnWriteArrayList, ConcurrentLinkedQueue, BlockingQueue producer-consumer.
CopyOnWriteArrayList
Every write copies the whole array. Iterators walk the array snapshot taken when the loop began: no locking, never a ConcurrentModificationException, never a change mid-loop. Reads are cheap, writes are costly: perfect for listener lists that are read constantly and changed rarely.
Your turn
The loop adds to the list it's iterating. What prints?
var list = new CopyOnWriteArrayList<String>();
list.add("a");
list.add("b");
for (String s : list)
list.add(s + s);
System.out.println(list.size());4Throws ConcurrentModificationExceptionLoops forever
Show the answer
4. The loop walks the snapshot [a, b], so it runs twice and adds "aa" and "bb". The new elements are really added, but this loop never visits them. With an ArrayList, this would throw.
A queue that never waits
ConcurrentLinkedQueue is a lock-free, unbounded FIFO queue. offer() adds; poll() removes the head, or returns **null when empty**. It never blocks. Good for handing off items when nobody needs to wait for them.
A queue that waits
A BlockingQueue adds **put(), which waits while the queue is full, and take(), which waits while it's empty. That's the backbone of producer-consumer. With capacity 2: put 1, put 2, put 3 waits**... a consumer's take() returns 1, frees a slot, and 3 goes in. Order stays FIFO.
BlockingQueue<Job> q =
new ArrayBlockingQueue<>(100);
q.put(job); // producer: waits if full
Job next = q.take(); // consumer: waits if emptyUnbounded vs bounded
BlockingQueue<LogLine> q =
new LinkedBlockingQueue<>();The no-arg constructor is effectively unbounded. Fast producers fill the heap until OutOfMemoryError.
BlockingQueue<LogLine> q =
new LinkedBlockingQueue<>(10_000);When it's full, put() makes producers wait: natural back-pressure.
Pick the right tool
Listener lists (many reads, rare writes): CopyOnWriteArrayList. Bounded producer-consumer buffer: ArrayBlockingQueue. Unbounded, non-blocking FIFO: ConcurrentLinkedQueue. Sorted concurrent map: ConcurrentSkipListMap. Each trades something (write cost, blocking, ordering) for an access pattern.
The log shipper incident
A log shipper's producers outpaced its network consumer. They shared new LinkedBlockingQueue<>(), which kept growing until the app died with OutOfMemoryError. The fix was one argument: a capacity. Blocked producers are a visible slowdown; a dead JVM is an outage.
Key takeaways
- CopyOnWriteArrayList: cheap reads, costly writes, snapshot iterators
- ConcurrentLinkedQueue: lock-free; poll() returns null when empty
- BlockingQueue: put() waits when full, take() waits when empty
- A bounded queue gives natural back-pressure
LinkedBlockingQueue's no-arg constructor uses a capacity of Integer.MAX_VALUE: over 2.1 billion slots, unbounded for any practical purpose.
Practice questions
What does this print?
var list = new CopyOnWriteArrayList<>(List.of(1, 2));
for (int x : list) {
list.add(x * 10);
}
System.out.println(list);- [1, 2, 10, 20]
- Throws ConcurrentModificationException
- [1, 2, 10, 20, 100, 200]
- [1, 2]
Check your answer
[1, 2, 10, 20]. The loop iterates over the snapshot that existed when it started, so the new elements are added but never visited.
What does this print?
var q = new ArrayBlockingQueue<Integer>(2);
Thread producer = new Thread(() -> {
for (int i = 1; i <= 3; i++) {
try { q.put(i); }
catch (InterruptedException e) { return; }
}
});
producer.start();
System.out.println(q.take() + "" + q.take() + q.take());- 123
- 12
- 321
- Throws IllegalStateException
Check your answer
123. The queue is FIFO. When it's full, the third put() simply waits until main's take() frees a slot, so all three values arrive in order.