Java Runnable Interface
Define units of work for threads in Java with the Runnable functional interface — the preferred form for thread, executor, and virtual-thread work.
Java Runnable Interface
Runnable is a one-method interface — possibly the most important one in java.lang. Everything that "runs on a thread" in Java is, ultimately, a Runnable somewhere: the Thread constructor accepts one, ExecutorService.execute accepts one, the JVM's shutdown hooks accept one. The reason the previous chapter recommended "pass a Runnable to the Thread constructor" over "extend Thread" is that Runnable separates what runs from what runs it. That separation is what makes the same task work on a platform thread, a thread pool, or a virtual thread without code change.
The shape
The whole definition fits in three lines:
@FunctionalInterface
public interface Runnable {
void run();
}That's it. Two consequences fall out of those three lines:
- It's a functional interface. Any lambda or method reference with a no-arg, void signature implements it:
() -> System.out.println("hi"),this::flush,Foo::staticMethod. - It returns void and throws no checked exceptions. That's the limit of what you can express. If you need a result, or to throw something checked, you need
Callable(a chapter or two from here).
Three ways to write one
// 1. Lambda — the modern default
Runnable r1 = () -> System.out.println("hello");
// 2. Method reference — when an existing method has the right signature
Runnable r2 = System.out::flush;
// 3. Anonymous class — pre-Java-8 form, occasionally useful when the body needs fields
Runnable r3 = new Runnable() {
@Override public void run() {
System.out.println("hello");
}
};All three produce an object of type Runnable. The lambda form has been preferred since Java 8; the anonymous-class form is only useful when you need fields of your own (which you usually don't — capture local variables instead).
How Runnable gets used
Three of the main APIs that take Runnable:
new Thread(runnable).start(); // platform thread, dedicated
executor.execute(runnable); // thread pool or virtual thread
Runtime.getRuntime().addShutdownHook(new Thread(runnable)); // JVM shutdownThe same Runnable instance works in all three contexts. That's the design point: the what (the work) and the where (the thread) are orthogonal. You can write code that does the work and someone else can decide what to run it on.
The contrast with the subclass-Thread form makes this concrete:
// Coupled: this work can only run on its own dedicated platform thread.
class ImageResizer extends Thread {
@Override public void run() { resize(); }
}
new ImageResizer().start();
// Decoupled: the same body runs anywhere.
Runnable resize = this::resize;
new Thread(resize).start(); // dedicated thread
executor.execute(resize); // pool
virtualExecutor.execute(resize); // virtual threadThe decoupled form is why production Java is full of Runnable (and Callable) and almost never has a class that extends Thread.
Captured variables must be effectively final
A lambda that becomes a Runnable can read local variables from the enclosing method, but only ones the compiler can prove are effectively final — assigned exactly once and never reassigned:
String name = "alice";
int n = 3;
Runnable r = () -> {
for (int i = 0; i < n; i++) {
System.out.println(name + " " + i);
}
};
// n = 4; // would break the lambda above — compile errorIf you need shared mutable state, you can't use a captured local — you need a field, an AtomicInteger, an array slot, or another object whose internals are mutable. The restriction is intentional: lambdas capture values, not aliases, and forbidding reassignment is the simplest rule that makes that consistent.
The most common workaround is the one-element array:
int[] counter = {0};
Runnable r = () -> counter[0]++; // works; the array reference is final, the int inside isn'tBut for thread-safe shared counters, an AtomicInteger is the right call — we'll see why a few chapters from here.
Exception handling: nothing to catch, nothing to recover
run() throws no checked exceptions. If your worker can fail with a checked exception, you have to catch it inside run():
Runnable parseFile = () -> {
try {
Files.readAllLines(path);
} catch (IOException e) {
log.error("parse failed", e); // you HAVE to handle it here
}
};For unchecked exceptions, the situation is worse: nothing in the calling code catches them. If your Runnable throws NullPointerException on a separate thread, the exception goes to that thread's uncaught-exception handler and the thread dies. The main thread doesn't know.
Two ways to deal with this:
- Catch everything inside
run()and log it yourself. Crude but reliable. - Use
CallableandFuture.get(). TheFuturere-throws the exception on the thread that calledget(). This is what the executor framework gives you.
For one-off work, option 1 is fine; for anything that produces a result the caller needs, option 2 is the right answer.
Runnable vs. Callable
A side-by-side comparison of the two task interfaces — you'll meet Callable properly later, but the contrast is useful now:
Runnable | Callable<V> | |
|---|---|---|
| Method | void run() | V call() throws Exception |
| Return value | None | Typed result V |
| Checked exceptions | Cannot throw | Can throw any Exception |
| Accepted by | new Thread, Executor.execute, shutdown hooks | ExecutorService.submit |
| Result handle | None (fire and forget) | Future<V> |
Whenever you need either a return value or the ability to throw checked exceptions, switch to Callable. For pure side-effect work — flushing, logging, scheduling — Runnable is the lighter tool.
A worked example: same Runnable, three runners
The program below defines one Runnable that does a small piece of work, then runs the same instance on (a) a brand-new platform thread, (b) an ExecutorService, and (c) the calling thread via direct .run(). The same body executes in all three contexts; the only thing that changes is the runner.
What to take from the run:
- The first three blocks ran the same
greetinstance in three different runners — direct call, dedicated thread, thread pool. The thread name printed bygreetchanged each time:main,dedicated-worker,pool-1-thread-1. That's the whole reason to preferRunnableover subclassingThread: the work is reusable, the runner is pluggable. - The
crashythread'sRuntimeExceptiondid not killmain. It died on its own thread and the uncaught-exception handler reported it. Without a handler, the JVM prints a stack trace to stderr and the rest of the program keeps running — which is often worse, because the work the thread was supposed to do silently didn't happen. - The
shoutlambda capturednameandnfrommain's locals. They're effectively final — assigned once, never reassigned. Addn = 4;anywhere after the lambda is defined and the file stops compiling. That restriction is what makes lambda-capture safe across threads. - The
bumpexample usedAtomicIntegerbecause two threads were incrementing the same counter. With a plainintfield, the final value would have been somewhere between1000and2000— lost updates from non-atomici++.incrementAndGet()is the simplest fix and we'll come back to it in the atomics chapter. - The single shared
Runnableinstance was passed tonew Thread(bump, "a")andnew Thread(bump, "b")— the same lambda ran on two threads simultaneously. The lambda has no fields of its own; everything it touches lives outside it. That's the shape of every safe parallelRunnable: do as little internal state as possible, push the state into a thread-safe object the threads share.
What's next
The next chapter, Java Thread Lifecycle, walks the six Thread.State values — NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED — and shows how to read a thread dump that exposes them.
Practice
Which statement about `Runnable` is true?