100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Java Mastery
50 minintermediate

Concurrency Practice: Multi-threaded Data Processor

What You'll Build

In this exercise you will build a multi-threaded data processor: a program that takes a large batch of work items, cricket match records to score and analyse, and processes them concurrently across multiple threads, safely aggregating the results. It brings together everything from Module 5: the Executor framework to run tasks, Callable and Future (or virtual threads) to produce results, thread-safe aggregation with atomics and concurrent collections, and CompletableFuture to compose the processing pipeline.

Rather than practising each concurrency tool in isolation, you will combine them into a realistic processing engine where correctness under concurrency is the whole point: many threads compute partial results simultaneously, and those results must be merged without races. By the end you will have a processor that turns a large workload into results far faster than sequential code while remaining correct, the core pattern behind every concurrent batch and request-processing system, consolidating Module 5 before the course moves into modern language features.

Analogy🏏Cricket
🏏 Think of it like cricket: after drilling individual skills in the nets, batting, bowling, fielding, a player finally puts them together in a full practice match where the skills must work in combination under live conditions. Just as the practice match reveals whether the separately-drilled skills actually cohere into a performance, these two programs reveal whether your separately-learned constructs, types, operators, branches, loops, actually cohere into working software. Just as the match is where a player first feels how the pieces fit, the exercise is where you first feel how the language fits together. Just as a coach moves players from drills to matches once the basics are sound, the course moves you from concepts to construction once the fundamentals are in place. The insight is that fundamentals only become capability when you assemble them into something complete that runs.

Prerequisites

  • Completion of lessons 21 through 24: threads and executors, synchronization and locks, CompletableFuture, and virtual threads.
  • A JDK installed, version 21 recommended so virtual threads are available; verify with java -version.
  • Comfort submitting Callable tasks to an ExecutorService and retrieving results via Future from lesson 21.
  • Understanding of why shared mutable state races and how atomics and concurrent collections make aggregation safe, from lesson 22.
  • Familiarity with composing asynchronous stages and handling failures with CompletableFuture from lesson 23, and the thread-per-task virtual-thread model from lesson 24.

Setup & Project Structure

The processor is organised into a work item (the data to process), a worker that computes a partial result for a chunk of items, a thread-safe aggregator that merges partial results, and a driver that splits the workload, dispatches the workers concurrently, and collects the final result. Separating the unit of work, the parallel computation, and the safe aggregation keeps each concern clear, which matters doubly in concurrent code where tangled responsibilities breed races.

Analogy🏏Cricket
🏏 Think of it like cricket: setting up these two programs is like preparing two separate match fixtures, each with its own scorecard and playing eleven. Just as each fixture is self-contained — its own teams, its own result, no shared state that could tangle the outcomes — each program lives in its own clearly-named Java file with a single public class and a main method, the simplest structure for a console app. Just as the Laws require a clear team sheet before play begins so everyone knows who is on the field, the one-public-class-per-file rule keeps each program's entry point unambiguous. And just as scheduling two independent matches means a problem in one never delays the other, keeping the calculator and the number game in separate files keeps the exercises independent. The payoff: clean, isolated structure that lets you build, run, and reason about each program without the other getting in the way.

Lay out the pieces so the data flow is explicit, raw items in, partial results computed in parallel, merged result out, and build incrementally: first a correct sequential version to validate the logic, then the concurrent version, so you can confirm the concurrent results match the sequential ones. That comparison is the single most valuable test for concurrent code, since it catches races that only manifest under parallelism.

bash
# Project layout for the multi-threaded data processor.
mkdir data-processor && cd data-processor

# Pieces (one public type each):
#   MatchRecord.java     -- an immutable work item (a match to process)
#   ScoreWorker.java     -- a Callable that processes a CHUNK and returns a partial result
#   ResultAggregator.java-- thread-safe merge of partial results (atomics / ConcurrentHashMap)
#   Processor.java       -- main: split work, dispatch concurrently, collect final result

# Build strategy:
#   1) Write a SEQUENTIAL processor first and record its output (the source of truth).
#   2) Write the CONCURRENT processor; assert its result EQUALS the sequential result.
#      (Mismatches under concurrency reveal races -- the key test.)

# Compile and run:
#   javac *.java   then   java Processor

Step 1 — Foundation

Step 1 builds the immutable work item and a correct sequential processor as the baseline. The MatchRecord is immutable, processing must never mutate the input, which is exactly what makes it safe to share across threads later. The sequential processor computes whatever aggregate the exercise targets (say, total runs and a count per team) by iterating all records in one thread.

Establishing the sequential version first matters because it is the ground truth: it is simple enough to be obviously correct, and it produces the exact result the concurrent version must reproduce. This is the disciplined way to approach concurrency, get a correct single-threaded answer, then parallelise while verifying the answer is unchanged, so any discrepancy points straight at a concurrency bug rather than a logic error you cannot distinguish from a race.

Analogy🏏Cricket
🏏 Think of it like cricket: before drilling specific shots, a batter establishes their basic routine at the crease, take guard, face the ball, decide, repeat, the rhythm that frames every delivery. Just as that repeating routine frames each shot before the shot itself is chosen, the menu loop frames each operation before the operation is computed. Just as the batter always takes guard at least once before deciding whether to continue the innings, the do-while shows the menu at least once before checking whether to quit. Just as a sound routine lets the batter focus on each delivery cleanly, a sound interaction loop lets you focus on each operation cleanly. The insight is that establishing the repeating interaction structure first gives every later piece a clean frame to slot into.
java
// MatchRecord.java + a sequential baseline -- the ground truth for correctness.
import java.util.*;

public record MatchRecord(String team, int runs, int wickets) {}

class SequentialProcessor {
    // Baseline: total runs per team, computed in ONE thread. Obviously correct.
    static Map<String, Integer> totalRunsByTeam(List<MatchRecord> records) {
        Map<String, Integer> totals = new HashMap<>();
        for (MatchRecord r : records) {
            totals.merge(r.team(), r.runs(), Integer::sum);
        }
        return totals;
    }

    static long grandTotal(List<MatchRecord> records) {
        long total = 0;
        for (MatchRecord r : records) total += r.runs();
        return total;
    }
}

Step 2 — Core Logic

Step 2 implements the worker and the thread-safe aggregator, the heart of the concurrent design. A ScoreWorker is a Callable that takes a chunk of records and returns a partial result computed entirely from its own chunk, with no shared mutable state, which is the safest possible concurrent task: it reads immutable input and returns a value. The aggregator then merges these partial results, and here is where thread-safety is decided.

The key design choice is how to aggregate without races. Two clean approaches: have each worker return its own partial map and merge them on a single thread afterward (no shared state during computation at all), or have workers update a shared ConcurrentHashMap or AtomicLong directly using thread-safe operations. The first is simplest and race-free by construction; the second uses the concurrent tools from lesson 22. Either way, the principle is the same: never let two threads perform an unsynchronised read-modify-write on shared data, exactly the race the module warned about.

Analogy🏏Cricket
🏏 Think of it like cricket: the routine at the crease means nothing if the actual shots are mistimed, the core skill is making clean contact, and a batter who plays down the wrong line gets out no matter how good their routine. Just as clean contact is where runs are actually scored, correct arithmetic is where the calculator actually works. Just as misjudging the line of the ball, the equivalent of integer division truncating, produces a wrong result despite a good setup, forgetting to cast to double produces a wrong number despite a good menu. Just as a batter carefully watches for the ball that could dismiss them, you carefully guard against the zero denominator that would crash the calculation. The insight is that the core computation must be exactly right, because no amount of surrounding structure rescues a wrong result.
java
// ScoreWorker.java + ResultAggregator.java -- Step 2: race-free partial results + safe merge.
import java.util.*;
import java.util.concurrent.*;

// A Callable that processes ITS OWN chunk and returns a partial map -- no shared state.
class ScoreWorker implements Callable<Map<String, Integer>> {
    private final List<MatchRecord> chunk;
    ScoreWorker(List<MatchRecord> chunk) { this.chunk = chunk; }

    @Override public Map<String, Integer> call() {
        Map<String, Integer> partial = new HashMap<>();   // LOCAL: nothing shared
        for (MatchRecord r : chunk) partial.merge(r.team(), r.runs(), Integer::sum);
        return partial;                                    // result flows back via Future
    }
}

class ResultAggregator {
    // APPROACH A: merge partial maps on ONE thread -> race-free by construction.
    static Map<String, Integer> mergeAll(List<Map<String, Integer>> partials) {
        Map<String, Integer> total = new HashMap<>();
        for (Map<String, Integer> p : partials) {
            p.forEach((team, runs) -> total.merge(team, runs, Integer::sum));
        }
        return total;
    }
    // APPROACH B (alternative): workers update a shared ConcurrentHashMap directly,
    // using merge(...) which is atomic per key -> also race-free, via lesson 22's tools.
}

Step 3 — Integration & Enhancement

Step 3 wires it together in the driver: split the workload into chunks, submit a ScoreWorker per chunk to an executor, collect the partial results from the Futures, and merge them into the final answer. This is where the Executor framework, Callables, and Futures from lesson 21 combine with the safe aggregation from Step 2. You can use a fixed thread pool sized to the cores for this CPU-bound aggregation, or, to practise lesson 24, a virtual-thread-per-task executor.

The enhancement is to express the whole pipeline with CompletableFuture: launch each chunk asynchronously with supplyAsync, combine all the partial-result futures with allOf, and then merge, with exceptionally handling any chunk that fails, so the processor degrades gracefully rather than losing the whole batch to one bad chunk. This demonstrates the progression of the module: from raw executors and Futures to composed asynchronous pipelines, all over the same race-free partial-result design.

Analogy🏏Cricket
🏏 Think of it like cricket: a bowler searching for the right length to dismiss a batter does not know in advance how many balls it will take, they bowl, observe whether the ball was too short or too full, adjust, and repeat until they get it right. Just as the bowler loops an unknown number of times, adjusting from feedback each ball, the guessing game loops until correct, adjusting from higher/lower feedback each round. Just as 'too short, pitch it up' guides the bowler's next delivery, 'too low, guess higher' guides the player's next guess. Just as the bowler is guaranteed to eventually find the length, the game must guarantee the loop can end. The insight is that feedback-driven repetition of unknown length, the while loop's natural shape, models exactly this kind of iterative homing-in.
java
// Processor.java -- Step 3: split, dispatch concurrently, collect, and merge.
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;

public class Processor {
    static <T> List<List<T>> chunk(List<T> items, int parts) {
        int size = (int) Math.ceil(items.size() / (double) parts);
        List<List<T>> chunks = new ArrayList<>();
        for (int i = 0; i < items.size(); i += size)
            chunks.add(items.subList(i, Math.min(i + size, items.size())));
        return chunks;
    }

    public static void main(String[] args) throws Exception {
        // Build a large workload.
        List<MatchRecord> records = IntStream.range(0, 100_000)
            .mapToObj(i -> new MatchRecord(i % 4 == 0 ? "IND" : "AUS", i % 200, i % 11))
            .collect(Collectors.toList());

        Map<String,Integer> expected = SequentialProcessor.totalRunsByTeam(records); // ground truth

        // CONCURRENT: one async worker per chunk, combine with allOf, then merge.
        try (ExecutorService pool = Executors.newFixedThreadPool(
                Runtime.getRuntime().availableProcessors())) {
            List<CompletableFuture<Map<String,Integer>>> futures = chunk(records, 8).stream()
                .map(c -> CompletableFuture.supplyAsync(() -> new ScoreWorker(c).call(), pool)
                            .exceptionally(ex -> Map.of()))   // a bad chunk -> empty, not fatal
                .collect(Collectors.toList());

            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
            List<Map<String,Integer>> partials = futures.stream().map(CompletableFuture::join)
                                                         .collect(Collectors.toList());
            Map<String,Integer> result = ResultAggregator.mergeAll(partials);

            // THE KEY TEST: concurrent result must EQUAL the sequential ground truth.
            System.out.println("matches sequential? " + result.equals(expected));
            System.out.println("totals: " + result);
        }
    }
}

Step 4 — Testing & Verification

The decisive test for concurrent code is that the concurrent result exactly equals the sequential ground truth, run it repeatedly, since races are intermittent, and confirm it matches every time. Also verify graceful degradation: inject a chunk that throws and confirm the processor still completes with the other chunks rather than failing entirely, and confirm the executor is shut down so the program exits. If you used a shared aggregator instead of partial-result merging, this comparison is exactly what exposes any race in it.

Analogy🏏Cricket
🏏 Think of it like cricket: verifying your programs is like checking a scorecard against the video before it's official. Just as a scorer confirms a strike rate is a true decimal — 150.00, not a truncated 150 — you check each calculator operation returns a real decimal result, not an integer that silently drops the fraction. Just as the Laws guard against computing an economy rate for a bowler who hasn't bowled a single over, your zero-denominator guards prevent a divide-by-zero crash. Just as you'd test average, strike rate, and run rate with a known innings you can total by hand, you verify with values you can check yourself. And just as an umpire confirms a decision at the exact edge — level scores, an exact target — you test the higher/lower game's boundary where the guess equals the target. The payoff: normal and edge cases both proven correct, so the result stands up to scrutiny.
bash
# Compile and run, then verify correctness and resilience.
javac *.java && java Processor

# Expected:
#   matches sequential? true        <- concurrent result EQUALS the single-threaded result
#   totals: {IND=..., AUS=...}
#
# Verification checklist:
#   [ ] Run it 10+ times: 'matches sequential? true' EVERY time (races are intermittent).
#   [ ] Concurrent run is faster than the sequential baseline on a multi-core machine.
#   [ ] Injecting a throwing chunk still yields a completed result (exceptionally handles it).
#   [ ] The program EXITS promptly -> the executor was shut down (try-with-resources).
#
# If 'matches sequential?' is EVER false, you have a race: a worker is sharing/mutating
# state it should not, or aggregation does an unsynchronised read-modify-write.
# Re-run many times under load to surface intermittent races before trusting the result.

Warning: The most insidious concurrency bug is the intermittent race that passes most runs and fails occasionally, so never conclude concurrent code is correct from a single successful run. Test repeatedly, ideally under load and on multiple cores, and always compare against a sequential ground truth. A common mistake here is sharing a plain HashMap across workers and calling merge on it without synchronization, HashMap is not thread-safe, so concurrent updates corrupt it unpredictably. Either give each worker its own local map and merge afterward, or use a ConcurrentHashMap; never share a non-thread-safe collection across threads.

Extension Challenge: Compare the wall-clock time of the sequential baseline, the platform-thread-pool version, and a virtual-thread version (Executors.newVirtualThreadPerTaskExecutor) to see where each shines, recall that for this CPU-bound aggregation a core-sized platform pool is ideal, while virtual threads favour I/O-bound work. Then add a stage that performs a simulated blocking I/O call per record (a short sleep) and re-measure, observing the virtual-thread version pull ahead. For a stretch, replace the partial-map approach with a shared ConcurrentHashMap and an AtomicLong grand total, and confirm the result still matches the ground truth.

  • The processor combines all of Module 5: an Executor running Callable workers, results via Future/CompletableFuture, and thread-safe aggregation of partial results.
  • Immutable work items are safe to share across threads because they are only read, never mutated, the safest foundation for concurrent processing.
  • Each worker computes a partial result from its own chunk with no shared mutable state, the most race-free design; merging happens afterward or via concurrent collections.
  • The driver splits the workload, dispatches workers concurrently, collects results, and merges them, optionally composing the pipeline with CompletableFuture and allOf.
  • exceptionally lets a failed chunk degrade to an empty partial result rather than losing the whole batch, and the executor is shut down via try-with-resources so the program exits.
  • The decisive test is that the concurrent result exactly equals a sequential ground truth, run many times, since races are intermittent and never proven absent by one passing run.
Lesson 25 of 35
0% complete