Ticket Drop Concurrency Lab

Build a Java lab that finds and fixes ticket reservation races.

Introduction

30 Second Summary

When thousands of people rush for the last few tickets, a split-second overlap can leave two buyers believing they own the same seat. One duplicate reservation is enough to damage trust.

In this project, you will build a Java 25 command-line lab that deliberately creates a race condition where one seat goes to two buyers. The lab tests protected reservation strategies across platform threads and virtual threads during a 10,000-buyer burst.

What You'll Build

Your terminal shows a repeatable double reservation followed by a benchmark table that checks every strategy for duplicate seats, overselling, throughput, contention, and correctness.

By the end of this project, you'll have:

  • A deterministic correctness lab that reproduces one double reservation before showing how synchronized and ReentrantLock protect the reservation invariant.
  • A machine-specific thread benchmark that processes 10,000 buyer attempts against 100 seats and reports timing from your own Mac.
  • A GitHub-ready README.md with a Mermaid architecture diagram that maps local locking to database transactions, idempotency, and shared admission control.
  • Secret Mission: Add a Semaphore admission gate that proves no more than 50 virtual-thread tasks enter the protected section.

Are there any prerequisites?

You need a Mac, a code editor, and comfort with Java syntax and classes. No prior Java threading experience is required.

Before We Start

A ticket reservation system must keep every seat tied to one successful buyer. Before the lab begins, define the correctness rule that prevents overselling during a high-demand ticket drop.

Set Up the Java 25 Lab

The simulator relies on concurrency APIs from Java 25. Using one feature version makes later thread-model measurements comparable.

This step prepares a verified toolchain for the lab. You will also create the empty files that hold the race demonstration plus the full benchmark.

In this step, get ready to:
  • Verify the Java 25 toolchain.
  • Create the ticket-drop-concurrency-lab directory structure.
  • Confirm the empty starter files from the project directory.
Verify or install Oracle JDK 25

The Java launcher runs compiled programs. The Java compiler turns your source files into runnable classes.

  • Press Cmd+Space to open macOS search.
  • Type Terminal into the search bar.
  • Press Enter to open Terminal.
  • Check the active launcher plus compiler by running these commands:
java --version
javac --version

What do these commands check?

  • The first command reports the Java runtime used to launch programs.
  • The second command reports the Java compiler used to build source files.

Compare the feature number at the start of both outputs. Choose the tab that matches what Terminal shows.

✔️ I see Java 25 in both outputs

Both outputs include 25. Your installed toolchain has the required feature release.

ⓧ I see an older version

An older Java release can remain installed while you add feature release 25. Oracle provides separate installers for Apple silicon plus Intel processors.

  • Visit the Oracle JDK downloads page for release 25.0.4.1.
  • Select ARM64 DMG Installer if your Mac uses Apple silicon.
  • Select x64 DMG Installer if your Mac uses an Intel processor.
  • Open the downloaded .dmg file.
  • Open JDK 25.pkg from the Finder window.

The installer requests your Mac administrator credentials before it writes into the system Java directory. This is the expected macOS authorization step.

  • Click Continue in the installer.
  • Click Install.
  • Enter your Mac administrator credentials.
  • Click Install Software.

That finishes the system install. Your Mac now has a Java 25 toolchain ready to select.

ⓧ Java commands are not available

Your Mac needs a JDK before it can compile the simulator. Oracle provides release 25.0.4.1 as a macOS installer.

  • Visit the Oracle JDK downloads page for release 25.0.4.1.
  • Select ARM64 DMG Installer if your Mac uses Apple silicon.
  • Select x64 DMG Installer if your Mac uses an Intel processor.
  • Open the downloaded .dmg file.
  • Open JDK 25.pkg from the Finder window.

The installer requests your Mac administrator credentials before it writes into the system Java directory. This is the expected macOS authorization step.

  • Click Continue in the installer.
  • Click Install.
  • Enter your Mac administrator credentials.
  • Click Install Software.

That completes the JDK installation. Your Mac now has the launcher plus compiler required by this lab.

macOS can hold several JDK feature releases at once. The next command selects Java 25 for your current Terminal session.

  • Select Java 25 for this Terminal session by running these commands:
export JAVA_HOME=`/usr/libexec/java_home -v 25`
java --version
javac --version

What does this selection do?

  • The java_home tool locates an installed Java 25 JDK.
  • The JAVA_HOME value tells the macOS Java tools which JDK this Terminal session should use.
  • The final two lines reveal the selected runtime plus compiler versions.

Both outputs should now include 25. Your Java launcher plus compiler are aligned for the lab.

Still seeing an older Java version?

  • Confirm the Oracle installer reached its completion screen before repeating the selection command.
  • Keep using the same Terminal window after setting JAVA_HOME because the selection applies to that session.
  • Check that both version outputs begin with feature release 25.
  • Help me diagnose why macOS still selects an older Java release.
Create the project structure

The src/ directory keeps Java source files separate from project documentation. The three empty files give each later step a fixed destination.

  • Create the complete project structure on your Desktop by running these commands:
cd ~/Desktop
mkdir -p ticket-drop-concurrency-lab/src
cd ticket-drop-concurrency-lab
touch src/RaceDemo.java src/TicketDropLab.java README.md
ls README.md src/RaceDemo.java src/TicketDropLab.java

What do these commands create?

  • The first line moves Terminal to your Desktop.
  • The mkdir command creates the project directory plus its src/ subdirectory.
  • The -p option creates any missing directory components.
  • The touch command creates the three empty starter files.
  • The ls command prints each expected file path as the immediate check.

You should see README.md. You should also see both Java source paths under src/.

Missing a file path?

  • Confirm your Terminal prompt is inside ticket-drop-concurrency-lab.
  • Check that the directory name contains hyphens between every word.
  • Repeat the project-structure command block to create any missing directory or file.
  • Help me fix my Java lab directory structure.
Confirm the selected toolchain

The final check proves that this project directory uses the expected Java feature release. It also confirms that every empty starter file exists before you add code.

Before you run the check, do you expect all three compiler-related outputs to agree on the same feature release?

  • Verify the toolchain plus starter files from ticket-drop-concurrency-lab by running these commands:
java --version
javac --version
/usr/libexec/java_home -v 25 --exec javac --version
ls README.md src/RaceDemo.java src/TicketDropLab.java

What should you see?

The launcher output includes 25. Both compiler checks also report feature release 25.

The final output lists README.md. It also lists src/RaceDemo.java plus src/TicketDropLab.java.

You have the complete foundation in place. The runtime plus compiler now agree on Java 25.

Does the final check disagree?

  • Repeat the Java selection block if either of the first two outputs shows an older feature release.
  • Confirm the direct compiler check reports feature release 25.
  • Return to the project-structure block if the final command reports a missing file.
  • Help me reconcile my Java version checks or missing project files.

✔️ Awesome, I've got everything!

Your Java 25 toolchain is active. All three starter files exist as empty files.

ⓧ I'd like to double check the full code

The file README.md should contain zero characters.

The file src/RaceDemo.java should contain zero characters.

The file src/TicketDropLab.java should contain zero characters.

Your Java 25 lab is ready for source code. Next up, you will coordinate two buyers so they reproduce the same broken reservation every time.

Reproduce the Double Reservation

Your project now has a verified Java 25 toolchain and an empty src/RaceDemo.java file. The next challenge is exposing the reservation bug that could give one seat to multiple buyers.

A naturally occurring race condition depends on unpredictable scheduling. You will use two CountDownLatch objects to separate the shared read from the write so the same broken result appears every time.

In this step, get ready to:
  • Model an unsafe check-then-act reservation for one remaining seat.
  • Coordinate two buyers with latches so both read the same availability.
  • Compile the demonstration and inspect the broken capacity invariant.
Model the unsafe check-then-act operation

A check-then-act operation reads shared state before deciding whether to update it. Another thread can use the same stale value during the gap between those actions.

  • Open src/RaceDemo.java from the ticket-drop-concurrency-lab folder using your code editor's file browser.
  • Add the imports and unsafe buyer model by pasting this first section at the top of the empty file:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public final class RaceDemo {
    private static final int INITIAL_SEATS = 1;

    private static final class Inventory {
        private volatile int remaining = INITIAL_SEATS;
        private final AtomicInteger successes = new AtomicInteger();
    }

    public static void main(String[] args) throws InterruptedException {
        Inventory inventory = new Inventory();
        CountDownLatch bothBuyersRead = new CountDownLatch(2);
        CountDownLatch allowWrites = new CountDownLatch(1);

        Runnable buyer = () -> {
            int observedSeats = inventory.remaining;
            bothBuyersRead.countDown();
            await(allowWrites);

            if (observedSeats > 0) {
                inventory.successes.incrementAndGet();
                inventory.remaining = observedSeats - 1;
            }
        };

What does this code do?

  • The Inventory object holds one shared seat count and an atomic counter for successful buyers.
  • Each buyer copies inventory.remaining into observedSeats before deciding whether a seat exists.
  • The volatile field makes the latest write visible to other threads. It does not combine the read and write into one atomic reservation.
  • The buyer waits at allowWrites after completing its read. This gap lets both buyers act on the same observed seat count.
  • Save src/RaceDemo.java.
  • Confirm the current file ends with the buyer lambda's closing };.

Does the buyer section look incomplete?

The main method remains open because the next section submits the buyers. Keep the indentation shown above so the executor stays inside main.

If the braces are difficult to match, help me check the structure of my partial RaceDemo.java file.

Coordinate both buyers with latches

The first latch reaches zero only after both buyers have copied the seat count. The second latch releases their writes from the same starting observation.

  • Add the executor block directly below the buyer lambda in src/RaceDemo.java by pasting this code:
        try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
            executor.submit(buyer);
            executor.submit(buyer);
            bothBuyersRead.await();
            allowWrites.countDown();
        }

How do the latches control the race?

  • The fixed pool gives both submitted buyer tasks a worker thread.
  • The main thread waits at bothBuyersRead.await() until both buyers have recorded the shared seat count.
  • The allowWrites.countDown() call opens the write gate after both reads are complete.
  • Closing the ExecutorService waits for both submitted tasks to finish before the result is evaluated.
  • Save src/RaceDemo.java.
  • Confirm the executor block contains two executor.submit(buyer) calls.

Do the buyers appear out of order?

Task scheduling can change which buyer reaches each line first. The two latch gates still guarantee that both buyers finish their read before either buyer writes.

If the latch placement differs from the snippet, help me verify the two CountDownLatch gates in RaceDemo.java.

The final section evaluates the business invariant and adds a reusable latch-waiting helper. Preserving the interrupt status keeps cancellation information available if a worker is interrupted.

  • Add the result checks and await helper directly below the executor block by pasting this code:
        int successes = inventory.successes.get();
        boolean capacityInvariant = successes <= INITIAL_SEATS;
        boolean raceReproduced = successes == 2
                && inventory.remaining == 0
                && !capacityInvariant;

        System.out.println("Observed successes: " + successes);
        System.out.println("Remaining seats: " + inventory.remaining);
        System.out.println("Invariant successes <= initial seats: " + capacityInvariant);
        System.out.println("Race reproduced: " + (raceReproduced ? "PASS" : "FAIL"));

        if (!raceReproduced) {
            throw new AssertionError("The coordinated race was not reproduced.");
        }
    }

    private static void await(CountDownLatch latch) {
        try {
            latch.await();
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Buyer interrupted", exception);
        }
    }
}

How is the broken invariant detected?

  • The capacityInvariant check requires the number of successful buyers to stay within the initial seat count.
  • The raceReproduced check requires two successes from one initial seat. It also requires zero remaining seats and a failed capacity invariant.
  • The output prints each part of the result so the failure remains visible instead of hiding behind one status value.
  • The await helper restores the current thread's interrupt status before converting the interruption into an exception.
  • Save src/RaceDemo.java.
  • Confirm the final closing brace appears directly after the await helper.

Seeing unmatched braces in your editor?

  • Check that the result section begins after the executor block's closing brace.
  • Check that the await helper begins after the closing brace for main.
  • Ask for a brace-by-brace comparison with the full file if the editor still reports a structural problem. Help me find an unmatched brace in RaceDemo.java.

✔️ Awesome, I've got everything!

Your completed src/RaceDemo.java now coordinates the two shared reads and evaluates the resulting reservation count.

ⓧ I'd like to double check the full code

Compare your saved src/RaceDemo.java with this complete version.

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public final class RaceDemo {
    private static final int INITIAL_SEATS = 1;

    private static final class Inventory {
        private volatile int remaining = INITIAL_SEATS;
        private final AtomicInteger successes = new AtomicInteger();
    }

    public static void main(String[] args) throws InterruptedException {
        Inventory inventory = new Inventory();
        CountDownLatch bothBuyersRead = new CountDownLatch(2);
        CountDownLatch allowWrites = new CountDownLatch(1);

        Runnable buyer = () -> {
            int observedSeats = inventory.remaining;
            bothBuyersRead.countDown();
            await(allowWrites);

            if (observedSeats > 0) {
                inventory.successes.incrementAndGet();
                inventory.remaining = observedSeats - 1;
            }
        };

        try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
            executor.submit(buyer);
            executor.submit(buyer);
            bothBuyersRead.await();
            allowWrites.countDown();
        }

        int successes = inventory.successes.get();
        boolean capacityInvariant = successes <= INITIAL_SEATS;
        boolean raceReproduced = successes == 2
                && inventory.remaining == 0
                && !capacityInvariant;

        System.out.println("Observed successes: " + successes);
        System.out.println("Remaining seats: " + inventory.remaining);
        System.out.println("Invariant successes <= initial seats: " + capacityInvariant);
        System.out.println("Race reproduced: " + (raceReproduced ? "PASS" : "FAIL"));

        if (!raceReproduced) {
            throw new AssertionError("The coordinated race was not reproduced.");
        }
    }

    private static void await(CountDownLatch latch) {
        try {
            latch.await();
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Buyer interrupted", exception);
        }
    }
}
Compile and run the designed failure

The source file now turns a timing-dependent bug into a repeatable experiment. The compiler checks the complete class before you let the two buyers test the reservation invariant.

  • Compile src/RaceDemo.java for Java 25 by running this command from the ticket-drop-concurrency-lab directory:
javac --release 25 -d out src/RaceDemo.java

What does this command do?

The compiler targets the Java 25 release and writes the compiled class files into the out directory. A successful compilation returns you to the Terminal prompt.

Before you run the class, what do you think happens when both buyers make their decision from the same observed seat count?

  • Run the compiled race demonstration by using this command:
java -cp out RaceDemo

What should you see?

  • You will see Observed successes: 2 because both buyers act on the same visible seat.
  • You will see Remaining seats: 0 because both buyers write the same decremented value.
  • You will see Invariant successes <= initial seats: false because two successful reservations exceed the one-seat capacity.
  • You will see Race reproduced: PASS because the test successfully exposed the intended business failure.

You have made an unreliable scheduling bug deterministic. The simulator can now prove that splitting availability checking from seat assignment breaks reservation correctness.

Compilation or output different?

  • Confirm your Terminal is still inside the ticket-drop-concurrency-lab directory.
  • Compare src/RaceDemo.java with the full-code tab if the compiler reports a syntax problem.
  • Check that bothBuyersRead.await() appears before allowWrites.countDown() if the race result does not pass.
  • Share the compiler output or the four result lines for focused help. Help me debug my RaceDemo compilation or unexpected race output.

The double reservation is now visible on demand. Next, you will place the full reservation transition behind one synchronization boundary and verify that every seat has one owner.

Make Reservations Atomic

Your latch-controlled test now exposes the race condition every time. Two buyers can claim one final seat because each buyer acts on an old availability check.

Availability checking and seat assignment form one atomic state transition. This step places that complete transition behind one synchronization boundary.

In this step, get ready to:
  • Build unsafe and protected inventory strategies.
  • Track every reservation outcome against the capacity invariant.
  • Run the strategies under the same buyer load.
Add three inventory strategies

Each strategy exposes the same reservation interface. Their synchronization boundaries determine whether concurrent buyers can receive duplicate seat IDs.

  • Open src/TicketDropLab.java in your code editor.
  • Add the imports and shared inventory contract by pasting this code:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;

public final class TicketDropLab {
    private static final int BUYERS = 10_000;
    private static final int SEATS = 100;
    private static final int PLATFORM_WORKERS = 200;
    private static final long SIMULATED_WAIT_MILLIS = 2;

    private enum ThreadModel {
        PLATFORM,
        VIRTUAL
    }

    private interface Inventory {
        int reserveSeat();
        int remaining();
        int contentionMisses();
    }
}

What does this scaffold do?

  • The constants give every strategy the same buyer count and seat capacity.
  • The Inventory interface requires every strategy to reserve seats through the same method.
  • The contentionMisses() method provides one shared place for reporting immediate lock misses.
  • Insert the unsafe strategy before the final closing brace in src/TicketDropLab.java:
    private static final class UnsafeInventory implements Inventory {
        private volatile int remaining;

        private UnsafeInventory(int seats) {
            remaining = seats;
        }

        @Override
        public int reserveSeat() {
            if (remaining <= 0) {
                return -1;
            }
            Thread.yield();
            int seatId = remaining;
            remaining--;
            return seatId;
        }

        @Override
        public int remaining() {
            return remaining;
        }

        @Override
        public int contentionMisses() {
            return 0;
        }
    }

Why is this strategy unsafe?

  • The availability check reads remaining before the decrement occurs.
  • The call to Thread.yield() gives another buyer an opportunity to observe the same seat count.
  • The returned seatId lets the benchmark detect duplicate ownership directly.
  • Save src/TicketDropLab.java.
  • Compile the current inventory definitions by running this command:
javac --release 25 -d out src/TicketDropLab.java

What did this check prove?

The compiler should return to the Terminal prompt without an error. That confirms the shared contract and unsafe strategy form valid Java code.

Seeing a compiler error?

  • Check that UnsafeInventory sits inside TicketDropLab.
  • Confirm the file ends with one brace for UnsafeInventory and one brace for TicketDropLab.
  • Ask for help with the first compiler message.
  • Insert the synchronized strategy after UnsafeInventory.
    private static final class SynchronizedInventory implements Inventory {
        private int remaining;

        private SynchronizedInventory(int seats) {
            remaining = seats;
        }

        @Override
        public synchronized int reserveSeat() {
            if (remaining <= 0) {
                return -1;
            }
            int seatId = remaining;
            remaining--;
            return seatId;
        }

        @Override
        public synchronized int remaining() {
            return remaining;
        }

        @Override
        public int contentionMisses() {
            return 0;
        }
    }

How does synchronization protect the seat?

  • The synchronized method allows one buyer at a time to execute the complete reservation transition.
  • The protected transition checks capacity before assigning one seat ID.
  • The synchronized remaining() method reads inventory through the same intrinsic lock.
  • Save src/TicketDropLab.java.
  • Confirm the synchronized strategy compiles by running this command:
javac --release 25 -d out src/TicketDropLab.java

What should happen?

The compiler should return to the Terminal prompt without an error. The synchronized reservation boundary is now structurally valid.

  • Start the explicit lock strategy after SynchronizedInventory by pasting this first section:
    private static final class LockingInventory implements Inventory {
        private final ReentrantLock lock = new ReentrantLock();
        private final AtomicInteger contentions = new AtomicInteger();
        private int remaining;

        private LockingInventory(int seats) {
            remaining = seats;
        }

        @Override
        public int reserveSeat() {
            if (!lock.tryLock()) {
                contentions.incrementAndGet();
                lock.lock();
            }

            try {
                if (remaining <= 0) {
                    return -1;
                }
                int seatId = remaining;
                remaining--;
                return seatId;
            } finally {
                lock.unlock();
            }
        }

What does the lock path measure?

  • The immediate tryLock() attempt records whether another buyer already holds the lock.
  • A missed attempt increments contentions before the buyer waits with lock().
  • The finally block releases the lock after every reservation outcome.
  • Complete LockingInventory directly below its reserveSeat() method:
        @Override
        public int remaining() {
            lock.lock();
            try {
                return remaining;
            } finally {
                lock.unlock();
            }
        }

        @Override
        public int contentionMisses() {
            return contentions.get();
        }
    }

Why protect the remaining count?

The remaining() method reads inventory while holding the same lock used for writes. The benchmark therefore evaluates a consistent final value.

  • Save src/TicketDropLab.java.
  • Confirm all three strategies compile by running this command:
javac --release 25 -d out src/TicketDropLab.java

What did you establish?

The compiler should return to the Terminal prompt without an error. You now have one unprotected strategy and two protected strategies behind the same interface.

Is the lock strategy failing to compile?

  • Confirm java.util.concurrent.locks.ReentrantLock appears in the import section.
  • Check that unlock() remains inside the finally block.
  • Ask for help matching the lock braces.
Instrument reservation correctness

A lock is useful only when the business invariant survives the load. The benchmark records every outcome so a strategy cannot hide an oversell behind a plausible final seat count.

  • Insert the result record after LockingInventory.
    private record Result(
            String strategy,
            ThreadModel threadModel,
            int successes,
            int rejected,
            int remaining,
            int duplicateSeats,
            int contentionMisses,
            double elapsedMillis,
            double throughputPerSecond,
            boolean correct) {
    }

What does a result capture?

  • The counters show whether all buyer attempts received an outcome.
  • The duplicate count reveals whether two successful buyers received the same seat ID.
  • The correct field stores the final invariant decision for one benchmark row.
  • Insert createExecutor() after the result record.
    private static ExecutorService createExecutor(ThreadModel threadModel) {
        if (threadModel == ThreadModel.VIRTUAL) {
            return Executors.newVirtualThreadPerTaskExecutor();
        }
        return Executors.newFixedThreadPool(PLATFORM_WORKERS);
    }

Why centralize executor creation?

The benchmark runner requests an executor through one method. This keeps the reservation logic identical across execution models.

  • Insert the waiting helpers near the bottom of TicketDropLab.
    private static void await(CountDownLatch latch) {
        try {
            latch.await();
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Buyer interrupted while waiting", exception);
        }
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Buyer interrupted during simulated wait", exception);
        }
    }

How do the helpers handle interruption?

Both helpers restore the current thread's interrupted status before throwing an exception. The caller therefore retains the interruption signal.

  • Save src/TicketDropLab.java.
  • Compile the new result and helper definitions by running this command:
javac --release 25 -d out src/TicketDropLab.java

What should happen?

The compiler should return to the Terminal prompt without an error. The file can now represent benchmark results and create either executor model.

  • Insert the first part of runBenchmark() before createExecutor().
    private static Result runBenchmark(
            String strategy,
            Inventory inventory,
            ThreadModel threadModel) {

        AtomicInteger successes = new AtomicInteger();
        AtomicInteger rejected = new AtomicInteger();
        AtomicInteger duplicateSeats = new AtomicInteger();
        var claimedSeatIds = ConcurrentHashMap.<Integer>newKeySet();
        CountDownLatch startGate = new CountDownLatch(1);

        long startedAt;
        try (ExecutorService executor = createExecutor(threadModel)) {
            for (int buyer = 0; buyer < BUYERS; buyer++) {
                executor.submit(() -> {
                    await(startGate);
                    sleep(SIMULATED_WAIT_MILLIS);

                    int seatId = inventory.reserveSeat();
                    if (seatId < 0) {
                        rejected.incrementAndGet();
                    } else {
                        successes.incrementAndGet();
                        if (!claimedSeatIds.add(seatId)) {
                            duplicateSeats.incrementAndGet();
                        }
                    }
                });
            }

How are outcomes recorded?

  • The atomic counters safely record success and rejection totals from many buyer tasks.
  • The claimedSeatIds concurrent set accepts each seat ID once.
  • A failed set insertion increments duplicateSeats.
  • Continue runBenchmark() with timing and invariant calculations:
            startedAt = System.nanoTime();
            startGate.countDown();
        }

        long elapsedNanos = System.nanoTime() - startedAt;
        double elapsedMillis = elapsedNanos / 1_000_000.0;
        double elapsedSeconds = elapsedNanos / 1_000_000_000.0;
        double throughput = BUYERS / elapsedSeconds;
        int successCount = successes.get();
        int rejectedCount = rejected.get();
        int remaining = inventory.remaining();
        int duplicateCount = duplicateSeats.get();

        boolean correct = successCount + rejectedCount == BUYERS
                && successCount <= SEATS
                && remaining >= 0
                && successCount + remaining == SEATS
                && duplicateCount == 0;

What makes a row correct?

  • Every buyer must appear in either the success total or the rejection total.
  • Successful reservations cannot exceed the original seat capacity.
  • Successes plus remaining seats must equal the initial inventory.
  • Duplicate seat IDs must remain at zero.
  • Complete runBenchmark() by returning its result:
        return new Result(
                strategy,
                threadModel,
                successCount,
                rejectedCount,
                remaining,
                duplicateCount,
                inventory.contentionMisses(),
                elapsedMillis,
                throughput,
                correct);
    }

Why return one record?

The record keeps every metric from one strategy run together. The printing code can display each run through the same format.

  • Save src/TicketDropLab.java.
  • Confirm the benchmark runner compiles by running this command:
javac --release 25 -d out src/TicketDropLab.java

What did this check prove?

The compiler should return to the Terminal prompt without an error. Your runner can now account for every buyer and evaluate the seat invariant.

Is the benchmark runner failing to compile?

  • Confirm all three runBenchmark() sections appear consecutively before createExecutor().
  • Check that claimedSeatIds uses ConcurrentHashMap.<Integer>newKeySet().
  • Ask for help with the first runner error.
Run every strategy under shared load

The common runner now needs a report and an entry point. Every strategy receives the same seat capacity so the correctness comparison stays consistent.

  • Insert printHeader() after createExecutor().
    private static void printHeader() {
        System.out.printf(
                "%-16s %-9s %8s %9s %9s %10s %11s %11s %13s %8s%n",
                "strategy",
                "threads",
                "success",
                "rejected",
                "remaining",
                "duplicates",
                "contention",
                "elapsed-ms",
                "ops-per-sec",
                "correct");
    }

What does the header show?

The formatted header gives every correctness and timing metric a fixed column. Rows from different strategies remain easy to compare.

  • Insert printResult() directly below printHeader().
    private static void printResult(Result result) {
        System.out.printf(
                "%-16s %-9s %8d %9d %9d %10d %11d %11.2f %13.0f %8s%n",
                result.strategy(),
                result.threadModel(),
                result.successes(),
                result.rejected(),
                result.remaining(),
                result.duplicateSeats(),
                result.contentionMisses(),
                result.elapsedMillis(),
                result.throughputPerSecond(),
                result.correct() ? "PASS" : "FAIL");
    }

How is correctness displayed?

The result printer maps every record field to the matching table column. The final expression turns the boolean invariant result into PASS or FAIL.

  • Save src/TicketDropLab.java.
  • Confirm both printing helpers compile by running this command:
javac --release 25 -d out src/TicketDropLab.java

What should happen?

The compiler should return to the Terminal prompt without an error. The benchmark can now render one comparable row for every strategy run.

  • Insert the first part of main() between the result record and runBenchmark().
    public static void main(String[] args) {
        System.out.println("Ticket Drop Concurrency Lab");
        System.out.println("Buyers: " + BUYERS + ", seats: " + SEATS);
        System.out.println();

        Result unsafeVirtual = runBenchmark(
                "unsafe",
                new UnsafeInventory(SEATS),
                ThreadModel.VIRTUAL);
        Result synchronizedVirtual = runBenchmark(
                "synchronized",
                new SynchronizedInventory(SEATS),
                ThreadModel.VIRTUAL);
        Result lockingVirtual = runBenchmark(
                "reentrant-lock",
                new LockingInventory(SEATS),
                ThreadModel.VIRTUAL);
        Result lockingPlatform = runBenchmark(
                "reentrant-lock",
                new LockingInventory(SEATS),
                ThreadModel.PLATFORM);

What does the entry point run?

The entry point creates a fresh inventory for each benchmark. No strategy inherits seat state from an earlier run.

  • Complete main() with table output and safe-strategy assertions:
        printHeader();
        printResult(unsafeVirtual);
        printResult(synchronizedVirtual);
        printResult(lockingVirtual);
        printResult(lockingPlatform);

        if (!synchronizedVirtual.correct()
                || !lockingVirtual.correct()
                || !lockingPlatform.correct()) {
            throw new AssertionError("A protected reservation strategy broke correctness.");
        }

        System.out.println();
        System.out.println("Safe strategy assertions: PASS");
        System.out.println("Timing values are machine-specific lab results, not production guarantees.");
    }

Why keep the assertions?

The table makes every outcome visible. The assertion also stops the program if a protected strategy violates the reservation invariant.

  • Save src/TicketDropLab.java.

✔️ Awesome, I've got everything!

Your three inventory strategies now share one runner and one correctness test. Double check that src/TicketDropLab.java is saved.

ⓧ I'd like to double check the full code

Compare your saved src/TicketDropLab.java with this complete file.

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;

public final class TicketDropLab {
    private static final int BUYERS = 10_000;
    private static final int SEATS = 100;
    private static final int PLATFORM_WORKERS = 200;
    private static final long SIMULATED_WAIT_MILLIS = 2;

    private enum ThreadModel {
        PLATFORM,
        VIRTUAL
    }

    private interface Inventory {
        int reserveSeat();
        int remaining();
        int contentionMisses();
    }

    private static final class UnsafeInventory implements Inventory {
        private volatile int remaining;

        private UnsafeInventory(int seats) {
            remaining = seats;
        }

        @Override
        public int reserveSeat() {
            if (remaining <= 0) {
                return -1;
            }
            Thread.yield();
            int seatId = remaining;
            remaining--;
            return seatId;
        }

        @Override
        public int remaining() {
            return remaining;
        }

        @Override
        public int contentionMisses() {
            return 0;
        }
    }

    private static final class SynchronizedInventory implements Inventory {
        private int remaining;

        private SynchronizedInventory(int seats) {
            remaining = seats;
        }

        @Override
        public synchronized int reserveSeat() {
            if (remaining <= 0) {
                return -1;
            }
            int seatId = remaining;
            remaining--;
            return seatId;
        }

        @Override
        public synchronized int remaining() {
            return remaining;
        }

        @Override
        public int contentionMisses() {
            return 0;
        }
    }

    private static final class LockingInventory implements Inventory {
        private final ReentrantLock lock = new ReentrantLock();
        private final AtomicInteger contentions = new AtomicInteger();
        private int remaining;

        private LockingInventory(int seats) {
            remaining = seats;
        }

        @Override
        public int reserveSeat() {
            if (!lock.tryLock()) {
                contentions.incrementAndGet();
                lock.lock();
            }

            try {
                if (remaining <= 0) {
                    return -1;
                }
                int seatId = remaining;
                remaining--;
                return seatId;
            } finally {
                lock.unlock();
            }
        }

        @Override
        public int remaining() {
            lock.lock();
            try {
                return remaining;
            } finally {
                lock.unlock();
            }
        }

        @Override
        public int contentionMisses() {
            return contentions.get();
        }
    }

    private record Result(
            String strategy,
            ThreadModel threadModel,
            int successes,
            int rejected,
            int remaining,
            int duplicateSeats,
            int contentionMisses,
            double elapsedMillis,
            double throughputPerSecond,
            boolean correct) {
    }

    public static void main(String[] args) {
        System.out.println("Ticket Drop Concurrency Lab");
        System.out.println("Buyers: " + BUYERS + ", seats: " + SEATS);
        System.out.println();

        Result unsafeVirtual = runBenchmark(
                "unsafe",
                new UnsafeInventory(SEATS),
                ThreadModel.VIRTUAL);
        Result synchronizedVirtual = runBenchmark(
                "synchronized",
                new SynchronizedInventory(SEATS),
                ThreadModel.VIRTUAL);
        Result lockingVirtual = runBenchmark(
                "reentrant-lock",
                new LockingInventory(SEATS),
                ThreadModel.VIRTUAL);
        Result lockingPlatform = runBenchmark(
                "reentrant-lock",
                new LockingInventory(SEATS),
                ThreadModel.PLATFORM);

        printHeader();
        printResult(unsafeVirtual);
        printResult(synchronizedVirtual);
        printResult(lockingVirtual);
        printResult(lockingPlatform);

        if (!synchronizedVirtual.correct()
                || !lockingVirtual.correct()
                || !lockingPlatform.correct()) {
            throw new AssertionError("A protected reservation strategy broke correctness.");
        }

        System.out.println();
        System.out.println("Safe strategy assertions: PASS");
        System.out.println("Timing values are machine-specific lab results, not production guarantees.");
    }

    private static Result runBenchmark(
            String strategy,
            Inventory inventory,
            ThreadModel threadModel) {

        AtomicInteger successes = new AtomicInteger();
        AtomicInteger rejected = new AtomicInteger();
        AtomicInteger duplicateSeats = new AtomicInteger();
        var claimedSeatIds = ConcurrentHashMap.<Integer>newKeySet();
        CountDownLatch startGate = new CountDownLatch(1);

        long startedAt;
        try (ExecutorService executor = createExecutor(threadModel)) {
            for (int buyer = 0; buyer < BUYERS; buyer++) {
                executor.submit(() -> {
                    await(startGate);
                    sleep(SIMULATED_WAIT_MILLIS);

                    int seatId = inventory.reserveSeat();
                    if (seatId < 0) {
                        rejected.incrementAndGet();
                    } else {
                        successes.incrementAndGet();
                        if (!claimedSeatIds.add(seatId)) {
                            duplicateSeats.incrementAndGet();
                        }
                    }
                });
            }

            startedAt = System.nanoTime();
            startGate.countDown();
        }

        long elapsedNanos = System.nanoTime() - startedAt;
        double elapsedMillis = elapsedNanos / 1_000_000.0;
        double elapsedSeconds = elapsedNanos / 1_000_000_000.0;
        double throughput = BUYERS / elapsedSeconds;
        int successCount = successes.get();
        int rejectedCount = rejected.get();
        int remaining = inventory.remaining();
        int duplicateCount = duplicateSeats.get();

        boolean correct = successCount + rejectedCount == BUYERS
                && successCount <= SEATS
                && remaining >= 0
                && successCount + remaining == SEATS
                && duplicateCount == 0;

        return new Result(
                strategy,
                threadModel,
                successCount,
                rejectedCount,
                remaining,
                duplicateCount,
                inventory.contentionMisses(),
                elapsedMillis,
                throughput,
                correct);
    }

    private static ExecutorService createExecutor(ThreadModel threadModel) {
        if (threadModel == ThreadModel.VIRTUAL) {
            return Executors.newVirtualThreadPerTaskExecutor();
        }
        return Executors.newFixedThreadPool(PLATFORM_WORKERS);
    }

    private static void printHeader() {
        System.out.printf(
                "%-16s %-9s %8s %9s %9s %10s %11s %11s %13s %8s%n",
                "strategy",
                "threads",
                "success",
                "rejected",
                "remaining",
                "duplicates",
                "contention",
                "elapsed-ms",
                "ops-per-sec",
                "correct");
    }

    private static void printResult(Result result) {
        System.out.printf(
                "%-16s %-9s %8d %9d %9d %10d %11d %11.2f %13.0f %8s%n",
                result.strategy(),
                result.threadModel(),
                result.successes(),
                result.rejected(),
                result.remaining(),
                result.duplicateSeats(),
                result.contentionMisses(),
                result.elapsedMillis(),
                result.throughputPerSecond(),
                result.correct() ? "PASS" : "FAIL");
    }

    private static void await(CountDownLatch latch) {
        try {
            latch.await();
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Buyer interrupted while waiting", exception);
        }
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Buyer interrupted during simulated wait", exception);
        }
    }
}

Before you run the lab, which strategies do you expect to preserve the seat invariant?

  • Compile both source files by running the first command below.
  • Run the reservation lab by running the second command below.
javac --release 25 -d out src/RaceDemo.java src/TicketDropLab.java
java -cp out TicketDropLab

What should I see?

  • The table contains one unsafe row and three protected rows.
  • The unsafe row may show PASS or FAIL because its stress outcome depends on scheduling.
  • Each protected row shows 100 successes and 9,900 rejections.
  • Each protected row shows zero remaining seats and zero duplicate seat IDs.
  • The final status reads Safe strategy assertions: PASS.

That is the critical boundary secured. Your protected strategies now assign every available seat to exactly one buyer.

Did a safe strategy fail?

  • Check that the availability test and decrement both remain inside the synchronized method or lock boundary.
  • Confirm every successful reservation adds seatId to claimedSeatIds.
  • Ask for help interpreting the failing row.

Your reservation transition is atomic under both protection strategies. Next up, you'll compare how platform threads and virtual threads carry the same blocking-style workload.

Stress-Test Thread Models

The hard correctness work is holding. Your synchronized strategy and ReentrantLock strategy now protect every seat assignment.

Correct locking does not select an execution model. This step compares a bounded pool of platform threads with one virtual thread per buyer task under blocking-style work.

In this step, get ready to:
  • Compare the bounded platform-thread pool with the virtual-thread-per-task executor.
  • Measure the benchmark metrics under 10,000 buyer tasks.
  • Repeat the benchmark to separate stable correctness from machine-specific timing.
Compare the executor models

Both executor paths feed buyer tasks into the same reservation runner. This keeps the inventory logic constant while the scheduling model changes.

  • Locate createExecutor() near the bottom of src/TicketDropLab.java.
  • Trace the VIRTUAL branch to its executor.
  • Trace the platform fallback to its executor.

How do the executor models differ?

  • The createExecutor() method selects an executor from ThreadModel.
  • Executors.newFixedThreadPool(PLATFORM_WORKERS) caps active platform workers at 200. Excess tasks wait in the executor queue.
  • Executors.newVirtualThreadPerTaskExecutor() starts one virtual thread for every submitted task. The executor can represent all 10,000 buyers as separate tasks.
  • SIMULATED_WAIT_MILLIS makes each buyer pause for 2 milliseconds before attempting a reservation. This gives both executors blocking-style work.

✔️ I found both executor paths

Great. Your existing src/TicketDropLab.java already contains both execution models.

ⓧ I'd like to double check the full code

Compare src/TicketDropLab.java with this complete file. It should remain unchanged in this step.

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;

public final class TicketDropLab {
    private static final int BUYERS = 10_000;
    private static final int SEATS = 100;
    private static final int PLATFORM_WORKERS = 200;
    private static final long SIMULATED_WAIT_MILLIS = 2;

    private enum ThreadModel {
        PLATFORM,
        VIRTUAL
    }

    private interface Inventory {
        int reserveSeat();
        int remaining();
        int contentionMisses();
    }

    private static final class UnsafeInventory implements Inventory {
        private volatile int remaining;

        private UnsafeInventory(int seats) {
            remaining = seats;
        }

        @Override
        public int reserveSeat() {
            if (remaining <= 0) {
                return -1;
            }
            Thread.yield();
            int seatId = remaining;
            remaining--;
            return seatId;
        }

        @Override
        public int remaining() {
            return remaining;
        }

        @Override
        public int contentionMisses() {
            return 0;
        }
    }

    private static final class SynchronizedInventory implements Inventory {
        private int remaining;

        private SynchronizedInventory(int seats) {
            remaining = seats;
        }

        @Override
        public synchronized int reserveSeat() {
            if (remaining <= 0) {
                return -1;
            }
            int seatId = remaining;
            remaining--;
            return seatId;
        }

        @Override
        public synchronized int remaining() {
            return remaining;
        }

        @Override
        public int contentionMisses() {
            return 0;
        }
    }

    private static final class LockingInventory implements Inventory {
        private final ReentrantLock lock = new ReentrantLock();
        private final AtomicInteger contentions = new AtomicInteger();
        private int remaining;

        private LockingInventory(int seats) {
            remaining = seats;
        }

        @Override
        public int reserveSeat() {
            if (!lock.tryLock()) {
                contentions.incrementAndGet();
                lock.lock();
            }

            try {
                if (remaining <= 0) {
                    return -1;
                }
                int seatId = remaining;
                remaining--;
                return seatId;
            } finally {
                lock.unlock();
            }
        }

        @Override
        public int remaining() {
            lock.lock();
            try {
                return remaining;
            } finally {
                lock.unlock();
            }
        }

        @Override
        public int contentionMisses() {
            return contentions.get();
        }
    }

    private record Result(
            String strategy,
            ThreadModel threadModel,
            int successes,
            int rejected,
            int remaining,
            int duplicateSeats,
            int contentionMisses,
            double elapsedMillis,
            double throughputPerSecond,
            boolean correct) {
    }

