Back to Thread Basics & Overview
Q2Question 2 of 7

What are the ways to create a thread in Java?

Key Concepts
  • Extending Thread couples the task to the threading mechanism and wastes the single inheritance slot.
  • Implementing Runnable separates the task from the thread, allowing submission to any executor.
  • Callable returns a result via Future and can throw checked exceptions, unlike Runnable.
  • ExecutorService with a thread pool is the production standard: it limits concurrency, reuses threads, and manages lifecycle.
  • Lambda expressions (Java 8) make Runnable and Callable concise: () -> settleTradeOrder(order).
  • Never call new Thread().start() in production code: use a bounded thread pool instead.

Definition

Extend Thread (bad: wastes inheritance), implement Runnable (good: separates task), or use Callable with ExecutorService (best: returns result, managed pool).
  • Extend Thread – subclass Thread and override run(). 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 to new Thread(runnable) or submit to an ExecutorService. 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 type V and can throw checked exceptions. Submit to an ExecutorService to get a Future for 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().

JAVA
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();
    }
}
TEXT
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).

JAVA
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();
    }
}
TEXT
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.

JAVA
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();
    }
}
TEXT
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.

JAVA
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.

JAVA
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();
    }
}
TEXT
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.

JAVA
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 CompletableFuture instead of raw Future.get() which blocks the calling thread.
  • Structured concurrency (Java 21 preview): StructuredTaskScope ensures child tasks are bounded to the parent scope, preventing orphan threads.

Visual Overview

DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate