Most Java concurrency problems are really shared-state problems. When multiple threads can touch the same mutable object, correctness becomes difficult quickly.

That is why an important concurrency design choice is not just which lock to use. It is whether shared memory should exist at all for that part of the system.


Problem Statement

Suppose a system ingests jobs from many producers and processes them with worker threads. You have at least two broad design styles:

  • threads mutate shared state directly
  • threads exchange work through messages or queues

Both can work. They produce very different failure modes.


Naive Design

The naive design is to put shared mutable state in one object and let many threads update it.

Example:

class JobStats {
    int accepted;
    int processed;
    int failed;
}

If many workers update this object directly, you immediately need:

  • visibility guarantees
  • atomicity guarantees
  • consistency rules across fields

This is manageable for small cases. It becomes fragile as state and worker count grow.


Two Concurrency Styles

Shared Memory

Threads communicate implicitly by reading and writing the same memory.

Benefits:

  • direct access is fast
  • simple for small local state
  • natural in many in-process designs

Costs:

  • races are easy to introduce
  • invariants become harder to defend
  • debugging becomes timing-sensitive

Message Passing

Threads communicate by sending work or data through queues, mailboxes, or events.

Benefits:

  • clearer ownership
  • fewer shared-state races
  • easier backpressure boundaries

Costs:

  • queueing overhead
  • serialization or copying in some designs
  • delayed visibility by design

Neither style is universally better. The key is choosing the right ownership model for the workload.


Production-Style Example

Imagine an order event pipeline:

  • request threads accept orders
  • a billing worker charges payments
  • a notification worker sends emails

A shared-memory design may have multiple threads mutating the same OrderStateStore. A message-passing design may push order events into queues, where each worker owns its stage.

The second shape is often easier to reason about because ownership is clearer.


Runnable Example: Message Passing with a Queue

This example shows a simple producer-consumer pipeline.

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;

public class MessagePassingDemo {

    public static void main(String[] args) throws Exception {
        BlockingQueue<Job> queue = new LinkedBlockingQueue<>();

        Thread producer = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                Job job = new Job("job-" + i);
                try {
                    queue.put(job);
                    System.out.println("Produced " + job.id);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    throw new RuntimeException(e);
                }
            }
        }, "producer");

        Thread consumer = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                try {
                    Job job = queue.take();
                    System.out.println("Consumed " + job.id + " on " + Thread.currentThread().getName());
                    TimeUnit.MILLISECONDS.sleep(300);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    throw new RuntimeException(e);
                }
            }
        }, "consumer");

        producer.start();
        consumer.start();

        producer.join();
        consumer.join();
    }

    static final class Job {
        final String id;

        Job(String id) {
            this.id = id;
        }
    }
}

What this shows:

  • producer and consumer coordinate through a queue
  • work ownership is clearer than direct shared mutation
  • backpressure becomes possible by choosing bounded queues later

Shared Memory Still Has a Place

This post is not saying “never use shared memory.”

Shared memory is often fine when:

  • state is tiny
  • ownership is clear
  • access is infrequent
  • synchronization cost is acceptable
  • immutable snapshots can be published safely

Examples:

  • read-mostly configuration reference
  • atomic counters
  • synchronized cache metadata

The problem is not shared memory itself. The problem is casual shared mutable memory with weak ownership.


Failure Modes

Shared Memory Failure Modes

  • race conditions
  • stale reads
  • deadlock from lock layering
  • accidental invariants spread across many fields

Message Passing Failure Modes

  • queue overload
  • stuck consumers
  • unbounded buffering
  • hard-to-see end-to-end latency

So message passing does not remove complexity. It moves the complexity from memory visibility to flow control and queue management.


Testing and Debugging Notes

For shared memory, ask:

  1. who owns the state?
  2. what exact synchronization protects it?
  3. can readers observe partial updates?

For message passing, ask:

  1. is the queue bounded?
  2. what happens when consumers are slower than producers?
  3. what is the retry and shutdown policy?

Those are different debugging mindsets.


Decision Guide

Prefer shared memory when:

  • the state is small and synchronization is obvious

Prefer message passing when:

  • many independent tasks need controlled handoff
  • ownership can be transferred between stages
  • backpressure matters

Often the best systems mix both:

  • immutable shared config
  • queue-driven task flow
  • small lock-protected local invariants

Key Takeaways

  • shared memory is powerful but easy to misuse
  • message passing often improves ownership clarity
  • concurrency design is often really state-ownership design
  • both styles have costs; choose based on correctness and flow-control needs

Next Post

CPU Cache Main Memory and Why Visibility Bugs Happen