How Java Threads Work
A few months ago, we had a nasty production bug that made zero sense.
Sometimes the data written to our logs was perfectly fine. Other times — garbled, missing, or outright corrupted.
After hours of digging, we found the culprit: a mutable buffer inside a singleton. Multiple threads were modifying it at the same time. No synchronization, no safety net. Just chaos in shared memory.
That debugging session sent me deep down the rabbit hole of how Java threads really work and why understanding the JVM’s memory model, heaps, and stacks is the difference between “it works on my machine” and “it works in production.”
Processes vs Threads
A process is an independent running program with its own memory space.
A thread is a lightweight sub-unit of a process that shares memory with other threads.
Threads are teammates, processes are neighbors. Here’s how they live and work:
When you start a Java program with java MyApp, you’re not just running your code—you’re launching a whole ecosystem inside the JVM process. Here’s what happens under the hood:
- JVM Initialization — The Java Virtual Machine starts and sets up memory areas like the heap (for objects) and the stack (for method calls).
- Main Thread Starts — The JVM spins up the main thread, which begins executing your
main()method. This is where your program’s logic takes off. - Background JVM Threads — The JVM launches several system threads in parallel:
— Garbage Collector (GC) threads: automatically clean up unused objects to free memory.
— JIT (Just-In-Time) compiler threads: dynamically compile frequently used bytecode into native machine code for speed. - User Threads — Any additional threads your code creates, like for parallel processing, network requests, or scheduled tasks, run alongside these system threads.
All of this happens simultaneously inside a single process, with threads sharing memory (heap) but maintaining separate stacks. This is why Java feels “multitasking,” even in a single program. Under the hood, it’s a small orchestra of threads keeping everything running smoothly.
Memory Model: Heap vs Stack
In Java, memory is divided into heap and stack, and understanding how threads interact with them is crucial to writing safe concurrent code.
Heap
- Shared across all threads.
- Stores objects, class variables, and static data.
- Any thread can read or write an object on the heap.
Stack
- Each thread has its own stack.
- Stores method calls, local variables, and references to objects on the heap.
- Completely private to the thread, so no synchronization is needed for stack data.
Race Conditions on the Heap
When multiple threads access the same object in the heap, you can get race conditions. For example:
class BankAccount {
private int balance = 100;
synchronized void withdraw(int amount) {
balance -= amount; // shared heap data
}
}- Without
synchronized, two threads could withdraw simultaneously, causing a negative balance. synchronizedensures that only one thread at a time can modify the shared object, making it thread-safe.
Thread Lifecycle in Java
A Java thread goes through five key states:
NEW → RUNNABLE → RUNNING → WAITING/BLOCKED → TERMINATEDThread t = new Thread(() -> {
System.out.println("Running in thread: " + Thread.currentThread().getName());
});
t.start(); // moves from NEW → RUNNABLE → RUNNINGCommunication Between Threads
Using synchronized, wait(), and notify()
Threads can safely communicate via shared objects.
- One thread produces data or changes state.
- Another thread waits for that change before proceeding.
class SharedResource {
boolean available = false;
synchronized void produce() {
available = true;
notify(); // wake up waiting thread
}
synchronized void consume() throws InterruptedException {
while (!available)
wait(); // release lock, wait for producer
available = false;
}
}wait()releases the lock and pauses the thread untilnotify()ornotifyAll()is called.- Always check condition in a loop, not
if, to handle spurious wakeups. - Low-level, gives full control, but can be error-prone (deadlocks, missed signals).
Using High-Level Concurrency Tools (from java.util.concurrent)
BlockingQueue→ handles producer-consumer elegantlyCountDownLatch/CyclicBarrier→ thread coordinationSemaphore→ resource controlExecutorService→ thread pooling (preferred overThreadcreation since threads are reused, reducing overhead compared to creating a new thread each time.Atomic Variables→ Perform lock-free, thread-safe operations on shared data
These tools simplify multithreading, minimize boilerplate, and drastically reduce risks of race conditions and deadlocks.
Get Tanushree’s stories in your inbox
Join Medium for free to get updates from this writer.
Futures and Callables
- Threads can communicate results using Futures: one thread submits a task, another retrieves the result asynchronously.
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<Integer> future = executor.submit(() -> computeValue());
Integer result = future.get(); // blocks until doneYou can also use signals or flags via volatile or AtomicBoolean for simple communication between threads.
In modern Java, prefer BlockingQueue + ExecutorService for most producer-consumer or multithreading tasks — it’s safer and more maintainable than manual wait/notify.
The Executor Service
Creating threads manually (new Thread()) is messy and inefficient.ExecutorService abstracts all of that.
Let’s see it in action: running five tasks on a pool of three threads
ExecutorService pool = Executors.newFixedThreadPool(3);
for (int i = 0; i < 5; i++) {
int taskId = i;
pool.submit(() -> {
System.out.println("Task " + taskId + " running in " +
Thread.currentThread().getName());
});
}
pool.shutdown();What’s happening under the hood:
- Thread Pool Creation
newFixedThreadPool(3)creates 3 reusable worker threads. - Task Submission
Tasks are queued until a thread becomes available. - Concurrent Execution
Up to 3 tasks run at once; the rest wait in the queue. - Futures (Optional)
submit()returns aFutureto check status or retrieve results. - Shutdown
pool.shutdown()stops accepting new tasks and finishes pending ones graceful
Volatile and Atomic Variables
When threads share data, visibility and atomicity matter.
volatile boolean flag = false;volatiletells the JVM to always read/write the variable from main memory, bypassing CPU caches.- Ensures a happens-before relationship: write by one thread happens-before read by another.
Atomic Variables
- Classes like
AtomicInteger,AtomicBoolean, andAtomicReferenceprovide lock-free safety via Compare-And-Swap (CAS). - Guarantees atomicity for operations like increment, decrement, or update based on current value.
How CAS Works
CAS compares a variable’s expected value with its current value, and only updates if they match.
Algorithm:
if (current value at memory location == expected value) {
set memory location to new value
return true // success
} else {
return false // someone else updated it
}If another thread changed it in the meantime, CAS retries — ensuring atomic updates without locks.
Think of CAS as saying:
“I will update this variable only if no one else has changed it since I last looked.”
Concurrency Pitfalls
Even well-designed multithreaded programs can face subtle issues that are hard to detect and reproduce. Common pitfalls include:
Deadlock
Two threads waiting forever for each other’s locks.
Fix: Acquire locks in a consistent order, or use tryLock() with timeout.
Race Condition
Multiple threads modify shared data unsafely, causing unpredictable results.
Fix: Use synchronized blocks, locks, or atomic variables.
Starvation
One thread never gets CPU time or resources.
Fix: Use fair locks (new ReentrantLock(true)).
Livelock
Threads keep reacting to each other but never progress.
Like two people in a hallway, both stepping aside — forever.
Fix: Use randomized back-off or give one thread priority.
Multithreading isn’t just about running things in parallel — it’s about doing so safely. Threads are powerful, but with great power comes (you guessed it) synchronization.
Once you understand how memory visibility, stack isolation, and scheduling work, you stop fearing concurrency and start designing with it.
And maybe, just maybe, you’ll save your future self from debugging another mutable singleton at 3 AM.








