00:00

Multithreading in Java – Threads, Concurrency and Thread Safety

Multithreading is the ability of a program to run several threads of execution at the same time within one process. Each thread follows its own path through the code, while all threads share the process's memory. Java has supported threads since version 1.0, and every Java program already runs at least one: the main thread.

Threads let applications use multiple CPU cores, keep user interfaces responsive and handle many requests or I/O operations at once. They also introduce new problems, such as race conditions. This tutorial gives a complete overview and links to detailed tutorials on each topic.

1. Multitasking: Processes and Threads

Process-based and thread-based multitasking

Multitasking means doing several things at once. It comes in two forms:

  • Process-based multitasking: several programs run at the same time (a browser, an IDE, a music player). Each process has its own memory, so they are isolated but heavy to create and switch between.
  • Thread-based multitasking (multithreading): one program runs several threads. Threads share the process's heap, so they are lightweight and communicate easily, but must coordinate access to shared data.
Multiple threads running inside one Java process
AspectProcessThread
MemoryOwn address spaceShares the process heap; own stack
Creation costHighLow (very low for virtual threads)
CommunicationInter-process communication (pipes, sockets)Shared objects
Failure impactIsolatedCan corrupt shared state

See Process vs Thread.

2. Why Use Multithreading?

The biggest win comes when tasks spend time waiting, for example on network or disk I/O. Four simulated downloads of 500 ms each:

SequentialVsParallel.java
public class SequentialVsParallel {
    // Simulates a slow I/O task such as calling a web service
    static void download(String file) {
        try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }

    public static void main(String[] args) throws InterruptedException {
        String[] files = {"a.pdf", "b.pdf", "c.pdf", "d.pdf"};

        long start = System.nanoTime();
        for (String f : files) download(f);
        long sequential = (System.nanoTime() - start) / 1_000_000;

        start = System.nanoTime();
        Thread[] workers = new Thread[files.length];
        for (int i = 0; i < files.length; i++) {
            String f = files[i];
            workers[i] = new Thread(() -> download(f));
            workers[i].start();                 // all downloads run at the same time
        }
        for (Thread t : workers) t.join();      // wait for all of them
        long parallel = (System.nanoTime() - start) / 1_000_000;

        System.out.println("Sequential: ~" + Math.round(sequential / 100.0) * 100 + " ms");
        System.out.println("4 threads:  ~" + Math.round(parallel / 100.0) * 100 + " ms");
    }
}
Output
Sequential: ~2000 ms
4 threads:  ~500 ms
  • Responsiveness: long work runs in the background while the UI or server keeps responding.
  • Throughput: servers handle many requests at once.
  • Use of multiple cores: CPU-heavy work can be split across cores.
  • Simpler modelling of independent activities.

3. Creating and Starting Threads

A thread runs a Runnable task. Calling start() creates the new thread; join() waits for it to finish:

FirstThreads.java
public class FirstThreads {
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            String name = Thread.currentThread().getName();
            for (int i = 1; i <= 3; i++) {
                System.out.println(name + " step " + i);
            }
        };

        Thread t1 = new Thread(task, "worker-1");
        Thread t2 = new Thread(task, "worker-2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(Thread.currentThread().getName() + " thread finished last");
    }
}
Output (one possible run; the interleaving varies)
worker-1 step 1
worker-2 step 1
worker-1 step 2
worker-2 step 2
worker-1 step 3
worker-2 step 3
main thread finished last

The order of the worker lines changes from run to run, because the operating system schedules threads. Only the last line is guaranteed, thanks to join(). The ways to create threads (extending Thread, implementing Runnable, Callable, executors and virtual threads) are covered in How to Create a Thread.

4. Thread Life Cycle

A thread moves through the states NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING and TERMINATED, defined by Thread.State. See Thread Life Cycle.

5. Race Conditions and Thread Safety

When threads update shared data without coordination, updates can be lost. count++ is really read, add and write, and two threads can interleave these steps:

RaceCondition.java
import java.util.concurrent.atomic.AtomicInteger;

