Skip to main content
Java intermediate Lesson 35 of 58

Multithreading in Java

Learn Java concurrency — creating threads, the Runnable interface, thread lifecycle, synchronization, the Executor framework, and CompletableFuture.

Multithreading lets your program do multiple things at once — serving HTTP requests while writing to a database, downloading files in parallel, or keeping a UI responsive while computing in the background. Java has first-class concurrency support built in, from low-level threads to high-level async utilities. The key challenge is coordinating shared state safely, which is why understanding synchronization is as important as knowing how to create threads.

Threads

Extending Thread

The simplest way to create a thread is to extend Thread and override run(). The thread starts executing run() asynchronously when you call start() — never call run() directly, as that executes it on the current thread, defeating the purpose.

public class DownloadThread extends Thread {
    private final String url;

    public DownloadThread(String url) {
        this.url = url;
        setName("Downloader-" + url.substring(url.lastIndexOf('/') + 1));
    }

    @Override
    public void run() {
        System.out.println(getName() + " started");
        // ... download logic
        try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        System.out.println(getName() + " finished");
    }
}

DownloadThread t = new DownloadThread("https://example.com/file.zip");
t.start(); // starts a new OS thread — do NOT call run() directly

Implementing Runnable (Preferred)

Runnable separates the task from the thread that runs it. This matters because you might want to run the same task in a thread pool, a scheduled executor, or a plain thread — none of that requires changing the Runnable itself. It also avoids the single-inheritance limitation of extending Thread.

public class PrintTask implements Runnable {
    private final String message;
    private final int count;

    public PrintTask(String message, int count) {
        this.message = message;
        this.count = count;
    }

    @Override
    public void run() {
        for (int i = 0; i < count; i++) {
            System.out.println(Thread.currentThread().getName() + ": " + message + " #" + i);
            try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }
    }
}

Thread t1 = new Thread(new PrintTask("Hello", 3), "Thread-A");
Thread t2 = new Thread(new PrintTask("World", 3), "Thread-B");
t1.start();
t2.start(); // both run concurrently — output order is non-deterministic

// Or with a lambda — Runnable is a functional interface
Thread t3 = new Thread(() -> System.out.println("Lambda task"), "Thread-C");
t3.start();

Thread Lifecycle

A thread moves through five states. Understanding this helps you reason about what join(), sleep(), and interrupt() actually do.

NEW → RUNNABLE → (BLOCKED/WAITING/TIMED_WAITING) → TERMINATED
Thread t = new Thread(() -> {
    try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});

System.out.println(t.getState()); // NEW — created but not started
t.start();
System.out.println(t.getState()); // RUNNABLE — running or ready to run
Thread.sleep(500);
System.out.println(t.getState()); // TIMED_WAITING — sleeping for 2s
t.join();                         // current thread waits for t to finish
System.out.println(t.getState()); // TERMINATED — run() has returned

Synchronization

When multiple threads read and write the same variable, the result depends on scheduling — a race condition. The count++ operation looks atomic but is actually three steps: read, increment, write. Two threads interleaving those steps produce an incorrect result. Synchronization solves this by guaranteeing that only one thread executes a critical section at a time.

// UNSAFE — count++ is not atomic; two threads can lose increments
public class Counter {
    private int count = 0;
    public void increment() { count++; } // read-modify-write: NOT atomic
    public int get() { return count; }
}

// SAFE — synchronized keyword creates a mutual exclusion lock on the instance
public class SafeCounter {
    private int count = 0;

    public synchronized void increment() { count++; } // only one thread at a time
    public synchronized int get() { return count; }
}

// SAFE — AtomicInteger uses hardware compare-and-swap; faster than synchronized for single variables
import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private final AtomicInteger count = new AtomicInteger(0);
    public void increment() { count.incrementAndGet(); } // atomic, no lock needed
    public int  get()       { return count.get(); }
}

synchronized Block

Synchronizing an entire method locks the whole object. A synchronized block lets you lock only the lines that actually access shared state, reducing contention and improving throughput when the method does other non-shared work.

public class SharedBuffer {
    private final List<String> buffer = new ArrayList<>();
    private final Object lock = new Object(); // explicit lock object — more granular than 'this'

    public void add(String item) {
        synchronized (lock) {
            buffer.add(item); // only this section is protected
        }
    }

    public String remove() {
        synchronized (lock) {
            return buffer.isEmpty() ? null : buffer.remove(0);
        }
    }
}

volatile

volatile guarantees that reads and writes to a variable go directly to main memory rather than a thread-local CPU cache. This solves visibility problems — one thread writing a flag that another thread never sees — but it does not make compound operations like i++ atomic. Use it for simple flags and state variables that are written by one thread and read by others.

public class StopFlag {
    // Without volatile, the worker thread might cache 'running' and never see the update
    private volatile boolean running = true;

    public void stop() { running = false; }

    public void doWork() {
        while (running) {
            // processes work items until stop() is called from another thread
        }
    }
}

Executor Framework

Creating a raw Thread for every task is expensive — thread creation and teardown has significant overhead, and spawning thousands of threads can exhaust system resources. Thread pools reuse a fixed set of threads, queuing tasks until a thread is available. The ExecutorService API is the standard way to manage thread pools in Java.

import java.util.concurrent.*;

// Fixed pool — at most 4 threads run concurrently; extra tasks queue up
ExecutorService pool = Executors.newFixedThreadPool(4);

// Submit tasks — returns immediately; task runs asynchronously
for (int i = 0; i < 10; i++) {
    final int taskId = i;
    pool.submit(() -> {
        System.out.println("Task " + taskId + " on " + Thread.currentThread().getName());
        Thread.sleep(500);
        return "result-" + taskId;
    });
}

// Shutdown — stop accepting new tasks; existing tasks finish
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS); // block until all done or timeout

Callable and Future

Runnable cannot return a value or throw checked exceptions. Callable<T> solves both — it returns a value and can throw. Future<T> is the handle to a pending computation; calling get() blocks until the result is ready.

Callable<Integer> task = () -> {
    Thread.sleep(1000); // simulate work
    return 42;          // Callable can return a value, unlike Runnable
};

ExecutorService pool = Executors.newSingleThreadExecutor();
Future<Integer> future = pool.submit(task);

// Do other work while the task runs in the background
System.out.println("Task submitted, doing other work");

// get() blocks until the result is available or the timeout expires
Integer result = future.get(5, TimeUnit.SECONDS);
System.out.println("Result: " + result); // 42

pool.shutdown();

Multiple Tasks

invokeAll submits a batch of tasks and returns a list of Futures in the same order, making it easy to collect all results after all tasks complete.

List<Callable<String>> tasks = List.of(
    () -> { Thread.sleep(300);  return "fast"; },
    () -> { Thread.sleep(1000); return "slow"; },
    () -> { Thread.sleep(500);  return "medium"; }
);

ExecutorService pool = Executors.newFixedThreadPool(3);
List<Future<String>> futures = pool.invokeAll(tasks); // all three start concurrently

for (Future<String> f : futures) {
    System.out.println(f.get()); // fast, slow, medium — in submission order
}
pool.shutdown();

CompletableFuture (Java 8+)

Future.get() blocks the calling thread. CompletableFuture enables non-blocking async programming: you attach callbacks that run when the result is ready, and chain transformations without ever blocking. This is the foundation of reactive-style code in modern Java.

import java.util.concurrent.CompletableFuture;

// supplyAsync runs on the common ForkJoinPool by default
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
    Thread.sleep(1000);
    return "Hello";
});

// Chain transformations — these register callbacks, they don't block
CompletableFuture<String> result = cf
    .thenApply(s -> s + ", World")       // transform result when ready
    .thenApply(String::toUpperCase);     // chain another transform

System.out.println("Doing other work...");
System.out.println(result.get()); // blocks only here — HELLO, WORLD

// thenAccept for side effects (no return value)
cf.thenAccept(s -> System.out.println("Got: " + s));

// Combine two independent async operations — both run in parallel
CompletableFuture<String> userFuture  = CompletableFuture.supplyAsync(() -> fetchUser(1));
CompletableFuture<String> orderFuture = CompletableFuture.supplyAsync(() -> fetchOrders(1));

CompletableFuture<String> combined = userFuture.thenCombine(orderFuture,
    (user, orders) -> "User: " + user + ", Orders: " + orders);
System.out.println(combined.get());

// Wait for all to complete before continuing
CompletableFuture<Void> all = CompletableFuture.allOf(
    CompletableFuture.runAsync(() -> System.out.println("Task 1")),
    CompletableFuture.runAsync(() -> System.out.println("Task 2")),
    CompletableFuture.runAsync(() -> System.out.println("Task 3"))
);
all.join(); // join() is like get() but throws unchecked exceptions

// Error handling — exceptionally provides a fallback value on failure
CompletableFuture<Integer> safe = CompletableFuture
    .supplyAsync(() -> Integer.parseInt("bad")) // throws NumberFormatException
    .exceptionally(ex -> {
        System.err.println("Failed: " + ex.getMessage());
        return -1; // fallback value
    });
System.out.println(safe.get()); // -1

Thread-Safe Collections

