Skip to content
CalliCoder

Java Concurrency and Multithreading Basics

Java 12 min read

What a thread actually costs, why a race condition is two separate problems rather than one, the three failure modes worth being able to name, and what virtual threads change and do not.

Concurrency is two ideas that get used interchangeably and are not the same. Concurrency is structuring a program as tasks that can make progress independently. Parallelism is executing more than one of them at the same instant. A single-core machine can be concurrent and cannot be parallel; a program can be parallel and structured badly.

The distinction matters because the reason you want one is rarely the reason you want the other. Parallelism is for throughput on CPU-bound work. Concurrency is for not blocking, a server handling a thousand connections is concurrent regardless of core count.

Processes and threads

A process has its own memory. Two processes cannot corrupt each other’s data, and communicating between them means serialising something through a pipe, a socket or shared memory.

A thread shares memory with every other thread in its process. Each has its own program counter and its own stack; the heap is common. That sharing is the entire point and the entire problem.

A platform thread in the JVM maps to an operating system thread, which reserves stack space — commonly around a megabyte of virtual address space, and is scheduled by the kernel. Creating one takes on the order of tens of microseconds and a context switch takes on the order of a microsecond. Neither number matters for ten threads, and both matter a great deal for ten thousand.

Starting work

Raw Thread construction is worth seeing once and then not using:

Thread t = new Thread(() -> log.info("running on {}", Thread.currentThread().getName()));
t.start();     // start(), never run() — run() executes on the calling thread
t.join();      // wait for it

Calling run() instead of start() is a real and confusing bug: the code executes, correctly, on the wrong thread, and nothing is concurrent.

In practice you want a pool, so thread lifetime is not tied to task lifetime:

try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
    Future<Integer> total = pool.submit(() -> expensiveSum());
    // ... other work ...
    Integer result = total.get();          // blocks, and rethrows as ExecutionException
}

ExecutorService implements AutoCloseable from Java 19, so try-with-resources shuts it down and waits. Before that, shutdown() and awaitTermination() by hand, and a pool that is never shut down keeps non-daemon threads alive and stops the JVM exiting.

Sizing: roughly the number of cores for CPU-bound work, considerably more for I/O-bound work, since those threads spend their time waiting rather than computing.

Failure mode one: race conditions

This is the one people mean when they say “concurrency bug”, and it is really two problems wearing one name.

public class Counter {
    private int count = 0;

    public void increment() {
        count++;          // read, add, write — three operations, not one
    }

    public int get() {
        return count;
    }
}

Run increment() a million times across four threads and the result is under a million. Two problems produced that:

Atomicity. count++ is a read, an addition and a write. Two threads can both read 41, both compute 42, and both store 42. One increment is lost.

Visibility. Even with atomic operations, a thread is not guaranteed to see another thread’s write, the JVM memory model permits values to be held in registers or CPU caches; without a happens-before relationship between the write and the read, there is no promise the reader ever observes it. A loop reading a plain boolean flag set by another thread can spin forever, and will, under optimisation.

volatile fixes visibility and not atomicity:

private volatile boolean running = true;   // correct: a flag, written once, read often
private volatile int count = 0;            // wrong: count++ is still not atomic

Three correct approaches, in the order worth reaching for them:

// 1. don't share mutable state — an immutable value has no race
record Money(long cents, String currency) { }

// 2. an atomic class, for a single variable
private final AtomicInteger count = new AtomicInteger();
count.incrementAndGet();

// 3. a lock, when several fields must change together
private final Object lock = new Object();
private int deposits, total;

void record(int amount) {
    synchronized (lock) {
        deposits++;
        total += amount;      // both, or neither
    }
}

The first is not a cop-out. Most concurrency bugs are removed by not sharing, and the code is shorter.

Failure mode two: deadlock

Two threads each holding a lock the other needs, both waiting forever:

// thread A                       // thread B
synchronized (accountA) {         synchronized (accountB) {
    synchronized (accountB) {         synchronized (accountA) {
        transfer();                       transfer();
    }                                 }
}                                 }

Nothing throws. Nothing logs. Both threads sit in BLOCKED and the work they were doing never completes, while the rest of the application appears fine, which is why deadlock is usually diagnosed from a thread dump rather than from an error.

The standard defence is lock ordering: acquire locks in a globally consistent order, so a cycle cannot form.

Account first  = a.id() < b.id() ? a : b;
Account second = a.id() < b.id() ? b : a;

synchronized (first) {
    synchronized (second) {
        transfer(a, b, amount);
    }
}

The better defence is holding one lock at a time. tryLock with a timeout also converts a permanent hang into a failure you can retry and log:

if (lock.tryLock(200, TimeUnit.MILLISECONDS)) {
    try { ... } finally { lock.unlock(); }
} else {
    throw new ResourceBusyException();
}

Failure mode three: starvation and livelock

Starvation is a thread that is runnable but never scheduled to do useful work: a low-priority thread on a busy pool, or one always losing a contended lock. synchronized makes no fairness guarantee; ReentrantLock(true) does, at a throughput cost.

Livelock is two threads actively responding to each other and making no progress: both detect a conflict, both back off, both retry, in step. Randomised backoff is what breaks the symmetry.

Both are rarer than races and deadlocks, and both look like a performance problem rather than a bug, which is what makes them hard.

Where to look when something is wrong

A thread dump answers most questions:

$ jcmd <pid> Thread.print

Read the states. BLOCKED threads are waiting on a monitor and the dump names which, so a cycle is visible directly, the JVM will often label it Found one Java-level deadlock. WAITING on the same condition across many threads points at a pool that has run out of something. A single RUNNABLE thread at 100% CPU is a different problem entirely.

What virtual threads change

Java 21 made virtual threads final. They are scheduled by the JVM onto a small pool of carrier threads, and creating one costs on the order of hundreds of bytes rather than a megabyte:

try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request r : requests) {
        pool.submit(() -> handle(r));       // a million of these is fine
    }
}

What changes: blocking becomes cheap. A virtual thread that blocks on I/O unmounts from its carrier, so the thread-per-request model scales to hundreds of thousands of concurrent requests. Pool sizing largely stops being a tuning exercise, and reactive code written purely to avoid blocking loses much of its justification.

What does not change: every problem above still applies. Two virtual threads incrementing a shared int race exactly as before; two taking locks in opposite orders deadlock exactly as before. Virtual threads make concurrency cheaper, not safer.

One new caveat: pooling virtual threads is pointless, create one per task. And a synchronized block that blocks can pin a virtual thread to its carrier on some releases, so ReentrantLock is the safer choice in code designed for them.

Frequently asked questions

What is the difference between concurrency and parallelism?

Concurrency is structuring work as independent tasks; parallelism is running them simultaneously. One core can be concurrent without ever being parallel.

What is the difference between a process and a thread?

Processes have separate memory; threads in one process share the heap and have their own stacks. The sharing is why threads communicate cheaply and why they corrupt each other.

Why is my counter wrong with multiple threads?

count++ is read-modify-write, so two threads can read the same value and both store the same result. Use AtomicInteger, or hold a lock across the whole operation.

Does volatile make my code thread-safe?

It guarantees visibility of a write, not atomicity of a compound operation. Correct for a flag written once and read often; wrong for a counter.

Should I call start() or run()?

start(). run() executes the body on the calling thread. The code works and nothing is concurrent.

How do I find a deadlock?

Take a thread dump with jcmd <pid> Thread.print. Blocked threads name the monitors they wait on, and the JVM usually reports the cycle explicitly.

How do I avoid deadlock?

Hold one lock at a time. If you must hold two, acquire them in a consistent global order, and consider tryLock with a timeout so a cycle fails instead of hanging.

How many threads should a pool have?

Around the core count for CPU-bound work; considerably more for I/O-bound work, since those threads mostly wait. Measure rather than guess.

Do virtual threads remove the need for synchronization?

No. They make threads cheap, so blocking scales, races and deadlocks are unchanged. Prefer ReentrantLock over synchronized in code written for them, and never pool them.

What is the simplest way to avoid concurrency bugs?

Do not share mutable state. Immutable values and message passing remove entire categories of bug rather than defending against them.

Where should I go next?

CompletableFuture is the composition API for asynchronous work, and the Java guides cover the language features these examples use.