beginner~2h

Thread Basics & Lifecycle

What a thread actually is inside a running Java program, how it differs from a process, why concurrent execution exists at all, the six states a thread moves through, and why CPU caching means your code can misbehave even when the logic looks perfectly correct.

Learning objectives

  • Explain the difference between a process and a thread in terms of memory and isolation
  • Distinguish concurrency (I/O-bound) from parallelism (CPU-bound) and size thread counts accordingly
  • Identify which of Java's six Thread.State values a thread is in at a given moment and why
  • Explain the visibility problem caused by CPU caching and fix a broken flag with volatile
  • Describe why platform threads stop scaling past tens of thousands and how virtual threads remove that ceiling

◆ The problem

A single cook in a kitchen can chop, sear, and plate one dish at a time. The moment an order needs bread toasted and a phone answered, the cook is stuck: walk to the toaster, wait, walk back, and the stove sits cold the whole time. Nothing about the cook's skill is the bottleneck — it's that "waiting" and "working" can't happen at once with only one pair of hands.

Hire two helpers and hand them the toasting and the phone, and the cook keeps cooking while the helpers wait on the slow stuff. Nobody got faster individually. What changed is that waiting no longer blocks working. This is close to the entire reason threads exist in a running program: not to make a single computation finish quicker, but to stop one slow, waiting operation — a disk read, a network call, a database query — from freezing everything else the program could otherwise be doing.

A Java program starts as a single thread running your main method. Every object you create, every static field, every file handle lives in memory the whole program shares. A thread is a second (or third, or thousandth) independent line of execution inside that same running program, free to read and write the same shared memory as every other thread. That sharing is simultaneously the entire point of threads and the source of nearly every bug you will meet in concurrent code: two workers reaching for the same shared resource, with no built-in rule about who goes first.

There are two distinct reasons to reach for more than one thread, and confusing them leads to bad sizing decisions later. Crunching numbers faster by spreading work across CPU cores is called parallelism, and it is capped by how many physical cores you actually have. Keeping a program responsive while some tasks are stuck waiting on I/O is called concurrency, and here you can profitably run far more threads than you have cores, because most of them are idle at any given instant, just waiting for a reply. Everything in this topic — and the five that follow — builds on that distinction.

A process is a running program with its own private, isolated memory — the operating system guarantees that one process cannot casually read another process's memory, and the only way they exchange information is deliberate inter-process communication (pipes, sockets, shared memory segments). Starting a new process is expensive: the OS has to build an entire fresh address space, file descriptor table, and set of kernel bookkeeping structures.

A thread is a unit of execution living inside a process. Every thread in the same JVM process shares the exact same heap — the pool of memory holding every object created with new and every static field — plus the same loaded classes and open file handles. What a thread does not share is its stack: a private scratchpad, roughly 512KB to 1MB by default, holding local variables and the chain of method-call frames for that one thread only. A local variable declared inside a method is thread-safe automatically, purely because it lives somewhere no other thread can reach. An object's field, or anything sitting on the heap, is not safe the moment more than one thread can reach it — and creating a new thread is far cheaper than creating a new process, since it only needs a fresh stack, not a fresh address space.

AspectProcessThread
MemoryIsolated address spaceShared heap, private stack
Creation costHigh (new address space, file descriptors)Low (new stack only)
CommunicationIPC — pipes, sockets, shared memoryDirect — shared heap variables
Crash impactIsolated to that processCan bring down the whole JVM

A Thread object is never simply "running" or "not running." At any instant it occupies exactly one of six named states, queryable via thread.getState():

StateMeaningEntered viaExited via
NEWCreated, not yet startednew Thread(...)start()
RUNNABLEExecuting or eligible to executestart()Blocks, waits, or finishes
BLOCKEDWaiting to enter a synchronized block held by another threadContending for a taken lockLock becomes free
WAITINGPaused indefinitely for another thread to signalwait(), join(), LockSupport.park()notify()/notifyAll()/unpark()
TIMED_WAITINGPaused with a timeoutsleep(n), wait(n), join(n)Timeout or signal
TERMINATEDrun() returned or threwCompletionTerminal — cannot restart

The demo below walks one thread through four of these states in sequence, printing each as it happens — as close as you can get to watching the lifecycle unfold in real time.

💻 Code example

package concurrency.threadbasics; /** * Samples Thread.getState() at four points in one thread's life to observe * NEW -> RUNNABLE -> TIMED_WAITING -> TERMINATED. */ public class ThreadLifecycleWalkthrough { public static void main(String[] args) throws InterruptedException { // Create a worker thread that sleeps for 1 second once started. // It sits in the NEW state until start() is called. Thread worker = new Thread(() -> { try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); System.out.println(worker.getState()); // NEW // Registers the thread with the OS scheduler, moving it to RUNNABLE. worker.start(); System.out.println(worker.getState()); // RUNNABLE // Give the worker a brief head start so it reaches its own sleep() // call before we sample its state again — without this pause the // next read could still (correctly) print RUNNABLE. Thread.sleep(10); System.out.println(worker.getState()); // TIMED_WAITING (inside sleep) // Blocks the caller until the worker fully terminates. worker.join(); System.out.println(worker.getState()); // TERMINATED } }

Here is a bug shape almost every Java developer runs into at least once: a boolean flag that one thread flips to stop another thread's loop works perfectly on a laptop, then hangs forever on a colleague's machine or in production. Nothing about the logic is wrong — the problem sits a layer below the source code. Modern CPUs cache values aggressively across multiple levels (L1, L2, L3), and without an explicit instruction otherwise, a thread spinning on a shared variable may keep reading a stale, cached copy and never notice another thread already changed the "real" value in main memory. This is a visibility problem, and it is a different bug from the "two threads corrupt a counter" race condition covered later — here the write is never lost, it is simply never seen.

The Java Memory Model (JMM) is the formal specification that defines exactly when one thread is guaranteed to observe another thread's writes. Its central concept is happens-before: if action A happens-before action B, then B is guaranteed to see every effect of A. Four of these happens-before rules come up constantly:

  1. Unlock → lock: releasing a synchronized lock happens-before any later acquisition of that same lock.
  2. volatile write → volatile read: a write to a volatile field happens-before any later read of that field.
  3. Thread.start(): everything written before calling start() happens-before any code running inside the new thread.
  4. Thread.join(): everything a thread does happens-before a successful join() on it returns in the caller.

Marking a field volatile is the fix for the flag scenario above: every write goes straight to main memory, and every read fetches the current value instead of a cached one. What volatile does not do is make multi-step operations atomic — volatile int count; count++; is still a race condition, because increment is a read, an add, and a write, three separate steps with no protection against interleaving. Use volatile for simple flags and single-value publication; reach for AtomicInteger or synchronized when a compound operation needs to be atomic.

The two classes below model the exact same busy-spin pattern side by side, differing only in whether the shared flag is volatile — one can spin forever, the other reliably stops.

💻 Code example

package concurrency.threadbasics; /** * Two nested classes model the same busy-spin shutdown pattern, differing * only in whether `stop` is volatile. Demonstrates the JMM visibility * guarantee (or lack of one). */ public class VisibilityAndTheVolatileFix { static class BrokenFlag { // Plain field: nothing tells the JIT this is mutated by another // thread, so a read may be cached in a register and never // re-checked against main memory. static boolean stop = false; static void run() throws InterruptedException { Thread spinner = new Thread(() -> { while (!stop) { /* busy-spin */ } System.out.println("BrokenFlag: stopped"); }); spinner.start(); Thread.sleep(100); // let the spinner actually start spinning stop = true; // may never become visible to the spinner } } static class CorrectFlag { // volatile guarantees every read observes the latest write from // any thread — the JIT cannot cache this value across iterations. static volatile boolean stop = false; static void run() throws InterruptedException { Thread spinner = new Thread(() -> { while (!stop) { /* busy-spin */ } System.out.println("CorrectFlag: stopped"); }); spinner.start(); Thread.sleep(100); stop = true; // guaranteed to be seen promptly } } public static void main(String[] args) throws InterruptedException { CorrectFlag.run(); Thread.sleep(300); // Run the broken demo on its own thread so a stuck spinner can't // hang this whole program. Thread t = new Thread(() -> { try { BrokenFlag.run(); } catch (InterruptedException ignored) {} }); t.start(); Thread.sleep(1000); if (t.isAlive()) { System.out.println("BrokenFlag's spinner is still running (bug reproduced)."); } } }

▲ Common mistake

Treating a thread like a small, independent process. New Java developers often assume two threads are as isolated from each other as two separate programs. It is the opposite: two threads in the same process can read and overwrite the exact same variable at the same instant. That single fact — shared, mutable heap memory — is the root cause of nearly every concurrency bug you will encounter. A local variable on the stack is safe automatically; an object's field reachable from more than one thread is not safe by default.

▲ Edge case

A "private" stack does not mean a private object. A local variable such as Order o = new Order(); only has its reference on the stack — the Order object itself lives on the shared heap. Hand that reference to another thread, whether through a constructor argument or a shared field, and both threads now touch the same heap object even though each reached it through its own local variable. What matters is not where a variable is declared, but whether the object it points to is reachable from more than one thread.

▲ Common mistake

Using Thread.getState() to drive program logic. The state returned is explicitly documented as a monitoring and debugging snapshot, not a synchronization tool — there is no guarantee about how long that value stays accurate, and by the time your code reads it, the thread may already have moved on. Writing a loop that spins on getState() to coordinate two threads is a bug waiting to happen; use join(), wait()/notify(), or the tools in java.util.concurrent instead.

▲ Edge case

Sampling a thread's state without giving it time to actually transition into what you expect. If you check getState() immediately after start(), without any pause, the child thread may not have reached its first blocking call yet — you can see RUNNABLE when you expected TIMED_WAITING, simply because the race between "observer checks" and "worker gets scheduled" went the other way. State transitions happen on their own schedule, not in lockstep with whoever is watching.

The unifying lesson across all of these: shared mutable state and observation timing are both sources of nondeterminism, and code that "happened to work" in a quick manual test can still be wrong. Reasoning about correctness has to be based on what the language guarantees, not on what you observed once.

Every classic Java thread — now called a platform thread to distinguish it from what comes next — maps one-to-one onto a real operating-system thread. Each OS thread reserves its own dedicated stack, typically 512KB to 1MB, whether that thread is doing real work or sitting idle waiting on a database call. A JVM running 10,000 platform threads is committing roughly 10GB purely to stack memory before a single heap object exists. Most operating systems and JVM configurations start hitting practical limits somewhere between 10,000 and 50,000 threads — well below where CPU itself becomes the bottleneck.

This is the "thread-per-request" scalability wall: a server handling 10,000 concurrent requests, each spending most of its time waiting on a slow downstream call, needs 10,000 platform threads if it dedicates one thread per request — and that's 10GB of memory spent almost entirely on threads doing nothing but waiting. Push much further and the JVM throws OutOfMemoryError: unable to create new native thread, a failure mode purely about memory, not CPU.

Java 21 introduced virtual threads to remove this ceiling entirely. A virtual thread's stack starts as a few hundred bytes and grows elastically on the heap rather than as a fixed OS allocation, which is what makes running a million of them practical. The JVM schedules many virtual threads cooperatively onto a small pool of real carrier threads — by default, one per CPU core. When a virtual thread blocks on a recognized operation such as Thread.sleep() or blocking I/O, the JVM unmounts it from its carrier, freeing that carrier to run a different virtual thread, instead of leaving an entire OS thread parked and wasted.

The trade-off to keep in mind: virtual threads solve the memory cost of waiting, not the CPU cost of computing. A tight, CPU-bound loop running on a virtual thread still needs a real core to execute instructions, so spinning up more virtual threads than you have cores gives no speedup for pure computation — the benefit is specifically for workloads that spend most of their time blocked on I/O.

💻 Code example

package concurrency.threadbasics; /** * Two entry points contrasting platform-thread and virtual-thread scaling. * PlatformThreadLimit.main() and the outer main() are independent programs * -- run them separately, not chained. */ public class PlatformVsVirtualThreadLimits { public static class PlatformThreadLimit { public static void main(String[] args) throws Exception { // Attempting 100,000 platform threads typically exhausts memory // (OutOfMemoryError: unable to create new native thread) well // before this loop completes. for (int i = 0; i < 100_000; i++) { Thread t = new Thread(() -> { try { Thread.sleep(10_000); } catch (InterruptedException ignored) {} }); t.start(); } } } // Outer class's own entry point -- run via `java PlatformVsVirtualThreadLimits`. public static void main(String[] args) throws Exception { // 1,000,000 virtual threads succeed comfortably: each has a tiny, // elastic stack living on the heap instead of a fixed OS allocation. for (int i = 0; i < 1_000_000; i++) { Thread.ofVirtual().start(() -> { try { Thread.sleep(10_000); } catch (InterruptedException ignored) {} }); } System.out.println("Started 1,000,000 virtual threads without OOM."); } }

Understanding process versus thread memory is the first thing you reach for when reading a thread dump. Tools like jstack and VisualVM print exactly the six states covered in this topic for every live thread in a running JVM: a thread stuck in BLOCKED almost always points to lock contention or a deadlock; a thread stuck forever in WAITING usually means a missed notification or a swallowed interrupt. Diagnosing a hung production service is, in large part, the skill of reading a snapshot of thread states and reasoning backward to what each one is waiting for.

The Java Memory Model's happens-before guarantees are not academic — every safe publication pattern in the JDK's concurrency library, from ConcurrentHashMap to CompletableFuture, is built on top of them. When application code passes an object between threads via a properly synchronized handoff (a BlockingQueue.put()/take() pair, for instance), it is relying on exactly the happens-before edges this topic introduced, whether or not the code says so explicitly.

Virtual threads are already the default recommendation for new I/O-bound services in Java 21+. Spring Boot 3.2 and later can switch an entire application's request-handling threads to virtual threads with a single configuration flag, letting a server handle vastly more concurrent, slow, blocking calls (database queries, downstream HTTP calls) without the old thread-per-request memory ceiling. Reactive frameworks that exist largely to work around the platform-thread memory limit — writing non-blocking, callback-based code to avoid dedicating a whole OS thread to a slow call — become far less necessary once ordinary blocking code can run on millions of cheap virtual threads instead.

Even simple background services benefit from these ideas: a heartbeat sender, a cache-eviction sweeper, or a metrics flusher is exactly the kind of task where getting the state model right (is it really WAITING, or has it silently died?) and the visibility model right (will the shutdown flag actually be seen?) is the difference between a service that degrades gracefully and one that quietly stops working with no error anywhere in the logs.

Q: What is the fundamental difference between a process and a thread?

A: A process has its own isolated memory space; threads inside the same process share the heap (objects, static fields) but each has its own private stack for local variables and method frames. Local variables are thread-safe by default; heap objects reachable from more than one thread are not.

Q: Why do threads exist — what problem do they actually solve?

A: Two different problems, with two different sizing rules. Parallelism speeds up CPU-bound computation by spreading it across cores (threads ≈ core count). Concurrency keeps a program responsive during I/O-bound waiting by letting other work proceed while some threads are blocked (threads can far exceed core count, since most are idle).

Q: What are the six values of Thread.State, and what should you use getState() for?

A: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED. Use getState() only for diagnostics and monitoring (thread dumps, health checks) — never as a synchronization mechanism to coordinate two threads, since the JLS gives no guarantee tying an observed state to the exact instant it changed.

Q: Why can a non-volatile shared flag cause a thread to loop forever even after the flag is set to true?

A: CPU caches can let a thread keep reading a stale, cached copy of a variable instead of checking main memory. Without a happens-before edge (such as the one volatile provides), the JVM makes no promise the write will ever become visible to another thread, regardless of how the code "should" behave.

Q: Why can't a program comfortably run millions of platform threads, and how do virtual threads fix it?

A: Each platform thread reserves a fixed OS stack (roughly 512KB-1MB), so tens of thousands of threads can exhaust memory well before CPU becomes the bottleneck. Virtual threads use small, elastic, heap-based stacks multiplexed onto a small pool of carrier threads, letting millions coexist cheaply — but they don't speed up CPU-bound work, since execution still requires a real core.

Want a visual for this concept?

Generate a diagram tailored to “Thread Basics & Lifecycle” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Creating Threads — Runnable vs Thread← Back to all Java Concurrency & Multithreading chapters