You closed module 7 with an uncomfortable sentence: "your laptop has eight cores and BiblioTech uses one". The application remembers, configures itself without recompiling, validates its imports and does not corrupt its files. But it does things one after another, and that is why the menu goes mute during an import, why the two hundred notices take ten minutes and why the processor sits idle while somebody decides which option to type.

This lesson does not write the solution yet: it builds the mental model without which the solution is impossible to reason about. You are going to understand what a thread is exactly, how it differs from a process, what two threads share and what they do not —the diagram that literally explains half the concurrency bugs you will meet in your career—, how concurrency and parallelism differ, why the right number of threads depends on whether your task burns CPU or waits for a disk, and what dangers appear the moment two threads touch the same thing.

And you will finish with a twenty-line program that genuinely fails: two threads adding one to a counter two million times and producing a result lower than two million, reproducibly. That program is the hook for the whole module. Everything that follows —synchronized, volatile, atomic variables, concurrent collections— exists so that number goes back to being what it should be.

A note on method. Concurrency is the only topic in this course where reading is not enough. Run the examples. Run them several times. The ones that fail do not fail every time, and watching the same program give two different results in two consecutive runs is the lesson no paragraph can replace.

Contents

  1. The real problem: BiblioTech waits
  2. Process versus thread
  3. What two threads share and what they do not
  4. Concurrency versus parallelism
  5. Why threads are used: the three reasons
  6. CPU-bound and I/O-bound tasks
  7. The main thread and the current thread
  8. Daemon threads and when the JVM terminates
  9. The real cost of a thread
  10. The three dangers: race, deadlock, starvation
  11. The first example that fails: the shared counter
  12. Common Mistakes and Tips
  13. Exercises

  1. The real problem: BiblioTech waits

Before the theory, the three concrete cases that justify it. They are three real situations from code you have already written.

Case A — the import blocks the menu. CatalogImporter reads a fifty-thousand-line file, validates each one and builds a Report. It takes eight seconds. During those eight seconds, BiblioTechMenu does not read the keyboard, does not paint anything, does not respond. The user does not know whether the program is working or has hung, and cannot cancel it.

Case B — the notices are sent one at a time. NoticeQueue has two hundred overdue loans. Sending a notice means writing a notification file and waiting for the disk to confirm. Say 300 ms per notice. Two hundred notices are sixty seconds of wall-clock time, during which the processor is practically idle: it is waiting for the disk, not computing.

Case C — two employees at once. Today BiblioTech is single-user. If tomorrow Marta Ruiz and Diego Alonso registered loans simultaneously on the same Catalog, nothing in the code would stop one from trampling the other's work. There is not a single synchronized in the whole project because until now it was not needed.

The three cases share the same root —a single flow of execution— and have three different solutions that this module develops. Case A needs the work moved to another flow. Case B needs several flows overlapping their waits. Case C needs the shared state protected.

  1. Process versus thread

When you launch java BiblioTechApp, the operating system creates a process: a unit of execution with its own virtual memory space, its own file descriptors and its own security identity. Inside that process, the JVM creates threads: independent flows of execution that share all of that memory.

The essential difference fits in one sentence: two processes cannot see each other's memory; two threads of the same process share the whole heap. Every other difference follows from that one.

Aspect Process Thread
Memory space Its own, isolated Shared with the other threads of the process
Creation cost High (milliseconds, copying page tables) Low (microseconds)
Context-switch cost High (swap page tables, flush the TLB) Low (swap registers and stack pointer)
Communication between them Expensive: files, pipes, sockets, explicit shared memory Immediate: a variable
Isolation against failures Total: if one dies, the other carries on None: one OutOfMemoryError takes down the whole process
Typical memory usage Tens or hundreds of MB ~1 MB of reserved stack (Linux x64, by default)
Who manages it The operating system The operating system, exposed by the JVM
flowchart TB
    subgraph P1["Process A - BiblioTechApp"]
        direction TB
        H1["Shared heap<br/>Catalog, LoanRegistry, statics"]
        subgraph ThreadsA[" "]
            direction LR
            T1["main thread<br/>own stack"]
            T2["importer thread<br/>own stack"]
            T3["notices thread<br/>own stack"]
        end
        T1 --- H1
        T2 --- H1
        T3 --- H1
    end
    subgraph P2["Process B - text editor"]
        H2["Own heap<br/>invisible from process A"]
        T4["main thread<br/>own stack"]
        T4 --- H2
    end
    P1 -.->|"only through a file, pipe or socket"| P2

The solid arrows inside process A are direct memory access: the importer thread can write into the very same Catalog object the main thread is reading, without asking anyone's permission and without the operating system getting involved. The dashed arrow between processes represents the opposite: for the text editor to see anything belonging to process A, an explicit and expensive mechanism is required.

That solid arrow is at once all the power and all the danger of multithreading. Sharing memory is what makes threads cheap, and it is what makes a counter able to lose increments.

A historical and practical note. The "one process per task" model is not obsolete: it is exactly what modern browsers do with every tab, precisely because of the failure isolation. When isolation matters more than cheap communication, use processes. When communication matters more, use threads.

  1. What two threads share and what they do not

This is the most important section of the lesson. If you internalise this diagram, most concurrency bugs stop being black magic and become predictable consequences.

Every JVM thread has its own stack, its own program counter and, consequently, its own local variables and parameters. Everything else —the heap, where objects live, and the class area— is shared.

flowchart TB
    subgraph JVM["JVM process"]
        direction TB
        subgraph Shared["SHARED by all threads"]
            direction LR
            HEAP["Heap<br/>every object<br/>new Book(...), arrays, strings"]
            STAT["Class area<br/>static fields<br/>metadata, constants"]
        end
        subgraph Private["PRIVATE to each thread"]
            direction LR
            subgraph HA["main thread"]
                PA["Stack<br/>method frames<br/>local variables<br/>Program counter"]
            end
            subgraph HB["importer thread"]
                PB["Stack<br/>method frames<br/>local variables<br/>Program counter"]
            end
        end
        PA -->|"references point to"| HEAP
        PB -->|"references point to"| HEAP
        PA --> STAT
        PB --> STAT
    end

Translated into code:

public class WhatIsShared {

    // SHARED: a single value for the whole JVM.
    // If two threads modify it, they trample each other.
    private static int globalCounter = 0;

    // The object this reference points to lives on the heap:
    // if two threads hold the same reference, they see the SAME object.
    private final Catalog catalog;

    public WhatIsShared(Catalog catalog) {
        this.catalog = catalog;
    }

    public void process(String isbn) {
        // PRIVATE: 'found' and 'retries' live on the stack of the thread
        // that is executing 'process'. If three threads call this
        // method at once, there are THREE independent copies, one per stack.
        boolean found = false;
        int retries = 0;

        // 'catalog' is a reference (a private copy on the stack),
        // but the OBJECT it points to is shared.
        // Modifying it affects every thread.
        Material m = catalog.find(isbn);
        found = (m != null);

        globalCounter++;   // <-- SHARED. Here is the problem.
    }
}

The three rules that follow, and that you must memorise:

  1. Local variables are safe by construction. An int i declared inside a method cannot have a race condition: each thread has its own. This is stack confinement and it is the cheapest form of safety there is.
  2. Object fields and static fields are shared if the object is reachable from several threads. A Book created and used by a single thread is as safe as a local variable. The danger is not the field: it is the sharing.
  3. A local reference to a shared object protects nothing. It is the most frequent intuition failure among beginners: Material m is local, but the Material at the other end of it is not.

A practical rule you will use a thousand times. Before asking yourself "does this need synchronized?", ask yourself "can this object be reached by two threads at once?". If the answer is no, there is nothing to synchronise. Half the synchronisation written in the industry is unnecessary because nobody asked that question.

  1. Concurrency versus parallelism

The two terms are used as synonyms in informal conversation and they are not. The distinction is the one Rob Pike draws in a much-quoted sentence: "concurrency is about dealing with lots of things at once; parallelism is about doing lots of things at once".

  • Concurrency: structure. Several tasks are in progress during the same period of time. They can run interleaved on a single core, taking turns in time slices of a few milliseconds.
  • Parallelism: execution. Several tasks execute instructions at the same physical instant, which requires several cores.
flowchart TB
    subgraph C["CONCURRENCY - 1 core, interleaved tasks"]
        direction LR
        c1["A"] --> c2["B"] --> c3["A"] --> c4["B"] --> c5["A"] --> c6["B"]
    end
    subgraph P["PARALLELISM - 2 cores, simultaneous tasks"]
        direction TB
        subgraph N1["Core 1"]
            direction LR
            p1["A"] --> p2["A"] --> p3["A"]
        end
        subgraph N2["Core 2"]
            direction LR
            p4["B"] --> p5["B"] --> p6["B"]
        end
    end

Three consequences that really matter:

First: concurrency is useful even if you only have one core. BiblioTech's case A —the menu that responds while an import runs— is solved with pure concurrency. Even if the machine had a single core, interleaving the two tasks would make the menu respond. It does not go faster: it becomes more responsive, which is a different thing.

Second: parallelism is the only way to make a computation finish sooner. If you have to calculate the fines for fifty thousand loans and that is pure computation, interleaving on one core does not save a single millisecond. Only spreading the work across cores does.

Third, and the one that hurts most: your concurrent code will run in parallel whether you like it or not. It is tempting to reason "my development machine does things in turns, so the race bug will not show up". The production server has thirty-two cores and the memory reordering you will see in 08-04 shows up there and not on your laptop. A correct concurrent program has to be correct for every possible interleaving.

Concurrency Parallelism
What it is A property of the design A property of the execution
Cores needed One is enough Two or more
What it improves Responsiveness, use of waiting time Total computation time
BiblioTech case Live menu during the import 200 notices spread over 8 cores
Achieved with Threads, tasks, asynchrony Threads running at once on several cores

  1. Why threads are used: the three reasons

There are only three legitimate reasons to introduce threads into a program. If your reason is not one of them, you are probably adding complexity with no gain.

Reason 1: use several cores. A sequential program on an eight-core machine uses 12.5% of the computing capacity. If the work is divisible, spreading it out can approach a factor of eight. Can: Amdahl's law says the speed-up is limited by the fraction that cannot be parallelised. If 20% of your work is inherently sequential, the maximum speed-up with infinite cores is 5x, not infinite.

Reason 2: do not block the interface. Every interactive program —console, desktop, web— has a flow that must keep responding. That is case A: BiblioTech's menu cannot go mute for eight seconds. The solution is not to make the import faster: it is to get it off the thread that serves the user.

Reason 3: overlap I/O waiting with useful work. This is the most profitable reason and the worst understood. When a thread calls Files.readAllLines, the operating system suspends it until the disk responds. During those milliseconds the thread consumes no CPU: it is parked. If you have two hundred notices to write and each one waits 300 ms for the disk, a single thread adds up to sixty seconds of waiting; twenty threads overlap those waits and bring it down to three seconds —without needing twenty cores, because they are not computing, they are waiting.

The three reasons map exactly onto the three cases of section 1:

BiblioTech case Reason What is gained
A: import blocks the menu Do not block the interface Responsiveness and cancellation
B: 200 notices one at a time Overlap I/O waiting From 60 s to ~3 s
C: two employees at once (None: it is the cost) Nothing; protection has to be paid for

Case C matters precisely because it is not a reason, it is a bill. The moment you introduce threads for reasons 1, 2 or 3, the obligation to protect shared state appears. Concurrency is not free: it trades performance and responsiveness for complexity and entire classes of new bugs.

  1. CPU-bound and I/O-bound tasks

Every task falls, roughly, into one of two categories. The category determines how many threads it is worth using, and getting this wrong is the usual cause of "I added threads and it got slower".

  • CPU-bound: the thread spends almost all its time executing instructions. Calculating fines, sorting a million elements, compressing, encrypting, parsing text.
  • I/O-bound: the thread spends almost all its time waiting for a device. Reading a file, writing to disk, waiting for a database, waiting for the network.
import java.util.concurrent.TimeUnit;

public class TaskTypes {

    /** CPU-bound: the core works at 100% for the whole method. */
    static long computeHash(String text, int rounds) {
        long h = 0;
        for (int v = 0; v < rounds; v++) {
            for (int i = 0; i < text.length(); i++) {
                h = h * 31 + text.charAt(i);
            }
        }
        return h;
    }

    /** I/O-bound: the thread is suspended, the core is left free. */
    static void writeNotice(String recipient) throws InterruptedException {
        // We simulate the disk latency. During these 300 ms the thread
        // does NOT consume CPU: the operating system has it parked.
        TimeUnit.MILLISECONDS.sleep(300);
    }
}

The sizing rule, which will be developed with thread pools in 08-05:

CPU-bound I/O-bound
Where the thread is Executing Waiting, suspended
Useful threads ≈ number of cores Many more than cores (tens or hundreds)
What happens if you add more Worse: context switches are pure cost Better: more overlapping waits
What happens if you use fewer Idle cores Wall-clock time wasted waiting
Rule-of-thumb formula N = cores (or N+1) N × (1 + wait/compute)
BiblioTech case Recalculating all the fines Sending the 200 notices

Java tells you how many cores there are:

public class Cores {
    public static void main(String[] args) {
        int cores = Runtime.getRuntime().availableProcessors();
        System.out.println("Available processors: " + cores);

        // Rule-of-thumb sizing for pure computation tasks.
        System.out.println("Suggested pool (CPU): " + cores);

        // For I/O with a wait/compute ratio of ~9 (300 ms waiting for
        // every 30 ms computing), the formula gives N * (1 + 9) = N * 10.
        System.out.println("Suggested pool (I/O, ratio 9): " + (cores * 10));
    }
}

Two subtleties about availableProcessors() that avoid surprises in production:

  1. It returns hardware threads, not physical cores. A 4-core processor with hyperthreading returns 8. For pure computation tasks, logical threads perform noticeably worse than a real core.
  2. In a container with a CPU limit, modern JVMs (Java 10+) respect the cgroup limit. Old Java 8 returned the host machine's cores, which produced giant pools inside tiny containers. It is a migration classic.

  1. The main thread and the current thread

You have never written a single-threaded program. When the JVM starts, it creates the main thread and has it run your main method; on top of that there are several internal threads —the garbage collector, the JIT compiler, the finaliser— working in the background from the very first instant.

Thread.currentThread() returns the thread that is executing that line at that moment. It is the number-one diagnostic tool of this module:

public class CurrentThread {
    public static void main(String[] args) {
        Thread current = Thread.currentThread();

        System.out.println("Name   : " + current.getName());
        System.out.println("Id     : " + current.threadId());   // Java 19+; getId() before
        System.out.println("State  : " + current.getState());
        System.out.println("Prio.  : " + current.getPriority());
        System.out.println("Daemon : " + current.isDaemon());
        System.out.println("Group  : " + current.getThreadGroup().getName());

        greet();
    }

    static void greet() {
        // The current thread does not depend on the method: it depends on WHO calls it.
        System.out.println("greet() runs on: "
                + Thread.currentThread().getName());
    }
}

Output:

Name   : main
Id     : 1
State  : RUNNABLE
Prio.  : 5
Daemon : false
Group  : main
greet() runs on: main

Notice the detail in the greet() method: a method does not "belong" to a thread. The same method can be running simultaneously in five different threads, each with its own set of local variables on its own stack. That is section 3 again, seen from the code.

A tip you will apply throughout the module. When something odd happens, print or log Thread.currentThread().getName(). The question "which thread is this running on?" resolves a surprising proportion of concurrency mysteries. In BiblioTech, OperationLog should include the thread name in every entry; you will do that in exercise 3.

  1. Daemon threads and when the JVM terminates

Java distinguishes two kinds of thread:

  • Non-daemon (user thread): the normal thread. main is one.
  • Daemon: a service thread, meant for supporting tasks that should not prevent the program from shutting down.

The rule is blunt and you have to know it by heart:

The JVM terminates when no NON-daemon thread is left alive. Daemon threads are aborted at that instant, with no warning, without running finally, without closing resources.

main finishing does not shut down the JVM: if a non-daemon thread is still alive, the process stays up. It is the classic cause of "the program does not terminate and I do not know why".

public class DaemonVsUser {
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 1; i <= 5; i++) {
                System.out.println(Thread.currentThread().getName() + " step " + i);
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
            }
            System.out.println(Thread.currentThread().getName() + " FINISHED");
        };

        Thread user = new Thread(task, "user-thread");
        Thread daemon = new Thread(task, "daemon-thread");
        daemon.setDaemon(true);   // MANDATORY before start()

        user.start();
        daemon.start();

        Thread.sleep(500);
        System.out.println(">>> main ends here");
    }
}

Typical output:

user-thread step 1
daemon-thread step 1
user-thread step 2
daemon-thread step 2
user-thread step 3
daemon-thread step 3
>>> main ends here
user-thread step 4
user-thread step 5
user-thread FINISHED

Read it carefully, because there are two facts in that output:

  1. main finishes at step 3 and the process does not die: user-thread is non-daemon and the JVM waits for it until it prints FINISHED.
  2. daemon-thread never reaches steps 4 and 5. The instant user-thread finishes, no non-daemon thread is left, the JVM shuts down and the daemon is cut off halfway.
Non-daemon Daemon
Prevents the JVM from terminating Yes No
When the JVM shuts down It is waited for It is aborted without warning
Runs its finally on shutdown Yes, it finishes normally Not guaranteed
Typical use Business work Monitoring, periodic cleanup, caches
How it is marked By default setDaemon(true) before start()

Practical rules:

  • setDaemon(true) after start() throws IllegalThreadStateException. Always before.
  • A thread inherits the daemon status of the thread that creates it by default.
  • Never put work that cannot be lost on a daemon thread. If a daemon is writing the catalogue file when the JVM shuts down, the file is left half-written. For work that must complete, use a non-daemon thread and wait explicitly with join() (08-02).
  • The shutdown hook you added to BiblioTech in module 7 does run on shutdown, and it is the right place for saving state. Do not replace it with a daemon.

  1. The real cost of a thread

A thread looks free because new Thread(...) is one line. It is not.

Memory. Every thread reserves a stack. On 64-bit Linux the default is 1 MB (adjustable with -Xss). It is address space reserved, and physical memory is touched as the stack grows, but the order of magnitude rules: ten thousand threads are ten gigabytes of reserved stacks. On a normal machine, the JVM dies with OutOfMemoryError: unable to create native thread long before that.

Creation. Creating a platform thread means a system call and the kernel setting up its structures. The order is tens to hundreds of microseconds: a thousand times more expensive than creating a normal object. If you create a thread per request and the requests are short, the creation cost can exceed the useful work.

Context switching. When the scheduler takes one thread off and puts another on, registers have to be saved and restored, and —the expensive part— the core's data cache is full of the previous thread's data. The direct cost is around a microsecond; the indirect one, through cache misses, can be much higher. With more active threads than cores, the machine can spend more time switching threads than working: that is scheduling thrashing.

public class ThreadCreationCost {
    public static void main(String[] args) throws InterruptedException {
        int n = 10_000;

        long t0 = System.nanoTime();
        Thread[] threads = new Thread[n];
        for (int i = 0; i < n; i++) {
            threads[i] = new Thread(() -> { /* does nothing */ });
        }
        long tCreation = System.nanoTime() - t0;

        long t1 = System.nanoTime();
        for (Thread t : threads) t.start();
        for (Thread t : threads) t.join();
        long tStartup = System.nanoTime() - t1;

        System.out.printf("Building %d Thread objects: %.1f ms%n",
                n, tCreation / 1_000_000.0);
        System.out.printf("Starting and joining %d threads: %.1f ms%n",
                n, tStartup / 1_000_000.0);
        System.out.printf("Average cost per started thread: %.1f us%n",
                tStartup / 1_000.0 / n);
    }
}

Indicative output (it varies a lot from machine to machine):

Building 10000 Thread objects: 4.7 ms
Starting and joining 10000 threads: 812.3 ms
Average cost per started thread: 81.2 us

Eighty microseconds per thread doing absolutely nothing. If your real task takes less than that, creating a thread for it is a net loss.

From this comes the conclusion that governs modern practice: "one thread per task" does not scale. The classic solution is the thread pool, a fixed number of reused threads that consume tasks from a queue; that is 08-05. The new solution is Java 21's virtual threads, which make the cost so low that "one thread per task" becomes viable again; they are explained in 10-06.

  1. The three dangers: race, deadlock, starvation

Before writing concurrent code it is worth knowing the three failure modes. Here they are only introduced with an intuition; solving them is the job of 08-04 and what follows.

Race condition. The result depends on the order in which the threads interleave, and you do not control that order.

Intuition: two employees look at the same inventory sheet at once, both read "3 copies left", both write down "2" after lending one out. Two copies have been lent and the inventory says one was.

Deadlock. Two threads wait for each other forever, each holding what the other needs.

Intuition: Marta has the key to the archive and needs the one to the storeroom; Diego has the storeroom key and needs the archive one. Neither lets go of theirs. Both wait indefinitely and there is no error, no exception, no trace: quite simply nothing moves.

Starvation. A thread never gets the resource it needs because others always take it first.

Intuition: the library printer serves short jobs first. Nuria Vidal's 400-page annual report never gets printed, because there is always somebody with a two-page job ahead of it.

Failure Symptom Studied and solved in
Race condition Incorrect, intermittent, irreproducible results 08-04 (synchronized, atomics), 08-06
Deadlock Total, silent blockage, with no error 08-04 (global order, tryLock), 08-03 (diagnosis)
Starvation One task never progresses while the others do 08-04 (fairness), 08-05 (pools)

Add a fourth characteristic common to all three that makes concurrency especially treacherous: they are heisenbugs. Adding a System.out.println to debug changes the timings and makes the failure vanish. Running under the debugger, likewise. And a program with a race condition can work correctly for months until one day, under more load or on a machine with more cores, it produces incorrect data. That is why this module insists so much on correctness by design and not on testing until it seems to work.

  1. The first example that fails: the shared counter

Here is the promised program. Two threads, one shared counter, each thread adds one a million times. The expected result is 2,000,000. It almost never is.

public class BrokenCounter {

    // Shared by the two threads: a single int on the heap.
    private int value = 0;

    public void increment() {
        value++;   // Looks like one operation. It is THREE. See 08-04.
    }

    public int value() {
        return value;
    }

    public static void main(String[] args) throws InterruptedException {
        final int ROUNDS = 1_000_000;

        // We repeat the experiment several times: the failure is intermittent
        // and a single run could fool you.
        for (int attempt = 1; attempt <= 5; attempt++) {
            BrokenCounter counter = new BrokenCounter();

            Thread t1 = new Thread(() -> {
                for (int i = 0; i < ROUNDS; i++) counter.increment();
            }, "adder-1");

            Thread t2 = new Thread(() -> {
                for (int i = 0; i < ROUNDS; i++) counter.increment();
            }, "adder-2");

            t1.start();
            t2.start();

            // join() waits for the thread to finish. Without this we would read
            // the counter while it is still changing. Details in 08-02.
            t1.join();
            t2.join();

            int expected = ROUNDS * 2;
            int actual = counter.value();
            System.out.printf("Attempt %d -> expected %d, actual %d, lost %d (%.2f%%)%n",
                    attempt, expected, actual, expected - actual,
                    100.0 * (expected - actual) / expected);
        }
    }
}

Real output from one run (the numbers change on every run, which is precisely the point):

Attempt 1 -> expected 2000000, actual 1362544, lost 637456 (31.87%)
Attempt 2 -> expected 2000000, actual 1198801, lost 801199 (40.06%)
Attempt 3 -> expected 2000000, actual 2000000, lost 0 (0.00%)
Attempt 4 -> expected 2000000, actual 1455913, lost 544087 (27.22%)
Attempt 5 -> expected 2000000, actual 1734022, lost 265978 (13.30%)

Four observations, all of them important:

First: it is not one increment that is lost, it is hundreds of thousands. This is not an extremely rare case that happens once in a million: it is a massive failure. The reason is explained in 08-04, but the summary is that value++ compiles to three instructions —read the value, add one, write the result— and any interleaving among those three loses work.

Second: attempt 3 gave the correct result. This is the genuinely dangerous part. If you had run the program once and got attempt 3, you would have concluded that the code is fine. A concurrent program giving the correct result proves nothing.

Third: a value greater than 2,000,000 never comes out. Increments are lost, not duplicated. With a 32-bit int the writes are atomic —each write writes a complete value—, so the error is always downwards. With a long on a 32-bit JVM you could see impossible values, half of one write and half of another; also in 08-04.

Fourth: if you replace ROUNDS with 1000, it will probably always come out correct. With little work, the first thread finishes before the second starts and there is no overlap at all. It is the reason these bugs do not show up in small tests but do in production.

An experiment worth doing. Change private int value to private volatile int value and run again. It still fails. That is the demonstration that volatile does not solve this, and one of the most widespread confusions on the topic; the why is in 08-04.

Keep this program to hand. You will come back to it with synchronized in 08-04 and with AtomicInteger in 08-06, and you will see the two correct ways of fixing it, each with its own cost.

Common Mistakes and Tips

Mistake 1: believing that "I have not seen it fail" means "it is correct". It is the gravest conceptual mistake of the module. An unfavourable interleaving can be rare on your laptop and frequent on a loaded 32-core server. Reason about correctness; do not test for it by running things repeatedly.

Mistake 2: confusing "it is local" with "it is safe". Material m = catalog.find(isbn) declares a local reference to a shared object. The reference is safe; the object is not.

Mistake 3: thinking more threads are always faster. For computation tasks, going from 8 to 80 threads on 8 cores makes performance worse through context switching. Measure before deciding.

Mistake 4: calling setDaemon(true) after start(). It throws IllegalThreadStateException. And worse: putting important work on a daemon and discovering that it is cut off mid-write.

Mistake 5: assuming that main finishing shuts down the JVM. With a non-daemon thread alive, the process keeps going. If your program "does not terminate", look for non-daemon threads still running (you will see how to diagnose that with thread dumps in 08-03).

Mistake 6: adding threads without one of the three reasons from section 5. Complexity, new bugs and hard diagnosis in exchange for nothing.

Mistake 7: debugging concurrency with System.out.println. println is internally synchronised, so it changes the behaviour: it can make the race you are trying to see disappear. Use the logger from 06-07, include the thread name, and accumulate data in memory to dump at the end when you measure.

Tip 1: think in terms of "tasks", not "threads". The rest of the module goes in that direction: you describe what work has to be done and let an executor decide where it is done.

Tip 2: the best synchronisation is not sharing. If each thread works on its own data and only the results are combined at the end, no races are possible. That is the confinement principle, and it is developed in 08-04.

Tip 3: name all your threads from day one. A dump full of Thread-0, Thread-1 and Thread-2 tells you nothing; one with bibliotech-importer, bibliotech-notices-3 tells you where to look. It is done in 08-02.

Tip 4: measure with System.nanoTime(), not currentTimeMillis(). nanoTime is a monotonic clock meant for measuring intervals; currentTimeMillis can jump backwards if the system adjusts the time.

Exercises

Exercise 1: Runtime environment diagnostics

Write a class ConcurrencyDiagnostics with a main that prints a report of the environment: number of available processors, the JVM's maximum memory, the name, identifier, priority and daemon status of the current thread, and the total number of live threads in the JVM at that moment. In addition, for an I/O-bound task with an average wait of 400 ms and an average computation of 50 ms per task, calculate and print the recommended pool size using the formula N × (1 + wait/compute).

Hint: Thread.activeCount() gives an approximation of the live threads in the current group; Runtime.getRuntime().maxMemory() gives the maximum memory in bytes.

Exercise 2: Demonstrating lost increments as a function of load

Extend the example in section 11 to answer this question empirically: from how many rounds onwards does it start failing? Write RaceThreshold which, for each value of rounds in {100, 1_000, 10_000, 100_000, 1_000_000}, repeats the experiment 10 times with two threads and counts in how many of the 10 repetitions the result was incorrect. Print a table with the load, the percentage of failed runs and the average loss.

Exercise 3: Traces with thread identity

Add to BiblioTech a utility method OperationLog.threadTrace(String operation) that logs to the 06-07 logger, at INFO level, a line with the current thread's name, its identifier and the operation. Then write a demonstration main that launches two threads called bibliotech-importer and bibliotech-notices, each of which calls threadTrace three times with 100 ms pauses, and check in the output that the lines from the two threads appear interleaved and not in blocks.

Solutions

Solution to Exercise 1

public class ConcurrencyDiagnostics {

    public static void main(String[] args) {
        Runtime rt = Runtime.getRuntime();
        Thread current = Thread.currentThread();

        int cores = rt.availableProcessors();

        System.out.println("=== RUNTIME ENVIRONMENT ===");
        System.out.println("Java                 : " + System.getProperty("java.version"));
        System.out.println("Operating system     : " + System.getProperty("os.name"));
        System.out.println("Available processors : " + cores);
        System.out.printf ("JVM max memory       : %.0f MB%n", rt.maxMemory() / (1024.0 * 1024));
        System.out.printf ("Current total memory : %.0f MB%n", rt.totalMemory() / (1024.0 * 1024));

        System.out.println();
        System.out.println("=== CURRENT THREAD ===");
        System.out.println("Name      : " + current.getName());
        System.out.println("Id        : " + current.threadId());
        System.out.println("Priority  : " + current.getPriority());
        System.out.println("Daemon    : " + current.isDaemon());
        System.out.println("State     : " + current.getState());

        // activeCount() counts the live threads of the current group and its subgroups.
        // It is an ESTIMATE: the number can change while it is being computed.
        System.out.println("Live threads (approx.): " + Thread.activeCount());

        System.out.println();
        System.out.println("=== POOL SIZING ===");

        // CPU-bound task: one thread per core keeps the cores
        // busy without paying for unnecessary context switches.
        System.out.println("Pool for CPU-bound tasks : " + cores);

        // I/O-bound task: formula N * (1 + wait/compute).
        // With 400 ms of waiting and 50 ms of computing, the ratio is 8,
        // so each core can sustain 9 useful threads.
        double wait = 400.0;
        double compute = 50.0;
        double ratio = wait / compute;
        int ioPool = (int) Math.round(cores * (1 + ratio));

        System.out.printf("Wait/compute ratio       : %.1f%n", ratio);
        System.out.println("Pool for I/O-bound tasks : " + ioPool);
        System.out.println();
        System.out.println("Note: these are starting points, not truths. "
                + "The final size is decided by measuring (08-05).");
    }
}

Teaching points:

  • availableProcessors() can return different values on different runs if the JVM is running in a container with dynamic limits. Do not cache it in a static final if the process is long-lived.
  • activeCount() is explicitly approximate: threads can be born and die while the count is happening. For serious diagnosis you use ThreadMXBean (08-03).
  • The I/O pool comes out much larger than the number of cores, and that is exactly the intuition of section 6: threads that are waiting do not compete for CPU.

Solution to Exercise 2

public class RaceThreshold {

    /** Deliberately unsafe counter. */
    static class Counter {
        private int value = 0;
        void increment() { value++; }
        int value() { return value; }
    }

    /**
     * Runs one experiment: two threads incrementing 'rounds' times.
     * Returns how many increments were lost (0 if all went well).
     */
    static int experiment(int rounds) throws InterruptedException {
        Counter c = new Counter();

        // A single Runnable shared by the two threads: both
        // increment THE SAME Counter object.
        Runnable task = () -> {
            for (int i = 0; i < rounds; i++) c.increment();
        };

        Thread t1 = new Thread(task, "adder-1");
        Thread t2 = new Thread(task, "adder-2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        return (rounds * 2) - c.value();
    }

    public static void main(String[] args) throws InterruptedException {
        int[] loads = { 100, 1_000, 10_000, 100_000, 1_000_000 };
        int REPETITIONS = 10;

        System.out.printf("%-12s | %-14s | %-16s%n",
                "Rounds", "Failures /10", "Average loss");
        System.out.println("-------------|----------------|------------------");

        for (int rounds : loads) {
            int failures = 0;
            long totalLost = 0;

            for (int r = 0; r < REPETITIONS; r++) {
                int lost = experiment(rounds);
                if (lost != 0) {
                    failures++;
                    totalLost += lost;
                }
            }

            double averageLoss = failures == 0 ? 0 : (double) totalLost / failures;
            System.out.printf("%-12d | %-14s | %-16.1f%n",
                    rounds, failures + " / " + REPETITIONS, averageLoss);
        }

        System.out.println();
        System.out.println("Conclusion: the failure does NOT disappear with small loads,");
        System.out.println("it simply becomes less likely. Code that has 'never");
        System.out.println("failed' with 100 rounds is exactly as incorrect as");
        System.out.println("code that always fails with 1,000,000.");
    }
}

Indicative output:

Rounds       | Failures /10   | Average loss
-------------|----------------|------------------
100          | 0 / 10         | 0.0
1000         | 1 / 10         | 412.0
10000        | 6 / 10         | 2871.3
100000       | 10 / 10        | 39104.7
1000000      | 10 / 10        | 548219.2

What matters in this exercise is not the number: it is the shape of the curve. With 100 rounds it never fails because the first thread finishes before the second starts overlapping with it; with a million it always fails. A production program is the right-hand column, and your unit test is usually the left-hand one.

Solution to Exercise 3

package com.nexussoftware.bibliotech.infrastructure;

import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Extension of OperationLog (06-07) with thread identity.
 * From module 8 onwards, EVERY trace should say which thread it happens on:
 * without that piece of data, a concurrent log is unreadable.
 */
public final class OperationLog {

    private static final Logger LOG =
            Logger.getLogger(OperationLog.class.getName());

    private OperationLog() { }

    /** Logs an operation including the thread that runs it. */
    public static void threadTrace(String operation) {
        Thread t = Thread.currentThread();
        LOG.log(Level.INFO, "[thread={0} id={1}] {2}",
                new Object[] { t.getName(), t.threadId(), operation });
    }

    // --- Demonstration ---
    public static void main(String[] args) throws InterruptedException {

        Runnable work = () -> {
            for (int step = 1; step <= 3; step++) {
                threadTrace("step " + step);
                try {
                    TimeUnit.MILLISECONDS.sleep(100);
                } catch (InterruptedException e) {
                    // Correct protocol from 08-02: restore the flag and leave.
                    Thread.currentThread().interrupt();
                    return;
                }
            }
        };

        Thread importer = new Thread(work, "bibliotech-importer");
        Thread notices  = new Thread(work, "bibliotech-notices");

        threadTrace("starting worker threads");
        importer.start();
        notices.start();

        importer.join();
        notices.join();
        threadTrace("all threads have finished");
    }
}

Output (with the LogConfiguration formatter):

INFO: [thread=main id=1] starting worker threads
INFO: [thread=bibliotech-importer id=21] step 1
INFO: [thread=bibliotech-notices id=22] step 1
INFO: [thread=bibliotech-notices id=22] step 2
INFO: [thread=bibliotech-importer id=21] step 2
INFO: [thread=bibliotech-importer id=21] step 3
INFO: [thread=bibliotech-notices id=22] step 3
INFO: [thread=main id=1] all threads have finished

Three things this output teaches:

  1. The lines are interleaved and the order between threads changes on every run. At step 2 the notices thread got ahead of the importer; on another run it will be the other way round. There is no guaranteed ordering between different threads.
  2. Within a single thread the order is guaranteed: step 1 always precedes step 2 on the same thread. This is the first intuition about the memory model: within a thread, everything happens in program order.
  3. java.util.logging is thread-safe: the Handlers synchronise publication, which is why the lines do not come out mixed character by character. It is another reason to use the 06-07 logger and not System.out.println from now on.

Conclusion

You now have the mental model. You have not yet written any useful concurrency, but you have built the three ideas without which the rest of the module would be memorisation.

The first: a thread is a flow of execution inside a process, much cheaper to create and to switch than a process, and with one decisive difference: it shares the whole heap and the statics with the other threads, and only its stack, its local variables and its program counter are private. That split —shared above, private below— explains why a local variable never suffers a race condition and why an object reachable from two threads always can. The right question is never "is this field dangerous?", but "can this object be reached by two threads at once?".

The second: concurrency and parallelism are not the same thing. Concurrency is a property of the design —several tasks in progress at the same time, even if they interleave on a single core— and it is what keeps BiblioTech's menu alive during an import. Parallelism is a property of the execution —several tasks advancing at the same instant— and it is the only thing that makes a computation finish sooner. And the practical consequence: your code will run on machines with many more cores than yours, so it has to be correct for any interleaving, not just the one you see.

The third: threads are introduced for three reasons and only three —use the cores, do not block the interface, overlap I/O waiting— and they cost money: around 1 MB of stack and tens of microseconds per thread, plus the context switch. Hence the right number of threads depends on whether the task is CPU-bound (≈ cores) or I/O-bound (many more, according to the wait/compute ratio), and on the fact that "one thread per task" does not scale, which is the reason the pools in 08-05 exist.

You also know how to tell the main thread from the current thread, to check Thread.currentThread().getName() as your first diagnostic tool, and the shutdown rule: the JVM terminates when no non-daemon thread is left, and daemons are aborted without running their finally —which is why work that cannot be lost is never entrusted to them—.

And you know the three failure modes that define the rest of the module: the race condition that gives incorrect, intermittent results, the deadlock that stops everything silently and without an exception, and the starvation that leaves a thread waiting forever. With the warning that makes them especially treacherous: they are heisenbugs, and adding a trace to see them can make them disappear.

Above all, you have the broken counter. Two threads, one value++, two million increments and a result that is sometimes 1,362,544, sometimes 1,734,022 and every now and then —the most dangerous thing of all— exactly 2,000,000. That twenty-line program contains the whole module in miniature: a real failure, reproducible, invisible in small tests and massive under load.

In the next lesson, Creating Threads, you stop contemplating the problem and start building. You will see the four ways of creating a thread and why Runnable beats extending Thread; the classic mistake of calling run() instead of start(), with the demonstration that it runs on the wrong thread; how to wait with join(), how to name threads so that logs are worth something, and why priorities almost never do what you think; the complete interruption protocol —interrupt(), InterruptedException, and why swallowing that exception is one of the worst mistakes you can make in Java—; and what happens when a thread throws an exception nobody catches. By the end, BiblioTech's catalogue import will run on its own thread, with its own name, and it will be cancellable.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved