Concurrent Collections: One Pot, Many Spoons.

java java21 scala scala3 kotlin concurrent-collections concurrenthashmap blockingqueue trieMap mutex

Two threads walk into one kitchen, both grab the same spoon counter from a shared HashMap, both add one, and both put it back. Congratulations: your soup now has Schrödinger’s cutlery and the final count is wrong.

The Problem / Context

For Scala developers, this is the familiar shared-mutable-state trap: one variable, multiple threads, and confidence that disappears faster than free pizza in the office kitchen.

A deterministic race from this repository:

HashMap<String, Integer> sharedPot = new HashMap<>();
sharedPot.put("spoons", 0);

// Both threads read before either thread writes.
// Final value becomes 1, not 2.

HashMap is not thread-safe, but the deeper lesson is bigger: even with thread-safe structures, two individually safe calls can still form one unsafe business operation if you split the read and write across separate steps.

What “Thread-Safe” Actually Guarantees

A thread-safe collection guarantees its own operations are safe under concurrency. It does not automatically guarantee your whole sequence is atomic.

Situation Safe? Why
queue.offer(x) on ConcurrentLinkedQueue Yes One operation is internally synchronized/atomic
map.get(k) then map.put(k, v + 1) Not as a sequence Another thread can change k between calls
map.merge(k, 1, Integer::sum) Yes for that key update Read-modify-write is one atomic map operation

If your logic is “check current value, compute next, then update,” prefer a single atomic API such as merge or compute.

Java 21 Collection Choices by Use Case

Collection Use it when Trade-off
ConcurrentHashMap Many threads read/update shared key-value state Per-key atomic helpers are great, but cross-key transactions still need extra coordination
ConcurrentLinkedQueue Non-blocking producer/consumer handoff Great throughput, but consumers must handle empty polls
BlockingQueue (LinkedBlockingQueue) Producers/consumers should wait instead of spin Simpler coordination, but blocking semantics affect throughput and cancellation behavior
CopyOnWriteArrayList Reads massively outnumber writes Iteration is stable and lock-free, writes copy the full backing array

The Solution / Implementation

The repository includes a mirrored ConcurrentCollectionsExamples implementation in Java 21, Scala 3, and Kotlin.

var sharedPot = new ConcurrentHashMap<String, Integer>();
sharedPot.put("spoons", 0);
var first = new Thread(() -> sharedPot.merge("spoons", 1, Integer::sum));
var second = new Thread(() -> sharedPot.merge("spoons", 1, Integer::sum));
first.start();
second.start();
val sharedPot = new ConcurrentHashMap[String, Int]()
sharedPot.put("spoons", 0)
val first = Thread(() => sharedPot.merge("spoons", 1, Integer.sum))
val second = Thread(() => sharedPot.merge("spoons", 1, Integer.sum))
first.start()
second.start()
val sharedPot = ConcurrentHashMap<String, Int>()
sharedPot["spoons"] = 0
val first = Thread { sharedPot.merge("spoons", 1, Int::plus) }
val second = Thread { sharedPot.merge("spoons", 1, Int::plus) }
first.start()
second.start()

merge performs the read-modify-write as one map operation. No split-brain spoon math.

Queue and List Options in the Same Demo

The same file also includes:

  • drainOrdersWithConcurrentLinkedQueue() for non-blocking FIFO draining.
  • drainOrdersWithBlockingQueue() for consumer-waits-with-timeout flow.
  • copyOnWriteWaitersSnapshot() to show snapshot iteration (Ana, Ben) while writes (Cara) happen concurrently.

Those tests make the semantics explicit instead of hand-wavy interview claims.

Scala and Kotlin Comparison: Immutable vs Concurrent Mutable Sharing

The most useful distinction:

  • Immutable collection: safe to share as a fixed value.
  • Concurrent collection: safe to share while mutating from multiple threads.

Scala

  • Prefer immutable Map, List, Vector for stable snapshots and value-oriented design.
  • Use scala.collection.concurrent.TrieMap when many threads must update shared keys.
  • For mutable shared maps on the JVM, Java’s ConcurrentHashMap is still first-class from Scala.

Kotlin

  • Kotlin List means read-only view, not automatically immutable backing storage.
  • For real shared mutation, use JVM concurrent collections (ConcurrentHashMap, BlockingQueue, etc.).
  • Coroutines add Mutex for critical sections when your update spans multiple values and cannot be expressed as one collection operation.

Limits: Concurrent Collections Are Not Transactions

Even with thread-safe collections, multi-step workflows can still fail consistency checks:

  1. Remove item from inventory map.
  2. Add row to shipping queue.
  3. Publish analytics event.

If step 2 fails, you now have half-applied business state. Concurrent collections solve safe shared data structure access; they do not provide ACID transactions across several resources. For those workflows, use explicit orchestration, retries, and compensating actions.

Best Practices

  • Keep shared mutable state small and explicit; if you can model it as immutable snapshots, do that first.
  • Prefer atomic collection APIs (merge, compute, putIfAbsent) over manual get + put sequences.
  • Choose queue style intentionally: non-blocking polling (ConcurrentLinkedQueue) vs back-pressure waiting (BlockingQueue).
  • Use CopyOnWriteArrayList only when writes are rare and reader stability matters more than write cost.
  • When one operation spans multiple structures, coordinate with a higher-level lock or transaction strategy.
Why is map.get(k); map.put(k, v + 1) unsafe even on a concurrent map?
Because that is two operations, not one atomic operation. Another thread can update the same key after your get but before your put, so your write overwrites newer data. Use merge or compute to keep the read-modify-write in one map call.
When should I choose BlockingQueue over ConcurrentLinkedQueue?
Choose BlockingQueue when consumers should wait for work instead of spinning or sleeping. In this repository we use a timed poll, so the consumer can still fail fast instead of hanging forever when no new item appears. Use ConcurrentLinkedQueue when you want non-blocking behavior and can handle empty polls explicitly.
What does Kotlin's read-only List guarantee?
It guarantees that this reference cannot call mutating list methods. It does not guarantee that another reference cannot mutate the same backing collection. So read-only is an API contract, not a deep immutability guarantee.
When is Scala immutable data enough, and when do I still need concurrent structures?
Immutable values are enough when state is replaced as a whole and then shared as a snapshot. You still need concurrent structures when many threads must mutate shared state in place over time, such as counters, work queues, or shared caches.

Conclusion

Concurrent collections are like a well-organized kitchen: many people can work at once without stabbing each other with forks, but you still need a recipe for multi-step business workflows. For Scala developers moving into Java 21, keep the mental model crisp: immutable values are perfect for stable snapshots; concurrent collections are for shared, changing state; and atomic APIs are your first defense against race-condition soup.

Code Samples

All examples in this post are runnable:


This is part of our Java 21 Interview Preparation series. Start with Java 21 Interview Preparation Guide - Your Roadmap to Success. Next related posts: Collection Factory Methods and Stream Basics, CompletableFuture and Asynchronous Programming, and Virtual Threads and Structured Concurrency.