Passing Messages Instead of Sharing State

java java21 scala scala3 kotlin message-passing producer-consumer blockingqueue concurrentlinkedqueue ownership

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);
}

View full Java example

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

View full Scala example

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()
}

View full Kotlin example

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);
    }
}

View full Java example

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

View full Scala example

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")
    }
}

View full Kotlin example

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);

View full Java example

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)

View full Scala example

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)

View full Kotlin example

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:

  1. Blocking queue handoff keeps message order for order processing - the third customer’s flat white does not jump the queue.
  2. Non-blocking queue drain exits cleanly when no messages remain - no infinite spinning waiting for emails that will never come.
  3. 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?
Message passing is simpler whenever one logical owner can process updates in sequence. Instead of proving that every single read/write pair across the whole codebase is protected by the correct lock, you route all updates through one queue and let one consumer apply them one at a time. In the café example, the wallet owner thread is the only place balance math happens, so there is no interleaving to worry about - the app, till, and kiosk just drop messages in a box and walk away.
What is the practical difference between a queue-based design and a lock-heavy design?
A queue-based design coordinates through ownership and handoff: producers hand off work, and one consumer is responsible for consistency. A lock-heavy design coordinates through mutual exclusion: everybody who touches the shared object must grab the same lock, in the same order, every single time. The queue-based approach fails safely into "the queue fills up" or "a message waits a bit longer," while the lock-based approach can fail into deadlocks or a forgotten lock that silently corrupts data.
How do ownership boundaries reduce race conditions compared to more locking?
Ownership boundaries remove race conditions by removing the possibility of two writers, not by making the writers take turns nicely. If only one thread is ever allowed to change the wallet balance, there is no scenario where two threads read the same value and both write back a stale update, because there is no second writer to collide with. The queue becomes the one and only synchronization point, and everything downstream of it - the actual mutation - happens on a single thread with no surprises.
What Java 21 building blocks help with message-passing architectures?
The practical core lives in 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?
No - think of it as another tool on the same belt, not a replacement for the whole toolbox. Atomics are still the right choice for a single hot counter, and locks are still fine for short, well-understood critical sections. Message passing shines once you have multiple related pieces of state that must stay consistent together, or once several different producers need to update the same thing - that is exactly when "give one thread ownership" starts paying for itself.

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:


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.