    public static void main(String[] args) {
        System.out.println("Ticket Drop Concurrency Lab");
        System.out.println("Buyers: " + BUYERS + ", seats: " + SEATS);
        System.out.println();

        Result unsafeVirtual = runBenchmark(
                "unsafe",
                new UnsafeInventory(SEATS),
                ThreadModel.VIRTUAL);
        Result synchronizedVirtual = runBenchmark(
                "synchronized",
                new SynchronizedInventory(SEATS),
                ThreadModel.VIRTUAL);
        Result lockingVirtual = runBenchmark(
                "reentrant-lock",
                new LockingInventory(SEATS),
                ThreadModel.VIRTUAL);
        Result lockingPlatform = runBenchmark(
                "reentrant-lock",
                new LockingInventory(SEATS),
                ThreadModel.PLATFORM);

        printHeader();
        printResult(unsafeVirtual);
        printResult(synchronizedVirtual);
        printResult(lockingVirtual);
        printResult(lockingPlatform);

        if (!synchronizedVirtual.correct()
                || !lockingVirtual.correct()
                || !lockingPlatform.correct()) {
            throw new AssertionError("A protected reservation strategy broke correctness.");
        }

        System.out.println();
        System.out.println("Safe strategy assertions: PASS");
        System.out.println("Timing values are machine-specific lab results, not production guarantees.");
    }

    private static Result runBenchmark(
            String strategy,
            Inventory inventory,
            ThreadModel threadModel) {

        AtomicInteger successes = new AtomicInteger();
        AtomicInteger rejected = new AtomicInteger();
        AtomicInteger duplicateSeats = new AtomicInteger();
        var claimedSeatIds = ConcurrentHashMap.<Integer>newKeySet();
        CountDownLatch startGate = new CountDownLatch(1);

        long startedAt;
        try (ExecutorService executor = createExecutor(threadModel)) {
            for (int buyer = 0; buyer < BUYERS; buyer++) {
                executor.submit(() -> {
                    await(startGate);
                    sleep(SIMULATED_WAIT_MILLIS);

                    int seatId = inventory.reserveSeat();
                    if (seatId < 0) {
                        rejected.incrementAndGet();
                    } else {
                        successes.incrementAndGet();
                        if (!claimedSeatIds.add(seatId)) {
                            duplicateSeats.incrementAndGet();
                        }
                    }
                });
            }

            startedAt = System.nanoTime();
            startGate.countDown();
        }

        long elapsedNanos = System.nanoTime() - startedAt;
        double elapsedMillis = elapsedNanos / 1_000_000.0;
        double elapsedSeconds = elapsedNanos / 1_000_000_000.0;
        double throughput = BUYERS / elapsedSeconds;
        int successCount = successes.get();
        int rejectedCount = rejected.get();
        int remaining = inventory.remaining();
        int duplicateCount = duplicateSeats.get();

        boolean correct = successCount + rejectedCount == BUYERS
                && successCount <= SEATS
                && remaining >= 0
                && successCount + remaining == SEATS
                && duplicateCount == 0;

        return new Result(
                strategy,
                threadModel,
                successCount,
                rejectedCount,
                remaining,
                duplicateCount,
                inventory.contentionMisses(),
                elapsedMillis,
                throughput,
                correct);
    }

    private static ExecutorService createExecutor(ThreadModel threadModel) {
        if (threadModel == ThreadModel.VIRTUAL) {
            return Executors.newVirtualThreadPerTaskExecutor();
        }
        return Executors.newFixedThreadPool(PLATFORM_WORKERS);
    }

    private static void printHeader() {
        System.out.printf(
                "%-16s %-9s %8s %9s %9s %10s %11s %11s %13s %8s%n",
                "strategy",
                "threads",
                "success",
                "rejected",
                "remaining",
                "duplicates",
                "contention",
                "elapsed-ms",
                "ops-per-sec",
                "correct");
    }

    private static void printResult(Result result) {
        System.out.printf(
                "%-16s %-9s %8d %9d %9d %10d %11d %11.2f %13.0f %8s%n",
                result.strategy(),
                result.threadModel(),
                result.successes(),
                result.rejected(),
                result.remaining(),
                result.duplicateSeats(),
                result.contentionMisses(),
                result.elapsedMillis(),
                result.throughputPerSecond(),
                result.correct() ? "PASS" : "FAIL");
    }

    private static void await(CountDownLatch latch) {
        try {
            latch.await();
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Buyer interrupted while waiting", exception);
        }
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Buyer interrupted during simulated wait", exception);
        }
    }
}
Run the first benchmark

The runner measures elapsed time with System.nanoTime(). It calculates throughput by dividing 10,000 attempts by the elapsed seconds.

What does the benchmark measure?

  • success counts buyers that received a seat ID.
  • rejected counts buyers that reached unavailable inventory.
  • duplicates detects two successful buyers receiving the same seat ID.
  • contention counts immediate tryLock() misses before blocking acquisition.

Before you run the benchmark, which values do you expect to stay fixed across the protected rows?

  • Compile both source files and launch the benchmark by running these commands:
javac --release 25 -d out src/RaceDemo.java src/TicketDropLab.java
java -cp out TicketDropLab

What do these commands prove?

The first command compiles both classes for Java 25 into out.

The second command runs all four benchmark cases. The printed table exposes reservation correctness beside scheduling measurements.

You should see four metric rows for unsafe virtual threads, synchronized virtual threads, locked virtual threads, and locked platform threads. Every protected row should finish with PASS.

The unsafe row can finish with PASS or FAIL because its stress result depends on scheduling. RaceDemo remains the deterministic proof of the oversell.

Benchmark does not complete?

  • Confirm Terminal is still inside ticket-drop-concurrency-lab.
  • Compare src/TicketDropLab.java with the full-file reference above if compilation stops.
  • Recheck the protected strategy rows if the assertion stops the program.
  • Help me debug my Java thread benchmark.
Repeat and interpret the benchmark

Correctness should remain stable for protected inventory. Timing reflects your Mac, JVM warmup, background work, and scheduling during each run.

Before you repeat the benchmark, do you expect elapsed-ms and ops-per-sec to match exactly?

  • Run three fresh benchmark processes by running these commands:
java -cp out TicketDropLab
java -cp out TicketDropLab
java -cp out TicketDropLab

What should you compare?

  • Compare the total attempts reported by each row.
  • Compare the correctness result for every protected strategy.
  • Compare elapsed-ms across the three launches.
  • Compare ops-per-sec across the three launches.

Across all three outputs, each row processes 10,000 attempts. Every protected row should retain PASS correctness.

Your elapsed-ms and ops-per-sec values should vary between launches. Those changes are the machine-specific result you are looking for.

How should you interpret the result?

Virtual threads target higher throughput when many tasks spend time waiting. They do not make CPU-intensive work intrinsically faster.

The fixed pool limits active platform workers to 200. The virtual executor represents every buyer with a virtual thread.

Treat these timings as teaching output from your Mac. A controlled JMH study provides a stronger foundation for JVM performance claims.

You have proved that execution models can change scheduling behavior while protected inventory remains correct. Next, you will map that local guarantee to a design that coordinates separate application instances.

Document the Multi-Instance Design

Your simulator now compares safe reservation strategies across platform threads and virtual threads. Its correctness checks prove that one shared inventory object can protect every seat inside one JVM.

Production ticket systems run across multiple application instances. This step moves the correctness boundary into a shared database transaction. It also documents idempotency plus shared admission control.

In this step, get ready to:
  • Document the deterministic race plus the safe benchmark behavior.
  • Explain why local Java locks cannot coordinate separate application instances.
  • Map reservation correctness to a production transaction plus shared admission control.
Document the lab and benchmark

A GitHub-ready README.md should let another developer understand the lab before running it. The opening sections establish the failure being reproduced plus the Java environment required to reproduce it.

  • Select README.md from your code editor's file browser.
  • Replace its empty contents by pasting this opening section:
# Ticket Drop Concurrency Lab

A Java 25 command-line lab that deliberately reproduces a ticket oversell race, fixes the reservation boundary, and compares platform-thread and virtual-thread execution models under a 10,000-buyer burst.

## What this demonstrates

- A check-then-act race can give two buyers the same final seat.
- `CountDownLatch` can make the failure repeatable instead of scheduler-dependent.
- `synchronized` and `ReentrantLock` can protect one compound reservation transition inside one JVM.
- A fixed platform-thread pool queues excess work, while a virtual-thread-per-task executor can represent each buyer task with a virtual thread.
- Virtual threads target scale and throughput for waiting workloads. They are not faster CPU threads and do not guarantee lower latency.
- A JVM-local lock is not a multi-instance inventory solution.

## Requirements

- Oracle JDK 25.0.4.1 or another Java 25 JDK
- macOS Terminal

What does this section establish?

  • The introduction names the oversell race as the business problem.
  • The demonstration list connects the race to CountDownLatch plus the two safe locking strategies.
  • The requirements pin the lab to Java 25. This keeps the virtual-thread APIs consistent for every learner.
  • Save README.md.
  • Scroll to the top of the saved file.

You should see the project title followed by the demonstration list plus the two requirements.

Is the opening section hard to read?

  • Check that each heading begins on its own line.
  • Check that every bullet begins with a single hyphen.
  • Check that blank lines separate headings from their content.
  • Ask for help with the Markdown structure: Help me find the formatting problem in my README opening section.

The next section turns the simulator's commands plus expected output into a repeatable verification path. Anyone reading the repository can reproduce the designed failure before comparing the safe strategies.

  • Place your cursor after the macOS Terminal requirement.
  • Append the build instructions plus expected results:
## Build and run

From the repository root:

```bash
javac --release 25 -d out src/RaceDemo.java src/TicketDropLab.java
java -cp out RaceDemo
java -cp out TicketDropLab
```

`RaceDemo` should always report two successful reservations for one seat and end with `Race reproduced: PASS`. That PASS means the test successfully reproduced the intended failure.

`TicketDropLab` prints machine-specific results for unsafe inventory, synchronized inventory, ReentrantLock inventory with virtual threads, and ReentrantLock inventory with a fixed pool of 200 platform threads. Safe rows should report 100 successes, 9,900 rejections, 0 remaining seats, 0 duplicate seat IDs, and `PASS` correctness.

What does this runbook prove?

  • The compile command targets Java 25 plus places both compiled classes in out.
  • The RaceDemo result confirms that the coordinated test reproduced the intended business failure.
  • The TicketDropLab expectations define the invariant that every protected benchmark row must preserve.
  • Save README.md.
  • Scroll to the Build and run section.

You should see one fenced command block followed by separate expectations for the deterministic race plus the benchmark.

Is the command block rendering as plain text?

  • Check that the opening fence contains three backticks followed by bash.
  • Check that the closing fence contains exactly three backticks.
  • Ask for help with the fenced block: Help me repair the build command block in README.md.

A benchmark table becomes useful when every column has a precise meaning. The interpretation also sets expectations for the two execution models without claiming one universal winner.

  • Place your cursor after the safe-row expectations.
  • Append the metric definitions plus the thread comparison guidance:
## Metrics

- success: reservations that received a seat ID
- rejected: attempts made after inventory was unavailable
- remaining: unassigned seats after all tasks finish
- duplicates: successful buyers that received an already claimed seat ID
- contention: immediate `ReentrantLock.tryLock()` misses before blocking acquisition
- elapsed-ms: wall-clock duration measured with `System.nanoTime()`
- ops-per-sec: all buyer attempts divided by elapsed seconds
- correct: all attempts accounted for, no duplicate IDs, no negative inventory, no oversell, and successes plus remaining inventory equal initial capacity

## Interpreting the thread comparison

The platform case deliberately uses a bounded pool of 200 platform threads. The virtual case uses one virtual thread per submitted buyer task, and every task includes a short blocking-style wait before reservation. Run the benchmark several times: timing changes with hardware, JVM warmup, background work, and scheduling. This is a learning and load-shape benchmark, not a JMH microbenchmark.

Why define every metric?

  • The correctness fields expose overselling plus duplicate ownership directly.
  • The timing fields report measurements from the learner's own Mac.
  • The comparison identifies this program as a learning benchmark. A controlled JMH study requires a more rigorous benchmark setup.
  • Save README.md.
  • Scroll from the metric list into the thread comparison.

You should see eight metric definitions followed by a paragraph that keeps every timing claim machine-specific.

Are the metrics difficult to scan?

  • Check that each metric starts on a separate bullet line.
  • Check that ReentrantLock.tryLock() plus System.nanoTime() retain their backticks.
  • Ask for help with the metric list: Help me check the Markdown formatting of my benchmark metrics.
Explain the production coordination boundary

A local lock protects threads that share the same Java object. Separate application processes create separate objects, so both instances can enter their own protected sections while they update the same external inventory.

The production diagram uses a Mermaid flowchart to make that boundary visible. Both instances reach one inventory database through a shared admission layer.

  • Place your cursor after the thread comparison paragraph.
  • Append the lock-boundary explanation plus architecture diagram:
## Why the lock fails across application instances

A `ReentrantLock` object coordinates threads that use that same object. Instance A and Instance B are separate JVM processes, so each has its own inventory object and lock. Correctness must move to a coordination point visible to every instance, usually the database transaction that owns the inventory row.

## Production architecture

```mermaid
flowchart LR
    Buyers[Buyer requests] --> Gate[Shared admission control]
    Gate --> A[Instance A]
    Gate --> B[Instance B]
    A --> DB[(Inventory database)]
    B --> DB
    A --> Keys[(Idempotency records)]
    B --> Keys
    DB --> Holds[Expiring reservation holds]
```

How does the architecture change correctness?

  • Instance A plus Instance B have independent Java lock objects.
  • The inventory database provides the coordination point that both instances can reach.
  • The idempotency records preserve one outcome for repeated reservation requests.
  • The shared admission layer protects downstream capacity before work reaches either instance.
  • Save README.md.
  • Scroll through the Mermaid diagram in the saved file.

You should see both instances connected to the same inventory database plus the same idempotency record store.

Is the Mermaid diagram incomplete?

  • Check that the diagram begins with a mermaid fence.
  • Check that each connection uses the exact node names from the code block.
  • Check that the diagram ends with a closing fence.
  • Ask for help with the diagram: Help me find the syntax problem in my Mermaid architecture diagram.

The shared database still needs one atomic reservation transition. A short transaction locks the target inventory row, validates its current capacity, records the result plus commits before any user think time begins.

  • Place your cursor after the Mermaid closing fence.
  • Append the production consistency recipe:
## Production consistency recipe

1. Require an idempotency key for the reservation request.
2. Begin a database transaction.
3. Read the target inventory row with `FOR UPDATE`.
4. Check capacity inside the transaction.
5. Write the reservation or hold and decrement inventory in the same transaction.
6. Store the idempotency key under a unique constraint with the original outcome.
7. Commit; a retry with the same key returns the stored outcome.
8. Keep user think time outside the transaction and use an expiring hold for payment time.

Shared admission control protects downstream capacity before work reaches instances. A gateway, queue, or shared coordination service can enforce a fleet-wide limit. A local `Semaphore` only limits one JVM.

Why this transaction stays short

  • The inventory read uses FOR UPDATE to lock the selected row against conflicting writers until the transaction ends.
  • The capacity check plus inventory decrement happen inside the same transaction boundary.
  • The unique idempotency record lets a retry return its original result.
  • The expiring hold keeps payment time outside the database transaction.
  • Save README.md.
  • Read the recipe from step one through step eight.

You should see one transaction boundary around the inventory decision plus a separate fleet-wide capacity boundary before the instances.

Does the recipe lose its sequence?

  • Check that every transaction instruction begins with its original number.
  • Check that FOR UPDATE plus Semaphore retain their backticks.
  • Ask for help with the recipe: Help me check the order of my production consistency recipe.
Finish the README and verify the project

A responsible benchmark states what it leaves out. The final sections define those limits plus show the exact project layout another developer receives.

  • Place your cursor after the shared admission control paragraph.
  • Append the benchmark limitations plus project layout:
## Benchmark limitations

- This is a learning and load-shape benchmark, not a JMH microbenchmark.
- The simulated wait is not a real network or database call.
- The contention count measures immediate lock misses, not total blocked duration.
- The project does not model payment, hold expiry, database isolation failures, or cross-region replication.

## Project layout

```text
ticket-drop-concurrency-lab/
├── README.md
└── src/
    ├── RaceDemo.java
    └── TicketDropLab.java
```

## Next experiment

Complete the Secret Mission by adding `AdmissionGateDemo.java`. Then explain why a Semaphore is useful for local downstream protection but cannot enforce one limit across separate JVMs.

Why finish with limitations?

  • The limitations stop the teaching benchmark from being mistaken for a production performance study.
  • The project tree shows where the documentation plus source files belong.
  • The next experiment connects local admission control to the shared production boundary.
  • Save README.md.
  • Scroll to the bottom of the saved file.

You should see four benchmark limitations followed by the project tree plus the next experiment.

Does the project tree look misaligned?

  • Check that the project tree sits inside one text fence.
  • Check that the source filenames remain indented beneath src/.
  • Ask for help with the tree: Help me repair the project layout formatting in README.md.

✔️ Awesome, I've got everything!

Great. Double check that you saved README.md.

ⓧ I'd like to double check the full code

Compare your complete README.md with this reference.

# Ticket Drop Concurrency Lab

A Java 25 command-line lab that deliberately reproduces a ticket oversell race, fixes the reservation boundary, and compares platform-thread and virtual-thread execution models under a 10,000-buyer burst.

## What this demonstrates

- A check-then-act race can give two buyers the same final seat.
- `CountDownLatch` can make the failure repeatable instead of scheduler-dependent.
- `synchronized` and `ReentrantLock` can protect one compound reservation transition inside one JVM.
- A fixed platform-thread pool queues excess work, while a virtual-thread-per-task executor can represent each buyer task with a virtual thread.
- Virtual threads target scale and throughput for waiting workloads. They are not faster CPU threads and do not guarantee lower latency.
- A JVM-local lock is not a multi-instance inventory solution.

## Requirements

- Oracle JDK 25.0.4.1 or another Java 25 JDK
- macOS Terminal

## Build and run

From the repository root:

```bash
javac --release 25 -d out src/RaceDemo.java src/TicketDropLab.java
java -cp out RaceDemo
java -cp out TicketDropLab
```

`RaceDemo` should always report two successful reservations for one seat and end with `Race reproduced: PASS`. That PASS means the test successfully reproduced the intended failure.

`TicketDropLab` prints machine-specific results for unsafe inventory, synchronized inventory, ReentrantLock inventory with virtual threads, and ReentrantLock inventory with a fixed pool of 200 platform threads. Safe rows should report 100 successes, 9,900 rejections, 0 remaining seats, 0 duplicate seat IDs, and `PASS` correctness.

## Metrics

- success: reservations that received a seat ID
- rejected: attempts made after inventory was unavailable
- remaining: unassigned seats after all tasks finish
- duplicates: successful buyers that received an already claimed seat ID
- contention: immediate `ReentrantLock.tryLock()` misses before blocking acquisition
- elapsed-ms: wall-clock duration measured with `System.nanoTime()`
- ops-per-sec: all buyer attempts divided by elapsed seconds
- correct: all attempts accounted for, no duplicate IDs, no negative inventory, no oversell, and successes plus remaining inventory equal initial capacity

## Interpreting the thread comparison

The platform case deliberately uses a bounded pool of 200 platform threads. The virtual case uses one virtual thread per submitted buyer task, and every task includes a short blocking-style wait before reservation. Run the benchmark several times: timing changes with hardware, JVM warmup, background work, and scheduling. This is a learning and load-shape benchmark, not a JMH microbenchmark.

## Why the lock fails across application instances

A `ReentrantLock` object coordinates threads that use that same object. Instance A and Instance B are separate JVM processes, so each has its own inventory object and lock. Correctness must move to a coordination point visible to every instance, usually the database transaction that owns the inventory row.

## Production architecture

```mermaid
flowchart LR
    Buyers[Buyer requests] --> Gate[Shared admission control]
    Gate --> A[Instance A]
    Gate --> B[Instance B]
    A --> DB[(Inventory database)]
    B --> DB
    A --> Keys[(Idempotency records)]
    B --> Keys
    DB --> Holds[Expiring reservation holds]
```

## Production consistency recipe

1. Require an idempotency key for the reservation request.
2. Begin a database transaction.
3. Read the target inventory row with `FOR UPDATE`.
4. Check capacity inside the transaction.
5. Write the reservation or hold and decrement inventory in the same transaction.
6. Store the idempotency key under a unique constraint with the original outcome.
7. Commit; a retry with the same key returns the stored outcome.
8. Keep user think time outside the transaction and use an expiring hold for payment time.

Shared admission control protects downstream capacity before work reaches instances. A gateway, queue, or shared coordination service can enforce a fleet-wide limit. A local `Semaphore` only limits one JVM.

## Benchmark limitations

- This is a learning and load-shape benchmark, not a JMH microbenchmark.
- The simulated wait is not a real network or database call.
- The contention count measures immediate lock misses, not total blocked duration.
- The project does not model payment, hold expiry, database isolation failures, or cross-region replication.

## Project layout

```text
ticket-drop-concurrency-lab/
├── README.md
└── src/
    ├── RaceDemo.java
    └── TicketDropLab.java
```

## Next experiment

Complete the Secret Mission by adding `AdmissionGateDemo.java`. Then explain why a Semaphore is useful for local downstream protection but cannot enforce one limit across separate JVMs.

Before the checks, consider whether documentation changes can alter either program's result.

  • Run both compiled programs from the existing project directory:
java -cp out RaceDemo
java -cp out TicketDropLab

What do these checks prove?

  • The race test still reproduces two successful buyers competing for one seat.
  • The protected benchmark rows still account for all 10,000 attempts without duplicate seat IDs.
  • The documentation changed the explanation. It left the simulator behavior intact.

The first program should end with Race reproduced: PASS. The second program should keep every safe row at PASS plus finish with Safe strategy assertions: PASS.

Did either verification fail?

  • Confirm Terminal is still inside ticket-drop-concurrency-lab.
  • Confirm the compiled classes still exist inside out.
  • Ask for help with the verification: Help me diagnose why my compiled concurrency lab no longer passes its checks.
  • Use your editor's find feature to locate Instance A.
  • Use your editor's find feature to locate Instance B.
  • Use your editor's find feature to locate Shared admission control.
  • Use your editor's find feature to locate Inventory database.

Each architecture node should appear in the Mermaid section. The recipe beneath it should include the inventory read with FOR UPDATE plus the unique idempotency record.

That completes the production bridge. Your repository now demonstrates the local race plus explains where correctness belongs when multiple application instances share inventory.

Secret mission

Add an Admission Gate

A burst of 10,000 virtual threads can overwhelm a downstream service even when every reservation stays correct. Add a Semaphore admission gate that allows only 50 tasks into the simulated operation at once. Then prove that every permit returns after the workload finishes.

Clean Up Your Resources

Clean Up Your Resources

This Java lab runs entirely on your Mac, so it creates no cloud resources or ongoing costs. The options below let you keep the project, pause your work, or delete the lab.

Resources you used:

  • The ticket-drop-concurrency-lab folder on your Desktop stores the complete local project.

Keep everything running

No action is needed. This option suits you if you plan to rerun the demonstrations or extend the production design.

Each program exits after printing its results. Keeping the project does not leave a background service running.

  • Keep the ticket-drop-concurrency-lab folder on your Desktop.
  • Use the commands in README.md whenever you want to compile the demonstrations again.

Pause - I'll come back to this later

Pausing only requires closing macOS Terminal because the demonstrations already stop after each run. Your source files remain ready for your next session.

  • Keep the ticket-drop-concurrency-lab folder on your Desktop.
  • Close Terminal after you finish reviewing your benchmark output.
  • Return later by following the build instructions in README.md.

Delete - I don't want to use this again

Deleting this folder is permanent. Your Java installation stays available for other projects.

  • Copy any benchmark results you want to keep into a folder outside ticket-drop-concurrency-lab.
  • Move to your Desktop and remove the lab by running these commands:
cd ~/Desktop
rm -rf ticket-drop-concurrency-lab

What do these commands do?

  • The first line moves Terminal outside the project folder before deletion.
  • The second line permanently removes the ticket-drop-concurrency-lab folder.
  • Confirm the project folder is gone by running this command:
ls ~/Desktop

What should I see?

The Desktop listing should omit ticket-drop-concurrency-lab. That confirms the source files and compiled output are gone.

Nice Work!

Nice Work!

You pulled it off! Your Java concurrency lab now reproduces a guaranteed double reservation before proving which controls protect every seat.

You also measured thread-model behavior on your own Mac. Your production design now carries those local lessons into a multi-instance reservation system.

You've learned how to:

  • Build a deterministic CountDownLatch race that gives two buyers the same final seat. Use the failed capacity invariant as visible proof of the bug.
  • Make the compound reservation transition atomic with synchronized methods or ReentrantLock. Check reservation correctness automatically.
  • Compare platform threads with virtual threads under a 10,000-buyer burst. Turn those observations into a multi-instance design in README.md.
  • Complete the optional Secret Mission by using a Semaphore to cap local in-flight work at 50 tasks. Prove every permit returns after all 10,000 tasks finish. Explain why fleet-wide admission control needs a shared layer.

Ready to quiz yourself?