public class RaceCondition {
    static int unsafeCount = 0;
    static int lockedCount = 0;
    static final AtomicInteger atomicCount = new AtomicInteger();
    static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                unsafeCount++;                               // race condition
                synchronized (lock) { lockedCount++; }       // mutual exclusion
                atomicCount.incrementAndGet();               // lock-free atomic
            }
        };
        Thread a = new Thread(task), b = new Thread(task);
        a.start(); b.start();
        a.join();  b.join();

        System.out.println("Expected:      200000");
        System.out.println("Unsafe:        " + unsafeCount);
        System.out.println("synchronized:  " + lockedCount);
        System.out.println("AtomicInteger: " + atomicCount.get());
    }
}
Output (sample run; the unsafe count varies)
Expected:      200000
Unsafe:        199995
synchronized:  200000
AtomicInteger: 200000

Tools for thread safety:

  • Synchronization with synchronized or Lock objects (mutual exclusion).
  • Atomic classes such as AtomicInteger and LongAdder.
  • volatile fields for visibility of simple flags.
  • Concurrent collections: ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue.
  • Immutability and confinement: data that never changes, or is never shared, needs no locking.

6. Common Concurrency Problems

ProblemWhat happens
Race conditionThe result depends on thread timing; updates are lost or data is inconsistent
Visibility problemOne thread never sees another thread's write
DeadlockTwo threads each hold a lock the other needs; both wait forever
LivelockThreads keep reacting to each other but make no progress
StarvationA thread never gets CPU time or a lock

7. Thread Pools, CompletableFuture and Virtual Threads

Creating raw threads by hand is rarely the best choice in application code. The java.util.concurrent package provides higher-level tools:

ModernConcurrency.java
import java.util.List;
import java.util.concurrent.*;

public class ModernConcurrency {
    static int priceFrom(String shop) {
        try { Thread.sleep(300); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        return switch (shop) { case "ShopA" -> 999; case "ShopB" -> 949; default -> 1020; };
    }

    public static void main(String[] args) throws Exception {
        List<String> shops = List.of("ShopA", "ShopB", "ShopC");

        // 1. Thread pool with Callable and Future
        try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
            List<Future<Integer>> prices = pool.invokeAll(
                    shops.stream().map(s -> (Callable<Integer>) () -> priceFrom(s)).toList());
            for (int i = 0; i < shops.size(); i++)
                System.out.println("Pool:       " + shops.get(i) + " = " + prices.get(i).get());
        }

        // 2. CompletableFuture: asynchronous pipeline
        int best = shops.stream()
                .map(s -> CompletableFuture.supplyAsync(() -> priceFrom(s)))
                .toList().stream()
                .mapToInt(CompletableFuture::join)
                .min().orElseThrow();
        System.out.println("Async best: " + best);

        // 3. Virtual threads (Java 21): cheap threads, one per task
        try (ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor()) {
            Future<Integer> f = vt.submit(() -> priceFrom("ShopB"));
            System.out.println("Virtual:    ShopB = " + f.get());
        }
    }
}
Output
Pool:       ShopA = 999
Pool:       ShopB = 949
Pool:       ShopC = 1020
Async best: 949
Virtual:    ShopB = 949
  • Thread pools (ExecutorService) reuse a fixed number of threads for many tasks.
  • Callable and Future return results and exceptions from tasks.
  • CompletableFuture composes asynchronous steps without blocking.
  • Virtual threads (Java 21) are lightweight threads managed by the JVM. Millions can run at once, making simple blocking code scale for I/O-heavy servers.

8. Thread Priorities and Daemon Threads

  • Priority (1–10) is a hint to the scheduler and is often ignored by the operating system.
  • Daemon threads run background services and do not keep the JVM alive when all user threads have finished.

9. Best Practices

  • Prefer executors, CompletableFuture and virtual threads over manually managed threads.
  • Minimise shared mutable state; prefer immutable objects and thread confinement.
  • Keep synchronized sections short, and always acquire multiple locks in the same order.
  • Use concurrent collections instead of synchronizing ordinary collections yourself.
  • Handle InterruptedException properly: restore the interrupt flag or stop the task.
  • Name your threads; it makes logs and thread dumps much easier to read.

10. Summary

  • Multithreading runs several threads within one process, sharing memory.
  • It improves responsiveness, throughput and use of multiple cores.
  • Shared mutable data leads to race conditions; use synchronization, atomics and concurrent collections.
  • Use thread pools, CompletableFuture and virtual threads for modern concurrent code.

Related Tutorials