Runnable vs Callable in Java: Differences With Examples

Runnable vs Callable in Java explained with examples of return values, checked exceptions, Future, FutureTask, invokeAll and the Java 19 Future methods.

Runnable and Callable submitted to an ExecutorService with their results and exceptions flowing into a Future

The Runnable and Callable interfaces both describe a task for another thread, but Runnable.run() returns nothing and cannot throw checked exceptions, whereas Callable.call() returns a result and may throw any exception. In the Runnable vs Callable choice, the deciding question is whether the caller needs an answer back.

We use a Runnable for fire-and-forget work, such as writing an audit entry or evicting a cache, and a Callable when the task computes or fetches something, such as a shipping quote from a carrier API.

The following example submits one task of each kind to an executor and reads both results through a Future.

List<String> auditLog = new CopyOnWriteArrayList<>();
Runnable audit = () -> auditLog.add("quote requested");
Callable<Integer> quote = () -> 12;

Future<?> logged;
Future<Integer> price;
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    logged = executor.submit(audit);
    price = executor.submit(quote);
}                                                 // close() waits for both tasks
Object nothing = logged.get();                    // null, Runnable has no result
Integer cost = price.get();                       // 12

Notice that both tasks return a Future, but only the Callable fills it with a value. We look at both interfaces, compare them point by point, cover exception handling and the ways to run a Callable without an executor, and finish with a quote service that asks several carriers at once.

1. Introduction

Both interfaces are functional interfaces with one abstract method, so a lambda can implement either. They come from different eras of Java, which explains most of the differences.

1.1. What Runnable Defines

Runnable has been part of java.lang since Java 1.0. We can run its instances on a Thread or submit them to an ExecutorService. The interface contains only one abstract method, void run(), which we override to define the job, and since Java 8 it is annotated as a functional interface.

1.2. What Callable Adds

Callable arrived in Java 5 together with the executor framework in java.util.concurrent. Its single method V call() throws Exception returns a value of the type parameter V and declares throws Exception, so the task can pass both a result and a failure back to the caller.

2. Differences between Runnable and Callable

Four differences matter in practice, and they all follow from the two method signatures. The diagram shows where the result and the exception of each task end up.

Runnable and Callable submitted to an ExecutorService with their results and exceptions flowing into a Future
A Runnable fills its Future with null, a Callable fills it with a value; an exception from either ends up in Future.get() as an ExecutionException
PointRunnableCallable
Package and versionjava.lang, Java 1.0java.util.concurrent, Java 5
Methodvoid run()V call() throws Exception
ResultNoneA value of type V
Checked exceptionsMust be caught inside run()Can be thrown to the caller
Run by new Thread(task)YesNo, wrap it in a FutureTask first
Run by ExecutorServiceexecute() or submit()submit(), invokeAll(), invokeAny()
Result of submit()Future<?> whose get() returns nullFuture<V> whose get() returns the value

2.1. Methods to Override

To use the Runnable interface, we override the run() method. Our example task looks up a shipping rate, so it also needs a place to store the result.

static int quoteFrom(String carrier) throws IOException {
    Integer rate = Map.of("dhl", 12, "ups", 15).get(carrier);
    if (rate == null) {
        throw new IOException(carrier + " is offline");
    }
    return rate;
}
class QuoteRunnable implements Runnable {
    volatile int rate = -1;

    @Override
    public void run() {
        try {
            rate = quoteFrom("dhl");
        } catch (IOException e) {
            rate = -1;
        }
    }
}

To use the Callable interface, we override the call() method. The rate becomes the return value, and the IOException passes through without a try block.

class QuoteCallable implements Callable<Integer> {
    @Override
    public Integer call() throws IOException {
        return quoteFrom("dhl");
    }
}

We can see that the Runnable needs an extra field and has to decide on its own what a failure means, here the value -1. The Callable leaves that decision to the caller.

2.2. Execution Mechanism

Both interfaces describe tasks that another thread executes. A Runnable can be run by a Thread and by an ExecutorService.

QuoteRunnable runnable = new QuoteRunnable();
Thread thread = new Thread(runnable);
thread.start();
thread.join();
int fromThread = runnable.rate;                   // 12

No Thread constructor accepts a Callable, so the usual way is ExecutorService.submit(). The claim that a Callable can only run on an executor is not true, though. A FutureTask wraps a Callable and implements Runnable, so a plain thread can run it, and call() is an ordinary method that we can call on the current thread, for example in a unit test.

Future<Integer> submitted;
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
    submitted = executor.submit(new QuoteCallable());
}
Integer fromExecutor = submitted.get();           // 12

FutureTask<Integer> futureTask = new FutureTask<>(() -> quoteFrom("ups"));
new Thread(futureTask).start();
Integer fromFutureTask = futureTask.get();        // 15

Integer direct = new QuoteCallable().call();      // 12, on the current thread

2.3. Return Types

In Runnable, the return type of run() is void, so the method cannot return any value. Results have to go through a field or a shared object, as in section 2.1.

In Callable, the call() method returns the result itself, of type V. The Future comes from the executor, not from call(). The method submit() returns a Future<V> at once, and its get() method blocks until call() has returned. Since Java 19, the methods state(), resultNow() and exceptionNow() read a finished Future without blocking or checked exceptions.

Future<Integer> quoteFuture;
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    quoteFuture = executor.submit(() -> quoteFrom("ups"));
}
Future.State finished = quoteFuture.state();      // SUCCESS
Integer now = quoteFuture.resultNow();            // 15
Integer timed = quoteFuture.get(1, TimeUnit.SECONDS);   // 15

