Creating Threads — Runnable vs Thread
Java offers four distinct ways to create and run a thread — subclassing Thread, implementing Runnable, using a lambda, and the modern Thread.Builder API — plus a fifth answer that supersedes all of them in real production code. This topic compares them, explains why some are discouraged, and covers the interrupt-handling mistake that trips up nearly every developer at least once.
Learning objectives
- Create threads using extends Thread, implements Runnable, lambdas, and Thread.Builder, and explain the trade-offs of each
- Explain why implementing Runnable (composition) is generally preferred over extending Thread (inheritance)
- Use a custom ThreadFactory to give pooled threads meaningful, debuggable names
- Diagnose and avoid the classic run()-instead-of-start() mistake
- Handle InterruptedException correctly by restoring the interrupt flag or propagating the exception
◆ The problem
Say you need to get a stack of envelopes stuffed and mailed. You could turn a person into the mail job — hire someone whose entire identity is "the envelope stuffer," unable to do anything else. Or you could describe the job on a piece of paper — "stuff envelopes, then mail them" — and hand that description to whichever available person picks it up next, so the same instructions could be reused by a different person tomorrow, or handed to three people at once today.
Java's thread-creation APIs offer exactly this choice, dressed up in different syntaxes. You can make a class be a thread by extending Thread directly, or you can describe the work as a Runnable and hand it to a Thread (or, in real code, to a whole pool of them) to execute. The two approaches produce identical behavior for a simple demo, but they diverge sharply the moment your codebase grows: one couples your task logic permanently to the threading mechanism, the other keeps them separate.
Modern Java adds two more variations on the same theme. Because Runnable is a functional interface with exactly one abstract method, a lambda expression can stand in for a full class body. And Java 21 introduced Thread.Builder, a fluent configuration API that unifies how you create both classic platform threads and the newer virtual threads. By the end of this topic, four ways to create a thread will feel like four dialects of the same idea — and you'll understand why production code, in practice, avoids creating raw threads at all in favor of a managed pool.
The most literal way to make a runnable "thing" in Java is to subclass Thread itself and override run() with your task logic. It reads naturally — a DownloadTask object genuinely is a Thread — but Java only allows single inheritance, so the moment a class extends Thread, it can never extend anything else. This tightly couples task logic to the threading mechanism itself, and it means the class can never later be submitted to an ExecutorService, which expects a Runnable or Callable, not a Thread subclass.
The alternative flips the relationship: instead of a class being a thread, define a class that describes the work, and hand it to a separate Thread object to execute. This is composition instead of inheritance — "a Thread that has a Runnable" rather than "a class that is a Thread." Because the task class only implements an interface, it stays free to extend anything else it needs, and the exact same task instance can be reused across multiple threads or, more realistically, submitted straight to an ExecutorService without ever needing a dedicated Thread subclass.
The code below shows both side by side. DownloadTask extends Thread directly; Task implements Runnable and is handed to a plain Thread. Functionally, starting either produces a live thread running the same kind of work — the difference is entirely about what each approach lets you do afterward.
💻 Code example
package concurrency.creatingthreads; /** * extends Thread (inheritance) vs implements Runnable (composition), * shown side by side. */ public class ExtendsThreadVsImplementsRunnable { // Approach 1: the class IS a Thread. Cannot extend anything else, // and can never be submitted to an ExecutorService. static class DownloadTask extends Thread { private final String url; DownloadTask(String url) { super("download-" + url); // name the thread via the parent constructor this.url = url; } @Override public void run() { System.out.println("Downloading: " + url + " on " + Thread.currentThread().getName()); } } // Approach 2: the class only describes the work. Free to extend // anything else, and reusable across multiple threads or a pool. static class Task implements Runnable { private final String url; Task(String url) { this.url = url; } @Override public void run() { System.out.println("Downloading: " + url + " on " + Thread.currentThread().getName()); } } public static void main(String[] args) throws InterruptedException { DownloadTask t1 = new DownloadTask("https://example.com/file1.zip"); t1.start(); t1.join(); Thread t2 = new Thread(new Task("https://example.com/file2.zip"), "download-worker"); t2.start(); t2.join(); System.out.println("Both downloads complete"); } }
Runnable declares exactly one abstract method, void run(), which makes it a functional interface — any interface with a single abstract method can be implemented inline with a lambda (() -> { ... }) or a method reference (Class::method) instead of a full class body. Under the hood, a lambda passed as a Runnable is not compiled into an old-style anonymous class file; the compiler emits an invokedynamic instruction that asks LambdaMetafactory to generate a lightweight implementation on first use. From your code's perspective, and from the JVM's once created, it behaves like an ordinary Runnable object. Reach for a lambda for short, inline, one-off logic; reach for a method reference when the logic already exists as a well-named, reusable method.
Java 21 added Thread.Builder as the unified, fluent way to configure and start both platform and virtual threads — swap ofPlatform() for ofVirtual() and the rest of the call chain stays identical. Before this API, configuring a thread meant chaining several setter calls (setName, setDaemon, setPriority) before calling start() separately; Thread.Builder's .start(Runnable) builds and starts the thread in one atomic call, so there's no window where a configured-but-unstarted Thread object floats around your code.
In production, threads are rarely created one at a time by hand — most commonly an ExecutorService creates them on your behalf, and by default those threads get unhelpful names like pool-1-thread-1. A ThreadFactory is a single-method interface you implement once to control exactly how every thread a pool creates gets named and configured, so a thread dump immediately tells you which subsystem a hung thread belongs to instead of leaving you to guess.
💻 Code example
package concurrency.creatingthreads; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; /** * Lambda / method reference, Thread.Builder (Java 21+), and a custom * ThreadFactory for named, daemon worker threads. */ public class LambdaBuilderAndThreadFactory { static void printMessage() { System.out.println("Method reference thread running!"); } // A ThreadFactory centralizes naming/configuration so every thread a // pool creates is identifiable in a thread dump. static class NamedThreadFactory implements ThreadFactory { private final String prefix; // AtomicInteger, not a plain int: newThread() can be called // concurrently, and a plain counter++ would race. private final AtomicInteger counter = new AtomicInteger(1); NamedThreadFactory(String prefix) { this.prefix = prefix; } @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName(prefix + "-thread-" + counter.getAndIncrement()); t.setDaemon(true); return t; } } public static void main(String[] args) throws Exception { // Lambda: concise inline Runnable. Thread t1 = new Thread(() -> System.out.println("Lambda thread running!")); // Method reference: points at an existing, well-named method. Thread t2 = new Thread(LambdaBuilderAndThreadFactory::printMessage); t1.start(); t2.start(); t1.join(); t2.join(); // Thread.Builder: fluent, unified config for platform and virtual threads. Thread platform = Thread.ofPlatform() .name("platform-worker-", 1) .daemon() .start(() -> System.out.println("Platform thread: " + Thread.currentThread().getName())); Thread virtual = Thread.ofVirtual() .name("virtual-worker-", 1) .start(() -> System.out.println("Virtual thread: " + Thread.currentThread().getName())); platform.join(); virtual.join(); // ThreadFactory: production pattern for pooled thread naming. ThreadFactory factory = new NamedThreadFactory("db-pool"); Thread dbThread = factory.newThread(() -> System.out.println("Running on: " + Thread.currentThread().getName())); dbThread.start(); dbThread.join(); } }
▲ Common mistake
Calling run() instead of start(). This compiles fine and even produces output, which is exactly what makes it dangerous — the task executes synchronously on whichever thread called it, with no new thread ever created. If the printed thread name reads main instead of an expected worker name, this is almost always the cause. Only start() allocates a real thread and schedules run() to execute on it asynchronously.
▲ Edge case
Calling start() twice on the same Thread object throws IllegalThreadStateException — a Thread can only ever be started once. If you need to run the same logic again, create a new Thread (or, better, resubmit the same Runnable to an ExecutorService, which is designed for exactly this).
▲ Common mistake
Swallowing InterruptedException with an empty catch block. Thread.interrupt() does not forcibly stop a thread — it sets an internal flag, and if the target thread is blocked in an interruptible call like sleep(), the JVM clears that flag and throws InterruptedException from the blocking call instead. That throw happens exactly once. If the catch block does nothing with it, all evidence that cancellation was ever requested is destroyed permanently, and no other code can ever detect it happened.
The correct handling depends on context. If you're implementing an interface method that can't declare checked exceptions — Runnable.run() is the classic example — catch the exception, do any needed cleanup, then call Thread.currentThread().interrupt() before returning, restoring the flag so outer code can still see that cancellation was requested. If you're writing an internal or library method instead, it's often cleaner to declare throws InterruptedException and let it propagate, deferring the decision to whichever caller is best positioned to make it.
▲ Edge case
Thread.interrupted() (static) checks and clears the calling thread's interrupt flag; Thread.isInterrupted() (instance method) checks without clearing. When a blocking method detects the flag is set, the JVM clears it as part of throwing InterruptedException — so by the time your catch block runs, the flag is already false again. That is exactly why the "restore the flag" pattern must explicitly call interrupt() again: the JVM already took it away once.
💻 Code example
package concurrency.creatingthreads; /** * Bad vs. good handling of InterruptedException, side by side. */ public class HandlingInterruptedExceptionCorrectly { // ANTI-PATTERN: swallows the interrupt signal entirely. static void badHandling() { try { System.out.println("badHandling: sleeping..."); Thread.sleep(10_000); } catch (InterruptedException e) { // BUG: does nothing -- the "please stop" signal is lost forever. System.out.println("badHandling: interrupted, but ignoring it!"); } } // CORRECT: restores the interrupt flag so calling code can still see it. static void goodHandling() { try { System.out.println("goodHandling: sleeping..."); Thread.sleep(10_000); } catch (InterruptedException e) { System.out.println("goodHandling: interrupted, restoring flag and exiting."); Thread.currentThread().interrupt(); // restore the flag } } public static void main(String[] args) throws Exception { Thread t1 = new Thread(HandlingInterruptedExceptionCorrectly::badHandling); t1.start(); Thread.sleep(200); t1.interrupt(); t1.join(1000); Thread t2 = new Thread(HandlingInterruptedExceptionCorrectly::goodHandling); t2.start(); Thread.sleep(200); t2.interrupt(); t2.join(1000); } }
Every Thread object carries a small bundle of metadata beyond its name: a unique numeric ID (threadId()), whether it's virtual or platform (isVirtual()), whether it's a daemon (isDaemon()), and a scheduling priority from 1 to 10 (getPriority(), default 5). None of these are used constantly in application code, but they matter enormously the moment something goes wrong — a custom health check or admin dashboard querying isVirtual() to confirm a migration actually took effect, or a trace log including threadId() to disambiguate concurrent log lines.
Thread names deserve special attention because their payoff is almost entirely in debugging, not runtime behavior. A thread dump captured with jstack or a profiler shows thread names verbatim — if every thread in your application is named Thread-0, Thread-1, Thread-2, diagnosing a deadlock or a stuck worker under production load becomes close to impossible. Named threads like db-query-1, kafka-consumer-2, or http-handler-5 immediately tell you what each thread is doing and which subsystem to investigate.
One subtlety worth knowing: Thread.getAllStackTraces() returns a map of every live thread in the JVM to its current call stack, including JVM-internal housekeeping threads (garbage collector threads, JIT compiler threads) alongside your own. This is a genuinely useful tool for building a custom diagnostics endpoint, but the listing reflects a snapshot at one instant — by the time you finish iterating and printing it, some of those threads may already have changed state or terminated. Treat it the same way you treat getState(): a diagnostic read, not a coordination mechanism.
The choice between these approaches shows up immediately once a codebase adopts any concurrency framework. ExecutorService.submit() accepts a Runnable or Callable, never a Thread subclass — so any task class written by extending Thread has to be rewritten before it can be pooled. This is one of the concrete, practical reasons implements Runnable (or a lambda, which is just a Runnable written inline) is the default recommendation: it keeps the door open to the pooling pattern nearly every real application eventually needs.
Custom ThreadFactory implementations are everywhere in production Java: database connection pools name their threads db-pool-thread-N, message consumers name theirs kafka-consumer-N, and web frameworks typically expose a hook to customize the thread factory their internal executor uses specifically so operators can identify subsystems in a thread dump during an incident. Frameworks like Spring's ThreadPoolTaskExecutor accept a custom ThreadFactory for exactly this reason.
Cooperative cancellation via interrupt() is the mechanism underneath ExecutorService.shutdownNow(), Future.cancel(true), and virtually every "stop this background job" feature in a real application. A worker task that never checks its interrupt status, or that swallows InterruptedException, is a task that shutdownNow() cannot actually stop — which is precisely why disciplined interrupt handling, introduced here as a small-looking rule, has outsized consequences for whether a service can shut down cleanly during a deployment or scale-down event.
Thread.Builder and virtual threads specifically are the current recommendation for any new I/O-bound service written against Java 21+, and application frameworks (Spring Boot, Micronaut) are actively adding first-class support for switching their internal thread pools to virtual threads with minimal code changes.
Q: Why is implements Runnable generally preferred over extends Thread?
A: Java only supports single inheritance, so a class that extends Thread can never extend anything else, and it can never be submitted to an ExecutorService (which expects Runnable/Callable). implements Runnable uses composition instead, keeping the task logic reusable and decoupled from the threading mechanism.
Q: What makes a lambda a valid substitute for a class implementing Runnable?
A: Runnable is a functional interface — it declares exactly one abstract method, run(). Any functional interface can be implemented inline with a lambda or method reference instead of a full class body.
Q: What does Thread.Builder add over the older Thread constructors and setters?
A: A unified, fluent configuration API (Java 21+) that works identically for both platform threads (ofPlatform()) and virtual threads (ofVirtual()), and builds-and-starts a thread in one atomic call via .start(Runnable), avoiding an in-between unconfigured state.
Q: What is the single most common beginner mistake when starting a thread, and how do you spot it?
A: Calling run() instead of start(). It compiles and produces output, but runs synchronously on the calling thread instead of a new one — the giveaway is that Thread.currentThread().getName() prints the caller's name (often "main") instead of the expected worker thread's name.
Q: Why is silently swallowing InterruptedException dangerous?
A: The JVM clears the interrupt flag and throws InterruptedException exactly once when a blocking call detects cancellation was requested. An empty catch block destroys that signal permanently — no other code can ever learn cancellation happened. The fix is to restore the flag with Thread.currentThread().interrupt(), or declare throws InterruptedException and let the caller decide.
Want a visual for this concept?
Generate a diagram tailored to “Creating Threads — Runnable vs Thread” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →