W3docs

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 shutdown

The 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 thread

The 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 error

If 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't

But 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:

  1. Catch everything inside run() and log it yourself. Crude but reliable.
  2. Use Callable and Future.get(). The Future re-throws the exception on the thread that called get(). 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:

RunnableCallable<V>
Methodvoid run()V call() throws Exception
Return valueNoneTyped result V
Checked exceptionsCannot throwCan throw any Exception
Accepted bynew Thread, Executor.execute, shutdown hooksExecutorService.submit
Result handleNone (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.

java— editable, runs on the server

What to take from the run:

  • The first three blocks ran the same greet instance in three different runners — direct call, dedicated thread, thread pool. The thread name printed by greet changed each time: main, dedicated-worker, pool-1-thread-1. That's the whole reason to prefer Runnable over subclassing Thread: the work is reusable, the runner is pluggable.
  • The crashy thread's RuntimeException did not kill main. 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 shout lambda captured name and n from main's locals. They're effectively final — assigned once, never reassigned. Add n = 4; anywhere after the lambda is defined and the file stops compiling. That restriction is what makes lambda-capture safe across threads.
  • The bump example used AtomicInteger because two threads were incrementing the same counter. With a plain int field, the final value would have been somewhere between 1000 and 2000 — lost updates from non-atomic i++. incrementAndGet() is the simplest fix and we'll come back to it in the atomics chapter.
  • The single shared Runnable instance was passed to new Thread(bump, "a") and new 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 parallel Runnable: 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

Practice

Which statement about `Runnable` is true?