Java 21 Concurrent Order Processor

Build a Java 21 order simulator to explore threads, races, and executors.

Introduction

30 Second Summary

A warehouse queue can look orderly while every worker handles one package at a time. Speeding up the work creates a new risk when several workers update the same order count.

In this project, you will build a Java 21 console simulator that processes 20 warehouse orders through several concurrency strategies. Its output exposes a repeatable race condition before proving how synchronized updates, fixed thread pools, and virtual threads restore correct results.

What You'll Build

The finished simulator moves from a correct baseline to a forced unsafe total of 1, then restores the expected total of 20 across safer thread strategies.

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

  • A sequential baseline you can run to see all 20 orders counted with an elapsed time in milliseconds.
  • A repeatable race-condition demo that reports 1 instead of 20, followed by a synchronized run that restores the correct total.
  • A thread strategy comparison that prints false for the fixed platform-thread pool and true for virtual threads while both totals remain 20.
  • Secret Mission: Replace the monitor-protected counter with AtomicInteger while preserving the unsafe race and executor results.

Are there any prerequisites?

This project assumes JDK 21 or newer plus IntelliJ IDEA are already installed on Windows. You only need basic Java experience with classes, methods, loops, arrays, and exception handling.

Before We Start

Before the hands-on work begins, take a moment to define the behavior you want to observe. This gives every concurrency result a clear purpose as you build your 20-order processor.

Set Up the Order Processor

Concurrency experiments introduce several moving parts at once. A verified Java 21 project gives every later result a stable starting point.

This step creates the empty project in IntelliJ IDEA. A readiness message proves that the selected JDK can execute the class.

In this step, get ready to:
  • Create ConcurrentOrderLab with IntelliJ IDEA's native builder.
  • Select JDK 21 or newer for the project.
  • Run ConcurrentOrderProcessor.main() from the gutter to verify the setup.
Create the Java project

The JDK supplies the tools that compile and run your code. The native IntelliJ builder keeps this standard-library project free from additional build configuration.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ IDEA and press Enter to open it.

You'll see the IntelliJ IDEA Welcome screen.

  • Click New Project on the Welcome screen.

You'll see the new-project wizard with the project settings in one place.

  • Select Java from the language list.
  • Enter ConcurrentOrderLab in the Name field.

The wizard now identifies the project as a Java application named ConcurrentOrderLab.

  • Select IntelliJ in the Build system field.
  • Select your installed JDK 21 or newer from the JDK list.

The wizard should now show the native IntelliJ builder beside a compatible JDK.

  • Leave Add sample code disabled.
  • Click Create to generate the project.

You'll see ConcurrentOrderLab in the Project tool window. Its src folder is ready for your Java class.

JDK 21 Missing From the List?

The installed JDK may not be registered with IntelliJ IDEA yet.

  • Open the JDK list in the project wizard.
  • Select Add JDK from Disk….
  • Choose the home directory of your installed JDK 21 or newer.

Help me connect my installed JDK to IntelliJ IDEA.

Add the processor entry point

IntelliJ IDEA stores Java source files under src. A regular class gives every later processing method one stable home.

  • Right-click the src folder in the Project tool window.

You'll see the context menu for items that can be created inside src.

  • Select New.
  • Select Java Class.

You'll see a field for the new class name.

  • Enter ConcurrentOrderProcessor and press Enter.

The editor opens src/ConcurrentOrderProcessor.java with a generated class declaration.

  • Replace the entire contents of src/ConcurrentOrderProcessor.java with the code below:
public class ConcurrentOrderProcessor {
    public static void main(String[] args) {
        System.out.println("Concurrent Order Processor ready");
    }
}

What Does This Code Do?

  • The main(String[] args) method is the program's entry point. The JVM calls this method when you run the class.
  • The System.out.println("Concurrent Order Processor ready") statement writes the readiness message to the console. This gives you an immediate setup check.
  • Press Cmd+S (macOS) or Ctrl+S (Windows) to save src/ConcurrentOrderProcessor.java.

You should see a green run arrow in the gutter beside main(String[] args).

No Green Run Arrow?

The run arrow appears when IntelliJ IDEA recognizes a valid entry point inside a Java source folder.

  • Check that ConcurrentOrderProcessor.java is inside the src folder.
  • Check that the method signature is exactly public static void main(String[] args).

Help me restore the run arrow.

✔️ Awesome, I've got everything!

Great. Double-check that src/ConcurrentOrderProcessor.java is saved before you run it.

ⓧ I'd like to double check the full code

Compare your complete src/ConcurrentOrderProcessor.java file with this reference:

public class ConcurrentOrderProcessor {
    public static void main(String[] args) {
        System.out.println("Concurrent Order Processor ready");
    }
}
Run the readiness check

The gutter action compiles the current class before launching it. The Run tool window shows both the program output and the process result.

  • Find the green run arrow beside main(String[] args) in the editor gutter.

Before you run it, consider what the Run tool window should show if the class executes successfully.

  • Click the green gutter arrow beside main(String[] args).
  • Select Run 'ConcurrentOrderProcessor.main()'.

You'll see Concurrent Order Processor ready in the Run tool window. You'll also see exit code 0 after the program finishes.

Don't See the Ready Message?

  • Compare src/ConcurrentOrderProcessor.java with the full-code reference above.
  • Click File in the main menu.
  • Select Project Structure.
  • Select Project under Project Settings.
  • Confirm that the SDK list shows your installed JDK 21 or newer.

Help me debug the failed readiness check.

Your ConcurrentOrderProcessor class now runs with the selected JDK. Next, you'll replace the readiness message with a timed sequential baseline for all 20 orders.

Establish the Sequential Baseline

Your Java class already runs cleanly in IntelliJ IDEA. Now you'll give the order processor a correct one-thread result that every later concurrency experiment can challenge.

A correct sequential execution baseline gives you a result to compare against every concurrent version. It also records how long the simulated workload takes on your machine.

In this step, get ready to:
  • Define a fixed 20-order workload with a counter.
  • Model each order with a short blocking delay.
  • Run the sequential processor to display its total plus elapsed time.
Define the workload and counter

A fixed workload keeps every later comparison focused on the execution strategy. ORDER_COUNT sets the batch size while OrderCounter stores the number of completed orders.

  • Switch back to src/ConcurrentOrderProcessor.java in the editor.
  • Place these constants directly below the opening line of ConcurrentOrderProcessor:
    private static final int ORDER_COUNT = 20;
    private static final long PROCESSING_DELAY_MS = 100;

What do these constants control?

  • ORDER_COUNT fixes every run at 20 orders.
  • PROCESSING_DELAY_MS gives each order a 100-millisecond simulated delay.
  • The constants keep the workload identical across every processing strategy.
  • Save src/ConcurrentOrderProcessor.java.
  • Click the green gutter arrow beside main() to run the class.

The Run tool window still prints Concurrent Order Processor ready with exit code 0. That successful run confirms the workload constants compile correctly.

Constants not compiling?

Check that both declarations sit inside ConcurrentOrderProcessor but outside main().

Make sure each declaration ends with a semicolon.

Help me fix the workload constants

The counter keeps the processed total behind a small interface. Its synchronized increment method also prepares the same class for the later multithreaded comparisons.

  • Place your cursor before the final closing brace of ConcurrentOrderProcessor.
  • Add the nested counter class by pasting this code:
    private static class OrderCounter {
        private int processedOrders;

        synchronized void incrementSafely() {
            processedOrders++;
        }

        int get() {
            return processedOrders;
        }
    }

How does the counter work?

  • processedOrders starts at Java's default integer value of 0.
  • incrementSafely() adds one after an order finishes.
  • get() returns the current total for the console output.
  • Save src/ConcurrentOrderProcessor.java.
  • Click the green gutter arrow beside main().

You should see the readiness message again with exit code 0. Your counter is now part of a valid Java class.

Counter class not compiling?

Check that OrderCounter sits inside the final closing brace of ConcurrentOrderProcessor.

Compare the braces around incrementSafely() and get() with the snippet above.

Help me fix the nested counter

Simulate processing and measure time

A blocking delay gives each order a small piece of simulated work. System.nanoTime() provides the high-resolution clock used to calculate the elapsed duration.

  • Find the nested OrderCounter declaration near the bottom of src/ConcurrentOrderProcessor.java.
  • Insert these helper methods directly above OrderCounter:
    private static void simulateProcessing() {
        try {
            Thread.sleep(PROCESSING_DELAY_MS);
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Order processing was interrupted", exception);
        }
    }

    private static long elapsedMillis(long startNanos) {
        return (System.nanoTime() - startNanos) / 1_000_000;
    }

What do these helpers do?

  • simulateProcessing() pauses the current thread for the configured delay.
  • Thread.currentThread().interrupt() restores the interrupted status if the sleep is interrupted.
  • IllegalStateException stops processing instead of allowing an interrupted order to count as complete.
  • elapsedMillis() subtracts the starting time from the current time before converting nanoseconds to milliseconds.
  • Save src/ConcurrentOrderProcessor.java.
  • Click the green gutter arrow beside main().

The class should finish with exit code 0. This clean run proves the delay plus timing helpers compile before the baseline starts using them.

Helpers not compiling?

Confirm that both helper methods sit inside ConcurrentOrderProcessor but outside OrderCounter.

Check the try and catch braces around Thread.sleep(PROCESSING_DELAY_MS).

Help me fix the processing helpers

Run the sequential baseline

The sequential loop processes one order at a time. It records the start time before the loop so the final line can report both correctness and duration.

  • Insert runSequential() directly above simulateProcessing() in src/ConcurrentOrderProcessor.java:
    private static void runSequential() {
        OrderCounter counter = new OrderCounter();
        long startNanos = System.nanoTime();

        for (int orderNumber = 1; orderNumber <= ORDER_COUNT; orderNumber++) {
            simulateProcessing();
            counter.incrementSafely();
        }

        System.out.println(
                "Sequential total: " + counter.get()
                        + " | elapsed: " + elapsedMillis(startNanos) + " ms"
        );
    }

How does the baseline run?

  • counter starts with zero completed orders.
  • startNanos captures the time immediately before processing begins.
  • The loop runs once for each value from 1 through ORDER_COUNT.
  • Each iteration waits before incrementing the counter, so only completed orders contribute to the total.
  • The printed duration describes this simulated waiting workload on your machine. It is an observation instead of a production performance guarantee.
  • Save src/ConcurrentOrderProcessor.java.
  • Click the green gutter arrow beside main().

You should still see the readiness message with exit code 0. This confirms that runSequential() compiles before you connect it to the entry point.

  • Locate the existing main(String[] args) method near the top of the file.
  • Replace the entire method with this version:
    public static void main(String[] args) {
        System.out.println("=== Sequential baseline ===");
        runSequential();
    }

What changed in the entry point?

  • The heading separates this strategy from the additional strategies you add later.
  • runSequential() now performs the complete 20-order baseline whenever the class starts.

Before you run the class, what total do you expect after the loop finishes all 20 iterations?

  • Save src/ConcurrentOrderProcessor.java.
  • Click the green gutter arrow beside main().

The Run tool window first prints === Sequential baseline ===. The next line begins with Sequential total: 20 followed by an elapsed time in milliseconds.

Your baseline is working. You now have a correct total plus a local timing result for the concurrent versions to match.

Baseline total not showing 20?

  • Check that the loop starts with orderNumber = 1.
  • Confirm that the loop condition uses orderNumber <= ORDER_COUNT.
  • Keep counter.incrementSafely() inside the loop after simulateProcessing().

Help me debug the sequential total

✔️ Awesome, I've got everything!

Great work. Save src/ConcurrentOrderProcessor.java with the sequential total and elapsed time visible in the Run tool window.

ⓧ I'd like to double check the full code

Compare your cumulative file with this reference. Every declaration and method should appear in the same order.

public class ConcurrentOrderProcessor {
    private static final int ORDER_COUNT = 20;
    private static final long PROCESSING_DELAY_MS = 100;

    public static void main(String[] args) {
        System.out.println("=== Sequential baseline ===");
        runSequential();
    }

    private static void runSequential() {
        OrderCounter counter = new OrderCounter();
        long startNanos = System.nanoTime();

        for (int orderNumber = 1; orderNumber <= ORDER_COUNT; orderNumber++) {
            simulateProcessing();
            counter.incrementSafely();
        }

        System.out.println(
                "Sequential total: " + counter.get()
                        + " | elapsed: " + elapsedMillis(startNanos) + " ms"
        );
    }

    private static void simulateProcessing() {
        try {
            Thread.sleep(PROCESSING_DELAY_MS);
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Order processing was interrupted", exception);
        }
    }

    private static long elapsedMillis(long startNanos) {
        return (System.nanoTime() - startNanos) / 1_000_000;
    }

    private static class OrderCounter {
        private int processedOrders;

        synchronized void incrementSafely() {
            processedOrders++;
        }

        int get() {
            return processedOrders;
        }
    }
}

What should match?

This reference contains the complete sequential baseline. The constants appear first, followed by the entry point, processing methods, timing helper, and nested counter.

Your one-thread result is now reliable. Next, you'll create named platform threads and force a repeatable race condition in the shared counter.

Force a Race Condition

Your sequential baseline now processes all 20 orders with the correct total. Next, you'll send the same counter update through 20 platform threads.

A fast counter can appear correct when thread timing happens to line up. You'll use CountDownLatch to make the race condition repeatable on every run.

In this step, get ready to:
  • Coordinate every worker after it reads the shared counter.
  • Manage 20 named platform threads from creation through completion.
  • Observe a repeatable lost update in shared mutable state.
Coordinate every stale read

A CountDownLatch holds workers at a shared checkpoint until its count reaches zero. Here, that checkpoint ensures every worker captures the same counter value before any worker writes.

  • Switch back to src/ConcurrentOrderProcessor.java in the editor.
  • Add the latch import above the ConcurrentOrderProcessor class by copying this line:
import java.util.concurrent.CountDownLatch;

Why import CountDownLatch?

CountDownLatch provides the shared checkpoint for the worker threads. Its count represents how many workers still need to finish reading the counter.

  • Locate the processedOrders field inside the nested OrderCounter class.
  • Add incrementUnsafely() directly below the field by copying this method:
        void incrementUnsafely(CountDownLatch allReadsFinished)
                throws InterruptedException {
            int snapshot = processedOrders;
            allReadsFinished.countDown();
            allReadsFinished.await();
            processedOrders = snapshot + 1;
        }

What does this method do?

  • snapshot captures the counter value before the worker pauses.
  • countDown() records that one worker has completed its read.
  • await() blocks that worker until all 20 reads are complete.
  • processedOrders = snapshot + 1 writes the value calculated from the stale snapshot.
  • Click the green gutter arrow beside main() to run the current program.

You'll still see Sequential total: 20 with an elapsed time. Good progress. The latch-controlled update now compiles alongside your working baseline.

Does the latch code fail to compile?

Check that the import sits above the class declaration. Confirm that incrementUnsafely() sits inside OrderCounter.

Help me fix the latch code

Create the worker threads

Each Thread wraps one counter task. Calling start() schedules that task for execution.

The main thread uses join() to wait for every worker to terminate. This prevents the program from reading the total while workers are still running.

  • Locate the closing brace of runSequential().
  • Add runUnsafeThreads() immediately below it by copying this method:
    private static void runUnsafeThreads() throws InterruptedException {
        OrderCounter counter = new OrderCounter();
        CountDownLatch allReadsFinished = new CountDownLatch(ORDER_COUNT);
        Thread[] workers = new Thread[ORDER_COUNT];

        for (int index = 0; index < ORDER_COUNT; index++) {
            int orderNumber = index + 1;
            workers[index] = new Thread(() -> {
                try {
                    counter.incrementUnsafely(allReadsFinished);
                } catch (InterruptedException exception) {
                    Thread.currentThread().interrupt();
                }
            }, "order-worker-" + orderNumber);
            workers[index].start();
        }

        for (Thread worker : workers) {
            worker.join();
        }

        System.out.println(
                "Unsafe total: " + counter.get() + " | expected: " + ORDER_COUNT
        );
    }

How does the worker lifecycle work?

  • workers stores references to all 20 threads.
  • new Thread() gives each task a name from order-worker-1 through order-worker-20.
  • start() lets the workers reach the latch concurrently.
  • join() waits for each worker before the total is printed.
  • Thread.currentThread().interrupt() restores the interrupted status if a waiting worker is interrupted.
Run the forced race

The unsafe method is ready. The final connection adds it to main() after the sequential baseline.

  • Replace the existing main() method with this version:
    public static void main(String[] args) throws InterruptedException {
        System.out.println("=== Sequential baseline ===");
        runSequential();

        System.out.println("\n=== Unsafe platform threads ===");
        runUnsafeThreads();
    }

What changed in main?

  • throws InterruptedException allows main() to pass through an interruption from runUnsafeThreads().
  • runUnsafeThreads() starts the race after the sequential baseline finishes.
  • Unsafe platform threads gives the new console section a visible heading.

Before you run it, what total do you think the unsafe section will print after all 20 workers finish?

  • Click the green gutter arrow beside main() to run the completed experiment.

The Run tool window first shows Sequential total: 20 with an elapsed time. The new section then prints Unsafe total: 1 | expected: 20.

You have forced the lost update instead of waiting for thread timing to expose it by chance.

Why does the total equal 1?

  • All 20 workers read processedOrders while its value is 0.
  • CountDownLatch releases the workers only after the twentieth read.
  • Every worker calculates snapshot + 1 as 1.
  • Each assignment overwrites the counter with the same value.
  • join() guarantees that every worker has terminated before the total is read.
  • The separate read and write remain a non-atomic operation on shared mutable state.

Not seeing the unsafe total?

Confirm that main() declares throws InterruptedException. Check that it calls runUnsafeThreads() after runSequential().

If the program keeps running, compare the latch count with ORDER_COUNT. Every created worker must call countDown() before await().

Help me debug the unsafe thread experiment

✔️ Awesome, I've got everything!

Your src/ConcurrentOrderProcessor.java file now contains the sequential baseline plus the repeatable unsafe thread experiment.

ⓧ I'd like to double check the full code

  • Compare src/ConcurrentOrderProcessor.java with this complete reference:
import java.util.concurrent.CountDownLatch;

public class ConcurrentOrderProcessor {
    private static final int ORDER_COUNT = 20;
    private static final long PROCESSING_DELAY_MS = 100;

    public static void main(String[] args) throws InterruptedException {
        System.out.println("=== Sequential baseline ===");
        runSequential();

        System.out.println("\n=== Unsafe platform threads ===");
        runUnsafeThreads();
    }

    private static void runSequential() {
        OrderCounter counter = new OrderCounter();
        long startNanos = System.nanoTime();

        for (int orderNumber = 1; orderNumber <= ORDER_COUNT; orderNumber++) {
            simulateProcessing();
            counter.incrementSafely();
        }

        System.out.println(
                "Sequential total: " + counter.get()
                        + " | elapsed: " + elapsedMillis(startNanos) + " ms"
        );
    }

    private static void runUnsafeThreads() throws InterruptedException {
        OrderCounter counter = new OrderCounter();
        CountDownLatch allReadsFinished = new CountDownLatch(ORDER_COUNT);
        Thread[] workers = new Thread[ORDER_COUNT];

        for (int index = 0; index < ORDER_COUNT; index++) {
            int orderNumber = index + 1;
            workers[index] = new Thread(() -> {
                try {
                    counter.incrementUnsafely(allReadsFinished);
                } catch (InterruptedException exception) {
                    Thread.currentThread().interrupt();
                }
            }, "order-worker-" + orderNumber);
            workers[index].start();
        }

        for (Thread worker : workers) {
            worker.join();
        }

        System.out.println(
                "Unsafe total: " + counter.get() + " | expected: " + ORDER_COUNT
        );
    }

    private static void simulateProcessing() {
        try {
            Thread.sleep(PROCESSING_DELAY_MS);
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Order processing was interrupted", exception);
        }
    }

    private static long elapsedMillis(long startNanos) {
        return (System.nanoTime() - startNanos) / 1_000_000;
    }

    private static class OrderCounter {
        private int processedOrders;

        void incrementUnsafely(CountDownLatch allReadsFinished)
                throws InterruptedException {
            int snapshot = processedOrders;
            allReadsFinished.countDown();
            allReadsFinished.await();
            processedOrders = snapshot + 1;
        }

        synchronized void incrementSafely() {
            processedOrders++;
        }

        int get() {
            return processedOrders;
        }
    }
}

This reference includes the latch import, the unsafe worker lifecycle, the updated entry point, and the coordinated stale write.

Your race condition is now deterministic. Next, you'll protect the counter so the same 20 platform threads produce the correct total.

Protect the Shared Counter

The forced race from the previous step made one thing clear. All 20 platform threads can finish while shared mutable state is still wrong.

The join() calls coordinate completion. The synchronized method protects one counter update at a time.

In this step, get ready to:
  • Inspect how the counter object's monitor protects incrementSafely().
  • Add a second start-and-join run that calls the protected update.
  • Run all three demonstrations to compare completion coordination with data protection.
Inspect the protected update

The OrderCounter from your sequential baseline already contains incrementSafely(). The synchronized keyword makes this method a critical section guarded by the counter's object monitor.

  • Return to src/ConcurrentOrderProcessor.java in IntelliJ IDEA.
  • Locate synchronized void incrementSafely() inside OrderCounter.
  • Compare its processedOrders++ update with the separate read and write inside incrementUnsafely().

How Do synchronized and join() Differ?

  • The synchronized keyword requires a worker to acquire the counter object's monitor before entering incrementSafely().
  • Only one worker can hold that monitor for this counter at a time.
  • The join() method waits for a worker to terminate. This places the final print after every update attempt.
  • The monitor protects each update. The join() calls protect the timing of the final read.
Add the synchronized worker run

The protected experiment needs the same number of workers as the unsafe version. Keeping the thread pattern consistent isolates the counter update as the only meaningful difference.

  • Add runSynchronizedThreads() below runUnsafeThreads() in src/ConcurrentOrderProcessor.java by pasting this code:
    private static void runSynchronizedThreads() throws InterruptedException {
        OrderCounter counter = new OrderCounter();
        Thread[] workers = new Thread[ORDER_COUNT];

        for (int index = 0; index < ORDER_COUNT; index++) {
            int orderNumber = index + 1;
            workers[index] = new Thread(
                    counter::incrementSafely,
                    "safe-order-worker-" + orderNumber
            );
            workers[index].start();
        }

        for (Thread worker : workers) {
            worker.join();
        }

        System.out.println(
                "Synchronized total: " + counter.get()
                        + " | expected: " + ORDER_COUNT
        );
    }

What Does This Method Do?

  • A new OrderCounter gives this experiment an independent total.
  • The first loop creates 20 named workers. Each worker calls incrementSafely() through a method reference.
  • The start() call schedules each worker to run.
  • The second loop calls join() on every worker before the result is read.
  • The final print compares the protected total with ORDER_COUNT.
  • Save src/ConcurrentOrderProcessor.java by pressing Cmd+S on macOS or Ctrl+S on Windows.
  • Confirm that the new method has no red error highlights in the editor.

Seeing Errors in the New Method?

Confirm that runSynchronizedThreads() starts after the closing brace of runUnsafeThreads(). Confirm that it ends before simulateProcessing().

Check that the method reference is exactly counter::incrementSafely. Check that each worker uses the safe-order-worker- name prefix.

Help me fix the synchronized worker method.

Run the synchronized comparison

The new method is ready. Your main() method now needs to call it after the unsafe experiment.

  • Find the current final two statements in main() using this reference:
        System.out.println("\n=== Unsafe platform threads ===");
        runUnsafeThreads();

Where Does This Code Run?

These statements currently form the end of main(). The unsafe experiment completes before the method returns.

  • Replace those final statements with this expanded version:
        System.out.println("\n=== Unsafe platform threads ===");
        runUnsafeThreads();

        System.out.println("\n=== Synchronized platform threads ===");
        runSynchronizedThreads();

What Changed in main()?

The unsafe experiment still runs first. The synchronized experiment now follows it with a separate heading.

This order preserves the failed comparison. It then shows the same worker pattern with protected updates.

  • Save src/ConcurrentOrderProcessor.java.

✔️ Awesome, I've got everything!

Your file now includes the synchronized worker method. The final call is also in place.

ⓧ I'd like to double check the full code

Your completed src/ConcurrentOrderProcessor.java should match this reference.

import java.util.concurrent.CountDownLatch;

public class ConcurrentOrderProcessor {
    private static final int ORDER_COUNT = 20;
    private static final long PROCESSING_DELAY_MS = 100;

    public static void main(String[] args) throws InterruptedException {
        System.out.println("=== Sequential baseline ===");
        runSequential();

        System.out.println("\n=== Unsafe platform threads ===");
        runUnsafeThreads();

        System.out.println("\n=== Synchronized platform threads ===");
        runSynchronizedThreads();
    }

    private static void runSequential() {
        OrderCounter counter = new OrderCounter();
        long startNanos = System.nanoTime();

        for (int orderNumber = 1; orderNumber <= ORDER_COUNT; orderNumber++) {
            simulateProcessing();
            counter.incrementSafely();
        }

        System.out.println(
                "Sequential total: " + counter.get()
                        + " | elapsed: " + elapsedMillis(startNanos) + " ms"
        );
    }

    private static void runUnsafeThreads() throws InterruptedException {
        OrderCounter counter = new OrderCounter();
        CountDownLatch allReadsFinished = new CountDownLatch(ORDER_COUNT);
        Thread[] workers = new Thread[ORDER_COUNT];

        for (int index = 0; index < ORDER_COUNT; index++) {
            int orderNumber = index + 1;
            workers[index] = new Thread(() -> {
                try {
                    counter.incrementUnsafely(allReadsFinished);
                } catch (InterruptedException exception) {
                    Thread.currentThread().interrupt();
                }
            }, "order-worker-" + orderNumber);
            workers[index].start();
        }

        for (Thread worker : workers) {
            worker.join();
        }

        System.out.println(
                "Unsafe total: " + counter.get() + " | expected: " + ORDER_COUNT
        );
    }

