Free lesson · Concurrency Interview Mastery
Creating Threads in Java
This is the reference for getting code running on a thread in Java. We'll move fast and stay concrete - the concepts behind all this live in Module 1; here we're just nailing down the Java mechanics and the choices that matter. Quick orientation from me before the code: in modern Java you almost never create raw Thread objects by hand. You hand work to an executor and let it manage threads. But you need to know the raw mechanics first, because interviewers ask about them and because the higher-level tools are built on top.
There are essentially four ways to get work onto a thread, in rough order from low-level to high-level: extending Thread, implementing Runnable, implementing Callable (for results), and submitting to an ExecutorService.
Way 1: Extend the Thread class
Subclass Thread, override run(), call start().
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Running on: " + Thread.currentThread().getName());
}
}
MyThread t = new MyThread();
t.start(); // starts a NEW thread, calls run() on it
This works but is the least preferred approach. Java has single inheritance, so extending Thread burns your one superclass slot on a threading detail, and it conflates what to run (your logic) with how to run it (the thread). Prefer Runnable.
Way 2: Implement Runnable (preferred for simple tasks)
Runnable is a functional interface with a single run() method. You pass it to a Thread.
Runnable task = () -> {
System.out.println("Running on: " + Thread.currentThread().getName());
};
Thread t = new Thread(task);
t.start();
This separates the task from the thread. Your class is free to extend something else, the task is reusable, and the lambda form is clean. Whenever you'd reach for extends Thread, use Runnable instead.
Warning: The single most common beginner bug: calling
t.run()instead oft.start(). Callingrun()directly just executes the method on the current thread, synchronously, like any normal method call - no new thread is created at all. Onlystart()spins up a new thread and schedulesrun()on it. If your "threads" seem to run one after another in order, you almost certainly calledrun(). This is a classic interview gotcha - be ready to explain the difference.
flowchart LR
A["t.start()"] --> B[New thread created]
B --> C["run() executes on the NEW thread"]
D["t.run()"] --> E["run() executes on the CURRENT thread<br/>no new thread, fully synchronous"]
This flowchart contrasts start and run. Calling t dot start creates a new thread, and the run method executes on that new thread. Calling t dot run directly does not create any thread - the run method simply executes on the current thread, synchronously, like a normal method call. The takeaway is that only start launches a new thread; run by itself does nothing concurrent.
Way 3: Callable and Future (when you need a result)
Runnable.run() returns nothing and can't throw checked exceptions. When your task needs to return a value or throw, use Callable<V>, which returns a V. You don't run a Callable on a raw Thread - you submit it to an executor, which hands you a Future<V> to retrieve the result later.
Callable<Integer> task = () -> {
Thread.sleep(100);
return 42;
};
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(task);
Integer result = future.get(); // blocks until the result is ready
executor.shutdown();
future.get() blocks the calling thread until the result is available. That blocking is important to remember - it's where naive code accidentally serializes itself. There's also future.get(timeout, unit) to bound the wait, future.isDone() to poll, and future.cancel() to attempt cancellation.
| Interface | Returns a value? | Can throw checked exceptions? | How you run it |
|---|---|---|---|
Runnable |
No (void run()) |
No | Thread or executor |
Callable<V> |
Yes (V call()) |
Yes | Executor → Future<V> |
Way 4: ExecutorService - the way you should actually do it
Creating a raw Thread per task is expensive and unbounded - spin up thousands and you exhaust memory and thrash on context switches (see Module 1 on why we pool). The modern, idiomatic approach is a thread pool via ExecutorService: create a managed pool once, submit tasks to it, reuse threads.
ExecutorService pool = Executors.newFixedThreadPool(4); // 4 reused threads
for (int i = 0; i < 100; i++) {
final int id = i;
pool.submit(() -> System.out.println("Task " + id));
}
pool.shutdown(); // no new tasks accepted; existing ones finish
Common factory methods on Executors:
newFixedThreadPool(n)- fixednthreads; tasks queue when all are busy.newCachedThreadPool()- grows and shrinks on demand; good for many short-lived tasks.newSingleThreadExecutor()- one thread; tasks run sequentially in submission order.newScheduledThreadPool(n)- for delayed and periodic tasks.newVirtualThreadPerTaskExecutor()- a thread per task using virtual threads (Java 21+); covered in its own lesson.
Tip: Rule of thumb: don't
new Thread()in application code. Submit tasks to anExecutorServiceand let it own the threads. You get pooling, bounded resource use, lifecycle management, and a clean path toFutureresults. The rawThreadAPI is for understanding the model and for the rare low-level case; executors are for real work. In an interview, "I'd use anExecutorServicerather than creating threads directly" is the answer that signals you've worked at scale.
Running many tasks and shutting down cleanly
Two practical things you'll do constantly with executors: run a batch of tasks and collect results, and shut the pool down properly.
invokeAll submits a collection of Callables and blocks until all finish, returning their Futures in order:
List<Callable<Integer>> tasks = List.of(
() -> compute(1), () -> compute(2), () -> compute(3));
List<Future<Integer>> results = pool.invokeAll(tasks); // waits for all
for (Future<Integer> f : results) {
System.out.println(f.get()); // each is already done
}
Shutdown has two flavors, and confusing them is a common bug:
shutdown()- graceful. Stops accepting new tasks; lets already-submitted tasks finish.shutdownNow()- abrupt. Attempts to interrupt running tasks and returns the queued tasks that never ran.awaitTermination(timeout, unit)- blocks until the pool finishes or the timeout elapses; call it aftershutdown()to actually wait for completion.
pool.shutdown(); // stop accepting new work
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // force if it didn't finish in time
}
Warning: A pool you never shut down keeps its threads alive and can prevent the JVM from exiting (non-daemon pool threads are user threads). Forgetting
shutdown()is a classic resource leak - the program "hangs" at the end for no obvious reason. Always shut your executors down, ideally in afinallyor a lifecycle hook.
Thread essentials you'll be asked about
A few Thread mechanics worth keeping at your fingertips:
- Naming:
new Thread(task, "worker-1")orthread.setName(...). Named threads make thread dumps readable - do this in production code. - Daemon threads:
thread.setDaemon(true)beforestart(). The JVM exits when only daemon threads remain (Module 1 covered the hazard - don't put must-finish work on daemons). - Joining:
t.join()blocks the caller untiltfinishes.t.join(millis)bounds the wait. - Sleeping:
Thread.sleep(millis)parks the current thread for a duration (timed-waiting state). It does not release any locks the thread holds - a common misconception. - Uncaught exceptions: an exception that escapes
run()kills that thread silently unless you set a handler viasetUncaughtExceptionHandler(...). Unhandled exceptions in threads are a frequent source of "the work just stopped and nothing was logged."
Interview: Expect these three: (1) "Difference between
start()andrun()?" -start()creates a new thread and runsrun()on it; callingrun()directly executes synchronously on the current thread. (2) "RunnablevsCallable?" -Callablereturns a value and can throw checked exceptions;Runnabledoes neither. (3) "Why use a thread pool instead of creating threads?" - bounded resources, thread reuse avoids creation cost, and managed lifecycle; unbounded thread creation exhausts memory and thrashes. Answer all three crisply and you've cleared the Java-threading basics bar.
Quick reference
// Runnable (no result)
new Thread(() -> doWork()).start();
// Callable (result) via executor
Future<T> f = executor.submit(() -> computeResult());
T result = f.get();
// Preferred: pool + submit
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.submit(() -> doWork());
pool.shutdown();
That's the toolkit for getting code onto threads in Java. Next we go under it - the Java Memory Model, the rules that decide when one thread actually sees another thread's writes, which is what makes all of the above safe or unsafe.