Threads, Locks & Shared State

Two weeks after the checkout service from the Spring track went live, finance flagged something strange: the loyalty-points totals were wrong. Not for everyone — just for a few dozen customers, and never by the same amount twice. Re-running the nightly reconciliation on the same data produced different totals each time. The points service was simple: read the customer's balance, add the earned points, write it back. It passed every test. It failed in production at exactly the rate you'd expect from a bug that only strikes when two requests for the same customer land in the same millisecond.

The same week gave us a second wound on a different node. A rolling deploy needed every instance to shut down its background workers gracefully. The code set a shutdownRequested flag; the worker looped on while (!shutdownRequested). On nine nodes the workers stopped cleanly. On the tenth, the worker never saw the flag and the deploy pipeline timed out — with the flag provably true in the debugger. Same code, same JVM version, different behavior.

Both incidents are the same subject wearing two masks: shared mutable state without a contract between threads. The first is a failure of atomicity — an update lost between read and write. The second is a failure of visibility — a write one thread made that another thread never observed. This post makes those two words precise, then builds the toolkit that fixes them: synchronized, volatile, the atomic classes, and ReentrantLock. Every snippet below was compiled with javac and run on OpenJDK 21.0.3, and every output block is the real output. When a result is timing-dependent, I'll say so — because in concurrency, the difference between "what the spec guarantees" and "what happened to happen on my machine" is the entire game.

By the end, you should be able to look at two threads touching one variable and say exactly which guarantee is missing — atomicity, visibility, or both — and pick the cheapest tool that provides it.

What a race condition actually is

A race condition is not "two threads ran at the same time." It's any program whose correctness depends on the relative timing of operations on different threads — on an interleaving the programmer didn't control. The scheduler decides who runs when, and it owes you nothing: no fairness, no round-robin, no "probably in order." If your code is correct only for some interleavings, it's incorrect, full stop.

The most common shape is the lost update. This line:

count++;

looks like one operation and compiles to three:

// what count++ really means at the hardware level
int tmp = count;   // 1. READ the current value
tmp = tmp + 1;     // 2. MODIFY it (in a register)
count = tmp;       // 3. WRITE it back

Now interleave two threads doing that on a shared count starting at 10:

Thread A Thread B time ↓ READ count → 10 READ count → 10 WRITE count ← 11 WRITE count ← 11 count = 11 — one increment silently lost (expected 12) Both threads read before either wrote: a classic read-modify-write race.

Two increments happened; the counter moved by one. Nobody threw an exception, no log line appeared, and the bug is invisible in a debugger that slows everything down. This is what hit the loyalty-points service: balance = balance + earned across concurrent requests, each one a read-modify-write that could straddle another.

Principle: count++ is three operations, not one. Any time a shared variable is read, modified, and written back without protection, some interleaving loses an update. "It worked in testing" proves nothing — it only proves the scheduler didn't pick the bad interleaving that day.

Measuring a lost update: 8 threads, 1.6 million increments

Let's stop talking about interleavings and count them. The demo below runs 8 threads, each doing 200,000 increments (1,600,000 expected total), against three counters: a plain int, a synchronized counter, and an AtomicLong. A CountDownLatch releases all threads at the same instant so they genuinely contend:

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;

public class RaceDemo {
    static final int THREADS = 8;
    static final int INCREMENTS = 200_000;

    // Variant 1: plain field, no synchronization — the "racy" version
    static class Racy {
        int count = 0;
        void increment() { count++; }
        int get() { return count; }
    }

    // Variant 2: synchronized method
    static class SyncCounter {
        private int count = 0;
        synchronized void increment() { count++; }
        synchronized int get() { return count; }
    }

    public static void main(String[] args) throws Exception {
        System.out.println("Expected total (threads x increments): " + (long) THREADS * INCREMENTS);

        Racy racy = new Racy();
        run(() -> racy.increment());
        System.out.println("Racy plain-int counter:                " + racy.get()
            + "   lost updates = " + ((long) THREADS * INCREMENTS - racy.get()));

        SyncCounter sync = new SyncCounter();
        run(() -> sync.increment());
        System.out.println("synchronized counter:                  " + sync.get());

        AtomicLong atomic = new AtomicLong(0);
        run(atomic::incrementAndGet);
        System.out.println("AtomicLong counter:                    " + atomic.get());
    }

    static void run(Runnable work) throws Exception {
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(THREADS);
        for (int t = 0; t < THREADS; t++) {
            new Thread(() -> {
                try { start.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                for (int i = 0; i < INCREMENTS; i++) work.run();
                done.countDown();
            }).start();
        }
        long t0 = System.nanoTime();
        start.countDown();
        done.await();
        long ms = (System.nanoTime() - t0) / 1_000_000;
        System.out.println("  (took " + ms + " ms)");
    }
}
$ javac RaceDemo.java && java RaceDemo
Expected total (threads x increments): 1600000
  (took 41 ms)
Racy plain-int counter:                510649   lost updates = 1089351
  (took 203 ms)
synchronized counter:                  1600000
  (took 133 ms)
AtomicLong counter:                    1600000

Read that first number twice: 1,089,351 of 1,600,000 increments vanished — 68% of the work, silently. The synchronized version and the AtomicLong both landed exactly on 1,600,000.

And here is the part that should change how you test concurrent code. I ran the racy variant five times total. The lost-update counts were 0, 5,031, 7,745, 50,048, and 1,089,351. One run lost nothing — every increment survived, purely by scheduling luck. If that had been the run in CI, the test would have passed and the bug would have shipped anyway. A race condition is not a bug that happens sometimes; it's a bug that is always present and manifests sometimes. No number of green test runs proves it absent.

Also notice the timings vary run to run (the racy version took 24–41 ms, the safe versions 50–200 ms on this 2-core box). Don't read a performance story into single-run timings here — the point is correctness, and timing microbenchmarks of contended locks deserve their own methodology (JMH), not a one-off printout.

synchronized: one lane at a time

synchronized gives you mutual exclusion: only one thread at a time may hold a given monitor. Mark the increment method synchronized and the read-modify-write becomes indivisible from every other thread's point of view — Thread B's read cannot slip between Thread A's read and write anymore, because B can't enter until A has left.

The mental model is a room with one key. Every Java object has a hidden lock called its monitor (also called the intrinsic lock). synchronized on a method locks this; a synchronized (obj) block locks obj. Threads that arrive while the room is occupied don't spin — they block, sleeping in an entry set until the owner leaves:

Monitor of Counter the intrinsic lock OWNER: Thread-3 inside increment() ENTRY SET Thread-1: BLOCKED Thread-7: BLOCKED (not spinning — parked) Heap count = 1_038_441 single, shared copy Only the owner runs inside the room. When it unlocks, the JVM hands the key to one waiter — and the unlock happens-before the next lock, so the new owner is guaranteed to see every write the previous owner made. synchronized buys you BOTH mutual exclusion (atomicity) AND visibility.

That last line is the part people undervalue. synchronized solves both of this post's problems at once: atomicity (nobody interleaves your read-modify-write) and visibility (the unlock of one thread is guaranteed visible to the next thread that locks — the Java Memory Model calls this happens-before). If you remember one sentence: locking the same monitor that the writer unlocked is a delivery guarantee for memory writes.

Three rules that keep synchronized from biting you:

1. It's reentrant. A thread that holds a monitor can re-enter synchronized blocks on the same monitor — including calling another synchronized method of the same object. The lock counts entries and needs the same number of exits. This is why a synchronized method calling another synchronized method on the same object doesn't deadlock with itself.

2. Keep the critical section small. Everything inside the lock is serialized. The demo's increment is one line — ideal. If your synchronized block does a database call, a network call, or any slow I/O, every other thread queues behind it and your "concurrent" service is concurrent in name only. Compute outside, lock only around the shared-state touch.

3. Never call alien code under a lock. Don't invoke a callback, listener, or overridable method from inside synchronized. You don't know what it does — it might call back into your object (fine, reentrant) or block on another lock held by a thread waiting on yours (deadlock, which we'll build shortly).

Visibility: the write that never arrived

The loyalty-points bug was atomicity. The stuck worker was something sneakier. Each thread may keep its own cached copy of a variable — in a register, in a CPU cache — and the Java Memory Model (JMM) only requires threads to reconcile those copies at defined synchronization points. Without one, there is no promise, ever, that a write by thread A becomes visible to thread B. Not "eventually." Never. That's not a performance quirk; it's the contract. It exists so the JIT and the CPU are free to reorder, hoist, and cache aggressively.

Watch it happen. One thread spins waiting for a flag; another thread sets it after 200 ms. A 3-second watchdog stops the demo so a hang can't hang us:

public class VisibilityDemo {

    static class PlainFlag { boolean stop = false; }
    static class VolatileFlag { volatile boolean stop = false; }

    public static void main(String[] args) throws Exception {
        // --- Variant A: plain (non-volatile) flag ---
        PlainFlag plain = new PlainFlag();
        System.out.println("plain flag:    " + spinOn(() -> plain.stop, () -> plain.stop = true));

        // --- Variant B: volatile flag ---
        VolatileFlag vol = new VolatileFlag();
        System.out.println("volatile flag: " + spinOn(() -> vol.stop, () -> vol.stop = true));
    }

    interface Flag { boolean read(); }

    /** Spins until flag reads true or a 3-second watchdog fires.
        Returns a description of what happened. */
    static String spinOn(Flag flag, Runnable writer) throws Exception {
        Thread t = new Thread(() -> {
            try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            writer.run();
        });
        t.start();
        long deadline = System.nanoTime() + 3_000_000_000L;
        long spins = 0;
        while (!flag.read()) {
            spins++;
            if (System.nanoTime() > deadline) {
                return "STILL SPINNING after 3.0s (writer set the flag; reader never saw it), spins=" + spins;
            }
        }
        return "saw the flag after ~200ms, spins=" + spins;
    }
}
$ javac VisibilityDemo.java && java VisibilityDemo
plain flag:    STILL SPINNING after 3.0s (writer set the flag; reader never saw it), spins=34366416
volatile flag: saw the flag after ~200ms, spins=440795

The plain-flag reader spun 34 million times over 3 seconds and never saw the write — while the writer had set the flag within the first 200 ms. All three of my runs behaved identically on this machine: plain never terminates, volatile terminates in ~200 ms.

Now the honesty clause, because this is where concurrency teaching usually lies to you. Whether the plain version hangs depends on the JIT, the CPU, and the phase of the moon. The HotSpot JIT hoists the repeated read out of the loop (it compiles while (!stop) {} into roughly if (!stop) while (true) {} once the loop is hot), so on this box it hangs reliably. On another JVM, with different flags, or on ARM hardware with different memory ordering, the same code might terminate every time. If your reader runs this and the plain version terminates, that proves nothing either — the JMM guarantees nothing, so "it terminated" and "it hung" are both legal outcomes. Volatile is the contract; the observed behavior is just weather. Never test visibility by running it and squinting.

What volatile actually promises, precisely:

1. Visibility. A write to a volatile variable is immediately visible to every subsequent read of that variable, on every thread. No stale cached copies.

2. Ordering / happens-before. Everything your thread wrote before writing the volatile is visible to any thread that reads the volatile afterwards. This is how volatile doubles as a safe-publication mechanism: write all your fields, then flip the volatile flag; readers that see the flag set are guaranteed to see the fields too.

3. Not atomicity. This is the trap. volatile int count; count++ is still a read-modify-write race — volatile fixes the "stale copy" problem, not the "two threads interleaved between read and write" problem. My RaceDemo's plain counter was racy for atomicity reasons; slapping volatile on it would have changed nothing about the lost updates. Volatile is for flags and safe publication, never for counters.

Two finer points worth knowing early. First, on 64-bit JVMs, reads and writes of long and double are not guaranteed atomic unless the variable is volatile (or guarded by a lock) — a non-volatile long can theoretically be observed half-written ("torn"), with the high 32 bits from one write and the low 32 bits from another. Second, on x86 specifically, the hardware memory model is relatively strong (writes become visible in order), which is why visibility bugs often refuse to reproduce on a laptop and then appear on ARM servers or under a different JIT profile. The JMM is the only promise that travels with your code.

Atomic classes: lock-free counters done right

If synchronized is a room with one key and volatile is a flag, the atomic classes (AtomicLong, AtomicInteger, AtomicReference) are a purpose-built machine for one job: single-variable read-modify-write, atomically, without blocking. Under the hood they use a hardware compare-and-set (CAS) instruction — "set this to the new value only if it's still the old value I read; otherwise retry." No thread ever parks; on contention they spin briefly and retry.

From the earlier demo:

AtomicLong atomic = new AtomicLong(0);
run(atomic::incrementAndGet);
System.out.println("AtomicLong counter:                    " + atomic.get());
AtomicLong counter:                    1600000

Correct on all five runs, same as synchronized. So which do you pick?

Use an atomic class when the shared state is a single variable and the operation is one of its built-ins (incrementAndGet, compareAndSet, getAndUpdate, …). It's simpler than a lock and never blocks. Use synchronized (or a lock) when you need to update two or more variables as one unit — moving money from account A to account B needs both balances to change atomically, and no single-variable atomic can do that. Use volatile when you only need visibility of a value that one thread writes and others read — flags, configuration snapshots, safe publication.

One scaling note for later: under extreme contention, even CAS retries burn CPU, and that's what LongAdder exists for — it keeps per-thread cells and sums them on read, trading exact-at-any-instant reads for throughput. Reach for it when a counter is hammered by dozens of threads; for everything else, AtomicLong is the default.

ReentrantLock: when you need more than synchronized

synchronized is the right default — simple, automatic release, reentrant. But it has three hard limits: you cannot time out waiting for it, you cannot be interrupted while waiting for it, and you cannot try and give up. If a thread blocks on a monitor held by a thread stuck in a slow downstream call, it waits forever, uninterruptibly.

ReentrantLock is the explicit version of the same idea — you lock and unlock by hand — plus the missing operations. The one that matters most in production is tryLock(timeout): attempt the lock, and if it's not available within the timeout, do something else instead of queueing forever. Here's a slow worker holding a lock for 800 ms while another thread tries with a 200 ms budget, then a 5-second budget:

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

public class LockTimeoutDemo {
    public static void main(String[] args) throws Exception {
        ReentrantLock lock = new ReentrantLock();

        // Worker A: holds the lock for 800ms (simulates a slow DB call inside a critical section)
        Thread a = new Thread(() -> {
            lock.lock();
            try {
                System.out.println("[A] acquired lock, doing slow work for 800ms...");
                Thread.sleep(800);
                System.out.println("[A] done, releasing lock");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                lock.unlock();
            }
        });

        // Worker B: wants the same lock but can't wait forever — tries with a timeout
        Thread b = new Thread(() -> {
            try {
                System.out.println("[B] tryLock(200ms) ...");
                if (lock.tryLock(200, TimeUnit.MILLISECONDS)) {
                    try {
                        System.out.println("[B] got the lock");
                    } finally { lock.unlock(); }
                } else {
                    System.out.println("[B] timed out after 200ms -> take the fallback path (serve stale cache, log, retry later)");
                }
                System.out.println("[B] tryLock(5s) ...");
                if (lock.tryLock(5, TimeUnit.SECONDS)) {
                    try {
                        System.out.println("[B] got the lock on the second attempt");
                    } finally { lock.unlock(); }
                } else {
                    System.out.println("[B] still nothing after 5s");
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        a.start();
        Thread.sleep(100); // make sure A grabs the lock first
        b.start();
        a.join();
        b.join();
        System.out.println("done — B never blocked longer than its timeouts");
    }
}
$ javac LockTimeoutDemo.java && java LockTimeoutDemo
[A] acquired lock, doing slow work for 800ms...
[B] tryLock(200ms) ...
[B] timed out after 200ms -> take the fallback path (serve stale cache, log, retry later)
[A] done, releasing lock
[B] tryLock(5s) ...
[B] got the lock on the second attempt
done — B never blocked longer than its timeouts

Thread B's worst case was bounded by its own timeouts — 200 ms, then 5 s — instead of by however long A felt like holding the lock. That bound is the whole point: in a service, an unbounded wait on a contended lock is how one slow dependency turns into thread-pool exhaustion and a cascading outage. A timeout converts "hang forever" into "fail fast with a fallback."

ReentrantLock also offers lockInterruptibly() — a thread waiting on the lock can be interrupted, which synchronized cannot do — and optional fairness (new ReentrantLock(true) hands the lock to the longest-waiting thread; the default unfair mode is usually faster and can starve waiters under heavy contention).

The price of the explicit lock is the mandatory try/finally. If you lock() and an exception escapes before unlock(), the lock stays held forever and every future acquirer hangs. synchronized releases automatically; ReentrantLock trusts you. The pattern is non-negotiable:

lock.lock();
try {
    // critical section
} finally {
    lock.unlock();   // always, even on exception
}

Decision rule: default to synchronized (or an atomic class for single variables). Reach for ReentrantLock only when you specifically need a timeout, interruptible waiting, fairness, or multiple condition variables — and when you do, the try/finally is part of the deal.

Deadlock: when locks wait on each other

Two locks are fine until two threads want them in opposite order. Thread 1 holds lock A and wants lock B; thread 2 holds lock B and wants lock A. Neither will ever release what it holds. No exception, no timeout, no log — both threads simply stop, forever. This is deadlock, and a single instance in production is enough to wedge a service until restart.

Real deadlocks are intermittent, which makes them miserable to learn from — so this demo forces the interleaving with a CountDownLatch, making it deterministic instead of "run it until it breaks." Then a watchdog asks the JVM's own ThreadMXBean to report deadlocked threads, and finally the fixed version acquires both locks in the same global order:

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class DeadlockDemo {
    static final ReentrantLock LOCK_A = new ReentrantLock();
    static final ReentrantLock LOCK_B = new ReentrantLock();

    public static void main(String[] args) throws Exception {
        deadlock();
        System.out.println();
        fixed();
    }

    // Both threads hold one lock and want the other. The latches force the
    // interleaving so this deadlocks deterministically, not "sometimes".
    static void deadlock() throws Exception {
        CountDownLatch bothHaveOne = new CountDownLatch(2);

        Thread t1 = new Thread(() -> {
            LOCK_A.lock();
            try {
                System.out.println("[T1] holds LOCK_A, waiting for T2 to grab LOCK_B...");
                bothHaveOne.countDown();
                bothHaveOne.await();
                System.out.println("[T1] wants LOCK_B");
                LOCK_B.lockInterruptibly();
                try { System.out.println("[T1] got LOCK_B"); }
                finally { LOCK_B.unlock(); }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally { LOCK_A.unlock(); }
        }, "Worker-1");

        Thread t2 = new Thread(() -> {
            LOCK_B.lock();
            try {
                System.out.println("[T2] holds LOCK_B, waiting for T1 to grab LOCK_A...");
                bothHaveOne.countDown();
                bothHaveOne.await();
                System.out.println("[T2] wants LOCK_A");
                LOCK_A.lockInterruptibly();
                try { System.out.println("[T2] got LOCK_A"); }
                finally { LOCK_A.unlock(); }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally { LOCK_B.unlock(); }
        }, "Worker-2");

        t1.setDaemon(true);
        t2.setDaemon(true);
        t1.start();
        t2.start();

        Thread.sleep(1500);
        ThreadMXBean bean = ManagementFactory.getThreadMXBean();
        long[] deadlocked = bean.findDeadlockedThreads();
        if (deadlocked != null) {
            System.out.println("DEADLOCK DETECTED by ThreadMXBean:");
            for (ThreadInfo info : bean.getThreadInfo(deadlocked)) {
                System.out.println("  - " + info.getThreadName()
                    + " waiting on " + info.getLockName()
                    + " held by " + info.getLockOwnerName());
            }
        } else {
            System.out.println("no deadlock found (unexpected)");
        }
        t1.interrupt();
        t2.interrupt();
        t1.join(500);
        t2.join(500);
    }

    // The fix: everybody acquires the locks in the SAME global order (A then B).
    static void fixed() throws Exception {
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(2);

        Runnable job = () -> {
            try {
                start.await();
                LOCK_A.lockInterruptibly();
                try {
                    LOCK_B.lockInterruptibly();
                    try {
                        System.out.println("[" + Thread.currentThread().getName() + "] holds A then B, doing work...");
                        Thread.sleep(50);
                    } finally { LOCK_B.unlock(); }
                } finally { LOCK_A.unlock(); }
                System.out.println("[" + Thread.currentThread().getName() + "] finished cleanly");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally { done.countDown(); }
        };

        Thread t1 = new Thread(job, "Worker-1");
        Thread t2 = new Thread(job, "Worker-2");
        t1.start();
        t2.start();
        start.countDown();
        if (done.await(5, TimeUnit.SECONDS)) {
            System.out.println("FIX VERIFIED: both threads finished, no deadlock");
        } else {
            System.out.println("STILL DEADLOCKED (unexpected)");
        }
    }
}
$ javac DeadlockDemo.java && java DeadlockDemo
[T1] holds LOCK_A, waiting for T2 to grab LOCK_B...
[T2] holds LOCK_B, waiting for T1 to grab LOCK_A...
[T2] wants LOCK_A
[T1] wants LOCK_B
DEADLOCK DETECTED by ThreadMXBean:
  - Worker-1 waiting on java.util.concurrent.locks.ReentrantLock$NonfairSync@24d46ca6 held by Worker-2
  - Worker-2 waiting on java.util.concurrent.locks.ReentrantLock$NonfairSync@a09ee92 held by Worker-1

[Worker-2] holds A then B, doing work...
[Worker-1] holds A then B, doing work...
[Worker-2] finished cleanly
[Worker-1] finished cleanly
FIX VERIFIED: both threads finished, no deadlock

(The hex identity hashes after @ differ on every run — everything else is verbatim.) The JVM itself can tell you exactly who is waiting on what and who holds it. In production, that same findDeadlockedThreads() call (or a jstack thread dump, which reports deadlocks at the bottom) turns "the service froze" from a mystery into a named cycle:

Worker-1 thread Worker-2 thread LOCK_A ReentrantLock LOCK_B ReentrantLock holds holds wants wants A closed wait-cycle: each thread holds what the other wants. Nobody can move.

Deadlock needs four conditions at once (hold-and-wait, mutual exclusion, no preemption, circular wait). You only have to break one, and the standard fix breaks the circular wait: impose a global lock ordering and acquire in that order everywhere. If every thread takes A before B, the cycle above is structurally impossible — which is exactly what the fixed half of the demo shows. Other escapes: hold only one lock at a time (restructure so you don't nest), or use tryLock with a timeout so a failed acquisition backs off and retries instead of waiting forever.

A note on the demo's cleanup: the deadlocked threads are daemon threads and get interrupted after detection, so the JVM exits. In a real service there is no such luck — deadlocked non-daemon threads keep the process alive but frozen, which is why deadlocks present as "the pod is up but serves nothing." Detection (ThreadMXBean, jstack) diagnoses; lock ordering prevents.

Which tool for which job

The whole post in one table. The question is never "which is fastest" — it's "which guarantee is missing":

Situation Tool Guarantee you get
Two+ threads read-modify-write one variable (counter, balance) AtomicLong / AtomicInteger Atomicity for that one variable, lock-free
Two+ variables must change as one unit (transfer A→B) synchronized block/method Atomicity across the block + visibility
One writer, many readers; a flag or config value volatile Visibility + happens-before; no atomicity
Lock wait must be bounded / interruptible / fair ReentrantLock (+ try/finally) Mutual exclusion + timeout/interrupt/fairness options
Multiple locks, nested acquisition Global lock ordering Deadlock structurally impossible

Three lines to carry forward: count++ is three operations — protect the read-modify-write, not the variable. Visibility is a contract, not an observation — volatile (or a lock) is the guarantee; what you saw on your laptop is weather. A race condition is always present and sometimes visible — passing tests prove the scheduler was kind, not that the code is safe.

What's next

You now know how to keep shared state correct when threads collide: which guarantee is missing, which tool supplies it, and what each tool costs. But so far every example has created raw Threads by hand — the way nobody writes server code. Real services don't spawn threads per request; they reuse a bounded pool, and sizing that pool is where this track's next hard lesson lives: too few threads and requests queue, too many and context-switching eats the machine.

The next post in this track, Executors & Thread Pools: Sizing the Old World, replaces hand-rolled threads with executors, shows what a saturated pool actually looks like under load, and works out the queue-vs-thread math for CPU-bound and I/O-bound work.

Field check before you move on: (1) Take RaceDemo and change the racy counter to a volatile int — run it five times and confirm the lost updates persist, proving volatile doesn't fix atomicity. (2) In VisibilityDemo, replace the 3-second watchdog spin with Thread.onSpinWait() inside the loop and re-run — the volatile variant should still terminate in ~200 ms; check whether the plain variant's behavior changes on your machine, and write down why either outcome is legal under the JMM. (3) Modify DeadlockDemo so the fixed version uses tryLock(100, MILLISECONDS) with a backoff-and-retry loop instead of lock ordering — verify both threads still finish, then deliberately reintroduce opposite ordering with plain lock() and confirm ThreadMXBean reports the cycle again. Bring the jstack output of a deadlocked run to the next post; we'll read a real thread dump there.

Continue: Java Learning Roadmap 2026

Comments

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number