In real code, get() with a timeout is the safer choice, because a carrier API that never answers would otherwise block the caller forever. More patterns for reading results are in Callable and Future.

2.4. Checked Exceptions

In Runnable, the run() method cannot throw checked exceptions, so we handle them inside run() with a try-catch block, as QuoteRunnable does. A lambda that calls a method declaring IOException does not compile as a Runnable.

// does not compile: unreported exception IOException
Runnable broken = () -> quoteFrom("dhl");

In Callable, the call() method can throw checked exceptions. The executor catches whatever call() throws and stores it in the Future, and Future.get() rethrows it wrapped in an ExecutionException.

Future<Integer> failed;
try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
    failed = executor.submit(() -> quoteFrom("fedex"));
}
boolean done = failed.isDone();                   // true, the task has ended
Future.State state = failed.state();              // FAILED
Throwable cause = failed.exceptionNow();          // java.io.IOException: fedex is offline
Integer value = failed.get();                     // ExecutionException: java.io.IOException: fedex is offline

If nobody calls get() or checks state(), the exception stays inside the Future and is never logged. The same is true for a Runnable passed to submit(), because submit() also captures unchecked exceptions. Only execute(runnable) and a plain Thread let the exception reach the thread’s UncaughtExceptionHandler.

Runnable crashing = () -> {
    throw new IllegalStateException("no carrier");
};
Future<?> crashed;
try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
    crashed = executor.submit(crashing);
}
Future.State crashState = crashed.state();        // FAILED

2.5. Converting a Runnable to a Callable

APIs such as invokeAll() accept only Callable tasks. The helper Executors.callable() adapts a Runnable, either with a null result or with a fixed result we pass in.

Runnable cleanup = () -> {};
Callable<Object> asCallable = Executors.callable(cleanup);
Callable<String> withResult = Executors.callable(cleanup, "done");
Object empty = asCallable.call();                 // null
String fixed = withResult.call();                 // "done"

3. When to Use

Both Runnable and Callable interfaces have their uses, and the executor framework supports both. Runnable has been there for a long time, and it is still the core interface for threads, virtual threads and CompletableFuture.runAsync().

  • Runnable does not return any value, so we use it for fire-and-forget calls, where we are not interested in a result, such as sending a metric or writing an audit entry.
  • Callable can throw exceptions and return values, so it is better for result-bearing tasks, such as fetching a resource from the network or running an expensive calculation.
  • Callable is also the right type when the task calls methods that throw checked exceptions and the caller should decide how to handle them.
  • For chains of async steps, CompletableFuture with supplyAsync() is often a better fit than raw Callable tasks, but its Supplier cannot throw checked exceptions.

The same comparison for the older question of extending Thread is covered in Runnable vs Thread.

4. Fetching Shipping Quotes From Several Carriers

A checkout page shows the cheapest shipping price. The shop asks three carriers in parallel, and each request is a Callable<Integer> that returns the price or throws an IOException when the carrier is down. The method invokeAll() runs all tasks and returns the futures in the same order as the tasks.

List<Callable<Integer>> requests = Stream.of("dhl", "ups", "fedex")
        .map(carrier -> (Callable<Integer>) () -> quoteFrom(carrier))
        .toList();

List<Future<Integer>> answers;
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    answers = executor.invokeAll(requests, 2, TimeUnit.SECONDS);
}
List<Integer> prices = answers.stream()
        .filter(f -> f.state() == Future.State.SUCCESS)
        .map(Future::resultNow)
        .toList();
int cheapest = Collections.min(prices);           // 12

The offline carrier does not break the page, because its Future has the state FAILED and the filter skips it. The timeout version of invokeAll() cancels any request still running after two seconds. Timeouts, cancellation and the related invokeAny() are covered in the invokeAll() guide, and the other executor tutorials are grouped in the Java concurrency overview.

5. Runnable and Callable FAQs

Once both interfaces are clear, the doubts move on to plain threads, speed, virtual threads and the look-alike Supplier interface.

5.1. Can we pass a Callable to a Thread?

No, not as it is, because every Thread constructor takes a Runnable. A FutureTask closes the gap, because it wraps the Callable and implements Runnable, so the thread accepts it and futureTask.get() returns the result.

5.2. Is Callable faster than Runnable?

No. Both are plain interfaces, and the cost of a task depends on the code inside it and on the thread that runs it. A Callable submitted to an executor creates a Future, but so does a Runnable passed to submit().

5.3. What is the difference between Callable and Supplier?

Both return a value, but Callable.call() can throw checked exceptions and belongs to the executor API, whereas Supplier.get() cannot and belongs to java.util.function. Executors accept Callable, and CompletableFuture.supplyAsync() accepts Supplier.

5.4. Can Runnable return a value in Java?

No, run() is void. A Runnable can store a result in a field or a thread-safe collection, or we can use submit(runnable, result), which returns a Future holding a fixed value. If the task computes the value, use a Callable.

5.5. Does a Callable run on a virtual thread?

Yes. Executors.newVirtualThreadPerTaskExecutor() accepts Callable tasks like any other executor and runs each one on a new virtual thread.

6. Conclusion

A Runnable is a task with no result and no checked exceptions, and a Callable is a task that returns a value or throws. Both run on executors and both produce a Future when submitted, but only the Callable fills it with something useful.

Choose Runnable for fire-and-forget work and Callable when the caller needs an answer or must handle the failure. Read every Future you create, preferably with a timeout or through state() and resultNow(), so exceptions do not disappear.

7. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.