Career OS

Every web server you will ever work on is concurrent. When your future Spring Boot app serves 100 users at once, that’s 100 threads running through your code at the same time — and if two of them touch the same variable, you get bugs that pass every test and only explode under real traffic. This module shows you the bug, makes you cause it on purpose, and teaches the small set of tools that fix it.

The Goal

By the end of this module you can:

  • Explain what a thread is — own stack, shared heap — and why that one sentence predicts every concurrency bug
  • Demonstrate a race condition with a broken counter and explain why counter++ is not one operation
  • Fix shared-state bugs two ways: synchronized and AtomicInteger
  • Run concurrent tasks properly with an ExecutorService instead of raw threads
  • Recite the beginner survival rule and apply it before writing any shared mutable state

The Lesson

Why you, specifically, care

You’re heading toward Spring Boot. Here’s the part nobody tells beginners: Spring creates one instance of your service class and lets every request thread run through it simultaneously.

flowchart TD
    U1[User 1 request] --> T1[Thread 1]
    U2[User 2 request] --> T2[Thread 2]
    U3[User 100 request] --> T3[Thread 100]
    T1 --> S[One shared PaymentService object]
    T2 --> S
    T3 --> S
    S --> D[Database]

One object. A hundred threads inside it at once. If that object has a mutable field — a counter, a list, a map — those threads are fighting over it right now. Most Spring services are safe by accident because they hold no state. The day you add private int requestCount; to “just track something,” you’ve written a production bug. This module is about understanding that day before it happens.

What a thread actually is

A thread is an independent path of execution inside your program. The JVM starts your code on one thread (main); you can start more, and the operating system runs them in parallel across CPU cores.

The one sentence that explains everything:

Each thread has its own stack, but all threads share the same heap.

Own stack = each thread’s local variables and method calls are private. Nobody can touch your int i inside a method.

Shared heap = every object lives in memory all threads can see. Fields of objects are common property.

Every concurrency bug ever written is two threads touching the same heap object without coordination. Local variables: always safe. Shared fields: danger zone. That’s the whole map.

Starting a thread — and why production code never does this raw

public class FirstThread {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {                 // a Runnable — behavior as a value,
            System.out.println("hello from " +        // same lambda idea as last module
                Thread.currentThread().getName());
        });
        t.start();                                    // start, not run — start spawns a thread,
        System.out.println("hello from main");        // run would just call the method normally
    }
}

Run it a few times — the print order changes between runs. That’s your first taste of nondeterminism: the OS schedules threads however it likes.

Production code never does new Thread(...) scattered around, because raw threads have no limits (1000 requests = 1000 threads = dead server), no reuse (creating threads is expensive), and no management (no way to wait for all of them or shut down cleanly). The fix is the thread pool — coming below. But first, the bug.

THE demo — watch two threads lose updates

This is the most important code in this module. Type it, run it, believe it.

public class BrokenCounter {
    static int counter = 0;   // shared mutable state — the crime scene

    public static void main(String[] args) throws InterruptedException {
        Runnable work = () -> {
            for (int i = 0; i < 100_000; i++) {
                counter++;    // looks atomic — is NOT
            }
        };

        Thread t1 = new Thread(work);
        Thread t2 = new Thread(work);
        t1.start();
        t2.start();
        t1.join();            // join = wait for this thread to finish
        t2.join();

        System.out.println("Expected 200000, got " + counter);
    }
}

Run it three times. You’ll get something like 137482, 151990, 144623 — a different wrong number every time. Thousands of increments simply vanished.

Why? Because counter++ is secretly three operations: read the value, add one, write it back. Two threads can interleave those steps:

sequenceDiagram
    participant A as Thread A
    participant M as Shared counter
    participant B as Thread B
    A->>M: read counter gets 5
    B->>M: read counter gets 5
    A->>A: add 1 makes 6
    B->>B: add 1 makes 6
    A->>M: write 6
    B->>M: write 6

Both threads read 5, both wrote 6. Two increments happened, the counter went up by one. One update was silently destroyed. This is called a race condition — the result depends on the accidental timing of threads. No exception, no log, no stack trace. Just wrong numbers. Now imagine counter is an account balance.

Fix 1 — synchronized

static int counter = 0;
static final Object lock = new Object();

// inside the loop:
synchronized (lock) {
    counter++;        // only one thread can be inside this block at a time
}

What synchronized actually does: every object can act as a lock. A thread must acquire the lock to enter the block; everyone else waits at the door until it leaves. The read-add-write happens as an uninterruptible unit, so no interleaving, no lost updates. Run the program again: 200000, every time.

The cost: waiting threads do nothing. Lock too much code and your concurrent server becomes a single-file queue. Synchronize the smallest block that needs it.

You can also put it on a method — synchronized void increment() — which locks on this (or the class, for static methods). Same mechanism, just implicit about which lock.

Fix 2 — AtomicInteger, the cleaner way

import java.util.concurrent.atomic.AtomicInteger;

static AtomicInteger counter = new AtomicInteger(0);

// inside the loop:
counter.incrementAndGet();   // read add write as ONE hardware-level operation

AtomicInteger uses a special CPU instruction that does read-modify-write atomically — no lock, no waiting room, faster under contention. For a simple counter, this is the right tool. Rule of thumb: one variable, simple update → atomic class. Multiple variables that must change together → synchronized.

ExecutorService — how real code runs tasks

Production Java doesn’t manage threads; it manages pools of them and submits tasks:

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

public class PoolDemo {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService pool = Executors.newFixedThreadPool(4); // exactly 4 worker threads

        for (int i = 1; i <= 10; i++) {
            final int taskId = i;                 // lambdas need effectively final captures
            pool.submit(() -> {
                System.out.println("task " + taskId + " on "
                    + Thread.currentThread().getName());
            });
        }

        pool.shutdown();                          // stop accepting new tasks
        pool.awaitTermination(1, TimeUnit.MINUTES); // wait for running ones to finish
    }
}

Ten tasks, four threads — the workers pick up the next task as they free up, like four counter clerks serving a queue of ten customers. shutdown() matters: forget it and your program never exits, because pool threads stay alive waiting for more work. This is exactly the model inside Tomcat, the server Spring Boot runs on — a fixed pool of worker threads picking HTTP requests off a queue.

ConcurrentHashMap exists

If multiple threads need a shared map, do not use HashMap — concurrent writes can corrupt it in genuinely weird ways. Use ConcurrentHashMap, which is built for simultaneous access and gives you atomic helpers like map.merge(key, 1, Integer::sum) for thread-safe “count per key.” Same idea exists for lists and queues in java.util.concurrent. The rule: shared collection → concurrent collection, always.

Deadlock — the preview

Two threads, two locks, opposite order:

flowchart LR
    A[Thread A holds Lock 1] -->|waiting for| L2[Lock 2]
    B[Thread B holds Lock 2] -->|waiting for| L1[Lock 1]
    L2 -.held by.- B
    L1 -.held by.- A

Thread A holds lock 1 and wants lock 2. Thread B holds lock 2 and wants lock 1. Neither can proceed, neither will ever release. The program doesn’t crash — it just freezes forever, which is worse. The classic prevention: always acquire locks in the same order everywhere. You’ll meet deadlock again in the database track — two transactions locking rows in opposite order is the same disease, and databases at least detect it and kill one transaction. Java just hangs.

volatile, in 3 lines

volatile on a field guarantees every thread sees the latest written value instead of a stale cached copy. It fixes visibility, not atomicity — volatile int counter still loses updates, because read-add-write is still three steps. Honest beginner advice: know it exists, recognize it in code, and reach for AtomicInteger or synchronized instead.

The survival rule

Tattoo this on your brain:

Don’t share mutable state between threads. If you must, protect it — synchronized, atomic classes, or concurrent collections. No exceptions, no “it’s probably fine.”

Local variables: safe. Immutable objects (records, List.of): safe — nothing to corrupt. Stateless services: safe. The danger is exactly one thing: a field that multiple threads write. Spot it, protect it, or better — design it away.

Build This

One file, plain terminal, three acts.

  1. Setup:
mkdir concurrency-lab
cd concurrency-lab
  1. Act 1 — break it. Create CounterLab.java with the BrokenCounter code from the lesson (two threads, 100,000 increments each). Compile and run three times:
javac CounterLab.java
java CounterLab
java CounterLab
java CounterLab

Write down all three numbers. All wrong, all different — that’s nondeterminism in your terminal, not in a textbook.

  1. Act 2 — fix it twice. Make two more variants in the same file (separate methods are fine): one using synchronized, one using AtomicInteger. Run each three times. Both must print exactly 200000 every time. If they don’t, your synchronized block isn’t covering the increment.

  2. Act 3 — the payment processor. Add a section that simulates a UPI payment batch:

    • Create ExecutorService pool = Executors.newFixedThreadPool(4);
    • Submit 10 tasks. Each task: print processing payment N on <thread name>, sleep 500 ms with Thread.sleep(500) (wrap in try-catch — it throws InterruptedException), increment an AtomicInteger processedCount, print payment N done.
    • After submitting, call shutdown() and awaitTermination, then print processed <count> of 10 payments.
  3. Watch the output: payments complete four at a time (four workers), interleaved and out of order, and the total still comes out exactly 10 because the counter is atomic. Time it mentally — 10 tasks of 500 ms on 4 threads takes about 1.5 seconds, not 5. That’s concurrency paying rent.

  4. Sabotage check: replace the AtomicInteger with a plain int and rerun a few times. With only 10 tasks you’ll usually still get 10 — which is the scariest lesson here: race conditions hide at low traffic and surface at scale. That’s why they reach production.

Where to Practice

ResourceWhat to do thereHow long
dev.javaThe concurrency tutorial — read the thread and synchronization pages after building, not before45 min
BaeldungSearch “Java ExecutorService” and “Java AtomicInteger” — recreate one example from each, from memory1 hour
exercism.org Java trackAny exercise — then refactor it to compute results across 2 threads with a pool1 session
HackerRank Java”Java Threads” challenges for syntax reps30 min

How to Practice

  • Run every demo at least three times. Concurrency bugs are timing-dependent; one run proves nothing.
  • Predict the output before each run, out loud. Being wrong is the lesson.
  • Break working code on purpose: remove synchronized, shrink the lock block, swap atomic for plain int. Watch what fails and how quietly.
  • When stuck, draw the threads as two columns and step through the interleaving by hand — the diagram technique from the counter demo works for every race.

Check Yourself

  1. What do threads share and what do they each own? Why does that one fact explain concurrency bugs?
  2. counter++ is three operations — name them, and explain how two threads lose an update.
  3. What does synchronized actually guarantee, and what is the cost?
  4. When is AtomicInteger the better fix than synchronized?
  5. Why does production code use an ExecutorService instead of new Thread() everywhere?
  6. What happens if you forget to call shutdown() on a pool?
  7. Describe deadlock and the standard rule that prevents it.
  8. Why doesn’t volatile int counter fix the broken counter?
Answers
  1. Each thread owns its stack (local variables, method calls); all threads share the heap (objects and their fields). Every concurrency bug is multiple threads touching a shared heap object without coordination — locals are always safe.
  2. Read the value, add one, write it back. Both threads read the same old value (say 5), both compute 6, both write 6 — two increments produce one. One update is silently destroyed.
  3. Only one thread at a time can hold a given lock and be inside blocks synchronized on it; others wait. Cost: waiting threads sit idle, so over-locking turns concurrent code into a queue.
  4. When it’s a single variable with a simple update (increment, add, compare-and-set). It uses an atomic CPU instruction — no lock, no waiting. Multiple variables that must change together still need synchronized.
  5. Pools cap thread count, reuse expensive threads, queue excess tasks, and give you lifecycle control (shutdown, await). Raw threads have no limits or management — 1000 requests would mean 1000 threads.
  6. The pool’s worker threads stay alive waiting for more tasks, so the JVM never exits — the program hangs after main finishes.
  7. Thread A holds lock 1 and waits for lock 2; thread B holds lock 2 and waits for lock 1; neither ever proceeds — frozen forever, no crash. Prevention: every thread acquires locks in the same global order.
  8. volatile only guarantees visibility — threads see the latest written value. The increment is still three separate steps, so interleaving still loses updates. You need atomicity: AtomicInteger or synchronized.

Explain it out loud: Explain to an imaginary junior why their Spring service with a private int loginCount; field is a bug, using only: one shared object, many threads, three-step increment, lost update. If you can connect “Spring has one instance” to “counter++ is three operations” in one breath, you own this module.

Still Unclear?

Copy-paste these into Claude:

  • “Walk me through the lost-update race condition on counter++ step by step, as a table with two columns for Thread A and Thread B and one column for the shared value in memory.”
  • “I’m a Java beginner. Compare synchronized vs AtomicInteger vs volatile for a shared counter — when does each work, when does each fail, with the shortest possible code examples.”
  • “Explain how a fixed thread pool of 4 processes 10 submitted tasks. What does the queue do, what happens when I call shutdown, and what goes wrong if I never call it?”

Why AI Can’t Do This For You

AI writes thread-safe code when you ask for thread-safe code — but race conditions live in code nobody knew was shared. Spotting that an innocent field in a Spring service is touched by a hundred threads requires understanding your system’s runtime, which the AI never sees. And when production loses one payment in ten thousand under Friday-night load, with zero exceptions in any log, the debugging instinct that says “this smells like a race” was built right here — by watching your own counter lose updates.

Module done? Add it to today’s tracker

Saves your progress on this device.