In the previous article, [Java Memory Model: How Multithreading Changes the Rules], We saw that when multiple threads execute concurrently, understanding the Java code alone isn’t always enough. We also need to understand how threads interact with memory—and what guarantees the Java Memory Model provides.
Now, let’s take the next step.
How do threads actually share data?
When multiple threads are running inside the same Java application, they don't live in completely isolated worlds. They can access objects that exist in the shared heap.
At the same time, each thread has its own stack, which contains its method calls, local variables, and execution state.
This gives us a fundamental picture:
The one idea this whole article is built around:
Each thread has its own stack. Objects on the heap can be reached — and mutated — by more than one thread at the same time.
Everything about Java concurrency (race conditions, visibility issues, the need for synchronized, volatile, atomics, and immutability) grows out of that single sentence
1. The scaffolding (you already know this part)
- Every thread has its own stack: each method call gets its own copy of local variables, method parameters, and references.
- All objects live on a shared heap. Any thread holding a reference can read or mutate that object.
- An instance or static field isn’t “shared” by definition — it becomes shared the moment more than one thread can reach the object or class that owns it.
Deep Dive: Stack vs. Heap Dynamics
Deep Dive: Local variable vs. Instance vs. Static
Before we dive into coding, let's break down the fundamental variable types you'll use every day in Java.
Deep Dive: Object vs. Reference
• The Object is the house. It is the actual structure built out of bricks and mortar, sitting on a plot of land. It occupies physical space in memory.
• The Reference is the address. It is a piece of paper with the address written on it (123 Heap Street). It is not the house itself; it just tells you where the house is so you can go find it.
Java is always pass-by-value, even for objects. For primitives, the value itself is copied, so each thread gets an independent value. For objects, the reference value is copied, so each thread has its own reference pointing to the same heap object. Therefore, a mutation through one reference can be seen through the other.
The Technical Boundaries
-
Local Variables are Thread-Safe by Design: Because they are allocated inside a private stack frame, if Thread-1 and Thread-2 execute the exact same method simultaneously, they each get a completely independent copy of that local variable(Any variable declared inside a method — including its parameters, primitives and references — lives in that method's stack frame) on their own stacks and cannot physically access by different thread's stack.**
-
Primitives (
int,boolean,double, ...) — the actual value sits directly in the stack frame. -
Object references (
Foo f,String s, ...) — the reference (a pointer/handle) sits in the stack frame. The object it points to sits separately, on the heap.
-
Primitives (
Fields are Vulnerable: Fields do not belong to methods; they belong to objects (Instance) or classes (Static). Therefore, they are allocated on the Heap, exposing them to concurrent modification.
2. How Multiple Threads Can Access the Same Object
A reference can "escape" to another thread in several common ways:
- Passed as a constructor or method argument
- Stored in a
staticfield - Stored in a field of an object that is itself shared
- Placed into a shared collection (queue, map, list)
- Captured by a lambda or
Runnablepassed to another thread
Once two threads hold a reference to the same object, the JVM provides no automatic coordination between them. You need explicit concurrency mechanisms such as synchronized, volatile, locks, atomic classes, or safe publication through concurrent collections.
Without proper synchronization, one thread's updates may not be visible to the other, or their operations may interleave unpredictably, leading to race conditions and corrupted state. This is where the Java Memory Model (JMM) rules from the previous article become critical.
3. What Shared Mutable State Really Means
The term shared mutable state is often used in concurrency, but the real issue comes down to two independent conditions:
- Shared — multiple threads can access the same state.
- Mutable — that state can be changed.
“State” itself isn't the problem; every object has state in the form of fields. The concurrency risk arises when the state is both shared and mutable.
| Immutable | Mutable | |
|---|---|---|
| Not shared | ✅ Safe | ✅ Safe — thread-confined |
| Shared | ✅ Safe — nothing to modify | ⚠️ Requires coordination |
The key takeaway is:
Shared + mutable = concurrency risk.
If the state is immutable, multiple threads can safely read it. If the state is not shared, each thread can modify its own copy without interfering with others.
- (Shared + immutable) An object that is shared but immutable (e.g. a String, or a properly constructed immutable class) is generally safe to share between threads because its state cannot be changed. One important nuance: final fields alone don't automatically make a class immutable. The fields must refer to immutable objects (or otherwise safely encapsulated state), and the object must be properly constructed/publicized.
final class User {
private final String name;
private final List<String> roles;
User(String name, List<String> roles) {
this.name = name;
this.roles = List.copyOf(roles);
}
}
(Mutable + not shared) An object that is mutable but never shared (a local variable confined to one thread) is also safe — no other thread can see the changes.
(Mutable + shared) A shared mutable object is where concurrency problems can arise. If multiple threads access and modify the same state without proper coordination, their operations can race, causing lost updates, stale reads, or other inconsistent results. This is where synchronization and the Java Memory Model become important.
4. Different Ways Threads Can Share Data
Java provides several mechanisms for sharing or coordinating data between threads. Each approach has different trade-offs in terms of safety, performance, complexity, and ownership.
The important distinction is not whether data is shared, but how that shared data is accessed and modified.
1. Shared Mutable Objects — synchronized / Locks
Multiple threads can access the same object. If they modify its state, use synchronization(Intrinsic Lock- Java automatically acquires and releases the object's monitor lock.) to coordinate access.
synchronized void increment() {
count++;
}
OR
Locks such as ReentrantLock provide mutual exclusion and visibility, ensuring that changes made before unlocking become visible to a thread that subsequently acquires the same lock.
Provides features such as:
tryLock()- Interruptible lock acquisition
- Fairness option
- Multiple
Conditions
class Counter {
private int count;
private final ReentrantLock lock = new ReentrantLock();
void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
OR
If the workload is read-heavy with fewer writes, a ReentrantReadWriteLock can be a good choice.
It allows:
- Multiple threads to read simultaneously
- Only one thread to write at a time
- Readers are blocked while a writer holds the write lock.
class Counter {
private int count = 0;
private final ReentrantReadWriteLock lock =
new ReentrantReadWriteLock();
void increment() {
lock.writeLock().lock();
try {
count++;
} finally {
lock.writeLock().unlock();
}
}
int getCount() {
lock.readLock().lock();
try {
return count;
} finally {
lock.readLock().unlock();
}
}
}
these provides mutual exclusion and visibility.
2. volatile Fields
Use volatile when multiple threads need to see the latest value of a variable.
private volatile boolean running = true;
It provides visibility, but not atomicity.
So this is still unsafe:
count++; // not atomic
3. Atomic Classes
Classes such as AtomicInteger provide atomic operations without explicit locks.
Use AtomicInteger when you need simple atomic operations on a shared integer, such as counters, flags, or sequence numbers.
class Counter {
private final AtomicInteger count = new AtomicInteger(0);
void increment() {
count.incrementAndGet();
}
int getCount() {
return count.get();
}
}
One important limitation
AtomicInteger is excellent for simple atomic state changes, but it doesn't automatically make a group of operations atomic.
For example:
if (balance.get()>=100) {balance.addAndGet(-100);
}
The check and update are two separate operations. Another thread could change the balance between them.
For such a multi-step operation, a lock or a single appropriate atomic operation such as compareAndSet() may be required.
Simple rule
Locks protect a section of code; atomic classes provide atomic operations on individual pieces of shared state.
4. Concurrent Collections
Java provides collections designed for concurrent access:
ConcurrentHashMap
CopyOnWriteArrayList
BlockingQueue
They make common collection operations safe for concurrent use.
5. Immutable Objects
Immutable objects can be freely shared between threads because their state cannot change.
String name = "Ankit";
This is one of the simplest and safest approaches to concurrency.
Shared + Immutable = Safe to share
6. Message Passing
Instead of sharing mutable state directly, threads can communicate by passing messages.
BlockingQueue<Order> queue =
new LinkedBlockingQueue<>();
One thread puts data into the queue, and another thread takes it.
Transfer data instead of sharing mutable state.
7. Thread Confinement / ThreadLocal
Sometimes the best approach is not to share the data at all.
ThreadLocal gives each thread its own value:
ThreadLocal<String> userId = new ThreadLocal<>();
Thread 1 → A
Thread 2 → B
Thread 3 → C
Each thread works with its own data, so no synchronization is needed.





Top comments (0)