Intermediate Reading time: ~8 min

Thread Basics

Thread vs Runnable, lifecycle and daemon threads

Thread Basics

Definition

A Java thread is an independent execution path managed by the JVM and typically mapped to an operating-system thread. The Thread object is only the Java-side handle; the real behavior is shaped by the scheduler, the Java Memory Model, blocking syscalls, safepoints, and the state of shared memory. In production systems, “thread basics” therefore means more than calling start(): it means understanding lifecycle, cancellation, failure handling, daemon behavior, and the cost of tying work to a platform thread.

Concurrency is about structuring a program so multiple units of work can make progress independently. Parallelism is about those units running at the same time on different cores. A service may be highly concurrent without being very parallel if most threads are blocked on I/O, locks, or backpressure. Senior engineers use thread primitives carefully because once work is attached to a raw thread, capacity planning, observability, and shutdown semantics become operational concerns.

Core Concepts

The first distinction is Thread versus Runnable. Extending Thread couples the task and the execution mechanism; implementing Runnable separates “what to do” from “how it runs”, which is why most real systems submit Runnable or Callable to executors rather than subclassing Thread. Callable matters because background work often needs a result or an exception channel.

Java exposes a well-defined thread lifecycle: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED. RUNNABLE is slightly misleading: on HotSpot it means “eligible to run”, not necessarily currently on CPU. A thread can sit in RUNNABLE while waiting for OS scheduling or native I/O. BLOCKED usually means waiting to enter a synchronized monitor. WAITING and TIMED_WAITING cover APIs such as Object.wait(), Thread.sleep(), LockSupport.park(), and Thread.join().

Daemon threads are service threads that do not keep the JVM alive. When only daemon threads remain, the JVM may exit immediately, even if those threads are in the middle of writing logs, flushing metrics, or updating state. That is why daemon threads are appropriate for background helpers, but dangerous for work that must complete reliably. A shutdown hook is not a substitute for correct lifecycle management; hooks run under time pressure and are a last-chance cleanup mechanism, not a business workflow.

Interrupts are Java’s cooperative cancellation mechanism. Calling interrupt() does not forcibly stop a thread. It sets an interrupt flag, and certain blocking methods react by throwing InterruptedException. Good code either propagates the interruption or restores the interrupt status with Thread.currentThread().interrupt() before returning. Swallowing interrupts makes pools hang during shutdown and makes cancellation unreliable.

Practical Usage

Use raw threads when you need to explain fundamentals, integrate with low-level APIs, or build small one-off background workers with explicit ownership. Even then, give threads meaningful names, attach an UncaughtExceptionHandler, and make shutdown behavior explicit. In production code, a thread without a naming convention becomes a debugging tax in thread dumps and profiler views.

Design tasks so they can stop cooperatively. Long-running loops should periodically check Thread.currentThread().isInterrupted(). Blocking code should use interruption-friendly APIs where possible. For code that must perform cleanup, catch InterruptedException, restore the flag, close resources, and exit quickly. “Ignore and continue” is almost always the wrong policy unless you are deliberately implementing retry semantics above the interruption boundary.

Thread-local state deserves special care. ThreadLocal can be useful for request correlation or per-thread caches, but in pooled threads the value outlives the logical request unless explicitly removed. That causes classloader leaks in app servers, confusing test pollution, and memory retention in long-lived services. Treat ThreadLocal like a manually managed resource: set it narrowly and remove it in finally.

Code Examples

Thread worker = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        try {
            System.out.println("processing batch");
            Thread.sleep(500);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    System.out.println("worker stopped cleanly");
}, "inventory-sync-1");

worker.setUncaughtExceptionHandler((t, ex) ->
    System.err.println(t.getName() + " failed: " + ex.getMessage()));
worker.start();
// later
worker.interrupt();
worker.join();

The key production detail is not the lambda syntax, but the interruption policy: the loop checks the flag, sleep interruption is converted back into the flag, and join() is used to wait for a clean stop.

class HeartbeatService {
    private final AtomicBoolean running = new AtomicBoolean();
    private Thread thread;

    void start() {
        if (!running.compareAndSet(false, true)) {
            return;
        }
        thread = new Thread(this::runLoop, "heartbeat-daemon");
        thread.setDaemon(true);
        thread.start();
    }

    private void runLoop() {
        while (running.get() && !Thread.currentThread().isInterrupted()) {
            try {
                sendHeartbeat();
                Thread.sleep(1_000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }

    void stop() throws InterruptedException {
        running.set(false);
        if (thread != null) {
            thread.interrupt();
            thread.join(2_000);
        }
    }

    private void sendHeartbeat() {}
}

This example shows a daemon helper with explicit stop logic. Making it a daemon avoids preventing JVM exit, but the service still exposes a stop() method because graceful shutdown should not rely on daemon semantics alone.

Trade-offs

Raw threads provide clarity and control, but they scale poorly as the unit of work. Each platform thread consumes stack memory, scheduler overhead, and context-switch cost. A “thread per request” model may work for small loads, but under spikes it usually fails as memory pressure, queueing, and latency amplification rather than a clean linear slowdown.

Daemon threads reduce shutdown friction for best-effort work, but increase the risk of silent data loss if they hold critical state. Interrupts are lightweight and composable, but only if your code and dependencies respect them. ThreadLocal improves ergonomics for cross-cutting concerns, but creates hidden coupling between logical requests and physical threads.

The practical rule is simple: use raw threads for ownership boundaries, not for general task dispatch. Once you need admission control, pooling, result handling, or structured shutdown, move to the concurrency utilities.

Common Mistakes

Calling run() instead of start() is the classic bug: it executes synchronously on the caller thread and creates no concurrency. Another common mistake is assuming sleep() releases locks; it does not. A sleeping thread inside a synchronized block still holds the monitor and can stall the system.

Interrupt handling is where many otherwise solid codebases fail. Catching InterruptedException and only logging it clears the interrupt status and often causes shutdown deadlocks. Similarly, checking Thread.interrupted() when you meant isInterrupted() can be surprising because the static method clears the flag.

Misusing daemon threads is another production issue. Teams mark a thread daemon because the JVM “hangs on shutdown”, but the real bug is unmanaged lifecycle. The daemon flag hides the symptom while making completion guarantees weaker. Finally, avoid uncontrolled thread creation in loops. If a load spike creates thousands of threads, the system spends more time scheduling than doing useful work.

Senior-level Insights

At the JVM level, thread state is visible not only in Java APIs but also in diagnostics such as jstack, Java Flight Recorder, and async profilers. Senior engineers learn to correlate application symptoms with dump evidence: many threads in BLOCKED suggests monitor contention; many in TIMED_WAITING might indicate backoff, sleep-based polling, or idle pools; many in RUNNABLE with high CPU can point to spin loops or native calls.

Thread lifecycle also interacts with safepoints and stop-the-world events. A thread may appear healthy in code review but still contribute to GC pauses or slow safepoint entry if it executes pathological native code or large tight loops. Understanding that a Java thread is part of a VM-wide coordination system helps explain why “just one more thread” is rarely free.

Modern Java adds virtual threads, but they do not remove the need for fundamentals. Interrupts, cooperative cancellation, thread naming, uncaught exception policies, and safe publication still matter. The big difference is that blocking becomes cheaper for many workloads, while pinning risks remain around synchronized blocks and some native operations. Engineers who understand platform-thread basics adapt to virtual threads faster because the invariants are the same.

Glossary

  • Thread: JVM-managed execution path with its own call stack.
  • Runnable: Task contract with no return value.
  • Callable: Task contract that returns a result or throws an exception.
  • Daemon thread: Background thread that does not keep the JVM alive.
  • Interrupt: Cooperative cancellation signal carried as thread state.
  • ThreadLocal: Per-thread storage that must be cleaned up explicitly in pooled environments.
  • Thread dump: Snapshot of thread stacks and states used for diagnosis.
  • Safepoint: JVM coordination point used for GC and VM operations.

Cheatsheet

  • Prefer Runnable/Callable over extending Thread.
  • Call start(), never run(), when you want concurrent execution.
  • Name threads based on business role: order-writer-1, not Thread-42.
  • Handle uncaught exceptions explicitly for long-lived threads.
  • Treat interrupts as cancellation requests; propagate or restore them.
  • sleep() pauses execution but does not release locks.
  • join() waits for another thread to finish; it is interruption-sensitive.
  • Use daemon threads only for best-effort background work.
  • Remove ThreadLocal values in finally when threads are reused.
  • Prefer executors for task dispatch, backpressure, and lifecycle management.

🎮 Games

10 questions