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)
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 Runnablevoid run()ThreadThreadrun()thread.start()run()
The RunnableRunnableThreadExecutorService
// 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();
thread.run()thread.start()Thread Lifecycle: The Six States
A Threadthread.getState()Thread.State
- NEW: The
object has been created butThread has not been called yet.start() - 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
block that another thread holds).synchronized - WAITING: The thread is waiting indefinitely for another thread to perform a specific action, for example, calling
,Object.wait() with no timeout, orThread.join() .LockSupport.park() - TIMED_WAITING (Like WAITING, but with a deadline)
,Thread.sleep(ms) ,Object.wait(ms) , orThread.join(ms) .LockSupport.parkNanos() - TERMINATED: The thread's
method has returned or thrown an uncaught exception. A terminated thread cannot be restarted.run()
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()join(long millis)
Thread.sleep(long millis)ScheduledExecutorServiceBlockingQueue
interrupt()sleep()join()wait()InterruptedExceptionThread.interrupted()isInterrupted()InterruptedException
// 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();
InterruptedExceptionThread.currentThread().interrupt()InterruptedExceptionDaemon 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()start()
An uncaught exception handler is invoked when a thread's run()
// 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);
});