Definition
- Extend Thread – subclass
Threadand overriderun(). Simple but bad practice because your class cannot extend any other class (Java has single inheritance) and the task logic is coupled to the threading mechanism. - Implement Runnable – implement the
run()method and pass the instance tonew Thread(runnable)or submit to anExecutorService. Preferred because it separates what to do (the task) from how to run it (the thread). - Implement Callable
– like Runnable but the call()method returns a result of typeVand can throw checked exceptions. Submit to anExecutorServiceto get aFuturefor the result. - ExecutorService (thread pool) – the production standard. Pre-creates a bounded set of threads, queues tasks, reuses workers, and provides lifecycle management via
shutdown().
Extending Thread is like making every bank teller also be the task form they carry: the teller can only do one type of job and cannot take on another role. Implementing Runnable is like writing the task on a separate form and handing it to any available teller. Callable with ExecutorService is like submitting a task form to the branch counter and getting a receipt number (Future). You come back later with the receipt to collect the result.
Understanding the Concepts
What are the three ways to create a thread?
Java provides three approaches, each with different trade-offs. In practice, the third approach (Callable with ExecutorService) is the only one suitable for production systems.
How does extending Thread work?
You create a subclass of Thread and override the run() method. Calling start() creates a new OS thread that executes run().
package com.bank.service;
public class TradeProcessor extends Thread {
private final String tradeId;
public TradeProcessor(String tradeId) {
this.tradeId = tradeId;
}
@Override
public void run() {
System.out.println("Processing " + tradeId + " on " + getName());
}
public static void main(String[] args) {
TradeProcessor tp = new TradeProcessor("TRD-001");
tp.start();
}
}
Processing TRD-001 on Thread-0
The problem: TradeProcessor already extends Thread, so it cannot extend any other class. The task logic is permanently tied to the threading mechanism.
How does implementing Runnable work?
You implement the Runnable interface and pass it to a Thread or an ExecutorService. This separates the task (what to do) from the thread (how to run it).
package com.bank.service;
public class TradeTask implements Runnable {
private final String tradeId;
public TradeTask(String tradeId) {
this.tradeId = tradeId;
}
@Override
public void run() {
System.out.println("Processing " + tradeId
+ " on " + Thread.currentThread().getName());
}
public static void main(String[] args) {
// Can pass to a Thread
new Thread(new TradeTask("TRD-001"), "Settler-1").start();
// Or use a lambda (Java 8)
new Thread(() -> System.out.println("Lambda task"), "Settler-2").start();
}
}
Processing TRD-001 on Settler-1
Lambda task
How does Callable with ExecutorService work?
Callable is like Runnable but the call() method returns a value and can throw checked exceptions. You submit it to an ExecutorService and get a Future to retrieve the result.
package com.bank.service;
import java.math.BigDecimal;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class TradeSettler implements Callable<String> {
private final String tradeId;
private final BigDecimal amount;
public TradeSettler(String tradeId, BigDecimal amount) {
this.tradeId = tradeId;
this.amount = amount;
}
@Override
public String call() throws Exception {
// Simulate settlement work
Thread.sleep(100);
return "SETTLED:" + tradeId + ":" + amount;
}
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(10);
Future<String> future = pool.submit(
new TradeSettler("TRD-001", new BigDecimal("50000.00")));
String result = future.get(); // blocks until result is ready
System.out.println(result);
pool.shutdown();
}
}
SETTLED:TRD-001:50000.00
How do the three approaches compare?
| Feature | Extend Thread | Implement Runnable | Callable + ExecutorService |
|---|---|---|---|
| Return value | No | No | Yes (Future) |
| Checked exceptions | No | No | Yes (call() throws) |
| Inheritance | Wastes extends slot | Implements interface | Implements interface |
| Thread pool | No | Yes | Yes |
| Production-ready | No | Better | Best |
Why is new Thread().start() dangerous in production?
Creating a raw thread for every request has no limit, no reuse, and no monitoring.
package com.bank.service;
public class DangerousThreadCreation {
// WRONG: unbounded thread creation
public static void handleRequest(Runnable task) {
new Thread(task).start(); // 10K requests = 10K threads = 10GB stack = OOM!
}
// CORRECT: bounded thread pool
private static final java.util.concurrent.ExecutorService pool =
java.util.concurrent.Executors.newFixedThreadPool(100);
public static void handleRequestSafely(Runnable task) {
pool.submit(task); // 10K requests queued, 100 threads process them
}
}
With a pool of 100 threads, 10,000 requests are queued and processed by reused workers. Without a pool, 10,000 threads consume 10GB of stack memory and the JVM crashes with OutOfMemoryError.
What are common mistakes?
The most common mistake is calling run() instead of start(). Calling run() directly executes the method on the current thread with no concurrency.
package com.bank.service;
public class CommonMistake {
public static void main(String[] args) {
Thread t = new Thread(() ->
System.out.println("Running on: " + Thread.currentThread().getName()));
// WRONG: runs on main thread, no concurrency
t.run();
// CORRECT: runs on a new thread
Thread t2 = new Thread(() ->
System.out.println("Running on: " + Thread.currentThread().getName()));
t2.start();
}
}
Running on: main
Running on: Thread-1
What about virtual threads (Java 21)?
Java 21 introduced virtual threads that are managed by the JVM rather than the OS. They are ideal for IO-bound tasks because you can create millions of them without running out of OS thread resources.
package com.bank.service;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualThreadExample {
public static void main(String[] args) throws Exception {
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10000; i++) {
final int tradeNum = i;
pool.submit(() -> {
System.out.println("Settling trade " + tradeNum
+ " on " + Thread.currentThread());
});
}
}
}
}
What should a senior engineer know?
- Thread pool sizing: for CPU-bound tasks, use a pool size equal to the number of CPU cores. For IO-bound tasks, use a larger pool (cores multiplied by 2 to 4) because threads spend time waiting for IO.
- Custom ThreadPoolExecutor: in production, avoid Executors factory methods. Use
new ThreadPoolExecutor(core, max, keepAlive, unit, boundedQueue, rejectionPolicy)for full control over queue bounds and rejection behavior. - CompletableFuture: for composing asynchronous tasks (chain, combine, handle errors), use
CompletableFutureinstead of rawFuture.get()which blocks the calling thread. - Structured concurrency (Java 21 preview):
StructuredTaskScopeensures child tasks are bounded to the parent scope, preventing orphan threads.
Visual Overview
Way 1: Extend Thread (Not Recommended)
┌──────────────────┐
│ TradeProcessor │
│ extends Thread │ ── task + thread coupled
│ run() { ... } │
└──────────────────┘
Way 2: Implement Runnable (Good)
┌──────────────┐ ┌─────────┐
│ TradeTask │────▶│ Thread │
│ implements │ │ or Pool │
│ Runnable │ └─────────┘
└──────────────┘
task separated from thread
Way 3: Callable + ExecutorService (Best)
┌───────────────┐ submit() ┌────────────────────┐
│ TradeSettler │────────────▶│ ExecutorService │
│ implements │ │ (thread pool) │
│ Callable<V> │◀────────────│ returns Future<V> │
└───────────────┘ Future └────────────────────┘
Key Takeaway
Java provides three ways to create threads, but only one belongs in production code. Extending Thread wastes the single inheritance slot and couples task logic to the threading mechanism. Implementing Runnable separates the task from the thread, which is a significant improvement. Callable with ExecutorService is the production standard because it returns results via Future, handles checked exceptions, reuses threads, and provides lifecycle management.
The critical rule is to never call new Thread().start() in production. Raw thread creation has no concurrency limit, no thread reuse, and no monitoring. A sudden spike in requests creates thousands of threads, each consuming 1MB of stack, and the JVM crashes with OutOfMemoryError. A thread pool bounds concurrency, queues excess work, and reuses existing threads.
Remember one sentence: separate the task (Runnable or Callable) from the execution mechanism (ExecutorService thread pool), and never create raw threads in production.
---