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 ofstart(). 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
ExecutorServicewithshutdown()(or use it inside try-with-resources — since Java 19ExecutorServiceextendsAutoCloseable). Pool threads are non-daemon by default, so a forgotten shutdown keeps the JVM alive aftermainreturns, 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?
What is the difference between start() and run()?
What is the difference between Runnable and Callable?
Why use an ExecutorService instead of creating threads manually?
How many threads should a thread pool have?
Want to Build Your Career in Java Full Stack with AI?
Join CodeBegun and train with working industry engineers — Explore the Program

