Back to Blog
Java

Java Create Thread: Runnable, Thread, and Executors

java create thread: Compare four ways to create threads in Java—Thread subclasses, Runnable, lambdas, and ExecutorService—with guidance on when each fits.

Java threadsRunnable interfaceExecutorServiceJava concurrencyThread lifecycle
Illustration of Java thread creation showing a main thread branching into multiple worker threads

To java create thread in Java, define the work in a run() method, pass it to a Thread instance, and call start(). The run() method can come from either a Thread subclass or a Runnable implementation. That single decision—subclass versus Runnable—shapes how you structure the code and how you manage the thread later.

The Two Core Ways to Define Thread Work

Java gives you two primary mechanisms to express what a thread executes:

  • Extend Thread and override run().
  • Implement Runnable and pass the instance to a Thread constructor.

Both produce a thread that executes the code in run() when start() is called. The difference is in how the task relates to the thread. Extending Thread couples the task to the thread object itself, while Runnable separates the work from the execution mechanism.

In modern Java code, Runnable is the more common choice because it leaves the task reusable across different execution contexts. A Runnable can be passed to a Thread, an ExecutorService, or a CompletableFuture without modification.

Extending the Thread Class Directly

The simplest form of java create thread syntax is subclassing Thread:

class DownloadTask extends Thread { @Override public void run() { System.out.println("Download started in " + Thread.currentThread().getName()); } } DownloadTask task = new DownloadTask(); task.start();

Calling start() launches a new native thread and invokes run() on it. Calling run() directly would execute the code on the current thread, not on a new one—a common mistake that silently turns concurrent code into sequential code.

Subclassing Thread is acceptable for small, self-contained tasks, but it has a structural limitation: Java supports single inheritance. If your task class already extends another class, it cannot extend Thread. That constraint pushes most production code toward Runnable.

Implementing Runnable and Passing It to a Thread

The Runnable interface declares a single method, run(), with no return value. You implement it and pass the instance to a Thread:

class ReportGenerator implements Runnable { @Override public void run() { System.out.println("Generating report in " + Thread.currentThread().getName()); } } Thread worker = new Thread(new ReportGenerator()); worker.start();

The task class remains independent of the thread mechanism. The same ReportGenerator instance can be submitted to an executor pool later without changing its code. That separation is the main reason Runnable is preferred in larger codebases.

One detail worth noting: a Thread can only be started once. Calling start() a second time on the same instance throws IllegalThreadStateException. If you need to run the same task multiple times, create a new Thread each time or use an executor.

Using Lambda Expressions to Create Threads

Since Java 8, Runnable is a functional interface, which means you can replace the anonymous class with a lambda:

Thread worker = new Thread(() -> { System.out.println("Worker running in " + Thread.currentThread().getName()); }); worker.start();

The lambda form is the most concise way to create a thread when the task is short. It does not change the underlying behavior—the compiler still produces a Runnable instance—but it removes boilerplate and makes the intent visible at the call site.

For a one-off background task, this is often the right level of abstraction. For repeated tasks or tasks that need lifecycle management, an executor is a better fit.

Creating Threads with ExecutorService

Direct Thread creation gives you a thread per task, but threads are expensive resources. Each thread consumes stack memory and requires OS-level scheduling. Creating a new thread for every short-lived task adds overhead and can exhaust system resources under load.

ExecutorService decouples task submission from thread creation:

ExecutorService executor = Executors.newFixedThreadPool(4); for (int i = 0; i < 20; i++) { int taskId = i; executor.submit(() -> { System.out.println("Task " + taskId + " on " + Thread.currentThread().getName()); }); } executor.shutdown();

The pool reuses four threads across twenty tasks. The executor decides which thread runs which task, and it queues tasks that cannot start immediately. This is the standard way to handle multiple concurrent tasks in production code.

shutdown() is important. It tells the executor to stop accepting new tasks and to terminate after running tasks finish. Without it, the JVM may not exit because the pool's threads remain alive.

Callable and Future for Return Values

Runnable.run() returns void. When a thread must produce a result, use Callable<T> instead:

ExecutorService executor = Executors.newSingleThreadExecutor(); Future<Integer> future = executor.submit(() -> { Thread.sleep(500); return 42; }); try { int result = future.get(); System.out.println("Result: " + result); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } executor.shutdown();

Callable is not a Runnable; it cannot be passed directly to a Thread constructor. It is designed for executors, which accept both types. The Future object returned by submit() represents the pending result. get() blocks until the computation completes, and it throws checked exceptions if the task fails.

If you need both a return value and a raw Thread, you have to bridge the gap manually, usually by storing the result in a field or using a FutureTask, which implements both Runnable and Future.

Thread Lifecycle After start()

Once start() returns, the thread moves through several states: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED. The JVM manages these transitions; you do not control them directly.

The Thread.State enum exposes the current state, which is useful for diagnostics:

Thread worker = new Thread(() -> { try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); System.out.println(worker.getState()); // NEW worker.start(); System.out.println(worker.getState()); // RUNNABLE or TIMED_WAITING

A thread that finishes its run() method enters TERMINATED and cannot be restarted. If you need a fresh execution, create a new thread or reuse a pooled thread via an executor.

Choosing Between Direct Threads and Executors

The decision is not about which syntax is newer, but about what the task needs:

ApproachBest fitCost
new Thread(runnable).start()One-off background task, no result neededPer-thread creation overhead
Thread subclassSmall self-contained task, no reuseSame as above, plus inheritance cost
Fixed thread poolMany short tasks, bounded resource usePool management overhead
Cached thread poolMany short tasks, variable loadUnbounded thread growth under load
Callable + FutureTask must return a result or throwBlocking on get()

Use a direct Thread when the task runs once and you do not need to track its completion. Use an executor when tasks are repeated, concurrent, or need pooling. Use Callable when the task produces a value that the caller must consume.

Concurrency Safety and Thread Creation Cost

Creating a thread is not free. Each thread allocates a stack (typically hundreds of kilobytes to a few megabytes, depending on the JVM and platform) and registers with the OS scheduler. Creating thousands of threads in a loop can exhaust memory before the tasks even start.

Thread safety is a separate concern. Sharing mutable state across threads requires synchronization, volatile, or concurrent collections. A thread that reads and writes the same field without synchronization can observe stale values, and the JVM may reorder operations in ways that break assumptions.

// Unsafe: unsynchronized read/write across threads class Counter { private int count = 0; void increment() { count++; // not atomic } int get() { return count; } }

The count++ operation is a read-modify-write sequence, not a single atomic step. Two threads can both read the same value and write back the same incremented value, losing one update. Use AtomicInteger, synchronized, or a lock when counters are shared.

For production systems, prefer executors with bounded pools over unbounded thread creation. The pool caps the number of live threads, which bounds memory usage and prevents thread exhaustion under load. Direct thread creation remains useful for simple cases, but it should not be the default for concurrent workloads.

java create thread: Practical Usage and Code Examples | RYUSLOG DEV