Back to Blog
Java

Java Callable vs Runnable: Choosing the Right Task Interface

java callable vs runnable: Compare Java's Runnable and Callable interfaces for concurrent tasks, including return values, exception handling, ExecutorService usage, an...

CallableRunnableConcurrencyExecutorServiceFuture
Diagram comparing Java Runnable and Callable interfaces showing return value and exception handling differences

Runnable has been part of Java since version 1.0. Callable arrived in Java 5 with the java.util.concurrent package. When you are choosing between java callable vs runnable for a concurrent task, two differences decide the outcome: whether the task must return a result, and whether it must propagate checked exceptions.

What Runnable Provides

Runnable defines a single abstract method, run(), that takes no arguments and returns no value:

@FunctionalInterface public interface Runnable { void run(); }

Because Runnable is a functional interface, you can implement it with a lambda expression. The simplest use case is a task that performs work without producing a result:

Runnable task = () -> { System.out.println("Processing batch " + batchId); };

The run() method cannot throw checked exceptions. If the task encounters an error that requires a checked exception, you must catch it inside run() and handle it locally. This constraint shapes how Runnable-based tasks report failures.

What Callable Adds

Callable's single abstract method, call(), returns a value and is permitted to throw checked exceptions:

@FunctionalInterface public interface Callable<V> { V call() throws Exception; }

The generic type parameter V is the type of the result. A Callable that computes a sum of integers would be declared as Callable<Integer>:

Callable<Integer> sumTask = () -> { int total = 0; for (int value : values) { total += value; } return total; };

The ability to return a value is the most visible difference between the two interfaces, but the exception handling difference is just as important for real code.

Submitting Tasks to an ExecutorService

Both Runnable and Callable can be submitted to an ExecutorService, but the return type of submit() differs. When you submit a Runnable, the Future you receive has no meaningful result:

ExecutorService executor = Executors.newFixedThreadPool(4); Future<?> runnableFuture = executor.submit(() -> { // work that produces no result }); Future<Integer> callableFuture = executor.submit(() -> { // work that produces a result return 42; });

The Future<?> returned for a Runnable task resolves to null when the task completes. The Future<Integer> returned for a Callable task holds the actual computed value.

The ExecutorService also offers an invokeAll() method that accepts a collection of Callable tasks and returns a list of Futures. There is no equivalent for Runnable because there would be no result to collect.

Retrieving Results with Future

The Future.get() method blocks until the task completes and then returns the result. For a Callable, this is how you obtain the computed value:

Future<Integer> future = executor.submit(() -> 21 * 2); Integer result = future.get(); // blocks until the task finishes

If the task takes longer than you want to wait, get(long timeout, TimeUnit unit) limits the blocking time. If the timeout expires, a TimeoutException is thrown and you can decide whether to cancel the task.

The Future also exposes isDone() for polling and cancel(boolean mayInterruptIfRunning) for attempting to stop a task. These methods work for both Runnable and Callable submissions, but they are more useful when the task produces a result you actually need.

Exception Handling Differences

The exception behavior is where Runnable and Callable diverge most sharply in practice.

A Runnable's run() method cannot declare checked exceptions. If the underlying operation throws one, you must catch it inside the method. An unchecked exception, such as a RuntimeException, will propagate to the thread's uncaught exception handler if the task runs on its own thread, or it will be captured by the Future if the task was submitted to an executor.

A Callable's call() method can throw any checked exception. When you retrieve the result with Future.get(), that checked exception is wrapped in an ExecutionException:

Callable<Integer> riskyTask = () -> { if (condition) { throw new IOException("source unavailable"); } return 1; }; // When submitted to an executor: Future<Integer> future = executor.submit(riskyTask); try { Integer value = future.get(); } catch (ExecutionException e) { Throwable cause = e.getCause(); // the IOException } catch (InterruptedException e) { Thread.currentThread().interrupt(); }

The ExecutionException wraps the original exception, so you can inspect getCause() to recover the underlying failure. This makes Callable the better choice when a task can fail with a checked exception that the caller needs to handle.

Choosing Between Runnable and Callable

The decision comes down to two questions: does the task need to return a value, and does the task need to propagate checked exceptions?

AspectRunnableCallable
Method signaturevoid run()V call() throws Exception
Return valueNoneGeneric type V
Checked exceptionsCannot throwCan throw
IntroducedJava 1.0Java 5
Best forFire-and-forget tasksTasks that produce results

Use Runnable when the task performs an action with no result that the caller must consume. Logging, sending a notification, or updating a cache are all examples where Runnable is the natural fit.

Use Callable when the task computes a value that the caller needs, or when the task may fail with a checked exception that should be surfaced to the caller. Database queries, remote API calls, and file parsing all fit this pattern.

There is also a practical middle ground. If you have a Runnable that needs to communicate a result, you can capture the result in a mutable field and read it after the task completes. This works but is awkward and not thread-safe without additional synchronization. Callable removes that need entirely.

Thread Pool and Runtime Considerations

Both interfaces have essentially the same runtime cost when submitted to an executor. The overhead of the Future wrapper is small, and the thread pool treats Runnable and Callable tasks identically in terms of scheduling. The real cost difference comes from how you use the result.

Calling Future.get() blocks the calling thread until the task completes. If you submit many Callable tasks and call get() on each one in order, you may be waiting for the slowest task while faster tasks sit idle in the queue. This is a common source of unnecessary latency in concurrent code.

One way to avoid this is to submit all tasks first and then retrieve results only after all tasks have been submitted. Another is to use invokeAll(), which waits for all tasks to complete before returning the list of Futures. For more advanced cases, CompletionService lets you consume results in the order they finish, rather than the order they were submitted.

The thread pool size matters for both interfaces equally. A pool that is too small will queue tasks and increase latency; a pool that is too large will waste memory on idle threads. The choice between Runnable and Callable does not change these sizing considerations.

java callable vs runnable: Practical Usage and Code Examples | RYUSLOG DEV