JavaMultithreadingintermediate
Updated:

Multithreading in Java: Threads, Runnable and Executors

5 min read

How to run code on multiple threads in Java — from extending Thread, to Runnable lambdas, to the ExecutorService pools you will actually use at work.

TL;DR – Quick Answer

Multithreading in Java means running multiple threads — independent paths of execution — inside one program, so slow tasks like I/O do not block everything else. You create threads by implementing Runnable (or Callable for results) and, in real projects, submit those tasks to an ExecutorService thread pool instead of starting raw threads yourself.

On This Page

Open any Spring Boot service in production and you will find dozens of threads working at once: one pool serving HTTP requests, another talking to the database, a scheduler firing background jobs. Multithreading is how Java keeps a slow network call from freezing everything else, and it is the topic where interviews separate candidates who have shipped real systems from those who have only read definitions. This tutorial builds the practical foundation: what a thread is, the three ways to create one, and why production code uses executors instead.

What a thread actually is

A thread is an independent path of execution inside a process. The process — your running JVM — owns the heap, loaded classes, and open files; every thread inside it shares all of that but keeps its own call stack and program counter. That sharing is the whole trade-off of multithreading: threads communicate cheaply through shared objects, and that same shared access is what causes race conditions when two threads touch one object without proper synchronization.

Every Java program starts with one thread, called main. Anything else — parallel downloads, background cleanup, request handling — exists because some code created more threads. On a 4-core laptop the JVM can happily manage hundreds of threads; the OS scheduler slices CPU time among them, so even one core can interleave many threads (concurrency), while multiple cores run them literally at the same instant (parallelism).

Creating a thread: Runnable first

There are two classic creation routes, and one of them is almost always right. Implement Runnable — the interface with a single void run() method — and hand it to a Thread:

public class HelloThreads {
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            String name = Thread.currentThread().getName();
            for (int i = 1; i <= 3; i++) {
                System.out.println(name + " -> step " + i);
            }
        };

        Thread worker1 = new Thread(task, "worker-1");
        Thread worker2 = new Thread(task, "worker-2");
        worker1.start();
        worker2.start();

        worker1.join(); // main waits for worker-1 to finish
        worker2.join();
        System.out.println("All workers done");
    }
}

Run this a few times and notice the interleaving changes on every run — worker-1 and worker-2 output can mix in any order. That nondeterminism is normal: the OS scheduler, not your code, decides who runs when. The join() calls are what guarantee "All workers done" prints last; without them, main could finish first.

Common mistake: A common mistake beginners make is calling run() instead of start(). The code still executes and prints, so everything looks fine — but it all ran on the main thread, sequentially. start() is the only method that actually creates a new thread.

The alternative route — extending Thread and overriding run() — works but costs you your single superclass slot and couples the task to the mechanism. Since Java 8, Runnable is a functional interface, so a lambda like the one above is the idiomatic form. Interviewers still ask for both routes; know the second exists, use the first.

ExecutorService: how real projects run tasks

Raw new Thread(...) calls do not survive code review in serious codebases. Threads are expensive (each reserves stack memory, typically around 1 MB) and creating one per task means an unexpected traffic spike can exhaust memory. The fix is a thread pool: a fixed set of long-lived threads that pick tasks off a queue. Java ships this as ExecutorService:

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class OrderProcessor {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(3);
        List<Future<String>> receipts = new ArrayList<>();

        for (int i = 1; i <= 6; i++) {
            final int orderId = i;
            receipts.add(pool.submit(() -> {
                Thread.sleep(300); // simulate payment gateway latency
                return "Order " + orderId + " processed by "
                        + Thread.currentThread().getName();
            }));
        }

        for (Future<String> receipt : receipts) {
            System.out.println(receipt.get()); // blocks until that task finishes
        }
        pool.shutdown();
    }
}

Six tasks, three threads: watch the output and you will see each pool thread process two orders. The lambda here is a Callable<String> — it returns a value and is allowed to throw checked exceptions, which plain Runnable cannot. submit() hands back a Future, a claim ticket you redeem later with get().

Three executor factory methods cover most needs:

Factory Threads Use when
newFixedThreadPool(n) Exactly n Steady, bounded workloads
newCachedThreadPool() Grows/shrinks on demand Many short-lived tasks
newSingleThreadExecutor() One Tasks that must run in order

Pro tip: Always pair an ExecutorService with shutdown() (or use it inside try-with-resources — since Java 19 ExecutorService extends AutoCloseable). Pool threads are non-daemon by default, so a forgotten shutdown keeps the JVM alive after main returns, which looks exactly like a hang.

Callable, Future and timeouts

Future.get() blocks forever by default, which quietly reintroduces the freezing problem multithreading was meant to solve. Production code sets a timeout and handles the failure path using regular exception handling:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public class TimeoutDemo {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newSingleThreadExecutor();
        Future<String> slow = pool.submit(() -> {
            Thread.sleep(2000); // pretend this API is having a bad day
            return "late response";
        });

        try {
            String result = slow.get(500, TimeUnit.MILLISECONDS);
            System.out.println("Got: " + result);
        } catch (TimeoutException e) {
            slow.cancel(true); // interrupts the task's thread
            System.out.println("Gave up after 500 ms, task cancelled");
        } finally {
            pool.shutdown();
        }
    }
}

cancel(true) delivers an interrupt to the running task — which only works because Thread.sleep() responds to interruption by throwing InterruptedException. Long-running loops you write yourself should check Thread.currentThread().isInterrupted() periodically to stay cancellable. This cooperative interruption model is a favorite follow-up in interviews at the 3-year mark.

The shared-state problem, briefly

The moment two threads write to the same variable, correctness stops being guaranteed. Two threads incrementing one counter 100,000 times each will usually total less than 200,000, because count++ is three operations (read, add, write) that interleave badly. The fixes — synchronized, locks, volatile, atomic classes — deserve their own deep dive in synchronization in Java, and the collections angle (why HashMap breaks under concurrent writes) is covered in HashMap vs ConcurrentHashMap.

For now, adopt the practical hierarchy we teach: prefer no shared mutable state (each task works on its own data), then immutable objects, then concurrent utilities, and only then manual locking. Locking done carelessly leads to deadlock — two threads waiting on each other forever.

Interview note: "How do you create a thread in Java?" is rarely the real question — it is the warm-up. The evaluation happens on the follow-ups: start() vs run(), Runnable vs Callable, and why you would choose an executor. Prepare the chain, not the first link.

Where to go next

You now have the creation toolkit: Runnable for fire-and-forget tasks, Callable + Future for results, ExecutorService to manage the threads that run them. Two topics complete the foundation. First, learn what states a thread moves through between start() and death — the thread lifecycle explains why a thread shows as BLOCKED or WAITING in a thread dump. Second, learn to protect shared state with the synchronization tools covered in the previous section's deep dive. Together these pages cover the multithreading questions asked in the overwhelming majority of Java service-company and product-company interviews.

Frequently Asked Questions

What is the difference between extending Thread and implementing Runnable?
Implementing Runnable separates the task from the thread that runs it, leaves your class free to extend something else, and works with thread pools and lambdas. Extending Thread couples the two and burns your only superclass slot. In practice, Runnable is the correct choice in almost every case.
What is the difference between start() and run()?
start() asks the JVM to create a new operating system thread and then invoke run() on it. Calling run() directly is just a normal method call — the code executes on the current thread and no concurrency happens. Calling start() twice on the same Thread object throws IllegalThreadStateException.
What is the difference between Runnable and Callable?
Runnable's run() method returns nothing and cannot throw checked exceptions. Callable's call() method returns a value and may throw checked exceptions. You submit a Callable to an ExecutorService and receive a Future, from which get() retrieves the result once the task completes.
Why use an ExecutorService instead of creating threads manually?
Thread creation is expensive — each thread reserves stack memory and takes OS resources. An ExecutorService reuses a fixed pool of threads across many tasks, gives you Futures for results, and provides orderly shutdown. Unbounded manual thread creation is a common cause of OutOfMemoryError in production.
How many threads should a thread pool have?
A common starting point is the number of CPU cores for CPU-bound work, obtained via Runtime.getRuntime().availableProcessors(), and a larger count for I/O-bound work since those threads spend most of their time waiting. The right number is ultimately found by measuring under realistic load.

Want to Build Your Career in Java Full Stack with AI?

Join CodeBegun and train with working industry engineers — Explore the Program

Apply for Demo Class →
Siva Prasad Galaba
Founder, CodeBegun · Staff Engineer

Founder of CodeBegun. 15+ years building Java systems at companies like Crunchyroll. Teaches Java, Spring Boot and system design the way the industry actually works, and mentors students through projects, mock interviews and placement preparation.

Technically reviewed by CodeBegun Technical TeamLast reviewed 14 July 2026 LinkedIn
Chat with us