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,Vectorfor stable snapshots and value-oriented design. - Use
scala.collection.concurrent.TrieMapwhen many threads must update shared keys. - For mutable shared maps on the JVM, Java’s
ConcurrentHashMapis still first-class from Scala.
Kotlin
- Kotlin
Listmeans read-only view, not automatically immutable backing storage. - For real shared mutation, use JVM concurrent collections (
ConcurrentHashMap,BlockingQueue, etc.). - Coroutines add
Mutexfor 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:
- Remove item from inventory map.
- Add row to shipping queue.
- 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 manualget+putsequences. - Choose queue style intentionally: non-blocking polling (
ConcurrentLinkedQueue) vs back-pressure waiting (BlockingQueue). - Use
CopyOnWriteArrayListonly 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?
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?
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?
When is Scala immutable data enough, and when do I still need concurrent structures?
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:
- Java 21 concurrent collections example
- Scala 3 concurrent collections example
- Kotlin concurrent collections example
- Java 21 tests
- Scala 3 tests
- Kotlin tests
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.