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:
- who owns the state?
- what exact synchronization protects it?
- can readers observe partial updates?
For message passing, ask:
- is the queue bounded?
- what happens when consumers are slower than producers?
- 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