    private static void runSynchronizedThreads() throws InterruptedException {
        OrderCounter counter = new OrderCounter();
        Thread[] workers = new Thread[ORDER_COUNT];

        for (int index = 0; index < ORDER_COUNT; index++) {
            int orderNumber = index + 1;
            workers[index] = new Thread(
                    counter::incrementSafely,
                    "safe-order-worker-" + orderNumber
            );
            workers[index].start();
        }

        for (Thread worker : workers) {
            worker.join();
        }

        System.out.println(
                "Synchronized total: " + counter.get()
                        + " | expected: " + ORDER_COUNT
        );
    }

    private static void simulateProcessing() {
        try {
            Thread.sleep(PROCESSING_DELAY_MS);
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Order processing was interrupted", exception);
        }
    }

    private static long elapsedMillis(long startNanos) {
        return (System.nanoTime() - startNanos) / 1_000_000;
    }

    private static class OrderCounter {
        private int processedOrders;

        void incrementUnsafely(CountDownLatch allReadsFinished)
                throws InterruptedException {
            int snapshot = processedOrders;
            allReadsFinished.countDown();
            allReadsFinished.await();
            processedOrders = snapshot + 1;
        }

        synchronized void incrementSafely() {
            processedOrders++;
        }

        int get() {
            return processedOrders;
        }
    }
}

What Should Match?

  • The end of main() calls runSynchronizedThreads() after the unsafe run.
  • The synchronized run creates workers named from safe-order-worker-1 through safe-order-worker-20.
  • The incrementSafely() declaration still includes synchronized.

Before you run it, which result do you expect to change when every update uses the counter's monitor?

  • Click the green gutter arrow beside main() to run ConcurrentOrderProcessor.main().

You'll see the sequential result begin with Sequential total: 20.

The unsafe section still reports Unsafe total: 1 | expected: 20. The new section reports Synchronized total: 20 | expected: 20.

You should also see exit code 0 in the Run tool window.

Missing the Synchronized Result?

Confirm that main() calls runSynchronizedThreads() after runUnsafeThreads().

If the synchronized total is below 20, confirm that the method declaration still starts with synchronized void incrementSafely().

Help me troubleshoot the synchronized result.

That is the shared counter protected. Next up, you'll compare execution strategies without managing every worker thread by hand.

Compare Fixed and Virtual Thread Executors

Your synchronized workers now protect shared mutable state. However, main() still creates every thread directly.

Executors separate task submission from thread management. The same 20-order workload can now run through a bounded pool of platform threads or one virtual thread per task.

In this step, get ready to:
  • Add a reusable executor runner for the 20-order workload.
  • Run the workload through a fixed pool of four platform threads.
  • Compare a virtual-thread-per-task executor with the fixed pool.
Build the reusable executor runner

ExecutorService accepts submitted tasks through one interface. The caller supplies the strategy that manages those tasks.

  • In src/ConcurrentOrderProcessor.java, find the current import at the top of the file:
import java.util.concurrent.CountDownLatch;

What Does This Import Support?

CountDownLatch coordinates the deliberately unsafe race from the earlier experiment. The new executor scenarios keep that demonstration intact.

  • Replace that import with this complete import group:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

What Do the New Imports Add?

  • ExecutorService provides the shared interface for submitting tasks.
  • Executors creates the fixed-pool strategy and the virtual-thread-per-task strategy.
  • Save src/ConcurrentOrderProcessor.java.
  • Run ConcurrentOrderProcessor.main() from the green gutter arrow beside main().

The existing sequential section reports 20. The Run tool window finishes with exit code 0.

Are the Executor Imports Red?

Confirm that both new imports sit directly below the CountDownLatch import. Check that each import ends with a semicolon.

Help me fix the executor imports

A shared runner keeps the order-processing task identical across both strategies. Any difference in thread type then comes from the supplied executor.

  • Find private static void simulateProcessing() { near the bottom of src/ConcurrentOrderProcessor.java.
  • Paste this method directly above simulateProcessing():
private static void runWithExecutor(String label, ExecutorService executor) {
    OrderCounter counter = new OrderCounter();
    long startNanos = System.nanoTime();

    try (ExecutorService service = executor) {
        for (int orderNumber = 1; orderNumber <= ORDER_COUNT; orderNumber++) {
            int currentOrder = orderNumber;
            service.submit(() -> {
                if (currentOrder == 1) {
                    System.out.println(
                            label + " virtual thread: "
                                    + Thread.currentThread().isVirtual()
                    );
                }
                simulateProcessing();
                counter.incrementSafely();
            });
        }
    }

    System.out.println(
            label + " total: " + counter.get()
                    + " | elapsed: " + elapsedMillis(startNanos) + " ms"
    );
}

What Does the Executor Runner Do?

  • executor holds the execution strategy supplied by main().
  • service.submit() submits each order as a task.
  • currentOrder == 1 limits the thread-type message to one task.
  • Thread.currentThread().isVirtual() reveals whether that task runs on a virtual thread.
  • simulateProcessing() gives every submitted task the same blocking delay.
  • counter.incrementSafely() protects the total while tasks finish concurrently.
  • The try-with-resources block closes the service after submission. Closing waits for the submitted tasks to finish before the total is printed.
  • Save src/ConcurrentOrderProcessor.java.
  • Run ConcurrentOrderProcessor.main() from the green gutter arrow beside main().

The sequential section still reports 20. The unsafe section still reports 1.

The synchronized section still reports 20. The Run tool window finishes with exit code 0.

Does the Runner Fail to Compile?

Confirm that runWithExecutor() sits outside runSynchronizedThreads(). Its closing brace must appear immediately before simulateProcessing().

Help me fix runWithExecutor

Run tasks on a fixed platform-thread pool

A fixed thread pool reuses a bounded number of workers. A pool size of 4 allows at most four submitted tasks to run at once.

  • In main(), find the call to runSynchronizedThreads().
  • Add this fixed-pool scenario directly below that call:
System.out.println("\n=== Fixed platform-thread pool ===");
runWithExecutor(
        "Fixed platform-thread pool",
        Executors.newFixedThreadPool(4)
);

How Does the Fixed Pool Work?

Executors.newFixedThreadPool(4) creates four reusable platform threads. Additional tasks wait until one of those workers becomes available.

The label identifies this strategy in every output line. The shared runner keeps the workload unchanged.

  • Save src/ConcurrentOrderProcessor.java.

Before you run the class, do you expect the fixed pool to identify its worker as virtual?

  • Run ConcurrentOrderProcessor.main() from the green gutter arrow beside main().

You should see a new Fixed platform-thread pool section. It prints virtual thread: false.

You have the fixed executor working. Its total is 20 with an elapsed time in milliseconds.

Missing the Fixed-Pool Section?

Confirm that the fixed-pool block sits inside main(). Place it after runSynchronizedThreads() and before the closing brace of main().

Help me fix the fixed executor call

Compare the virtual-thread executor

A virtual-thread-per-task executor creates a new virtual thread for every submitted task. This model fits workloads that spend much of their time blocked.

  • In main(), locate the fixed-pool call you just added.
  • Add this virtual-thread scenario directly below the fixed-pool call:
System.out.println("\n=== Virtual thread per task ===");
runWithExecutor(
        "Virtual thread per task",
        Executors.newVirtualThreadPerTaskExecutor()
);

How Does the Virtual Executor Differ?

Executors.newVirtualThreadPerTaskExecutor() creates one virtual thread for each order task. The executor does not pool those virtual threads.

Both strategies still call the same runWithExecutor() method. This isolates the thread-management choice.

  • Save src/ConcurrentOrderProcessor.java.

Before you run the final comparison, which executor do you think will identify its task as virtual?

  • Run ConcurrentOrderProcessor.main() from the green gutter arrow beside main().

The fixed-pool section prints virtual thread: false. The virtual-thread section prints virtual thread: true.

Both executor totals are 20. Each section also reports an elapsed time in milliseconds.

Your complete concurrency comparison now runs successfully. The Run tool window ends with exit code 0.

How Should You Read the Timings?

Thread.sleep() models a waiting workload. The elapsed times show what happened on your machine during this run.

Treat the results as observations of this simulator. Production performance decisions need measurements from the real workload.

Is the Virtual-Thread Factory Unavailable?

In IntelliJ IDEA, select File. Select Project Structure.

Select Project under Project Settings. Confirm that the project SDK is JDK 21 or newer.

Help me restore virtual-thread support

✔️ Awesome, I've got everything!

Your file now contains the sequential, unsafe, synchronized, fixed-pool, and virtual-thread scenarios.

  • Save src/ConcurrentOrderProcessor.java.

ⓧ I'd like to double check the full code

Compare src/ConcurrentOrderProcessor.java with this completed version.

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

public class ConcurrentOrderProcessor {
    private static final int ORDER_COUNT = 20;
    private static final long PROCESSING_DELAY_MS = 100;

    public static void main(String[] args) throws InterruptedException {
        System.out.println("=== Sequential baseline ===");
        runSequential();

        System.out.println("\n=== Unsafe platform threads ===");
        runUnsafeThreads();

        System.out.println("\n=== Synchronized platform threads ===");
        runSynchronizedThreads();

        System.out.println("\n=== Fixed platform-thread pool ===");
        runWithExecutor(
                "Fixed platform-thread pool",
                Executors.newFixedThreadPool(4)
        );

        System.out.println("\n=== Virtual thread per task ===");
        runWithExecutor(
                "Virtual thread per task",
                Executors.newVirtualThreadPerTaskExecutor()
        );
    }

    private static void runSequential() {
        OrderCounter counter = new OrderCounter();
        long startNanos = System.nanoTime();

        for (int orderNumber = 1; orderNumber <= ORDER_COUNT; orderNumber++) {
            simulateProcessing();
            counter.incrementSafely();
        }

        System.out.println(
                "Sequential total: " + counter.get()
                        + " | elapsed: " + elapsedMillis(startNanos) + " ms"
        );
    }

    private static void runUnsafeThreads() throws InterruptedException {
        OrderCounter counter = new OrderCounter();
        CountDownLatch allReadsFinished = new CountDownLatch(ORDER_COUNT);
        Thread[] workers = new Thread[ORDER_COUNT];

        for (int index = 0; index < ORDER_COUNT; index++) {
            int orderNumber = index + 1;
            workers[index] = new Thread(() -> {
                try {
                    counter.incrementUnsafely(allReadsFinished);
                } catch (InterruptedException exception) {
                    Thread.currentThread().interrupt();
                }
            }, "order-worker-" + orderNumber);
            workers[index].start();
        }

        for (Thread worker : workers) {
            worker.join();
        }

        System.out.println(
                "Unsafe total: " + counter.get() + " | expected: " + ORDER_COUNT
        );
    }

    private static void runSynchronizedThreads() throws InterruptedException {
        OrderCounter counter = new OrderCounter();
        Thread[] workers = new Thread[ORDER_COUNT];

        for (int index = 0; index < ORDER_COUNT; index++) {
            int orderNumber = index + 1;
            workers[index] = new Thread(
                    counter::incrementSafely,
                    "safe-order-worker-" + orderNumber
            );
            workers[index].start();
        }

        for (Thread worker : workers) {
            worker.join();
        }

        System.out.println(
                "Synchronized total: " + counter.get()
                        + " | expected: " + ORDER_COUNT
        );
    }

    private static void runWithExecutor(String label, ExecutorService executor) {
        OrderCounter counter = new OrderCounter();
        long startNanos = System.nanoTime();

        try (ExecutorService service = executor) {
            for (int orderNumber = 1; orderNumber <= ORDER_COUNT; orderNumber++) {
                int currentOrder = orderNumber;
                service.submit(() -> {
                    if (currentOrder == 1) {
                        System.out.println(
                                label + " virtual thread: "
                                        + Thread.currentThread().isVirtual()
                        );
                    }
                    simulateProcessing();
                    counter.incrementSafely();
                });
            }
        }

        System.out.println(
                label + " total: " + counter.get()
                        + " | elapsed: " + elapsedMillis(startNanos) + " ms"
        );
    }

    private static void simulateProcessing() {
        try {
            Thread.sleep(PROCESSING_DELAY_MS);
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Order processing was interrupted", exception);
        }
    }

    private static long elapsedMillis(long startNanos) {
        return (System.nanoTime() - startNanos) / 1_000_000;
    }

    private static class OrderCounter {
        private int processedOrders;

        void incrementUnsafely(CountDownLatch allReadsFinished)
                throws InterruptedException {
            int snapshot = processedOrders;
            allReadsFinished.countDown();
            allReadsFinished.await();
            processedOrders = snapshot + 1;
        }

        synchronized void incrementSafely() {
            processedOrders++;
        }

        int get() {
            return processedOrders;
        }
    }
}

What Does the Completed File Contain?

The file preserves all three earlier demonstrations. It adds one shared executor runner plus the fixed and virtual execution strategies.

Secret mission

Replace the Lock with an Atomic Counter

Your synchronized counter already protects every safe update. Replace its monitor-based increment with AtomicInteger while preserving the deliberately unsafe race and both executor comparisons.

Clean Up Your Resources

Clean Up Your Resources

The local ConcurrentOrderLab project contains everything you built at no ongoing cost. Choose whether to keep it, close it for now, or delete it.

Resources you used:

  • Local project folder ConcurrentOrderLab, including src/ConcurrentOrderProcessor.java.

Keep everything running

No action is required. Choose this option if you want to continue experimenting with the concurrency lab.

  • Leave the ConcurrentOrderLab folder on your computer.
  • Return to the project in IntelliJ IDEA whenever you want to rerun ConcurrentOrderProcessor.main().

Pause - I'll come back to this later

Your console application exits after each run. Closing the editor frees its memory while preserving every project file.

  • Close IntelliJ IDEA with the window's close control.
  • Keep the ConcurrentOrderLab folder in its current location.

Your concurrency lab is ready to reopen without rebuilding any code.

Delete - I don't want to use this again

Deleting the folder is permanent. It affects only this project.

Your existing development tools remain installed.

  • Close IntelliJ IDEA with the window's close control.
  • Press the Windows key to open the search bar.
  • Type ConcurrentOrderLab into the search field.
  • Use the folder result's file-location option to open its parent folder.
  • Select the ConcurrentOrderLab folder in File Explorer.

Windows may ask you to confirm permanent deletion after the next action.

  • Press Shift+Delete to remove the selected folder permanently.
  • Confirm the deletion if Windows displays a confirmation prompt.
  • Search for ConcurrentOrderLab again from the Windows search bar.

You should see no folder result for the project. That confirms the local project files are removed.

Nice Work!

Nice Work!

You made it! Your Java console application now turns concurrency behavior into repeatable results for a 20-order workload.

You've learned how to:

  • Build a sequential baseline that processes all 20 orders correctly. Measure its elapsed time with System.nanoTime().
  • Reproduce a deterministic lost-update race with CountDownLatch. Protect the same shared counter with a synchronized method.
  • Submit one blocking workload to a fixed platform-thread pool. Run it again with a virtual-thread-per-task executor. Use isVirtual() to identify each thread type at runtime.
  • Secret Mission: Replace monitor-based counter updates with AtomicInteger. Keep the unsafe get() and set() experiment intact. Make safe updates atomic with incrementAndGet().

Ready to quiz yourself?