Java

Threads in Java: Creation, Lifecycle, and Coordination

Executive Summary

Threads in Java begin with a Runnable: give a Thread a Runnable and call start. Never call run, which would execute the work on the current thread instead. The lifecycle runs NEW to RUNNABLE at start. Then it moves through BLOCKED, WAITING, and TIMED_WAITING as the thread contends for locks or waits by sleep, join, and wait, and it ends in TERMINATED. Meanwhile, interrupt is the cooperative stop signal. It sets a flag that your loop checks and that sleep and join surface as InterruptedException.

Shared mutable state is where correctness breaks. For example, count++ is three machine steps, two threads can interleave it, and lost updates make the result smaller than the number of operations. The three safety tools each cover a case. First, synchronized uses the object’s intrinsic lock for mutual exclusion. Second, the atomic classes like AtomicInteger make single-variable updates lock-free. Third, immutable values, records with final fields, need no synchronization because nothing can change. Daemon threads die with the JVM and suit background housekeeping. Production code rarely creates raw threads, because each carries roughly a megabyte of stack and unbounded creation exhausts memory. Instead, the executor and virtual-thread articles are the replacements. Still, the vocabulary, states, races, and interruption, is exactly what those tools are made of.

Creating Threads: Thread and Runnable

The work and the worker are separate types. Runnable is the work, a function that takes nothing and returns nothing. Thread is the worker, the scheduleable execution that runs one Runnable. The separation is the design lesson, because production code almost always keeps them separate and lets a pool supply the threads:

// the work: what should run
Runnable task = () -> System.out.println("working on " + Thread.currentThread().getName());

// the worker: what runs it
var worker = new Thread(task, "worker-1");
worker.start();     // a new thread begins; the JVM invokes run() there

Extending Thread is legal and almost always worse. Your class now spends its one superclass on a thread and cannot be anything else, while a Runnable is a lambda, a record’s method, anything. Subclass Thread only when you are changing thread behavior itself.

start() vs run(): The First Trap

One method call separates “concurrent” from “sequential with extra steps”, and the compiler will not save you:

var t = new Thread(() ->
        System.out.println("running on: " + Thread.currentThread().getName()));

t.run();    // WRONG: an ordinary method call, executed on the CURRENT thread
            // prints "running on: main"

t.start();  // RIGHT: asks the JVM for a new thread, which then calls run()
            // prints "running on: Thread-0"

The delta: run is just a method, and calling it directly is a same-thread function call wearing a thread costume. start is the method that creates the actual concurrency. Also, naming threads with the “worker-1” argument is a two-second habit that pays for itself the first time you read a thread dump in production.

Waiting for Work: join()

Once a thread runs, the parent usually needs to know when it is done. join blocks the calling thread until the target terminates, the simplest form of coordination:

var downloader = new Thread(() -> fetchAll(urls), "downloader");
downloader.start();

downloader.join();                     // main pauses here until downloader finishes
System.out.println("all URLs fetched");

join is your first taste of the part’s recurring theme: concurrency is easy to start and hard to finish. Getting results out, composing many threads, and canceling work all need structure, which is exactly what the ExecutorService article adds.

The Lifecycle: States Every Thread Moves Through

Thread.getState returns a Thread.State, and every state in the enum maps to something the JVM did to the thread:

   NEW
    |   start()
    v
 RUNNABLE  <--ready to run, or running: the scheduler decides-->
    |
    |---> BLOCKED        waiting to enter a synchronized region
    |---> WAITING        join(), wait(), no timeout
    |---> TIMED_WAITING  sleep(ms), join(ms), wait(ms)
    |
    v   run() returns (normally or by exception)
 TERMINATED
State How a thread lands here What resumes it
NEW new Thread(…), not yet started start()
RUNNABLE start() called, running or ready in the scheduler CPU time from the OS scheduler
BLOCKED Waiting for another thread’s intrinsic lock The lock holder releases it
WAITING join() or wait(), indefinite The joined thread ends; notify/notifyAll from the Object class
TIMED_WAITING sleep(500), join(5000), wait(1000) The timeout fires, or the same events as WAITING
TERMINATED run() returned or threw Nothing: threads are one-shot

Two readings of this table matter. First, RUNNABLE means “ready or running”. Java hands scheduling to the operating system, so a thread blocked on network I/O may still show RUNNABLE because the OS call, not the JVM, holds it. Second, threads are one-shot. You cannot restart a terminated thread, and calling start twice throws IllegalThreadStateException. Work that should run again is a new task for a pool, not a restarted thread.

Race Conditions: Where Correctness Breaks

Two threads, one variable, no synchronization. This is the smallest program that produces a wrong answer. In fact, it is the reason this entire part of the course exists:

class Counter {
    private int count = 0;
    void increment() { count++; }          // looks atomic. Is three operations.
    int get() { return count; }
}

// two threads, 10_000 increments each, then print count.get():
// expected: 20000
// typical actual output: 17342   18671   20000 (sometimes)   19405 ...
// never the same wrong answer twice: the bug is timing-dependent

count++ is not one operation. It is a read of count, an add, and a write back, and the two threads interleave those steps freely. Both threads read 57, both compute 58, both write 58: one increment vanished. A lost update, times thousands of interleavings, is the shortfall. The defining property of a race condition is in the name. The correctness of the result depends on the race between threads, on timing you neither control nor reproduce. Tests almost never catch it. After all, two threads on an idle test machine with tiny loops mostly happen to interleave harmlessly.

The Three Safety Tools

Java’s basic answers to shared mutable state, each with a different shape of guarantee:

// 1. synchronized: mutual exclusion on the object's intrinsic lock
class Counter {
    private int count = 0;
    synchronized void increment() { count++; }    // one thread at a time
    synchronized int get() { return count; }
}

// 2. atomics: lock-free, built for exactly this single-variable case
class Counter {
    private final AtomicInteger count = new AtomicInteger();
    void increment() { count.incrementAndGet(); } // one machine-level guarantee
    int get() { return count.get(); }
}

// 3. immutability: no shared mutable state, so nothing to race on
record Snapshot(int fetched, int failed) {}       // final fields, no updates, safe
                                                  // to share freely across threads
Tool Guarantee Use when Cost
synchronized One thread in the region at a time, and memory visibility across the boundary Multi-step updates on shared state, invariants spanning fields Contended threads wait; the JLS memory model rules apply
Atomic classes Single-variable operations, compare-and-swap, lock-free Counters, flags, statistics, accumulations Minimal; overkill for a single boolean is fine too
Immutable values Nothing can change after construction, so every reader sees a consistent object Results crossing thread boundaries, snapshots, messages Allocation per update; the records article’s discipline

The hierarchy of the three is the working rule. Prefer immutability when you can, because it removes the question. Choose atomics when the shared thing is one variable, because they are specialized and fast. Finally, use synchronized when the shared thing is an invariant across multiple operations, because that is what mutual exclusion means. One warning for later: volatile guarantees visibility of updates across threads but not atomicity of count++. It is the most common wrong fix for the race above, and the locks article covers when volatile is actually the answer.

Interrupting Threads: The Cooperative Stop

Thread.stop has been deprecated since Java 1.2, and not as a formality. After all, killing a thread mid-operation leaves locks held, invariants broken, and data half-written. The platform’s answer is interruption, a cooperative flag:

var worker = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {   // check the flag
        // one chunk of work per iteration
    }
    System.out.println("clean exit, state consistent");
}, "worker");
worker.start();
worker.interrupt();      // sets the flag: a request, not an order

The interrupt sets a flag and nothing else. Your loop checks it and exits through its normal path, releasing locks, finishing writes, leaving state consistent. If the thread is inside sleep, join, or wait when the flag arrives, those methods throw InterruptedException instead of continuing. That is the etiquette the exceptions article and the link checker practice both prefigured:

try {
    Thread.sleep(500);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();   // restore the flag: someone upstream
    return;                               // may be checking it. then leave promptly
}

Swallowing the interrupt, catching it and continuing, is how tools become unkillable.

Two smaller facts complete the vocabulary. First, a daemon thread, setDaemon(true) before start, never keeps the JVM alive, so it suits background housekeeping like log flushing. Second, a thread’s uncaught exception terminates only that thread and prints to stderr. It does not crash the process, which is the next article’s problem to manage.

Why Production Rarely Creates Raw Threads

Platform threads are expensive in exactly one dimension: memory. Each carries a fixed stack, roughly a megabyte by default, reserved up front. Also, creation and teardown cost the OS real work. Two consequences follow. First, a program that spawns a thread per task caps at a few thousand concurrent threads before memory pressure dominates. Second, an unbounded spawn loop, a thread per request, say, converts a traffic spike into an out-of-memory crash. The Thread API is not wrong to expose. It is simply an OS resource you are allocating, and the industry learned to manage it in pools:

The need Raw thread? The production tool
One fire-and-forget background task Acceptable, the one honest case Still usually an executor submit, for the naming and exception handling
Many short tasks, bounded parallelism No: creation cost dominates ExecutorService thread pool (next article)
Thousands of tasks that block on I/O No: stacks exhaust memory Virtual threads (article 47)
Shared counters, caches, queues Not the tool at all Atomics, concurrent collections (article 45), locks (article 44)
Coordinated async pipelines No: join-spaghetti CompletableFuture (article 46)

Read the table as the part’s map. This article teaches the vocabulary, and each row is a dedicated article that replaces raw-thread ceremony with structure. You learn threads in Java first for the same reason you learned files before NIO. The abstraction below explains the behavior above, and when the pool misbehaves at 3 a.m., the thread states in this article are what the thread dump shows you.

How Real Systems Do This

Search production Java code for “new Thread” and you will find it mainly in three places. These are framework and library internals (where pools get built), test utilities, and the occasional top-level background janitor thread, exactly the one honest case the table allows. Everything else routes through executors. Their whole purpose is owning the lifecycle this article described: creation, naming, reuse, bounded counts, exception capture, and orderly shutdown. The java.util.concurrent package summary is organized around this idea, and its memory-consistency documentation is the contract every higher-level tool builds on.

The race condition I remember best was not exotic. A service kept a running request counter, a plain long incremented in the request path, for a metrics endpoint. Then someone noticed the count drifted below the actual number of requests served. The counter lost updates at exactly the rate the traffic peaked. As a result, that timing made the bug look load-related when it was purely interleaving.

The fix was AtomicInteger, one line. However, the interesting part was the review conversation: the original author was certain the code was fine because “the counter worked in the demo”. Races do not fail in demos. Instead, they fail under production interleavings, which is why the fix is never “test harder”, it is “do not share mutable state without a tool”. Since then, every counter and shared structure I write defaults to the three-tools hierarchy: immutable results first, atomics for single variables, locks for real invariants.

Decision Framework

  1. Is the task fire-and-forget, one thread, short-lived, and its failure needs no handling beyond a log? A named raw thread is defensible; an executor submit is still better, for free.
  2. Are many tasks running, or tasks that repeat? Never thread-per-task: pool it, which is the next article’s entire subject.
  3. Is state shared across threads? Prefer immutability: publish final values and records, and there is nothing to protect.
  4. Is the shared thing one variable, counters, flags, accumulators? Atomic classes, lock-free and correct.
  5. Does the shared state span multiple operations or fields, a genuine invariant? synchronized now, and the locks article refines it.
  6. Does the thread loop indefinitely? Check the interrupt flag per iteration, handle InterruptedException by restoring it, and leave cleanly.
  7. Should the JVM exit while it still runs? Daemon thread, set before start, with the understanding that its death is abrupt.
  8. Is a thread stuck and you do not know why? getState plus the lifecycle table, plus a thread dump: the states are the diagnosis vocabulary.

When NOT to Use This

  • Do not spawn raw threads per request, per message, or per URL. Thread-per-task is a memory design, not a concurrency design, and the pool article exists because this pattern fails under load.
  • Do not call run() and believe you have concurrency. That is a sequential call, and the bug is invisible in output that happens to be correct.
  • Do not reach for synchronized before trying immutability and atomics. In practice, most “needs a lock” conclusions dissolve when the shared state is redesigned to be a result rather than a variable.
  • Do not hold a lock across I/O, network calls, or database work. Otherwise, everything else that needs the lock waits on a network you do not control.
  • Do not share a plain HashMap, ArrayList, or any non-thread-safe collection across threads “because it seems to work”. That is the counter race with more moving parts, and the concurrent collections article is the fix.
  • Do not use daemon threads for work that must complete, writes, commits, sends: the JVM kills them mid-sentence at exit.

Common Mistakes

  • Calling run() instead of start(): the work executes on the current thread. No concurrency exists, and nothing in the output necessarily reveals it.
  • Trusting tests over the race: a two-thread counter test on an idle machine passes most of the time. However, races reproduce on production interleavings, not in CI.
  • Fixing count++ with volatile: volatile gives visibility, not atomicity, and the lost updates continue. Instead, the fix is synchronized, AtomicInteger, or LongAdder.
  • Catching InterruptedException and continuing: swallowing the stop signal makes the thread, and every caller above it, unkillable. Instead, restore the flag and exit.
  • Restarting a terminated thread: threads are one-shot, start() twice throws IllegalThreadStateException, and repeating work is a pool’s job.
  • Leaving threads unnamed: “Thread-37” in a production thread dump tells you nothing. After all, the name is your only label at diagnosis time.
  • Assuming an uncaught exception crashes the app: it terminates only that thread, silently in the worst case. Also, pools capture it only if you let them, which the next article configures.

Key Takeaways

  • A Thread is a worker; a Runnable is the work. start creates the concurrency, run does not, and threads are one-shot.
  • The lifecycle states are NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED. They are the diagnosis vocabulary you will read in every thread dump.
  • count++ is a read, an add, and a write, so two threads interleaving those steps lose updates. Also, races are timing-dependent, which is why tests rarely catch them.
  • The safety hierarchy: immutable values first, atomic classes for single variables, synchronized for real multi-step invariants.
  • volatile is visibility, not atomicity: the most common wrong fix for the counter race.
  • Interruption is cooperative: interrupt sets a flag, and loops check it. Meanwhile, sleep and join convert it to InterruptedException, and the correct response is restore-and-exit.
  • Thread.stop is history, and daemon threads suit housekeeping only: abrupt death leaves state inconsistent.
  • Platform threads cost roughly a megabyte of stack each. That is why production pools them, and why the next four articles exist.

FAQ

How do I create a thread in Java?

Construct a Thread with a Runnable and call start: new Thread(() -> work(), “name”).start(). The JVM creates the new thread and runs the Runnable’s body there. Extending Thread is legal but spends your superclass slot; Runnable lambdas are the standard form.

What is the difference between start and run in Java threads?

start asks the JVM to create a new thread, which then calls run on that thread. In contrast, calling run yourself is an ordinary same-thread method call: no new thread, no concurrency, just code executing on the caller’s stack, usually a silent bug.

What are the thread states in Java?

Six: NEW before start, RUNNABLE while ready or running, and BLOCKED waiting for an intrinsic lock. Then come WAITING after join or wait without timeout, TIMED_WAITING in timed sleep or join, and TERMINATED once run returns or throws. Thread.getState returns them, and thread dumps report them.

What is a race condition in Java?

A bug where correctness depends on thread timing. Two threads interleave read-modify-write steps on shared state, like count++, and lose updates. The result is wrong, varying, and rarely reproducible in tests. That is why the fix is structural, immutability, atomics, or locks, never more testing.

How do I stop a thread safely in Java?

Cooperatively, with interruption. Call interrupt to set the flag, check isInterrupted in the loop, and handle InterruptedException by restoring the flag and exiting promptly. Thread.stop is deprecated because killing a thread mid-operation leaves locks held and state inconsistent.

Conclusion

You now hold the base vocabulary of Java concurrency. You know how to create and start threads in Java and the states they live and die in. You also know the race that shared mutable state guarantees, the three tools that neutralize it, and the cooperative etiquette of stopping. The raw Thread is the correct bottom of the stack and the wrong middle of it. It is expensive to spawn, unbounded in danger, and silent about exceptions.

The next article introduces the tool production code actually uses: ExecutorService. This thread pool owns creation, naming, reuse, exception capture, and shutdown, so that your code submits work and never manages a lifecycle again. The concurrent downloader practice at the end of this part will parallelize the link checker with exactly that machinery.

Understand the thread, then never create one by hand again. That is the healthy relationship with this API.

Last updated on 7 September 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *