Multi Threading #

A program with only one thread runs one step at a time — like a single cashier serving a long queue. While one task runs, everything else waits. Multithreading splits that queue into several parallel lanes: compiling code while downloading dependencies, processing HTTP requests while writing logs, or crunching numbers while showing progress to the user. Java has supported multithreading since its very first version and keeps enriching its toolkit — from the primitive Thread to ExecutorService, CompletableFuture, and concurrent collections. This article covers how to create and control threads, keep shared data consistent through synchronization, manage thread pools with the Executor Framework, and avoid classic traps like deadlocks and race conditions.

Basic Concepts #

Before writing code, there are several terms you need to understand clearly, because they’re often used interchangeably but have different meanings:

TermMeaning
ThreadThe smallest unit of execution within a process. Each thread has its own stack but shares the heap with other threads in the same process.
ConcurrencyThe ability to handle many tasks — not necessarily literally at the same time, it can be rapid alternation (time-slicing).
ParallelismTruly simultaneous execution on multiple CPU cores. Requires hardware with more than one core.
Race conditionA bug that appears when two threads access and modify the same data simultaneously without coordination, producing unexpected values.
DeadlockA condition where two or more threads wait on each other forever — none can proceed.
SynchronizationA mechanism to ensure only one thread accesses a shared resource at a time.
flowchart TD
    A[Main Thread] -->|"new Thread().start()"| B[Thread 1]
    A -->|"new Thread().start()"| C[Thread 2]
    A -->|"new Thread().start()"| D[Thread 3]
    B --> E[("Shared heap\n(objects, static variables)")]
    C --> E
    D --> E
    B --- F[Thread 1 stack]
    C --- G[Thread 2 stack]
    D --- H[Thread 3 stack]

Creating Threads #

Java provides three main ways to define a task that runs on a thread — choose based on whether your task needs to return a value or not.

Implementing Runnable #

Runnable is the most recommended way. By implementing an interface, your class is still free to extend another class — unlike extending Thread, which blocks further inheritance.

// Way 1: a separate class
class DataProcessor implements Runnable {
    private final String name;

    public DataProcessor(String name) {
        this.name = name;
    }

    @Override
    public void run() {
        for (int i = 1; i <= 3; i++) {
            System.out.println("[" + name + "] step " + i);
            try {
                Thread.sleep(500); // simulate work
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt(); // the correct pattern for handling interrupts
                return;
            }
        }
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new DataProcessor("Process-A"));
        Thread t2 = new Thread(new DataProcessor("Process-B"));

        t1.start(); // start execution on a new thread
        t2.start();

        t1.join(); // wait for t1 to finish before continuing
        t2.join();

        System.out.println("All processes finished.");
    }
}
// Way 2: lambda — more concise for simple logic (Java 8+)
Runnable shortTask = () -> {
    System.out.println("Running on: " + Thread.currentThread().getName());
};

Thread t = new Thread(shortTask, "custom-thread");
t.start();

Extending Thread #

Extending Thread is more direct but less flexible. Use it when you need to access Thread methods directly from inside run().

class FileDownloader extends Thread {
    private final String url;

    public FileDownloader(String url) {
        super("download-" + url); // give the thread a name for easier debugging
        this.url = url;
    }

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

// Usage
FileDownloader t1 = new FileDownloader("file-a.zip");
FileDownloader t2 = new FileDownloader("file-b.zip");
t1.start();
t2.start();

Choosing Runnable vs Thread #

Use RUNNABLE (or a lambda) when:
  ✓ You don't need to access Thread methods directly from inside run()
  ✓ The class needs to extend another class
  ✓ You want to separate the task definition from how it's executed
  ✓ Almost always — it's the better choice

Use extends THREAD when:
  ✓ You need to override Thread behavior itself (not just run())
  ✗ Avoid it if you only want to run one block of code

Thread Lifecycle #

A thread doesn’t start running as soon as it’s created. It passes through several states during its life.

flowchart LR
    A["NEW\nnew Thread()"] -->|"start()"| B["RUNNABLE\nwaiting for CPU"]
    B -->|"CPU scheduled"| C["RUNNING\nrun() active"]
    C -->|"sleep() / wait()\njoin()"| D["BLOCKED/WAITING\nwaiting"]
    D -->|"notified / timeout\nanother thread finished"| B
    C -->|"run() finished"| E[TERMINATED]
    C -->|"interrupt()"| E
Thread t = new Thread(() -> {
    try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});

System.out.println(t.getState()); // NEW
t.start();
System.out.println(t.getState()); // RUNNABLE or TIMED_WAITING
t.join();
System.out.println(t.getState()); // TERMINATED

Important Thread Methods #

Thread t = new Thread(() -> { /* ... */ });

t.start();            // start the thread — NEVER call run() directly
t.join();             // wait for this thread to finish before continuing
t.join(3000);         // wait at most 3 seconds
t.interrupt();        // send an interrupt signal to the thread
t.isAlive();          // true if the thread is still running
t.getName();          // the thread's name
t.setName("worker");  // give it a name
t.getPriority();      // 1 (MIN) to 10 (MAX), default 5 (NORM)
t.setDaemon(true);    // daemon thread: dies automatically when the main thread finishes

// Static methods — operate on the currently running thread
Thread.sleep(500);          // sleep 500 ms, throws InterruptedException if interrupted
Thread.currentThread();     // reference to the thread currently running
Thread.yield();             // give other threads a chance to run
Never call run() directly — that just calls a regular method on the same thread, not starting a new thread. Always use start(). And when catching InterruptedException, always call Thread.currentThread().interrupt() afterward so the interrupt status isn’t lost.

Synchronization #

When two threads read and write the same variable without coordination, the result is unpredictable. This is called a race condition — one of the hardest bugs to trace because it can’t always be reproduced.

Race Conditions and synchronized #

// ANTI-PATTERN: a counter without synchronization
class UnsafeCounter {
    private int value = 0;

    public void increment() {
        value++; // NOT an atomic operation: read → increment → write (3 steps!)
    }

    public int getValue() { return value; }
}

// With two threads each calling increment() 1000 times,
// the result is NOT 2000 — it can be less because of the race condition

// CORRECT: use synchronized
class SafeCounter {
    private int value = 0;

    // synchronized ensures only one thread can enter at a time
    public synchronized void increment() {
        value++;
    }

    public synchronized int getValue() { return value; }
}

// Test
SafeCounter counter = new SafeCounter();
Runnable task = () -> {
    for (int i = 0; i < 1000; i++) counter.increment();
};

Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start(); t2.start();
t1.join();  t2.join();

System.out.println(counter.getValue()); // always 2000

Synchronized Blocks #

synchronized at the method level locks the entire object — coarse but easy. For finer control, use a synchronized block that only locks the part of the code that truly needs protection.

class DataStore {
    private final Object readLock  = new Object();
    private final Object writeLock = new Object();

    private int reads = 0;
    private int writes = 0;

    public void processRequest() {
        // Only the read-count update needs locking
        synchronized (readLock) {
            reads++;
        }

        // Do other operations that don't need a lock here
        longProcess();

        // The write-count update has its own lock
        synchronized (writeLock) {
            writes++;
        }
    }

    private void longProcess() {
        // an operation that doesn't access shared data
    }
}

volatile #

volatile ensures that a variable’s changes by one thread are immediately visible to other threads — it solves the visibility problem but not atomicity. Good for simple flags, not for operations like value++.

class Worker extends Thread {
    // volatile: changes from another thread are immediately visible
    private volatile boolean running = true;

    @Override
    public void run() {
        while (running) {
            // do something
        }
        System.out.println("Worker stopped.");
    }

    public void stopWorker() {
        running = false; // another thread sets this, the Worker sees it immediately
    }
}

// Usage
Worker w = new Worker();
w.start();
Thread.sleep(2000);
w.stopWorker(); // stop signal

AtomicInteger and Atomic Types #

For simple operations like counters, java.util.concurrent.atomic provides types whose operations are truly atomic without needing synchronized.

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicBoolean;

AtomicInteger counter = new AtomicInteger(0);

// All of these operations are atomic — safe from many threads at once
counter.incrementAndGet();    // ++counter, returns the new value
counter.getAndIncrement();    // counter++, returns the old value
counter.addAndGet(5);         // counter += 5
counter.compareAndSet(10, 0); // if value == 10, set to 0 (CAS operation)
int value = counter.get();    // read the current value

// AtomicInteger is much faster than synchronized for simple counters

The Executor Framework #

Creating a new Thread() every time there’s a task is inefficient — creating threads is expensive, and too many simultaneously active threads actually slow the system down due to context-switching overhead. ExecutorService manages a pool of reusable threads for running many tasks without the cost of creating a thread each time.

Types of Thread Pools #

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;

// Fixed pool: a fixed number of threads, tasks queue up when all threads are busy
// Good for: servers with stable workloads
ExecutorService fixedPool = Executors.newFixedThreadPool(4);

// Cached pool: creates new threads if all are busy, removes idle ones after 60 seconds
// Good for: many short tasks arriving unpredictably
ExecutorService cachedPool = Executors.newCachedThreadPool();

// Single thread: one thread, all tasks queue sequentially
// Good for: tasks that must run one at a time (sequential)
ExecutorService singleThread = Executors.newSingleThreadExecutor();

// Scheduled pool: run tasks at specific times or periodically
ScheduledExecutorService scheduledPool = Executors.newScheduledThreadPool(2);

Running Tasks with submit() #

ExecutorService pool = Executors.newFixedThreadPool(3);

// submit a Runnable — no return value
pool.submit(() -> System.out.println("Task without a result"));

// execute a Runnable — same as submit but doesn't return a Future
pool.execute(() -> System.out.println("Task executed"));

// Sending many tasks at once
for (int i = 1; i <= 10; i++) {
    final int number = i;
    pool.submit(() -> {
        System.out.println("Task " + number + " on " + Thread.currentThread().getName());
        try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    });
}

// REQUIRED: shutdown when done — without this, the program won't stop
pool.shutdown(); // reject new tasks, wait for running tasks to finish

try {
    // Wait at most 30 seconds
    if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
        pool.shutdownNow(); // force stop if something is still running
    }
} catch (InterruptedException e) {
    pool.shutdownNow();
}

Scheduled Executor #

import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

// Run once after a 2-second delay
scheduler.schedule(() -> System.out.println("2 seconds late"), 2, TimeUnit.SECONDS);

// Run every 5 seconds, starting after an initial 1 second
scheduler.scheduleAtFixedRate(
    () -> System.out.println("Ping: " + System.currentTimeMillis()),
    1, 5, TimeUnit.SECONDS
);

// Run 3 seconds after the previous task finishes
scheduler.scheduleWithFixedDelay(
    () -> System.out.println("Delay after the task finishes"),
    0, 3, TimeUnit.SECONDS
);

Callable and Future #

Runnable can’t return a value and can’t throw checked exceptions. Callable<V> solves both. The result of a Callable execution is wrapped in a Future<V> that can be retrieved later.

Basic Callable and Future #

import java.util.concurrent.*;

ExecutorService pool = Executors.newFixedThreadPool(2);

// Callable: like Runnable but can return a value and throw exceptions
Callable<Integer> calculateTotal = () -> {
    int total = 0;
    for (int i = 1; i <= 100; i++) {
        total += i;
        Thread.sleep(10); // simulate computation
    }
    return total; // 5050
};

// Submitting a Callable returns a Future
Future<Integer> future = pool.submit(calculateTotal);

System.out.println("Calculating in the background...");

// do other work while waiting
System.out.println("You can do other things here");

try {
    // get() blocks until the result is available
    Integer result = future.get(); // 5050
    System.out.println("Total: " + result);

    // get() with a timeout — safer
    Integer safeResult = future.get(5, TimeUnit.SECONDS);
} catch (ExecutionException e) {
    // the Callable threw an exception — wrapped here
    System.err.println("Task failed: " + e.getCause().getMessage());
} catch (TimeoutException e) {
    System.err.println("Timeout!");
    future.cancel(true); // cancel the task
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
} finally {
    pool.shutdown();
}

Waiting for Many Futures at Once #

ExecutorService pool = Executors.newFixedThreadPool(4);

List<Callable<String>> taskList = List.of(
    () -> { Thread.sleep(1000); return "Result A"; },
    () -> { Thread.sleep(500);  return "Result B"; },
    () -> { Thread.sleep(800);  return "Result C"; }
);

try {
    // invokeAll: submit all, wait for all to finish
    List<Future<String>> futures = pool.invokeAll(taskList);

    for (Future<String> f : futures) {
        System.out.println(f.get()); // definitely done, won't block long
    }

    // invokeAny: submit all, return the result of the first to finish
    String fastest = pool.invokeAny(taskList);
    System.out.println("First to finish: " + fastest);

} catch (InterruptedException | ExecutionException e) {
    e.printStackTrace();
} finally {
    pool.shutdown();
}

Concurrent Data Structures #

Standard Java collections (ArrayList, HashMap, etc.) are not thread-safe. Don’t use them in multithreading environments without protection. java.util.concurrent provides alternatives designed specifically for this.

Concurrent Collections #

import java.util.concurrent.*;

// ConcurrentHashMap: the HashMap replacement for multithreading
// (covered in the Map article — more detail there)
ConcurrentHashMap<String, Integer> safeMap = new ConcurrentHashMap<>();

// CopyOnWriteArrayList: the ArrayList replacement when reads >> writes
// (covered in the List article)
CopyOnWriteArrayList<String> safeList = new CopyOnWriteArrayList<>();

// BlockingQueue: a queue that can block threads when full or empty
// Very useful for the Producer-Consumer pattern
BlockingQueue<String> queue = new LinkedBlockingQueue<>(100); // capacity 100

// Producer: add to the queue, block if full
queue.put("item-1");    // blocks until there's space
queue.offer("item-2");  // try to add, returns false if full (doesn't block)
queue.offer("item-3", 2, TimeUnit.SECONDS); // wait at most 2 seconds

// Consumer: take from the queue, block if empty
String item = queue.take();  // blocks until there's an item
String item2 = queue.poll(); // take or null if empty (doesn't block)
String item3 = queue.poll(3, TimeUnit.SECONDS); // wait at most 3 seconds

The Producer-Consumer Pattern #

// Scenario: a producer generates data, a consumer processes it
// BlockingQueue acts as the buffer in between

BlockingQueue<Integer> buffer = new LinkedBlockingQueue<>(10);

// Producer: keep generating numbers
Thread producer = new Thread(() -> {
    try {
        for (int i = 1; i <= 20; i++) {
            buffer.put(i); // blocks if the buffer is full (max 10)
            System.out.println("Produced: " + i);
            Thread.sleep(100);
        }
        buffer.put(-1); // sentinel: the done signal
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}, "producer");

// Consumer: take and process one by one
Thread consumer = new Thread(() -> {
    try {
        while (true) {
            int item = buffer.take(); // blocks if the buffer is empty
            if (item == -1) break;    // received the sentinel, stop
            System.out.println("Consumed: " + item);
            Thread.sleep(200); // the consumer is slower than the producer
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}, "consumer");

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

Deadlocks and How to Avoid Them #

A deadlock happens when two threads each wait for a lock held by the other thread. Neither can ever proceed.

A Deadlock Example #

// ANTI-PATTERN: two threads lock two objects in opposite orders
Object lockA = new Object();
Object lockB = new Object();

Thread thread1 = new Thread(() -> {
    synchronized (lockA) {                    // thread1 holds lockA
        System.out.println("T1 holds A, waiting for B...");
        try { Thread.sleep(100); } catch (InterruptedException e) {}
        synchronized (lockB) {                // thread1 waits for lockB (held by thread2!)
            System.out.println("T1 holds both");
        }
    }
});

Thread thread2 = new Thread(() -> {
    synchronized (lockB) {                    // thread2 holds lockB
        System.out.println("T2 holds B, waiting for A...");
        try { Thread.sleep(100); } catch (InterruptedException e) {}
        synchronized (lockA) {                // thread2 waits for lockA (held by thread1!)
            System.out.println("T2 holds both");
        }
    }
});

// Both threads wait on each other → DEADLOCK

How to Avoid Deadlocks #

// CORRECT: always lock in the same order in all threads
Object lockA = new Object();
Object lockB = new Object();

// Thread 1 and Thread 2 both: A first, then B
Thread thread1 = new Thread(() -> {
    synchronized (lockA) {
        synchronized (lockB) {
            System.out.println("T1 done");
        }
    }
});

Thread thread2 = new Thread(() -> {
    synchronized (lockA) { // the same order: A first, then B
        synchronized (lockB) {
            System.out.println("T2 done");
        }
    }
});

// No deadlock — both lock in a consistent order
Tips for avoiding deadlocks:
  ✓ Always lock multiple locks in the same, consistent order
  ✓ Shrink the synchronized scope — lock as little as possible
  ✓ Use tryLock() with a timeout from ReentrantLock as an alternative
  ✓ Avoid calling external methods from inside a synchronized block
  ✓ Consider redesigning to reduce the need for multiple locks

When to Use Multithreading #

Use MULTITHREADING when:
  ✓ The task can be split into parts that can be done in parallel
  ✓ There are slow I/O operations (disk, network) that shouldn't block
  ✓ You need UI responsiveness — heavy operations on a background thread
  ✓ You have many CPU cores you want to take advantage of

Avoid or be careful when:
  ✗ Tasks depend tightly on each other (hard to parallelize)
  ✗ Synchronization overhead outweighs the parallel gains
  ✗ The code is already fast enough — don't optimize prematurely
  ✗ The team isn't familiar — multithreading bugs are very hard to trace

Tool choice by need:
  → Thread + Runnable       : simple tasks without return values
  → Callable + Future       : tasks that need to return results
  → ExecutorService         : manage a thread pool for many tasks
  → ScheduledExecutorService: periodic or scheduled tasks
  → AtomicInteger/Long      : simple counters or flags without synchronized
  → ConcurrentHashMap       : a map accessed by many threads
  → BlockingQueue           : producer-consumer patterns

Summary #

  • Runnable or a lambda is the primary choice for defining thread tasks. Extending Thread blocks inheritance of other classes — avoid it unless truly needed.
  • Always call start(), not run()run() just runs a regular method on the same thread, it doesn’t create a new thread.
  • Race conditions happen when two threads modify shared data without synchronizationvalue++ is not an atomic operation. Use synchronized, AtomicInteger, or concurrent collections.
  • synchronized locks the entire object — for finer control, use synchronized blocks with separate lock objects. Lock as little as possible to reduce bottlenecks.
  • volatile solves visibility, not atomicity — good for boolean or int flags written by one thread and read by many.
  • Use ExecutorService, not manual new Thread() — thread pools are far more efficient because threads are reused. Always call shutdown() when done.
  • Callable + Future for tasks that return resultsfuture.get() blocks until the result is available. Always use get(timeout) to avoid waiting forever.
  • Deadlocks happen when threads wait on each other’s locks — always lock multiple locks in a consistent order across all threads to prevent them.
  • BlockingQueue is the foundation of the producer-consumer pattern — a buffer that automatically blocks the producer when full and the consumer when empty, without needing manual wait()/notify().

← Previous: Build Tools   Next: I/O →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact