Hook: Interviewers love this one because it looks tiny, but it reveals whether you understand how Java tasks actually move through a thread pool.
Question: What is the difference between Runnable and Callable in Java?
Answer: Runnable is for work that does not return a value and cannot throw checked exceptions from run(). Callable<T> is for work that returns a value of type T and can throw checked exceptions from call().
Interview-Ready Answer: I use Runnable when I just need to run a task and do not care about a return value, like logging or updating a cache. I use Callable when the task must produce a result or may fail with a checked exception, like reading a file or computing a report. In practice, both are often submitted to an ExecutorService, but only Callable gives me a typed result directly through Future.get().
Detailed Explanation: Think of both as packaged units of work for background execution. The key difference is simple: Runnable is a one-way task, while Callable is a task that brings something back.
ExecutorService.Runnable.run() or Callable.call().Callable, its return value is stored for later retrieval.submit() and exposes it through Future.get().execute() with a Runnable, uncaught runtime exceptions go to the thread's uncaught exception handler instead of coming back to the caller.| Feature | Runnable | Callable |
|---|---|---|
| Method | run() | call() |
| Return value | None | Yes, typed T |
| Checked exceptions | Cannot declare them | Can throw Exception |
| Executor result | Usually Future<?> or none | Future<T> |
| Typical use | Fire-and-forget work | Compute and return |
Runnable for side effects only: sending metrics, clearing a cache, printing logs, or notifying another service.Callable for computation: database lookups, file reads, report generation, or any task where the caller needs the answer.Callable to an executor and call Future.get() later.With thread pools, your task does not create a brand-new thread every time. The pool queues the task and a worker thread pulls it later. The interface itself is just the contract; the executor decides when and where to run it. That is why the interface choice mainly affects behavior at the boundary: return type, exception handling, and how the caller receives completion.
Internally, executors often use FutureTask, which implements both Runnable and Future. That is the bridge: it lets the pool run the task like a Runnable while still remembering the result like a Future.
The choice between Runnable and Callable has effectively O(1) overhead. The real cost is the task itself and thread scheduling. Future.get() is O(1) if already complete, but it blocks if the task is still running. In real systems, the pool size matters far more: a common starting point for CPU-bound work is around the number of available processors, while I/O-bound work often uses a larger pool because threads spend time waiting.
Runnable submitted with submit() gives you a Future<?>, but get() returns null unless you used the overload that supplies a fixed result.Callable can return null; that is still a valid result.execute() versus submit().Runnable is simpler and clearer.Memory hook: Runnable runs; Callable calls back with a value.
Real-World Example: Imagine a checkout service in an e-commerce platform. One background task writes an audit log, which is a perfect Runnable. Another task calculates the final shipping price, which should be a Callable<BigDecimal> because the checkout flow needs the value before it can charge the customer.
What goes wrong when a team confuses them? A developer uses Runnable for the price calculation and stores the result in a shared field instead of returning it. Under load, two orders overlap, one thread overwrites the other, and the wrong total gets charged. The symptoms are messy: customers see mismatched totals, logs show background threads doing work, and the API returns success because the request thread never waited for a real result. If the task throws an exception, it may only appear in worker-thread logs, which makes the bug feel random and hard to reproduce.
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class RunnableVsCallableDemo {
public static void main(String[] args) throws InterruptedException {
// Custom thread factory helps us show where uncaught exceptions go when using execute().
ThreadFactory factory = new ThreadFactory() {
private final AtomicInteger count = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "demo-worker-" + count.getAndIncrement());
t.setUncaughtExceptionHandler((thread, ex) ->
System.out.println("Uncaught in " + thread.getName() + ": " + ex.getClass().getSimpleName() + " - " + ex.getMessage()));
return t;
}
};
ExecutorService executor = Executors.newFixedThreadPool(2, factory);
// Runnable: side effect only. No return value.
Runnable cacheWarmup = () -> System.out.println("Runnable: warming cache");
// Callable: returns a value and may throw a checked exception.
Callable<String> buildReceipt = () -> {
int orderId = 42;
if (orderId <= 0) {
throw new Exception("Invalid order id");
}
return "RECEIPT-" + orderId;
};
// Runnable submitted through submit() gives Future<?>; get() returns null.
Future<?> runnableFuture = executor.submit(cacheWarmup);
try {
System.out.println("Runnable future result: " + runnableFuture.get());
} catch (ExecutionException e) {
System.out.println("Unexpected Runnable failure: " + e.getCause());
}
// Callable submitted through submit() gives a typed result.
Future<String> callableFuture = executor.submit(buildReceipt);
try {
System.out.println("Callable result: " + callableFuture.get());
} catch (ExecutionException e) {
System.out.println("Callable failed: " + e.getCause());
}
// Failure path: submit() captures the exception and rethrows it from Future.get().
Future<?> failingFuture = executor.submit(() -> {
throw new IllegalStateException("Boom inside submitted Runnable");
});
try {
failingFuture.get();
} catch (ExecutionException e) {
System.out.println("Exception observed through Future.get(): " + e.getCause());
}
// execute() does not give a Future. Uncaught runtime exceptions go to the thread handler.
executor.execute(() -> {
throw new RuntimeException("Boom from execute()");
});
// Give the background task a moment to print before shutdown.
executor.shutdown();
if (!executor.awaitTermination(2, TimeUnit.SECONDS)) {
System.out.println("Timed out waiting for tasks to finish");
executor.shutdownNow();
}
}
}Follow-up & Tricky Questions:
execute() and submit()? execute() is fire-and-forget and returns nothing, while submit() returns a Future so you can observe completion, result, or failure.submit(), exceptions are captured and rethrown as ExecutionException from Future.get(). With execute(), an uncaught runtime exception goes to the thread's uncaught exception handler.Runnable return a value? Not directly. You can only give it an external place to write to, or use submit(Runnable, result) to attach a fixed result value.Callable generic? The type parameter lets the compiler know exactly what result comes back, so Future<String> is type-safe and does not need casting.CompletableFuture instead? When you need chaining, combining tasks, or non-blocking composition. Runnable and Callable are task contracts; CompletableFuture is a richer async workflow tool.Callable.call() have to be used with an executor? No, you can call it directly, but it is mainly useful when paired with a thread pool or Future.submit(Runnable) throw the task exception immediately? No. The call usually returns right away; the failure is stored and shows up later when you call get().return always a Runnable? Usually yes in context, because Callable must return a value. But the target type matters: Java chooses the functional interface based on the method you pass it to.null a valid Callable result? Yes. Callable<T> may return null; that still counts as a successful completion.Common Mistakes:
Runnable and Callable are interchangeable. Correction: they solve different problems; one returns nothing, the other returns a typed value.Runnable cannot declare checked exceptions. Correction: if your task may fail with checked exceptions, Callable fits better.submit() and execute() behave the same. Correction: submit() gives you a Future; execute() does not.Runnable. Correction: return a value from Callable or use proper synchronization if shared state is unavoidable.Memory Hook: Runnable is for run-only work; Callable is for call-back-with-a-result work.
Cheat Sheet:
Runnable.run() returns void.Callable.call() returns T.Callable can throw checked exceptions; Runnable cannot.submit() returns a Future; execute() returns nothing.Future.get() blocks until done and may throw ExecutionException.Runnable for side effects, Callable for results.Practice Tasks:
Runnable that logs the current time, submit it, and confirm Future.get() returns null.Callable<Integer> that counts words in a string and returns the count through Future.get().Callable to throw an exception for invalid input and observe how ExecutionException wraps the cause.