The Java Realization / JBCT course / Part III — Patterns

Thread Safety

Lesson 4 of 4 · Part III — Patterns

In this lesson

What You’ll Learn

Prerequisites: Advanced Patterns


Core Rules

Two principles ensure thread safety:

  1. Immutable at boundaries: All data passed between functions (parameters, return values) must be immutable
  2. Thread confinement: Mutable state is allowed within a single-threaded execution path (sequential patterns)

These rules mean you can use mutable local variables in sequential code, but any data shared across threads must be immutable.


Designing Out Contention

Thread confinement protects state inside one execution. It says nothing about two executions — two requests, or two nodes — reaching for the same row at once. That contention is real, and the JBCT answer is not a lock around the section but a design that makes the conflicting state impossible to write: move the contention to one named point, and make the bad combination unconstructible.

Promise composition keeps these honest: the I/O that performs the guarded write is a leaf, the chain is non-blocking, and no lock is held across it. The contention that remains is the irreducible business fact — two people want one seat — funneled to the single transition where it is decided. The methodology states this design-out principle in general; this is the Java and SQL it becomes.


Pattern-by-Pattern Safety Guarantees

Leaf (Sequential)

Thread-safe through sequential execution:

// SAFE: Mutable local state OK
public Result<Order> calculateTotal(Cart cart) {
    var total = 0.0;  // Mutable local variable
    for (Item item : cart.items()) {
        total += item.price();  // Sequential mutation
    }
    return Result.success(new Order(cart, total));
}

Sequencer (Sequential)

Thread-safe, mutable local state OK:

// SAFE: Each step runs sequentially
return ValidRequest.validRequest(request)
                   .async()
                   .flatMap(checkEmail::apply)    // Runs first
                   .flatMap(hashPassword::apply)  // Runs second
                   .flatMap(saveUser::apply);     // Runs third

Fork-Join (Parallel)

Requires strict immutability:

// SAFE: Immutable cart passed to both operations
Promise.all(applyBogo(cart),          // cart is immutable
            applyPercentOff(cart))    // cart is immutable
       .map(this::mergeDiscounts);

// UNSAFE: Shared mutable context creates data race
private final DiscountContext context = new DiscountContext();  // Mutable!
Promise.all(applyBogo(cart, context),     // Both access context
            applyPercentOff(cart, context))  // DATA RACE
       .map(this::merge);

Condition (Sequential)

Thread-safe, no shared mutable state:

// SAFE: Only one branch executes
return user.isPremium()
    ? processPremium(user)
    : processBasic(user);

Iteration (Sequential)

Thread-safe through sequential processing:

// SAFE: Stream processes sequentially
var results = items.stream()
                   .map(Item::validate)  // Sequential
                   .toList();

Aspects (Inherits)

Safety depends on wrapped operation:

// SAFE: Aspect preserves underlying safety
var retried = withRetry(retryPolicy, sequentialStep);  // Still sequential
var retried = withRetry(retryPolicy, forkJoinStep);    // Still parallel

Promise Resolution Thread Safety

Promise resolution has exactly-once semantics with built-in synchronization:

Promise<User> promise = Promise.promise();

// Thread 1
promise.resolve(Result.success(user));

// Thread 2
promise.resolve(Result.success(anotherUser));  // Ignored - already resolved

Common Mistakes

Mistake 1: Shared Mutable State in Fork-Join

// WRONG: Mutable list shared across parallel operations
private final List<String> errors = new ArrayList<>();

Promise.all(validateEmail(request).onFailure(e -> errors.add(e.message())),     // DATA RACE
            validatePassword(request).onFailure(e -> errors.add(e.message())))  // DATA RACE
       .map(this::process);

Fix: Use immutable results and combine after:

// CORRECT: Each operation returns its own result
Promise.all(validateEmail(request),
            validatePassword(request))
       .map((emailResult, passwordResult) -> combineResults(emailResult, passwordResult));

Mistake 2: Assuming Sequential Execution in Promise.all

// WRONG: Assuming order
Promise.all(incrementCounter(), incrementCounter())  // May execute concurrently!

Promise.all runs operations in parallel. If order matters, use Sequencer.

Mistake 3: Mutating Input Parameters

// WRONG: Mutating shared input
private Promise<Discount> applyDiscount(Cart cart) {
    cart.setSubtotal(cart.subtotal().subtract(discount));  // DATA RACE
    return Promise.success(new Discount(discount));
}

// CORRECT: Create new instances
private Promise<Discount> applyDiscount(Cart cart) {
    var discountAmount = calculateDiscountFor(cart);
    return Promise.success(new Discount(cart, discountAmount));  // Returns new data
}

Independence Validation Checklist

Use this checklist to verify parallel operations are truly independent:

If any checkbox fails, use Sequencer instead of Fork-Join.


When to Use Mutable State

Allowed:

Forbidden:


Testing for Thread Safety

Mutable test fixtures are acceptable - tests execute sequentially:

// SAFE: Test-scoped mutable state
@Test
void execute_succeeds_forValidInput() {
    List<String> callLog = new ArrayList<>();  // Mutable, but test-scoped

    CheckEmail checkEmail = req -> {
        callLog.add("email");  // Sequential execution
        return Promise.success(req);
    };

    useCase.execute(request)
           .await()
           .onSuccess(response -> assertEquals(List.of("email"), callLog));
}

Tests are sequential, so mutable fixtures don’t create races.


Quick Reference Table

Pattern Thread Safety Model Local Mutable State Input Data Result Data
Leaf Thread confinement (single invocation) Safe - confined to function scope Must be read-only Must be immutable
Sequencer Sequential execution (steps don’t overlap) Safe - confined to each step Must be read-only Must be immutable
Fork-Join Parallel execution (no synchronization) Safe - confined within each branch MUST be immutable Must be immutable
Iteration (Sequential) Single-threaded (operations execute sequentially) Safe - accumulators OK Must be read-only Must be immutable
Iteration (Parallel) Parallel execution (no synchronization) Safe - confined within each operation MUST be immutable Must be immutable
Condition Depends on branch pattern Follow pattern rules for each branch Must be read-only Must be immutable
Aspects Inherits the wrapped operation’s model Inherits Inherits Inherits

Key Principles:

Common Mistakes:


Key Takeaways

  1. Immutable at boundaries - Parameters and return values always immutable
  2. Thread confinement - Mutable local state safe in sequential patterns
  3. Fork-Join strictness - All inputs must be immutable, no shared state
  4. Promise resolution - Exactly-once, acts as synchronization point
  5. Independence validation - Use checklist before parallelizing
  6. When in doubt - Use Sequencer; premature parallelization causes subtle bugs

Exercises

See Appendix B for exercises on:


What’s Next

Testing Philosophy introduces the testing philosophy - evolutionary testing and integration-first approaches that work naturally with JBCT patterns.

Exercise — Find the Shared Mutable State ~15 min

Find a class in your codebase that mutates shared fields inside a callback or lambda (a counter, a list append, an accumulator). Identify the race and refactor to remove the shared mutable state, returning immutable results instead. Solution discussion in the book’s Appendix B (Exercise 3.5).