Welcome to The Bean Counter Café, the busiest coffee shop in town and, unfortunately, also the buggiest piece of concurrent software you will read about today. Three baristas take orders at once. The mobile app, the till, and the loyalty kiosk all try to top up the same customer’s rewards wallet at the same time. And somewhere in the back, one very tired synchronized block is holding the whole shop together with duct tape.
This post is the sequel to locks and atomics. You already know how to protect a single variable with a lock or an atomic reference. Today we ask a more interesting question: what if, instead of protecting the shared thing, we simply stopped sharing it?
The Problem / Context
Here is the mistake almost everyone makes on day one of learning concurrency: they see “three threads need to update the same counter” and immediately reach for synchronized, a ReentrantLock, or an atomic. That is not wrong, exactly - it is just the first tool in the box, not the only one.
Locks answer the question “how do I let many threads touch the same object safely?” Message passing answers a different, often better question: “what if only one thread ever touches the object at all?”
Picture the café’s rewards wallet. The app, the register, and the kiosk are all producers - they generate top-up events. If all three reach directly into the same balance field, you need:
- A lock (or several) around every read-modify-write.
- Careful thought about lock ordering so two operations never deadlock each other.
- A prayer that nobody adds a new update path later and forgets to acquire the lock.
Message passing throws that whole checklist away. The producers do not touch balance at all. They write a message - “add 500 cents” - onto a queue. One dedicated owner thread reads that queue and is the only code in the universe allowed to mutate balance. No lock needed, because there is no contention: only one thread was ever going to write to it.
| Concept | Queue-based message passing | Lock-heavy shared state |
|---|---|---|
| Who mutates state | One owner thread, always | Any thread holding the lock |
| Coordination mechanism | Messages + a queue | Mutexes, monitors, or atomics |
| Mental model | “Who owns this value?” | “Which lock protects this field?” |
| Typical failure mode | Full/blocked queues, timeouts | Deadlocks, forgotten locks, stale reads |
| Debugging story | Replay the message log | Reconstruct interleavings from stack dumps |
If this smells familiar to Scala developers, that is no accident. It is the same instinct behind actors, ZIO queues, and “don’t share mutable state, pass immutable values instead.” Java 21 does not need a fancy actor framework to get there - java.util.concurrent queues plus a bit of discipline about ownership will do the job.
Locks: The Old Way (and Why It Gets Ugly Fast)
Let’s be honest about what the lock-based version of the café’s wallet looks like, because message passing only makes sense once you’ve felt this pain:
public final class LockedWallet {
private final Object lock = new Object();
private int balanceInCents = 0;
public void topUp(int cents) {
synchronized (lock) {
// Every single caller - app, till, kiosk - must remember
// to go through this method. Miss one spot and you have
// a silent, hard-to-reproduce race condition.
balanceInCents += cents;
}
}
public int balance() {
synchronized (lock) {
return balanceInCents;
}
}
}
This works. It also does not scale in the way that matters most: scale of understanding. Every new feature that touches balanceInCents has to remember the lock. Every reviewer has to check that the lock is held everywhere it needs to be. Add a second field that must stay consistent with the first (say, a lastTopUpTimestamp) and now you are reasoning about lock granularity, too. It is not that locks are bad - it is that they ask everyone, forever to follow the rules correctly.
The runnable LockedWallet example and its concurrent top-up test live in the repository alongside the message-passing examples, so you can run both approaches side by side.
Message passing flips the responsibility: only the owner thread needs to follow any rules, because it is the only thread doing the mutating.
Building Block 1: Blocking Handoff for “Must-Process” Work
When every message absolutely must be processed - like a coffee order, which a customer is standing there waiting for - use a blocking queue. Producers offer orders, and one kitchen worker thread blocks on take/poll until there is something to make.
public static List<String> handOffOrdersWithBlockingQueue(List<String> incomingOrders) {
var orders = new LinkedBlockingQueue<String>();
var prepared = new ArrayList<String>();
var kitchenWorker = new Thread(() -> {
for (var processed = 0; processed < incomingOrders.size(); processed++) {
var order = takeStringMessage(orders);
prepared.add("prepared:" + order);
}
}, "kitchen-worker");
kitchenWorker.start();
incomingOrders.forEach(orders::offer);
join(kitchenWorker);
return List.copyOf(prepared);
}
def handOffOrdersWithBlockingQueue(incomingOrders: List[String]): List[String] =
val orders = new LinkedBlockingQueue[String]()
val prepared = scala.collection.mutable.ListBuffer.empty[String]
val kitchenWorker = Thread(
() =>
for _ <- incomingOrders.indices do
val order = takeStringMessage(orders)
prepared += s"prepared:$order"
,
"kitchen-worker"
)
kitchenWorker.start()
incomingOrders.foreach(orders.offer)
join(kitchenWorker)
prepared.toList
fun handOffOrdersWithBlockingQueue(incomingOrders: List<String>): List<String> {
val orders = LinkedBlockingQueue<String>()
val prepared = mutableListOf<String>()
val kitchenWorker =
Thread(
{
repeat(incomingOrders.size) {
val order = takeStringMessage(orders)
prepared.add("prepared:$order")
}
},
"kitchen-worker",
)
kitchenWorker.start()
incomingOrders.forEach(orders::offer)
join(kitchenWorker)
return prepared.toList()
}
Notice what is missing: no synchronized, no Lock, no compareAndSet retry loop. The queue itself is the synchronization point. LinkedBlockingQueue.take()/poll(timeout) already handles “wait safely until something shows up,” so the kitchen worker never busy-spins and never races anyone for the prepared list, because it is the only thread touching it.
Building Block 2: Non-Blocking Drain for “Best Effort” Work
Not everything is as urgent as a hot espresso. Marketing emails (“Come back, we miss you!”) can wait. For that kind of work, ConcurrentLinkedQueue.poll() is a better fit: it returns null immediately instead of blocking, which is perfect for “check the mailbox, send what’s there, and move on.”
public static List<String> handOffEmailsWithNonBlockingQueue(List<String> outgoingEmails) {
var mailbox = new ConcurrentLinkedQueue<String>();
outgoingEmails.forEach(mailbox::offer);
var sent = new ArrayList<String>();
while (true) {
var email = mailbox.poll();
if (email == null) {
return List.copyOf(sent);
}
sent.add("sent:" + email);
}
}
def handOffEmailsWithNonBlockingQueue(outgoingEmails: List[String]): List[String] =
val mailbox = new ConcurrentLinkedQueue[String]()
outgoingEmails.foreach(mailbox.offer)
val sent = scala.collection.mutable.ListBuffer.empty[String]
var next = mailbox.poll()
while next != null do
sent += s"sent:$next"
next = mailbox.poll()
sent.toList
fun handOffEmailsWithNonBlockingQueue(outgoingEmails: List<String>): List<String> {
val mailbox = ConcurrentLinkedQueue<String>()
outgoingEmails.forEach(mailbox::offer)
val sent = mutableListOf<String>()
while (true) {
val email = mailbox.poll() ?: return sent.toList()
sent.add("sent:$email")
}
}
| Handoff style | Java 21 building block | Best when | Café analogy |
|---|---|---|---|
| Blocking | LinkedBlockingQueue.take() / poll(timeout) |
Consumers should wait for work instead of spinning | The barista waits for the next order ticket |
| Non-blocking | ConcurrentLinkedQueue.poll() |
Work is optional; “nothing to do” is a normal outcome | Checking the marketing-email tray on a slow afternoon |
| Bounded blocking | ArrayBlockingQueue |
You need backpressure and explicit capacity | The bar only has room for 20 cups waiting to be picked up |
Building Block 3: One Owner, Many Producers - Solving the Race Condition for Real
Now the finale. Remember the loyalty wallet from the lock example? Here is the same problem solved with message passing: the app, the till, and the kiosk are producers that never touch balance. They only ever put a top-up amount onto a queue. One wallet-owner thread is the sole reader of that queue and the sole writer of the balance.
var topUpMessages = new LinkedBlockingQueue<Integer>();
var finalBalanceInCents = new AtomicInteger(0);
var walletOwner = new Thread(() -> {
var localBalance = 0;
for (var processed = 0; processed < expectedTopUps; processed++) {
var message = takeIntMessage(topUpMessages);
localBalance += message;
}
finalBalanceInCents.set(localBalance);
}, "wallet-owner");
walletOwner.start();
// Every producer only ever calls put(...) - it never touches localBalance.
put(topUpMessages, centsPerTopUp);
val topUpMessages = new LinkedBlockingQueue[Int]()
val finalBalanceInCents = new AtomicInteger(0)
val walletOwner = Thread(
() =>
var localBalance = 0
for _ <- 0 until expectedTopUps do
val message = takeIntMessage(topUpMessages)
localBalance += message
finalBalanceInCents.set(localBalance)
,
"wallet-owner"
)
walletOwner.start()
// Every producer only ever calls put(...) - it never touches localBalance.
put(topUpMessages, centsPerTopUp)
val topUpMessages = LinkedBlockingQueue<Int>()
val finalBalanceInCents = AtomicInteger(0)
val walletOwner =
Thread(
{
var localBalance = 0
repeat(expectedTopUps) {
val message = takeIntMessage(topUpMessages)
localBalance += message
}
finalBalanceInCents.set(localBalance)
},
"wallet-owner",
)
walletOwner.start()
// Every producer only ever calls put(...) - it never touches localBalance.
put(topUpMessages, centsPerTopUp)
Run this with a dozen producer threads hammering top-ups at once, and the final balance is always correct - not because of a clever lock, but because there is only ever one thread doing arithmetic on localBalance. Compare that to the LockedWallet from earlier: same guarantee, but here nobody has to remember to synchronize anything, because there is nothing left to forget.
Ownership Beats Locking (Most of the Time)
| Question you’re really asking | Lock-based answer | Message-passing answer |
|---|---|---|
| “Who can change this value?” | Whoever grabs the lock first | Only the owner thread, by construction |
| “What happens under heavy contention?” | Threads block on the lock, or spin-retry with CAS | Messages queue up; owner drains them in order |
| “How do I add a new writer?” | Make sure it acquires the same lock | Give it a reference to the queue - done |
| “How do I shut everything down?” | Careful lock release, maybe finally blocks everywhere |
Send a shutdown message (a “poison pill”) through the queue |
| “How do I test it?” | Simulate interleavings, hope you covered the bad ones | Feed in messages, assert on the final state |
That last row matters more than it looks. A poison pill is just a special message - “no more work is coming” - that tells the owner thread to stop looping and exit cleanly. It is the message-passing equivalent of Thread.interrupt(), except it goes through the same queue as everything else, so the owner never has two different shutdown paths to reason about.
What the Tests Prove
The mirrored tests in Java, Scala, and Kotlin verify:
- Blocking queue handoff keeps message order for order processing - the third customer’s flat white does not jump the queue.
- Non-blocking queue drain exits cleanly when no messages remain - no infinite spinning waiting for emails that will never come.
- Single-owner queue processing applies all concurrent top-ups without lost updates, even with many producer threads racing to enqueue at once.
Best Practices
- Start by asking “who should own this mutable state?” before reaching for a lock.
- Prefer message passing when one component can naturally be the sole owner of a value.
- Use blocking queues for must-process workflows (orders) and non-blocking queues for opportunistic polling (marketing emails).
- Define an explicit shutdown protocol - a poison pill, a close signal, or a completion marker - instead of just killing threads.
- Keep messages immutable. If a message is a mutable object that producers can still modify after sending it, you have smuggled shared mutable state back in through the side door.
- Validate queue capacity and timeout behavior under real production load, not just a happy-path unit test with three messages.
When is message passing simpler than shared-state coordination?
What is the practical difference between a queue-based design and a lock-heavy design?
How do ownership boundaries reduce race conditions compared to more locking?
What Java 21 building blocks help with message-passing architectures?
java.util.concurrent: blocking queues like LinkedBlockingQueue and ArrayBlockingQueue for producer-consumer handoff, ConcurrentLinkedQueue for non-blocking mailboxes, and CountDownLatch or structured concurrency scopes for lifecycle coordination such as "wait until all producers are done." Pair these with immutable message payloads and one clearly documented owner per queue. That combination gives you a design that maps cleanly onto Scala's actor and isolation-first mental model, while staying completely idiomatic, boring, dependency-free Java 21.
Does message passing mean I should throw away everything I learned about locks and atomics?
Conclusion
For Scala developers learning Java 21, message passing is often the cleanest way to dodge shared-state traps entirely: instead of asking every caller to lock correctly forever, you isolate the mutation behind one owner, hand off work as immutable messages, and let the queue carry the thread-safety burden. Locks and atomics still have their place - just not everywhere, and definitely not in the back room of The Bean Counter Café anymore.
Code Samples
All examples in this post are runnable. Find them in the repository:
- Java 21 message passing examples
- Scala 3 message passing examples
- Kotlin message passing examples
- Java 21
LockedWallet“old way” example - Java 21 tests
- Scala 3 tests
- Kotlin tests
This is part of our Immutability and Concurrency Preparation Guide. Next related posts: Atomic Operations: Defuse the Race Condition and Concurrent Collections: One Pot, Many Spoons.