Threads and Executors

Learning goals

By the end of this chapter you should be able to:

  • Create and start threads and understand the Runnable / Callable split
  • Use ExecutorService to pool threads and submit tasks
  • Shut down executors cleanly with shutdown and awaitTermination
  • Choose between platform threads and virtual threads (preview of next chapter)

Why concurrency?

Modern servers handle many requests at once. One thread blocked on IO should not force you to spawn an OS thread per socket at massive scale — but you still need a model for parallel work and background tasks.

ExecutorService pool = Executors.newFixedThreadPool(4);

Future<Integer> future = pool.submit(() -> heavyCompute(42));
int result = future.get();  // blocks until done
pool.shutdown();

This chapter covers platform threads and executors — the baseline before synchronization and virtual threads.


Thread basics

A thread is a sequential flow of execution sharing process memory (heap) but with its own stack.

Thread t = Thread.ofPlatform().name("worker").start(() -> {
    System.out.println(Thread.currentThread().getName());
});
t.join();  // wait for completion

Legacy style still appears in older code:

new Thread(() -> task()).start();

Prefer ExecutorService over raw Thread sprawl — pooling and lifecycle management are built in.


Runnable vs Callable

Runnable job = () -> log.info("no return value");

Callable<String> task = () -> {
    return fetchFromDb();
};

Runnable — no return, no checked exceptions in signature.

Callable<V> — returns V, may throw Exception; submit via executor → Future<V>.


ExecutorService patterns

try (ExecutorService exec = Executors.newFixedThreadPool(8)) {
    List<Callable<Path>> jobs = files.stream()
        .map(f -> (Callable<Path>) () -> process(f))
        .toList();

    List<Future<Path>> futures = exec.invokeAll(jobs);

    for (Future<Path> f : futures) {
        Path done = f.get();  // unwrap; ExecutionException wraps task failure
    }
}

Common factory methods:

Factory Use
newFixedThreadPool(n) bounded worker pool
newCachedThreadPool() unbounded growth — risky under load
newSingleThreadExecutor() sequential tasks, ordered queue
newVirtualThreadPerTaskExecutor() one virtual thread per task (Java 21+)

Future and CompletableFuture (intro)

Future.get() blocks. Timeout variant:

String value = future.get(2, TimeUnit.SECONDS);

CompletableFuture composes async pipelines (Java 8+):

CompletableFuture.supplyAsync(() -> fetchUser(id))
    .thenApply(User::email)
    .thenAccept(System.out::println);

Default supplyAsync uses ForkJoinPool.commonPool() — understand that shared resource.


Graceful shutdown

pool.shutdown();  // no new tasks
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
    pool.shutdownNow();  // interrupt running
}

Failing to shutdown thread pools prevents JVM exit in small apps and leaks threads in redeployed containers.


Thread factories and naming

Named threads simplify logs and thread dumps:

ThreadFactory factory = Thread.ofPlatform()
    .name("worker-", 0)
    .factory();

ExecutorService exec = Executors.newFixedThreadPool(4, factory);

Common mistakes

  1. Unbounded newCachedThreadPool under bursty load — creates unlimited threads → OOM or OS limit.
  2. Calling get() on the event/request thread without timeout — stalls latency.
  3. Sharing mutable objects without synchronization — race conditions (next chapter).
  4. Ignoring ExecutionException — real failure is getCause().
  5. Not shutting down executor on app stop — leaked threads.

Practice checkpoint

  1. Submit 10 tasks and wait for all — which method? Answer: invokeAll or submit loop + get on each Future.

  2. Difference between execute(Runnable) and submit(Callable)? Answer: execute void fire-and-forget; submit returns Future with result/exception.

  3. Why avoid creating a new Thread per request in a server? Answer: OS thread cost and context switching; use pools or virtual threads.

  4. After shutdownNow(), are queued tasks guaranteed to run? Answer: no — they are returned as cancelled; running tasks get interrupt.


Interview angles

  • Platform thread cost: ~1 MB stack default, kernel scheduling — motivates virtual threads.
  • Executor vs manual threads: separation of task submission from thread creation.
  • ForkJoinPool.commonPool: shared by parallel streams and default async CF — contention risk.
  • Daemon threads: JVM exits when only daemons remain — use for background housekeeping sparingly.

Next: /java/learn/concurrency/synchronization-and-visibility/