Module 9 · Modern Java: Functional Style & Concurrency ⏱ 19 min

Threads and Executors (Concepts)

By the end of this lesson you will be able to:
  • Create a Thread and a Runnable task
  • Explain why thread pools are preferred over raw threads
  • Predict why output order is not guaranteed when multiple threads run

Modern programs rarely do one thing at a time. A web server handles thousands of connections simultaneously; a desktop application keeps the interface responsive while it saves a file in the background. In Java, the unit of concurrent work is the thread — an independent path of execution inside the same program.

Creating a thread in Java means giving the JVM a new stack, a new program counter, and a new set of local variables that run alongside the main thread. The operating system scheduler decides when each thread runs and for how long, which means threads are genuinely concurrent on multi-core machines and interleaved on single-core ones. That scheduling decision is outside your control — and that lack of control is the first thing every concurrent programmer must accept.

flowchart LR
  S["Single thread<br/>A then B then C"] -->|"sequential"| SEQ["predictable order"]
  M["Multiple threads"] --> T1["Thread 1<br/>A C B"]
  M --> T2["Thread 2<br/>B A C"]
  T1 -->|"scheduler decides"| UN["order not guaranteed"]
  T2 -->|"scheduler decides"| UN
  style M fill:#c2410c,color:#fff
  style UN fill:#b45309,color:#fff
A single thread executes sequentially; multiple threads interleave under the scheduler's control.

Thread and Runnable

There are two ways to create a thread. The first is to extend java.lang.Thread and override its run() method. The second — preferred — is to implement java.lang.Runnable and hand it to a Thread constructor. Runnable is a functional interface with a single void run() method, so you can also supply a lambda.

Implementing Runnable is preferred over extending Thread because Java does not support multiple inheritance. If your class extends Thread, it cannot extend anything else. A Runnable is just a task description; it separates what to run from how to run it, which is why executors accept Runnable directly.

The crucial method is start(), not run(). Calling run() directly is just a normal method call on the current thread — nothing becomes concurrent. Calling start() asks the JVM to schedule the new thread independently, and its run() method will execute later, in parallel or interleaved with whatever the caller does next.

A thread moves through states: NEW after creation, RUNNABLE after start(), RUNNING when the scheduler picks it, TERMINATED when run() finishes. You cannot restart a terminated thread; doing so throws IllegalThreadStateException.

Main.java — creating and starting two threads. Real, compiling Java.
public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            System.out.println("Running in: " + Thread.currentThread().getName());
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();
    }
}

The problem with raw threads

Creating a thread is expensive. The JVM must ask the operating system for resources, allocate a stack (typically one megabyte), and bookkeep the thread in the scheduler. If your server creates a fresh thread for every incoming request, it will spend more time managing threads than serving requests, and it will eventually run out of memory.

Worse, there is no limit. A thousand simultaneous requests create a thousand threads, and the OS grinds to a halt swapping stacks in and out. You also lose control over prioritisation, cancellation, and gathering results. Raw threads are a low-level tool; production code needs a thread pool — a fixed group of worker threads that recycle themselves.

flowchart TD
  R["Raw threads<br/>1 task = 1 thread"] -->|"expensive"| C["create destroy<br/>create destroy"]
  P["Thread pool<br/>fixed workers"] -->|"reuse"| Q["Task 1 to Worker A"]
  P -->|"reuse"| W["Task 2 to Worker B"]
  P -->|"queue"| QU["Task 3 waits<br/>then reuses A"]
  style R fill:#b45309,color:#fff
  style P fill:#3776ab,color:#fff
A thread pool reuses a fixed set of worker threads instead of creating and destroying one per task.

Executors and thread pools

java.util.concurrent.Executors is a factory class that builds thread pools for common situations. Executors.newFixedThreadPool(4) gives you a pool with exactly four threads; Executors.newCachedThreadPool() creates threads on demand but reuses idle ones; Executors.newSingleThreadExecutor() guarantees tasks run sequentially on one thread.

The returned ExecutorService accepts tasks via submit(Runnable) or execute(Runnable). submit returns a Future that lets you check completion or wait for a result; execute fires and forgets. The pool stores excess tasks in an internal queue and hands them to workers as threads become free. If all four workers are busy, task five waits — memory and CPU stay bounded.

Choosing the right pool matters. A fixed pool is ideal for steady, predictable workloads because it caps resource usage. A cached pool suits burst workloads where tasks are short-lived and you want responsiveness over bounds. A single-thread executor is useful when you need guaranteed sequential execution without synchronizing manually — every task waits for the previous one to finish.

Main.java — submitting tasks to a fixed thread pool. Real, compiling Java.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(2);

        for (int i = 1; i <= 4; i++) {
            final int taskNum = i;
            pool.submit(() -> {
                System.out.println("Task " + taskNum + " on " + Thread.currentThread().getName());
            });
        }

        pool.shutdown();
    }
}
flowchart LR
  T1["Task 1"] --> W1["Worker 1"]
  T2["Task 2"] --> W2["Worker 2"]
  T3["Task 3"] --> Q["Queue"]
  T4["Task 4"] --> Q
  Q -->|"when free"| W1
  Q -->|"when free"| W2
  style Q fill:#c2410c,color:#fff
  style W1 fill:#3776ab,color:#fff
  style W2 fill:#3776ab,color:#fff
Tasks queue up when all workers are busy, then execute as threads become free.

Shutting down gracefully

An ExecutorService does not stop when your tasks finish — its threads wait for more work. If you forget to shut it down, your program may never exit. Call shutdown() to stop accepting new tasks and let running ones finish. Call shutdownNow() to attempt immediate cancellation, though tasks that ignore interruption may keep running.

Always pair pool creation with shutdown, usually in a try-finally block or using try-with-resources if you wrap the pool in an ExecutorService that implements AutoCloseable. A shutdown pool cannot be restarted; you must build a new one.

A common pattern is to submit all tasks, call shutdown(), then await termination with a timeout: pool.awaitTermination(60, TimeUnit.SECONDS). This gives tasks a fair chance to finish but prevents the program from hanging indefinitely if a task is stuck.

Exercise

What is the difference between calling t.run() and t.start() on a Thread t?

Exercise

Why is creating a new Thread for every incoming request generally a bad idea in production?

Exercise

Two threads both call System.out.println. What can you say about the order of the printed lines?

Exercise

What does ExecutorService.shutdown() do?

Exercise

Which interface should your task implement when you want to hand it to a Thread or ExecutorService?

Recap

  • A thread is an independent path of execution; start() schedules it, while run() is just a normal method call.
  • Runnable is the preferred way to define work; pass it to a Thread or an ExecutorService.
  • Thread pools reuse a fixed set of workers, bounding memory and avoiding the cost of per-task thread creation.
  • ExecutorService accepts tasks via submit() and execute(); always call shutdown() when done.
  • Thread scheduling is non-deterministic: never depend on the order in which threads produce output or finish work.

Next you will learn how to protect shared data when multiple threads access it simultaneously — and the hazards that await when you do not.

Checkpoint quiz

What happens if you call start() twice on the same Thread object?

Which method stops an ExecutorService from accepting new tasks but lets running tasks finish?

Go deeper — technical resources