Standard collections like HashMap and ArrayList are not thread-safe. Java provides thread-safe alternatives in java.util.concurrent that are optimised for concurrent access — far better than wrapping everything in synchronized blocks.

import java.util.concurrent.*;

// ConcurrentHashMap — thread-safe HashMap; reads never lock, writes lock only one segment
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("a", 1);
map.computeIfAbsent("b", k -> 2);
map.merge("a", 1, Integer::sum); // atomic read-modify-write: a becomes 2

// CopyOnWriteArrayList — iteration is always safe; writes create a new copy of the array
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("item");

// BlockingQueue — the backbone of producer-consumer patterns
// put() blocks if full; take() blocks if empty — no busy-waiting needed
BlockingQueue<String> queue = new LinkedBlockingQueue<>(10);

Thread producer = new Thread(() -> {
    try {
        queue.put("item1"); // blocks if queue is full
        queue.put("item2");
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});

Thread consumer = new Thread(() -> {
    try {
        System.out.println(queue.take()); // blocks until an item is available
        System.out.println(queue.take());
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});

producer.start();
consumer.start();

Project: Multi-threaded File Downloader

This project demonstrates the thread pool pattern in practice. A fixed pool of 8 threads processes all downloads concurrently. Using Future lets the main thread collect results in submission order after all work is done, and ExecutionException wraps any per-task failures without stopping the other downloads.

import java.util.concurrent.*;
import java.util.*;

public class MultiDownloader {

    record DownloadTask(String url, String destination) {}
    record DownloadResult(String url, boolean success, long bytesDownloaded, long durationMs) {}

    public static List<DownloadResult> downloadAll(List<DownloadTask> tasks) throws InterruptedException {
        // Cap threads at 8 — more threads don't help if bandwidth is the bottleneck
        ExecutorService pool = Executors.newFixedThreadPool(Math.min(tasks.size(), 8));
        List<Future<DownloadResult>> futures = new ArrayList<>();

        for (DownloadTask task : tasks) {
            futures.add(pool.submit(() -> simulate(task)));
        }

        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.MINUTES);

        List<DownloadResult> results = new ArrayList<>();
        for (Future<DownloadResult> f : futures) {
            try {
                results.add(f.get());
            } catch (ExecutionException e) {
                // One failure doesn't stop the others
                System.err.println("Download failed: " + e.getCause().getMessage());
            }
        }
        return results;
    }

    private static DownloadResult simulate(DownloadTask task) throws InterruptedException {
        long start = System.currentTimeMillis();
        long bytes = (long)(Math.random() * 5_000_000) + 100_000;
        Thread.sleep(bytes / 100_000 * 50); // simulate variable network delay
        long duration = System.currentTimeMillis() - start;
        System.out.printf("Downloaded %s (%.1f KB) in %dms%n",
            task.url().substring(task.url().lastIndexOf('/') + 1),
            bytes / 1024.0, duration);
        return new DownloadResult(task.url(), true, bytes, duration);
    }

    public static void main(String[] args) throws InterruptedException {
        List<DownloadTask> tasks = List.of(
            new DownloadTask("https://example.com/file1.zip", "/tmp/file1.zip"),
            new DownloadTask("https://example.com/file2.zip", "/tmp/file2.zip"),
            new DownloadTask("https://example.com/file3.zip", "/tmp/file3.zip"),
            new DownloadTask("https://example.com/file4.zip", "/tmp/file4.zip")
        );

        long start = System.currentTimeMillis();
        List<DownloadResult> results = downloadAll(tasks);
        long total = System.currentTimeMillis() - start;

        long totalBytes = results.stream().mapToLong(DownloadResult::bytesDownloaded).sum();
        System.out.printf("%nAll done in %dms. Total: %.1f MB%n",
            total, totalBytes / 1_048_576.0);
    }
}

Frequently Asked Questions

When should I use Runnable vs Thread?
Almost always use Runnable (or Callable). Extending Thread is limiting — your class can't extend anything else. Runnable separates the task from the execution mechanism, so you can run it in a thread pool, a single thread, or anywhere else without changing the task.
What is a race condition?
A race condition happens when two threads access shared data simultaneously and the outcome depends on the timing of their execution. For example, two threads both reading count (100), both adding 1, and both writing 101 — the final value should be 102 but is 101. Fix with synchronized, AtomicInteger, or proper locking.
What is the difference between synchronized and volatile?
synchronized ensures mutual exclusion — only one thread executes the block at a time, and it flushes the thread's local cache to main memory on entry/exit. volatile only ensures visibility — reads/writes go directly to main memory without caching, but it does NOT prevent race conditions on compound operations like i++.