skip to content

What is the difference between Executor.execute(Runnable) and ExecutorService.submit(...)? Pay special attention to return values and exception handling.

level: middleimportance: must knowfreq 80%

answer

  1. execute → void, fire-and-forget, exception → UncaughtExceptionHandler
  2. submit → Future, exception captured, re-thrown at get() as ExecutionException
  3. submit accepts Runnable AND Callable
  4. Pitfall: never calling get() swallows the exception
  5. submit wraps task in FutureTask, then calls execute internally

basics

~20 s

execute runs a Runnable and returns nothing (fire-and-forget); if it throws, the exception goes to the thread's uncaught-exception handler. submit accepts a Runnable or Callable and returns a Future you can wait on; any exception is captured inside that Future and re-thrown only when you call get().

solid answer

~50 s

execute(Runnable) is the base Executor method: fire-and-forget, returns void. If the task throws an unchecked exception, it propagates to the worker thread and is delivered to that thread's UncaughtExceptionHandler (default: prints the stack trace) — you have no handle to observe it. submit(...) is on ExecutorService; it accepts a Runnable or a Callable<T> and returns a Future<T>. The crucial difference for errors: submit wraps the task so any thrown exception is captured and stored in the Future, not delivered to the uncaught handler. You only see it when you call future.get(), which throws ExecutionException wrapping the original cause. This means a swallowed exception is a classic submit pitfall: if you never call get(), the failure is silently lost. So: use execute for true fire-and-forget where a logging uncaught handler suffices; use submit when you need the result, want to wait for/cancel the task, or must not lose exceptions — but then you must actually inspect the Future.

code

java · 17 lines
java
ExecutorService es = Executors.newFixedThreadPool(2);

// execute: fire-and-forget. Exception -> UncaughtExceptionHandler (printed by default).
es.execute(() -> { throw new RuntimeException("seen in stderr"); });

// submit: exception captured in the Future, surfaces only at get().
Future<Integer> f = es.submit(() -> 10 / 0); // ArithmeticException
try {
    Integer r = f.get();                 // blocks; rethrows as ExecutionException
} catch (ExecutionException e) {
    Throwable cause = e.getCause();      // the ArithmeticException
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();  // restore interrupt status
}

// Pitfall: ignoring the Future swallows the failure entirely.
es.submit(() -> { throw new IllegalStateException("silently lost"); });

go deeper

for a junior

Knows execute returns nothing and submit returns a Future you can call get() on to wait for a result.

for a middle

Explains that submit captures the task's exception in the Future (surfaced at get() as ExecutionException) whereas execute sends it to the uncaught handler, and warns about the swallowed-exception pitfall.

for a senior

Discusses overloads (Runnable+result, Callable), get()'s ExecutionException vs InterruptedException distinction, and chooses execute vs submit by monitoring/result needs.

for a principal

Reasons about failure-visibility as an observability concern across a system, mandating Future inspection or wrapping policies, and understands submit is layered on execute via FutureTask.

## Two ways to hand a task to an executor Both methods exist to run a task, but they belong to different interfaces and behave very differently around **return values** and **exceptions**. ### Definitions first - **Runnable** — a task with `void run()`: no result, cannot throw checked exceptions. - **Callable<T>** — a task with `T call() throws Exception`: returns a value of type `T` and *may* throw checked exceptions. - **Future<T>** — a handle to a result that will be available later. You can `get()` (block until done, returning the value or throwing), `cancel(...)`, `isDone()`, `isCancelled()`. - **UncaughtExceptionHandler** — a per-thread hook the JVM calls when a thread dies because an exception escaped its `run()`. The default handler prints the stack trace to stderr. ### execute(Runnable) — fire-and-forget `execute` is declared on the base `Executor` interface: ```java void execute(Runnable command); ``` - **Return value:** none (`void`). You get no handle to the task — you cannot wait for it, cancel it, or read a result. - **Exceptions:** if `run()` throws an unchecked (runtime) exception, it propagates up the **worker thread** and is delivered to that thread's `UncaughtExceptionHandler`. By default this just prints the stack trace; the worker thread (in a `ThreadPoolExecutor`) is then replaced by a fresh one. The submitting code never learns about the failure. ### submit(...) — result-bearing `submit` is declared on the `ExecutorService` subinterface, with three overloads: ```java <T> Future<T> submit(Callable<T> task); <T> Future<T> submit(Runnable task, T result); // returns the given result on success Future<?> submit(Runnable task); // Future's get() returns null ``` - **Return value:** a `Future`. You can block on `get()` for the result, poll `isDone()`, or `cancel()` the task. - **Exceptions — the key behavioral difference:** `submit` internally wraps your task in a `FutureTask`. When the task throws, the exception is **caught and stored inside the Future** rather than reaching the uncaught-exception handler. It surfaces **only** when someone calls `future.get()`, which throws an **ExecutionException** whose `getCause()` is the original exception. If the task was a `Callable` that threw a checked exception, that too is wrapped in `ExecutionException`. ### The classic pitfall: swallowed exceptions Because `submit` *captures* exceptions in the Future, **if you never call get(), the exception is silently lost** — no stack trace, no handler, nothing in your logs. This is one of the most common concurrency bugs: ```java executorService.submit(() -> { throw new RuntimeException("boom"); }); // nobody calls get() → "boom" vanishes silently ``` With `execute`, the same `boom` would at least hit the uncaught handler and print. So the rule of thumb: **if you submit, you must inspect the Future** (call `get()`, or attach error handling), otherwise you lose failures. ### get() and its checked exceptions ```java Future<Integer> f = es.submit(() -> 10 / 0); try { Integer r = f.get(); // blocks until done } catch (ExecutionException e) { // task threw Throwable cause = e.getCause(); // the ArithmeticException } catch (InterruptedException e) { // this thread was interrupted while waiting Thread.currentThread().interrupt(); } ``` `get()` declares two checked exceptions: `ExecutionException` (the task failed — unwrap `getCause()`) and `InterruptedException` (the *waiting* thread was interrupted, unrelated to the task itself). ### When to use which - **execute** — genuine fire-and-forget where you don't need a result and a logging uncaught handler is enough to notice failures (e.g. background metrics flush). - **submit** — you need the result, want to cancel/await the task, or must not lose its exceptions. Always handle the returned Future. ### Under the hood In `ThreadPoolExecutor`, `submit(...)` simply wraps the task in a `FutureTask` (which is itself a `Runnable`) and calls `execute` on it. So `submit` is built on top of `execute`; the exception-capturing behavior comes from `FutureTask` catching the throwable and storing it for `get()`.

  • If a task submitted via submit() throws, where does the exception end up and when do you see it?
    It is caught by the FutureTask wrapper and stored in the Future. You see it only when you call future.get(), which throws ExecutionException wrapping the original cause. If you never call get(), it is silently lost — never reaching the uncaught-exception handler.
  • Why might execute() be safer than submit() for an unmonitored background task?
    Because an unchecked exception from an execute task reaches the thread's UncaughtExceptionHandler and is logged/printed by default, so a failure is at least visible. A submitted task whose Future is ignored swallows the exception entirely.

saying these in an interview costs you the question

  • Saying submit throws the task's exception immediately — it stores it; it only surfaces at get().
  • Believing execute exceptions are swallowed — they go to the uncaught handler (printed by default), the opposite of submit.
  • Thinking get() throws the original exception type — it throws ExecutionException; the original is getCause().
  • Forgetting that not calling get() on a submitted task silently loses failures.
  • Claiming execute can take a Callable — it only takes Runnable.

context