Stage 8: Concurrency and the JVM, lesson 1 of 7

Threads and ExecutorService

Intermediate3 min read@since 19Code runs on your Java 25
Explain it forThe essentials plus production detail and pitfalls.

A thread is an independent path of execution. You can start one with new Thread(runnable).start(), but real code uses an ExecutorService (Java 5), a managed pool of threads.

  • Runnable: a task with no result.
  • Callable<T>: a task that returns a value and may throw.
  • Future<T>: a handle to a pending result; get() waits for it.

Thread states: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING and TERMINATED.

Always shut executors down. Since Java 19, ExecutorService is AutoCloseable, so try-with-resources does it for you.

Creating threads

Give a Thread a Runnable (often a lambda) and call start(), which runs it on a new thread. Calling run() directly just runs it on the current thread, a classic mistake.

Java
Thread worker = new Thread(() -> System.out.println("Working on " + Thread.currentThread().getName()));
worker.start();          // new thread
// worker.run();         // same thread: not concurrent

Thread vt = Thread.ofVirtual().start(() -> fetchPrices());   // Java 21 virtual thread

Thread states

A thread moves through NEW โ†’ RUNNABLE โ†’ (BLOCKED, WAITING, TIMED_WAITING) โ†’ TERMINATED. A thread dump (jcmd <pid> Thread.print) shows each thread's state, which is how you find stuck or deadlocked code.

sleep, join and interrupt

sleep pauses the current thread; join waits for another thread to finish. interrupt() politely asks a thread to stop: blocking calls then throw InterruptedException. Handle it by stopping, and restore the flag if you can't rethrow.

Java
Thread t = new Thread(() -> {
    try {
        while (true) { doWork(); Thread.sleep(1000); }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();   // keep the flag, then exit
    }
});
t.start();
t.interrupt();
t.join();

Daemon and user threads

The JVM exits when all user threads finish; daemon threads (background helpers) don't keep it alive. Virtual threads are always daemon threads.

ExecutorService and thread pools

Creating threads by hand doesn't scale. An ExecutorService reuses a pool of threads and queues tasks. Always shut it down; since Java 19 it's AutoCloseable, so try-with-resources waits for tasks and closes it.

Java
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
    for (String url : urls) pool.submit(() -> download(url));
}   // waits for all tasks, then shuts down

try (var perTask = Executors.newVirtualThreadPerTaskExecutor()) {
    urls.forEach(u -> perTask.submit(() -> download(u)));
}

Callable and Future

A Callable returns a value (and may throw). Submitting it gives a Future; get() waits for the result, preferably with a timeout. invokeAll runs a batch and waits for all.

Java
Future<Integer> count = pool.submit(() -> countLines(path));
try {
    int lines = count.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    count.cancel(true);
}

Scheduled tasks

A ScheduledExecutorService runs tasks after a delay or repeatedly. In Spring Boot, @Scheduled does the same with less code.

Java
ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor();
timer.scheduleAtFixedRate(this::refreshCache, 0, 10, TimeUnit.MINUTES);

Example

Java
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {     // Java 19+: auto-close
    Future<Integer> price = pool.submit(() -> fetchPrice("SPRING-101"));
    Future<Integer> seats = pool.submit(() -> fetchSeats("SPRING-101"));

    System.out.println(price.get() + " / " + seats.get(2, TimeUnit.SECONDS));
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();       // restore the interrupt flag
} catch (ExecutionException | TimeoutException e) {
    log.error("Lookup failed", e);
}

Common mistake

Calling thread.run() instead of thread.start(). run() executes on the current thread, so nothing happens in parallel.

Under the hood

Platform threads map one-to-one to OS threads and each reserves around 1 MB of stack, which is why pools are sized: roughly the number of cores for CPU-bound work, more for I/O-bound work. newCachedThreadPool can create unbounded threads under load; in production prefer a ThreadPoolExecutor with a bounded queue and a rejection policy. In Spring, configure a ThreadPoolTaskExecutor for @Async.

Check yourself

Which method actually starts a new thread?

How this connects

Part of Crack the Java interview.

Was this lesson helpful?

Finished reading? Mark it complete to track your progress.