Free Handbook · Every example compiled & verified

Concurrency

Java threads without the fear: Runnable and join, race conditions, synchronized, atomics, ExecutorService, CompletableFuture, virtual threads and deadlock.

0 / 142 lessons🔥 0 day streak
ShareXLinkedIn

Module 10 · what you'll be able to do

  • Start threads with Runnable, wait for them with join, and explain why start() is not run()
  • Spot a race condition and fix it with synchronized, a lock or an atomic class
  • Run work on a thread pool with ExecutorService and collect results through Future and CompletableFuture
  • Use Java 21 virtual threads for thousands of blocking tasks, and pick the right concurrent collection
  • Explain how a deadlock happens and prevent it with a consistent lock order
01

Threads, Runnable and join

A thread is an independent path of execution inside one program. Every Java program starts with one, called main. Creating more lets the program do several things at once: serve many web requests, download files while the UI stays responsive, or split a big calculation across CPU cores. All threads in a program share the same heap, so they can see the same objects — which is both the power and the danger of this module.

The work a thread does is a Runnable: an interface with one method, void run(), so a lambda fits. Hand it to a Thread and call start(). Calling join() makes the current thread wait until that thread has finished.

javaMain.java
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Runnable job = () -> System.out.println("hello from " + Thread.currentThread().getName());

        Thread worker = new Thread(job, "worker-1");
        worker.start();          // runs job on a NEW thread
        worker.join();           // main waits here until worker finishes
        System.out.println("main continues on " + Thread.currentThread().getName());

        job.run();               // a plain method call: runs on main
    }
}
Outputcompiled & run with real Java
hello from worker-1
main continues on main
hello from main

Because of join(), the worker always prints first. Without it, the two lines could come out in either order.

Your turn

Delete the worker.join() line and run it several times. The order of the first two lines is no longer guaranteed.

start() versus run()
start() asks the JVM to create a new thread, which then calls run(). Calling run() yourself just executes the method on the current thread — no concurrency at all. It is the most common threading mistake in interviews and code reviews.

Splitting work across threads

The safe pattern for a beginner: give each thread its own slot to write to, join them all, and only then read the results on main. No two threads touch the same variable, so there is nothing to race on, and join guarantees main sees everything the workers wrote.

javaMain.java
public class Main {
    public static void main(String[] args) throws InterruptedException {
        long[] partial = new long[2];

        Thread low = new Thread(() -> {
            for (int i = 1; i <= 500; i++) partial[0] += i;
        });
        Thread high = new Thread(() -> {
            for (int i = 501; i <= 1000; i++) partial[1] += i;
        });

        low.start();
        high.start();
        low.join();
        high.join();

        System.out.println(partial[0] + " + " + partial[1] + " = " + (partial[0] + partial[1]));
    }
}
Outputcompiled & run with real Java
125250 + 375250 = 500500
Your turn

Split the range 1..1000 across four threads instead of two, each writing to its own slot of a long[4].

You may also see threads written as class Worker extends Thread with run() overridden. It works, but passing a Runnable is preferred: your class stays free to extend something else, and the same task can later move to a thread pool unchanged.

Error you will hit

unreported exception InterruptedException

java
public class Main {
    public static void main(String[] args) {
        Thread worker = new Thread(() -> System.out.println("working"));
        worker.start();
        worker.join();
        System.out.println("done");
    }
}
Main.java:5: error: unreported exception InterruptedException; must be caught or declared to be thrown
        worker.join();
                   ^
1 error
error: compilation failed
Why the compiler said that

join(), Thread.sleep() and most other blocking calls can be woken early if another thread interrupts the waiting one. They signal that with the checked exception InterruptedException, and the compiler insists you deal with every checked exception.

The fix

In a small program, declare it on main: throws InterruptedException. Inside a Runnable (which cannot throw checked exceptions) catch it and restore the flag with Thread.currentThread().interrupt() so code further up can still see the thread was asked to stop.

java
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> System.out.println("working"));
        worker.start();
        worker.join();
        System.out.println("done");
    }
}
Error you will hit

IllegalThreadStateException: starting a thread twice

java
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> System.out.println("hello"));
        t.start();
        t.join();
        t.start();
    }
}
hello
Exception in thread "main" java.lang.IllegalThreadStateException
	at java.base/java.lang.Thread.start(Unknown Source)
	at Main.main(Main.java:6)
Why the compiler said that

A Thread object is single-use. Once it has been started it moves through its lifecycle (NEW, RUNNABLE, TERMINATED) and can never go back to NEW, so a second start() is illegal even after it has finished.

The fix

Create a new Thread for each run, or — better — submit the Runnable to an ExecutorService as many times as you like.

java
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Runnable hello = () -> System.out.println("hello");
        for (int i = 0; i < 2; i++) {
            Thread t = new Thread(hello);
            t.start();
            t.join();
        }
    }
}
02

Race conditions: why count++ is not safe

A race condition is a bug where the result depends on the timing of threads. The classic example is two threads incrementing a shared counter. Run the program below and you will almost never see 200000 — and you will see a different wrong number every time. That is exactly why it is shown as a static block: its output is not predictable.

javaMain.java
public class Main {
    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable inc = () -> { for (int i = 0; i < 100_000; i++) count++; };
        Thread a = new Thread(inc);
        Thread b = new Thread(inc);
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println(count);   // you expect 200000
    }
}

Three real runs on one laptop printed 123815, 175075 and 118237.

count++ looks like one step but is three: read the value, add one, write it back. When both threads read the same old value before either writes, one increment is silently lost. Step through what happens when the two threads interleave:

VisualizeTwo threads, one lost updateStep 1 / 6
int tmp = count; // read
tmp = tmp + 1; // add
count = tmp; // write
Line 1

Thread A reads the shared count, which is 5, into its own local copy.

Variables now
count5
A.tmp5
All 6 steps as a table
StepLineWhat happenedVariables now
11Thread A reads the shared count, which is 5, into its own local copy.count = 5 A.tmp = 5
21Before A writes anything, the scheduler switches to thread B. B also reads 5.B.tmp = 5
32A adds one to its copy.A.tmp = 6
43A writes 6 back.count = 6
52B adds one to its copy, which still holds the stale 5.B.tmp = 6
63B writes 6. Two increments ran, but count only went up by one: an update was lost.count = 6

There is a second, sneakier problem: visibility. Each CPU core caches memory, and the JIT compiler may keep a variable in a register. Without a synchronization point, one thread may never see another thread's write at all. The Java Memory Model defines which actions create a happens-before edge that guarantees visibility: Thread.start(), join(), entering and leaving a synchronized block, and reading a volatile field are the ones you will use.

Error you will hit

local variables referenced from a lambda expression must be final

java
public class Main {
    public static void main(String[] args) throws InterruptedException {
        int count = 0;
        Thread t = new Thread(() -> {
            for (int i = 0; i < 1000; i++) count++;
        });
        t.start();
        t.join();
        System.out.println(count);
    }
}
Main.java:5: error: local variables referenced from a lambda expression must be final or effectively final
            for (int i = 0; i < 1000; i++) count++;
                                           ^
1 error
error: compilation failed
Why the compiler said that

A lambda gets a copy of the local variables it uses, so Java only allows capturing locals that never change. That rule also protects you: a local int lives on one thread's stack and was never meant to be shared.

The fix

Make the shared state an object designed for sharing, such as an AtomicInteger (next lessons). The reference is effectively final; the value inside it changes safely.

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

public class Main {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger count = new AtomicInteger();
        Thread t = new Thread(() -> {
            for (int i = 0; i < 1000; i++) count.incrementAndGet();
        });
        t.start();
        t.join();
        System.out.println(count.get());
    }
}
03

synchronized, locks and volatile

Every Java object has a built-in lock (the monitor). A synchronized method or block takes that lock on entry and releases it on exit — even if an exception is thrown. Only one thread can hold a given lock at a time, so the read-add-write of value++ becomes atomic with respect to every other synchronized access on the same object, and leaving the block publishes the new value to the next thread that enters.

javaMain.java
public class Main {
    static class Counter {
        private int value;

        synchronized void increment() { value++; }
        synchronized int get() { return value; }
    }

    public static void main(String[] args) throws InterruptedException {
        Counter c = new Counter();
        Runnable inc = () -> { for (int i = 0; i < 100_000; i++) c.increment(); };
        Thread a = new Thread(inc);
        Thread b = new Thread(inc);
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println(c.get());
    }
}
Outputcompiled & run with real Java
200000

The same program as the race above, with the counter behind a lock. Now it is right every time.

A synchronized method locks this; a static synchronized method locks the class object. A synchronized (someObject) { … } block lets you lock only the few lines that touch shared state, which keeps the slow part of a method outside the lock. Many teams lock a private final Object lock = new Object(); so outside code cannot grab the same monitor by accident.

ReentrantLock: a lock you can hold in your hand

java.util.concurrent.locks.ReentrantLock does the same job as synchronized but as an object, adding tryLock() (give up instead of waiting forever), timeouts, and fairness. The price: you must unlock yourself, always in a finally block.

javaMain.java
import java.util.concurrent.locks.ReentrantLock;

public class Main {
    static class Account {
        private final ReentrantLock lock = new ReentrantLock();
        private int balance = 100;

        boolean withdraw(int amount) {
            lock.lock();
            try {
                if (balance < amount) return false;   // check...
                balance -= amount;                    // ...then act, under one lock
                return true;
            } finally {
                lock.unlock();
            }
        }

        int balance() { return balance; }
    }

    public static void main(String[] args) throws InterruptedException {
        Account acc = new Account();
        int[] ok = new int[2];
        Thread a = new Thread(() -> { for (int i = 0; i < 10; i++) if (acc.withdraw(10)) ok[0]++; });
        Thread b = new Thread(() -> { for (int i = 0; i < 10; i++) if (acc.withdraw(10)) ok[1]++; });
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println("successful withdrawals: " + (ok[0] + ok[1]));
        System.out.println("balance: " + acc.balance());
    }
}
Outputcompiled & run with real Java
successful withdrawals: 10
balance: 0

Twenty attempts, but only ten can succeed and the balance never goes negative. How the ten split between the threads varies, which is why only the total is printed.

Your turn

Remove lock.lock() and lock.unlock() and think about what could go wrong: two threads can both pass the balance < amount check before either subtracts.

Check-then-act
The withdraw bug above has a name: check-then-act. Whenever code tests a condition and then acts on it (if not in map, put; if balance enough, withdraw), both steps must happen under the same lock, or another thread can change the world in between.

volatile: visibility without locking

Marking a field volatile guarantees every read sees the latest write from any thread. It does not make count++ atomic. Use it for simple flags that one thread writes and others read, such as a stop signal.

javaMain.java
public class Main {
    static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            long spins = 0;
            while (running) spins++;      // re-reads the flag every time
            System.out.println("worker saw the flag and stopped");
        });
        worker.start();
        Thread.sleep(50);
        running = false;                   // visible to the worker at once
        worker.join();
        System.out.println("main: done");
    }
}
Outputcompiled & run with real Java
worker saw the flag and stopped
main: done

Without volatile, the JIT is allowed to hoist the read out of the loop, and the worker can spin forever.

04

Atomic classes

For a single counter or reference, a lock is heavier than needed. java.util.concurrent.atomic provides AtomicInteger, AtomicLong, AtomicBoolean and AtomicReference, which use the CPU's compare-and-set (CAS) instruction: "set it to X only if it is still Y". If another thread got there first, the operation retries. No thread ever blocks.

javaMain.java
import java.util.concurrent.atomic.*;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger hits = new AtomicInteger();
        LongAdder bytes = new LongAdder();
        Runnable work = () -> {
            for (int i = 0; i < 50_000; i++) {
                hits.incrementAndGet();
                bytes.add(2);
            }
        };
        Thread a = new Thread(work);
        Thread b = new Thread(work);
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println("hits=" + hits.get() + " bytes=" + bytes.sum());

        AtomicInteger level = new AtomicInteger(10);
        System.out.println(level.compareAndSet(10, 15) + " " + level.get());
        System.out.println(level.compareAndSet(10, 99) + " " + level.get());
        System.out.println(level.updateAndGet(v -> v * 2) + " " + level.accumulateAndGet(40, Math::max));
    }
}
Outputcompiled & run with real Java
hits=100000 bytes=200000
true 15
false 15
30 40

The second compareAndSet fails because the value is no longer 10 — that "only if still" check is what makes CAS safe.

Your turn

Use getAndIncrement() and incrementAndGet() on a fresh AtomicInteger and print both return values. What is the difference?

ClassUse it for
AtomicInteger / AtomicLongIDs, sequence numbers, counters you read often
LongAdderHot counters written by many threads and read rarely (metrics). Faster under contention; read with sum()
AtomicBooleanRun-once flags: if (started.compareAndSet(false, true)) init();
AtomicReference<T>Swapping a whole immutable object (a config snapshot) in one step
Two atomics are not one atomic
Each call on an atomic is safe on its own, but if (a.get() > 0) a.decrementAndGet(); is check-then-act again. Use a single method that does both (updateAndGet, compareAndSet) or fall back to a lock.
05

ExecutorService and Future

Creating a thread per task does not scale: platform threads are expensive (about 1 MB of stack each) and the operating system can only schedule so many. Real code hands tasks to an ExecutorService — a pool of reusable worker threads with a queue in front. You submit a task and get back a Future, a handle to a result that may not exist yet. future.get() blocks until it does.

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

public class Main {
    static int slowSquare(int n) throws InterruptedException {
        Thread.sleep(20);            // pretend this is a network call
        return n * n;
    }

    public static void main(String[] args) throws Exception {
        try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
            Future<Integer> one = pool.submit(() -> slowSquare(7));
            System.out.println("result=" + one.get());

            List<Callable<Integer>> jobs = new ArrayList<>();
            for (int n = 1; n <= 5; n++) {
                int k = n;                  // lambdas need an effectively final copy
                jobs.add(() -> slowSquare(k));
            }
            int total = 0;
            for (Future<Integer> f : pool.invokeAll(jobs)) total += f.get();
            System.out.println("sum of squares 1..5 = " + total);
        }   // close() waits for running tasks, then shuts the pool down
    }
}
Outputcompiled & run with real Java
result=49
sum of squares 1..5 = 55

A Callable is a Runnable that returns a value and may throw. invokeAll returns the futures in the same order as the tasks, so reading them in a loop is deterministic.

Since Java 19 an ExecutorService is AutoCloseable, so try-with-resources shuts it down for you. In older code you will see the manual version, and you must write it yourself on Java 17 and earlier — forget it and the JVM never exits because the pool threads keep it alive.

java
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
    // submit tasks...
} finally {
    pool.shutdown();                                  // stop accepting new tasks
    if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
        pool.shutdownNow();                           // interrupt stragglers
    }
}

The shutdown pattern for Java 17 and earlier.

FactoryWhat you getTypical use
newFixedThreadPool(n)n threads, unbounded queueCPU-bound work: n = number of cores
newCachedThreadPool()Grows and shrinks on demandMany short tasks; can explode under load
newSingleThreadExecutor()One thread, tasks run in orderSerialising access to something
newScheduledThreadPool(n)Delayed and repeating tasksHeartbeats, cleanup jobs
newVirtualThreadPerTaskExecutor()A new virtual thread per taskBlocking I/O at scale (Java 21+)
Error you will hit

ExecutionException: the task threw, get() rethrows

java
import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws Exception {
        try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
            Future<Integer> f = pool.submit(() -> Integer.parseInt("abc"));
            System.out.println(f.get());
        }
    }
}
Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.NumberFormatException: For input string: "abc"
	at java.base/java.util.concurrent.FutureTask.report(Unknown Source)
	at java.base/java.util.concurrent.FutureTask.get(Unknown Source)
	at Main.main(Main.java:7)
Caused by: java.lang.NumberFormatException: For input string: "abc"
	at java.base/java.lang.NumberFormatException.forInputString(Unknown Source)
	at java.base/java.lang.Integer.parseInt(Unknown Source)
	at java.base/java.lang.Integer.parseInt(Unknown Source)
	at Main.lambda$main$0(Main.java:6)
	at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
	...
Why the compiler said that

The exception happened on a pool thread, not on main. The pool catches it and stores it in the Future; get() rethrows it wrapped in ExecutionException. The real problem is always in the Caused by section — here, line 6 of the lambda.

The fix

Catch ExecutionException around get() and inspect e.getCause(). Note that a task passed to execute() or a Future nobody calls get() on swallows the exception silently, so always check your futures.

java
import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
            Future<Integer> f = pool.submit(() -> Integer.parseInt("abc"));
            try {
                System.out.println(f.get());
            } catch (ExecutionException e) {
                System.out.println("task failed: " + e.getCause().getMessage());
            }
        }
    }
}
06

CompletableFuture: chaining async work

A plain Future can only be waited on. CompletableFuture lets you describe what should happen when the value arrives — transform it, combine it with another result, recover from failure — without blocking a thread in between. It is how Java services call two backends in parallel and merge the answers.

javaMain.java
import java.util.concurrent.CompletableFuture;

public class Main {
    static String fetchUser(int id)  { return "user" + id; }
    static int    fetchOrders(int id) { return id * 3; }

    public static void main(String[] args) {
        CompletableFuture<String> user = CompletableFuture.supplyAsync(() -> fetchUser(7));
        CompletableFuture<Integer> orders = CompletableFuture.supplyAsync(() -> fetchOrders(7));

        String summary = user
            .thenApply(String::toUpperCase)                               // transform
            .thenCombine(orders, (u, o) -> u + " has " + o + " orders")    // merge two results
            .join();                                                      // wait for the end
        System.out.println(summary);

        int safe = CompletableFuture.supplyAsync(() -> Integer.parseInt("oops"))
            .exceptionally(ex -> {
                System.out.println("failed: " + ex.getCause().getClass().getSimpleName());
                return -1;                                                // fallback value
            })
            .join();
        System.out.println(safe);
    }
}
Outputcompiled & run with real Java
USER7 has 21 orders
failed: NumberFormatException
-1

The two fetches run at the same time on the common pool. join() is like get() but throws an unchecked CompletionException, so it is easier inside lambdas.

Your turn

Add a .thenApply(s -> s.length()) step before join() and change the variable type to int.

MethodPlain meaning
supplyAsync(supplier)Start computing a value on another thread
thenApply(fn)When done, transform the value (like map)
thenCompose(fn)When done, start another async step that itself returns a CompletableFuture (like flatMap)
thenCombine(other, fn)When both are done, merge the two values
allOf(f1, f2, ...)Completes when all of them have completed
exceptionally(fn) / handle(fn)Recover from a failure with a fallback
join() / get()Block and take the result (do this once, at the edge)
javaMain.java
import java.util.*;
import java.util.concurrent.CompletableFuture;

public class Main {
    public static void main(String[] args) {
        List<CompletableFuture<Integer>> prices = new ArrayList<>();
        for (int shop = 1; shop <= 4; shop++) {
            int s = shop;
            prices.add(CompletableFuture.supplyAsync(() -> 100 - s * 7));
        }
        CompletableFuture.allOf(prices.toArray(new CompletableFuture[0])).join();

        List<Integer> all = prices.stream().map(CompletableFuture::join).toList();
        System.out.println(all + " cheapest=" + Collections.min(all));
    }
}
Outputcompiled & run with real Java
[93, 86, 79, 72] cheapest=72

Fan out, wait for all, then read the results in the order you created them. The order of the list never depends on which task finished first.

In real jobs
By default supplyAsync runs on ForkJoinPool.commonPool(), which is sized for CPU work. For blocking calls (HTTP, JDBC) pass your own executor as the second argument, or use virtual threads, so slow I/O does not starve every other async task in the JVM.
07

Virtual threads (Java 21)

A classic platform thread is a wrapper around an operating-system thread. A virtual thread (final in Java 21) is managed by the JVM instead: it costs a few hundred bytes, and when it blocks on I/O or sleep, the JVM parks it and reuses the underlying carrier thread for another virtual thread. You can run hundreds of thousands of them, which makes the simple "one thread per request" style scale again without callbacks.

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

public class Main {
    public static void main(String[] args) throws Exception {
        Thread vt = Thread.ofVirtual().name("v-1").start(() ->
            System.out.println(Thread.currentThread().getName()
                + " virtual=" + Thread.currentThread().isVirtual()));
        vt.join();

        try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Integer>> futures = new ArrayList<>();
            for (int i = 0; i < 10_000; i++) {
                int id = i;
                futures.add(exec.submit(() -> {
                    Thread.sleep(10);        // blocking wait: cheap on a virtual thread
                    return id % 7;
                }));
            }
            long total = 0;
            for (Future<Integer> f : futures) total += f.get();
            System.out.println("10000 tasks, total=" + total);
        }
    }
}
Outputcompiled & run with real Java
v-1 virtual=true
10000 tasks, total=29994

Ten thousand tasks that each sleep 10 ms finish in well under a second, because none of them holds an OS thread while sleeping.

Your turn

Swap the executor for Executors.newFixedThreadPool(10) and compare how long it takes (use System.nanoTime() around the loop).

Use virtual threads for

  • Web requests that wait on databases and other services
  • Many concurrent HTTP or JDBC calls
  • Code you want to keep simple and blocking

Do not bother for

  • CPU-heavy number crunching (more threads than cores does not help)
  • Pooling them — they are cheap, create one per task
  • Code that holds a lock around slow I/O for long periods (keep lock sections short)
In real jobs
Spring Boot 3.2+ switches its web server to virtual threads with one property, spring.threads.virtual.enabled=true. The concurrency rules in this module still apply: virtual threads share memory exactly like platform threads, so races and locks work the same way.
08

Concurrent collections

HashMap and ArrayList are not thread-safe: concurrent writes can lose entries or corrupt the structure. java.util.concurrent ships collections built for sharing. The one you will use most is ConcurrentHashMap, whose merge, compute and putIfAbsent are atomic, so check-then-act on a key is solved for you.

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

public class Main {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
        String[] words = {"java", "go", "java", "rust", "java", "go"};

        try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
            for (int round = 0; round < 100; round++) {
                for (String w : words) {
                    pool.submit(() -> counts.merge(w, 1, Integer::sum));
                }
            }
        }
        System.out.println(new TreeMap<>(counts));   // sorted copy for stable output
    }
}
Outputcompiled & run with real Java
{go=200, java=300, rust=100}

600 tasks on four threads, and not one count lost. With a plain HashMap the totals would come out short.

Producer and consumer with a BlockingQueue

A BlockingQueue connects threads that produce work to threads that consume it. put waits when the queue is full; take waits when it is empty. That built-in waiting is called back-pressure: a fast producer cannot run away from a slow consumer. A special "poison pill" value tells the consumer to stop.

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

public class Main {
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<String> queue = new ArrayBlockingQueue<>(2);

        Thread producer = new Thread(() -> {
            try {
                for (String job : new String[] {"resize.jpg", "scan.pdf", "notes.txt"}) queue.put(job);
                queue.put("STOP");                         // poison pill
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        Thread consumer = new Thread(() -> {
            try {
                while (true) {
                    String job = queue.take();             // waits if empty
                    if (job.equals("STOP")) break;
                    System.out.println("processing " + job);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        producer.start();
        consumer.start();
        producer.join();
        consumer.join();
        System.out.println("all jobs done");
    }
}
Outputcompiled & run with real Java
processing resize.jpg
processing scan.pdf
processing notes.txt
all jobs done

Only one thread prints the jobs and the queue is first-in-first-out, so the order is fixed.

Instead ofUseWhy
HashMapConcurrentHashMapLock-striped, atomic merge/compute; never throws ConcurrentModificationException
ArrayList (read-mostly)CopyOnWriteArrayListEvery write copies the array; iteration never locks. Good for listener lists
LinkedList as a queueArrayBlockingQueue / LinkedBlockingQueueBlocking put/take between threads
TreeMapConcurrentSkipListMapSorted and thread-safe
Collections.synchronizedMap(...)ConcurrentHashMapOne global lock is a bottleneck, and iteration still needs manual locking
09

Deadlock and how to avoid it

A deadlock is two (or more) threads each holding a lock the other needs, both waiting forever. Nothing crashes and nothing is printed — the program just hangs. The classic cause is taking two locks in different orders on different threads. The code below can hang on the first run or the hundredth, which is why it is a static block and not a verified example.

javaMain.java
public class Main {
    static final Object accountA = new Object();
    static final Object accountB = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (accountA) {             // thread 1 holds A...
                pause();
                synchronized (accountB) {         // ...and waits for B
                    System.out.println("A -> B");
                }
            }
        }).start();

        new Thread(() -> {
            synchronized (accountB) {             // thread 2 holds B...
                pause();
                synchronized (accountA) {         // ...and waits for A
                    System.out.println("B -> A");
                }
            }
        }).start();
    }

    static void pause() {
        try { Thread.sleep(100); } catch (InterruptedException e) { }
    }
}

With the 100 ms pause, each thread grabs its first lock before the other asks for it. Neither line is ever printed.

  1. 1
    Spot it

    A hung JVM with low CPU usage is the signature. Run jstack <pid> (or jcmd <pid> Thread.print); it prints Found one Java-level deadlock and names the threads and locks involved.

  2. 2
    Fix it with lock ordering

    Give every lock a global order (by account id, say) and always take them in that order. If both threads lock A before B, the cycle cannot form.

  3. 3
    Or give up instead of waiting

    ReentrantLock.tryLock(timeout, unit) returns false instead of waiting forever; release what you hold, back off, and retry.

  4. 4
    Or avoid holding two locks

    The best fix is design: hold one lock at a time, keep lock sections tiny, and never call unknown code (listeners, callbacks) while holding a lock.

java
static void transfer(Account from, Account to, int amount) {
    Account first  = from.id() < to.id() ? from : to;   // always lock the lower id first
    Account second = from.id() < to.id() ? to : from;
    synchronized (first) {
        synchronized (second) {
            from.withdraw(amount);
            to.deposit(amount);
        }
    }
}

Lock ordering: transfer(A, B) and transfer(B, A) now take the locks in the same order, so they can never deadlock.

The rules that prevent most concurrency bugs
Prefer immutable objects (records, List.of) — they can be shared freely. Prefer higher-level tools (executors, concurrent collections, CompletableFuture) over raw threads and wait/notify. Keep shared mutable state small and behind one lock. Join or get() before reading results.
Thread
An independent path of execution. All threads in a JVM share the heap.
Race condition
A bug where the result depends on the timing of threads, such as two unsynchronized count++ calls losing an update.
Happens-before
The Java Memory Model guarantee that one action's writes are visible to another (start, join, synchronized, volatile).
Monitor lock
The built-in lock every object has, taken by synchronized.
CAS (compare-and-set)
An atomic CPU instruction: set a value only if it still equals an expected value. Powers the atomic classes.
ExecutorService
A pool of worker threads that runs submitted tasks and returns Futures.
Future
A handle to a result that may not be ready yet; get() waits for it.
CompletableFuture
A Future you can chain: transform, combine and recover without blocking.
Virtual thread
A lightweight JVM-managed thread (Java 21) that releases its carrier thread while blocked.
Deadlock
Threads each holding a lock the other needs, waiting forever.
Quick check

Two threads each call counter.incrementAndGet() 1,000 times on a shared AtomicInteger, then main joins both. What does counter.get() return?

Quick check

Thread 1 locks A then B. Thread 2 locks B then A. What is the simplest reliable fix?

Frequently asked questions

What is the difference between a process and a thread in Java?
A process is a running program with its own memory; a JVM is one process. Threads live inside a process and share its heap, so they can read and write the same objects. That sharing makes threads cheap to communicate through, and it is also the source of race conditions.
Should I use virtual threads or a thread pool?
For tasks that mostly wait on I/O (HTTP calls, database queries), use virtual threads via Executors.newVirtualThreadPerTaskExecutor() on Java 21+. For CPU-bound work, use a fixed pool sized to the number of cores, because extra threads cannot make the CPU go faster.
Is synchronized slow in Java?
An uncontended synchronized block is very cheap on a modern JVM. It becomes slow when many threads fight over the same lock. Keep locked sections short, use atomics or ConcurrentHashMap for hot counters and maps, and measure before optimising.

Finish the Java handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.