Java Thread Basics: Creating Threads, Lifecycle, join, sleep & interrupt

A thread is an independent path of execution within a JVM process. Multiple threads run concurrently, sharing the process's memory and resources while each maintaining its own call stack and program counter. Java exposes threads through the Thread class and the Runnable functional interface. This article covers the two ways to create threads, the six lifecycle states, controlling execution with join/sleep/interrupt, daemon vs user threads, and modern Thread.ofVirtual() for lightweight concurrency.

What Is a Thread and Why Use One?

A Java program starts with a single thread, the main thread, that executes public static void main(String[] args). Everything you write in a typical sequential program runs on this thread. A thread is an independent sequence of instructions that the OS scheduler can run concurrently with other threads, sharing the same JVM heap and static fields but owning its own call stack.

You reach for multiple threads when a program has work that can overlap: downloading a file while updating a progress bar, serving multiple HTTP requests simultaneously, performing a CPU-intensive computation without freezing the UI, or waiting for I/O on one connection while processing data from another. Without threads, any blocking call (reading from a socket, waiting for a database query) would halt the entire program.

The cost of threads is complexity: shared mutable state requires synchronisation; bugs like race conditions and deadlocks are hard to reproduce and diagnose. Modern Java also offers virtual threads (Java 21+), which are far cheaper than OS threads and simplify high-concurrency I/O-bound code. The concepts in this article apply equally to platform threads and virtual threads.

Creating Threads: Runnable and Thread

Java provides two approaches to defining the work a thread performs. The first is to implement Runnable, a functional interface with a single void run() method, and pass it to a Thread constructor. The second is to subclass Thread directly and override run(). Both start the thread by calling thread.start(), which asks the OS to schedule the thread for execution. Calling run() directly does not start a new thread: it executes synchronously on the calling thread like a normal method call.

The Runnable approach is preferred because it separates the task from the execution mechanism. The same Runnable can be run by a Thread, an ExecutorService, or a virtual thread without changing the task code.

Java
// Approach 1 — Runnable (preferred)
Runnable task = () -> {
    System.out.println("Task running on: " + Thread.currentThread().getName());
};

Thread t1 = new Thread(task, "worker-1");
t1.start(); // schedule for execution — returns immediately

// Approach 2 — Subclass Thread (less flexible)
Thread t2 = new Thread("worker-2") {
    @Override
    public void run() {
        System.out.println("Subclass thread: " + getName());
    }
};
t2.start();

// Java 21+ — Thread.Builder API for cleaner configuration
Thread t3 = Thread.ofPlatform()
    .name("worker-3")
    .daemon(true)
    .start(() -> System.out.println("Builder thread"));

// Java 21+ — virtual thread (lightweight, for I/O-bound work)
Thread vt = Thread.ofVirtual()
    .name("virtual-1")
    .start(() -> System.out.println("Virtual thread"));

// Wait for all threads to finish
t1.join();
t2.join();
t3.join();
vt.join();
Never call thread.run() directly: it executes the task on the current thread, not a new one. Only thread.start() creates a new OS thread.

Thread Lifecycle: The Six States

A Thread moves through a defined set of states during its lifetime, accessible via thread.getState() which returns a Thread.State enum value. Understanding these states is essential for diagnosing stuck threads in thread dumps.

  • NEW: The Thread object has been created but start() has not been called yet.
  • RUNNABLE: The thread is either actively running on a CPU or ready and waiting for the OS scheduler to give it CPU time.
  • BLOCKED: The thread is waiting to acquire a monitor lock (e.g., trying to enter a synchronized block that another thread holds).
  • WAITING: The thread is waiting indefinitely for another thread to perform a specific action, for example, calling Object.wait(), Thread.join() with no timeout, or LockSupport.park().
  • TIMED_WAITING (Like WAITING, but with a deadline) Thread.sleep(ms), Object.wait(ms), Thread.join(ms), or LockSupport.parkNanos().
  • TERMINATED: The thread's run() method has returned or thrown an uncaught exception. A terminated thread cannot be restarted.
Java
Thread t = new Thread(() -> {
    try { Thread.sleep(500); } catch (InterruptedException e) { /* ignore */ }
});

System.out.println(t.getState()); // NEW

t.start();
System.out.println(t.getState()); // RUNNABLE (or TIMED_WAITING if sleep started)

t.join();
System.out.println(t.getState()); // TERMINATED

join(), sleep() and interrupt()

join() makes the calling thread wait until the target thread finishes. This is how you synchronise the main thread with worker threads: start several workers, then join each one before processing their results. join(long millis) adds a deadline so you do not wait forever if a thread gets stuck.

Thread.sleep(long millis) pauses the current thread for at least the specified duration. It relinquishes CPU time without releasing any monitor locks the thread holds. Sleep is rarely the right tool in production code: prefer ScheduledExecutorService for delayed tasks or BlockingQueue polling for waits.

interrupt() is the standard cooperative cancellation mechanism. It sets a flag on the target thread. If that thread is currently blocked in sleep(), join(), or wait(), the block immediately throws InterruptedException. If the thread is not blocked, the flag is simply set and the thread must check it explicitly with Thread.interrupted() or isInterrupted(). Always restore the interrupt flag or propagate InterruptedException upward, swallowing it silently is a common bug.

Java
// join() — wait for a worker to finish
Thread worker = new Thread(() -> {
    System.out.println("Working...");
    try { Thread.sleep(1000); } catch (InterruptedException e) {
        System.out.println("Worker interrupted");
        Thread.currentThread().interrupt(); // restore flag
        return;
    }
    System.out.println("Done");
});

worker.start();
worker.join(2000); // wait at most 2 seconds
System.out.println("Worker state: " + worker.getState());

// interrupt() — cooperative cancellation
Thread longTask = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // do incremental work
        System.out.println("Processing...");
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt(); // restore the flag
            break; // exit the loop cleanly
        }
    }
    System.out.println("Task cancelled gracefully");
});

longTask.start();
Thread.sleep(700);     // let it run for a bit
longTask.interrupt();  // request cancellation
longTask.join();
The interrupt flag is cleared when InterruptedException is thrown. Always either re-interrupt the thread (Thread.currentThread().interrupt()) or rethrow the exception so callers can observe the cancellation request. Catching InterruptedException and doing nothing is almost always wrong.

Daemon Threads and Uncaught Exception Handlers

Java threads are classified as either user threads or daemon threads. The JVM keeps running as long as any user thread is alive. When all user threads finish, the JVM exits, even if daemon threads are still running. Daemon threads are suitable for background services that should not prevent shutdown: garbage collection, the JVM's own internal housekeeping, log flushers, heartbeat monitors.

Set a thread as a daemon before calling start(). After start(), the daemon status cannot be changed. Threads inherit their daemon status from their creator: threads started by a daemon thread are also daemon threads by default.

An uncaught exception handler is invoked when a thread's run() method throws an unchecked exception that is not caught inside the thread. Without one, the JVM prints the stack trace to stderr and the thread silently terminates, which is easy to miss. Register a handler to log errors properly or notify monitoring systems.

Java
// Daemon thread — JVM won't wait for it on shutdown
Thread monitor = new Thread(() -> {
    while (true) {
        System.out.println("Heartbeat...");
        try { Thread.sleep(1000); } catch (InterruptedException e) { break; }
    }
});
monitor.setDaemon(true);  // must be called before start()
monitor.setName("health-monitor");
monitor.start();

// Uncaught exception handler — per-thread
Thread risky = new Thread(() -> {
    throw new RuntimeException("Something went wrong");
});
risky.setUncaughtExceptionHandler((thread, ex) ->
    System.err.println("Thread " + thread.getName() + " failed: " + ex.getMessage())
);
risky.start();

// Default uncaught exception handler — for all threads without a specific handler
Thread.setDefaultUncaughtExceptionHandler((thread, ex) -> {
    System.err.println("[GLOBAL] Unhandled exception in " + thread.getName());
    ex.printStackTrace(System.err);
});
In production applications, always register at least a default uncaught exception handler that logs to your structured logging system. Silent thread death, where a background thread throws and dies without anyone noticing, is a common source of mysterious production failures.