Introduction
A thread is the smallest unit of execution within a program. When a program uses multiple threads, it can perform multiple tasks at the same time. In Java, threads are the primary mechanism for achieving concurrency.
Threads share data and memory with other threads that run in parallel. When you are building high-throughput backend services, concurrency is often the engine of performance. Before scaling horizontally across distributed nodes, you usually want to scale vertically across CPU cores on a single machine.
How Do Threads Interact with RAM in Java?
According to the Java Memory Model (JMM), memory is broadly divided into the stack and the heap.
The Thread Stack (Private)
Every time a program spawns a new thread, that thread is allocated its own private memory area called the thread stack. The stack holds local primitive variables and reference variables created and used by that specific thread.
Even if multiple threads execute the exact same code, each thread maintains its own independent copy of local variables in its respective stack.
The Heap (Shared)
The heap is a global memory space shared by all threads running in parallel. It holds the actual objects referenced by the reference variables stored in thread stacks.
If you pass a single Runnable instance to multiple threads, each thread keeps its own reference variable in its private stack, but those references point to the same shared object on the heap.
Because threads share data on the heap, concurrent access to a shared object (such as a counter) can cause two major problems.
Data Races and Race Conditions
1. Data Race (Memory Inconsistency)
A data race occurs when threads read stale or incorrect data due to memory visibility issues. One thread might be in the middle of writing a new value while another thread reads it at the same time. Without coordination, the reading thread may fetch an outdated or partially updated value.
2. Race Condition (Thread Interference)
A race condition is a flaw in a program caused by the unpredictable timing or ordering of thread execution. There is no guarantee which thread will run first.
If two threads increment a shared counter using a non-atomic operation such as counter++, their operations can interleave and interfere with each other. For example, if two separate threads each increment a shared counter one million times, there is no guarantee the final value will be two million.
Example: Demonstrating a Race Condition
// MyRunnable.java
public class MyRunnable implements Runnable {
private int counter;
public int getCounter() {
return counter;
}
@Override
public void run() {
long startTime = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
counter++;
}
long elapsedTime = System.nanoTime() - startTime;
System.out.println(
Thread.currentThread().getName()
+ " increased the counter up to: "
+ counter
+ " in "
+ elapsedTime / 1_000_000
+ " milliseconds"
);
}
}
// Main.java
public class Main {
public static void main(String[] args) throws InterruptedException {
MyRunnable myRunnable = new MyRunnable();
Thread thread1 = new Thread(myRunnable, "Thread-1");
Thread thread2 = new Thread(myRunnable, "Thread-2");
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("Final value of counter: " + myRunnable.getCounter());
}
}
Run this program several times. The final counter value is often well below 2_000_000.
Data races and race conditions are related but not identical. Code can have a data race without producing an incorrect result, or exhibit incorrect behavior without a formal data race. In practice, you should treat both as signals that shared state needs protection.
How to Resolve Concurrency Issues
The volatile Keyword and Its Limitations
A common first attempt to fix concurrency issues is marking shared variables with volatile.
- What it does:
volatileimproves visibility by forcing threads to read from and write to main memory rather than relying on CPU-local caches. This reduces the chance of one thread seeing stale data. - What it does not do:
volatiledoes not make compound operations atomic. If a thread reads a value, modifies it, and writes it back (as incounter++), another thread can still interleave in the middle. - The core problem:
volatiledoes not lock shared data. It prevents some visibility bugs, but it does not protect against thread interference during read-modify-write operations.
Example: volatile Still Loses Updates
public class VolatileCounter implements Runnable {
private volatile int counter;
public int getCounter() {
return counter;
}
@Override
public void run() {
for (int i = 0; i < 1_000_000; i++) {
counter++; // still not atomic
}
}
}
Even with volatile, running two threads against a shared VolatileCounter instance can still produce a final value far below two million.
Synchronization
To prevent both data races and race conditions on shared mutable state, use synchronization.
synchronizedkeyword: Marks a method or block of code as a critical section.- Critical section: A region of code that reads or writes data shared across threads.
- The guarantee: When a thread enters a synchronized section, Java ensures that only one thread at a time can execute that code against the same lock. This protects both reads and writes.
How Synchronization Works Under the Hood
Synchronization is implemented with intrinsic locks, also called monitor locks.
- Monitor object: Every Java object has an associated monitor.
- The lock: The monitor owns a lock that protects critical sections.
- Exclusive access: Only one thread can hold the lock at a time. Other threads attempting to enter the same synchronized section block until the lock is released.
Example: Synchronized Method
public class SynchronizedCounter implements Runnable {
private int counter;
public synchronized int getCounter() {
return counter;
}
@Override
public void run() {
for (int i = 0; i < 1_000_000; i++) {
increment();
}
}
private synchronized void increment() {
counter++;
}
}
Using the same Main pattern as before, but with a shared SynchronizedCounter instance, the final value should consistently be 2_000_000.
Example: Synchronized Block
Locking a smaller section of code is often better than synchronizing an entire method:
public class BlockSynchronizedCounter implements Runnable {
private final Object lock = new Object();
private int counter;
public int getCounter() {
synchronized (lock) {
return counter;
}
}
@Override
public void run() {
for (int i = 0; i < 1_000_000; i++) {
synchronized (lock) {
counter++;
}
}
}
}
A dedicated lock object keeps the critical section as small as possible while still protecting the shared counter.
AtomicInteger (Lock-Free Alternative)
For simple shared counters, java.util.concurrent.atomic.AtomicInteger is often the best choice. It uses CPU-level compare-and-swap (CAS) operations to update values atomically without explicit synchronized blocks. That means threads do not block waiting for a monitor lock, which can improve throughput under heavy contention.
AtomicInteger is a good fit when you only need atomic increment, decrement, or compare-and-set on a single integer. For more complex invariants involving multiple fields, you may still need synchronized or higher-level concurrency utilities.
Example: Atomic Counter
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter implements Runnable {
private final AtomicInteger counter = new AtomicInteger(0);
public int getCounter() {
return counter.get();
}
@Override
public void run() {
for (int i = 0; i < 1_000_000; i++) {
counter.incrementAndGet(); // atomic read-modify-write
}
}
}
// Main.java — same pattern as before
public class Main {
public static void main(String[] args) throws InterruptedException {
AtomicCounter atomicCounter = new AtomicCounter();
Thread thread1 = new Thread(atomicCounter, "Thread-1");
Thread thread2 = new Thread(atomicCounter, "Thread-2");
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("Final value of counter: " + atomicCounter.getCounter());
}
}
Run this multiple times and the final value should consistently be 2_000_000, with less lock contention than the synchronized versions above.
Other useful methods on AtomicInteger include getAndIncrement(), addAndGet(int delta), and compareAndSet(int expect, int update) for conditional updates.
Practical Guidelines
- Prefer synchronized blocks over synchronizing entire methods when only part of the method touches shared state.
- For single-variable atomic updates, prefer
AtomicInteger,AtomicLong, or related classes injava.util.concurrent.atomic. - Keep critical sections short to reduce contention and improve throughput.
- Use synchronization deliberately; over-synchronizing can hurt performance.
- Watch for deadlocks, starvation, and livelock when multiple locks are involved.
For thread pooling and task submission, also consider the ExecutorService API instead of creating raw Thread instances manually.