Many Hands — Parallel Streams

QuickCart is running a flash sale. Priya needs to check ten thousand products and mark each one “on sale” or not. Doing them one by one in a loop feels slow. Java streams can split the work across CPU cores with parallelStream(). It looks easy — add one word and go faster. Sometimes it is. Sometimes it makes things worse.

Sequential vs parallel

A normal stream processes items one after another on the calling thread:

List<Product> products = loadProducts();

long onSaleCount = products.stream()
    .filter(p -> p.getPrice() < 20.0)
    .count();

A parallel stream splits the list into chunks and processes chunks on different threads:

long onSaleCount = products.parallelStream()
    .filter(p -> p.getPrice() < 20.0)
    .count();

Creating parallel streams — three ways

Method 1: parallelStream()

List<String> productNames = List.of("milk", "bread", "eggs");

productNames.parallelStream()
    .map(String::toUpperCase)
    .forEach(System.out::println);

Method 2: stream().parallel()

Same result — call .parallel() on a sequential stream:

productNames.stream()
    .parallel()
    .map(String::toUpperCase)
    .forEach(System.out::println);

Method 3: switch back to sequential

The last call wins. You can go parallel then back to sequential:

productNames.stream()
    .parallel()    // Makes parallel
    .sequential()  // Makes sequential again (this wins)
    .map(String::toUpperCase)
    .forEach(System.out::println); // Runs sequentially

Under the hood, parallel streams use ForkJoinPool.commonPool() — a shared pool of worker threads. You do not create the threads yourself.

The common pool

By default, the common pool size is roughly number of CPU cores minus 1:

// Default parallelism: Runtime.getRuntime().availableProcessors() - 1

You can tune it with a system property (use carefully in production):

System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "8");

All parallel streams in your JVM share this one pool. Heavy parallel work in one part of QuickCart can slow parallel work elsewhere.

When parallel helps

Parallel streams shine when:

  1. The list is large (thousands of items, not ten).
  2. The work per item is CPU-heavy (math, parsing, complex rules).
  3. Each item is independent — processing item A does not need the result of item B.

Priya’s sale-badge check fits well:

public class SaleBadgeService {
    public List<Product> markSaleProducts(List<Product> products, double maxPrice) {
        return products.parallelStream()
            .filter(p -> p.getPrice() <= maxPrice)
            .peek(p -> p.setOnSale(true))
            .toList();
    }
}

Each product is checked on its own. No shared counter. No shared list being mutated by many threads at once.

Large dataset filtering:

List<Product> cheapItems = products.parallelStream()
    .filter(p -> p.getPrice() < 10.0)
    .toList();

CPU-intensive transform:

List<String> badges = products.parallelStream()
    .map(this::computeSaleBadge) // Expensive per product
    .toList();

When parallel hurts

Do not reach for parallelStream() when:

The list is tiny. Thread setup costs more than the work itself.

List<String> threeItems = List.of("milk", "bread", "eggs");
threeItems.parallelStream().map(String::toUpperCase).toList(); // Slower, not faster

The work waits on I/O. Calling a remote API or reading a file blocks a thread. Parallel streams are built for CPU work, not network waits. Use an ExecutorService for I/O instead.

// Bad — blocks common pool threads waiting on HTTP
products.parallelStream()
    .map(p -> httpClient.fetchRating(p.getId()))
    .toList();

Order matters and you rely on side effects. Parallel streams do not guarantee processing order unless you ask for it.

A simple benchmark shows the difference on a big list:

long start = System.currentTimeMillis();
long sum = java.util.stream.IntStream.range(0, 1_000_000)
    .map(n -> n * 2)
    .sum();
long sequentialMs = System.currentTimeMillis() - start;

start = System.currentTimeMillis();
long sumParallel = java.util.stream.IntStream.range(0, 1_000_000)
    .parallel()
    .map(n -> n * 2)
    .sum();
long parallelMs = System.currentTimeMillis() - start;

System.out.println("Sequential: " + sequentialMs + " ms");
System.out.println("Parallel: " + parallelMs + " ms");

Always measure with your real data. Parallel is not free.

Thread safety of lambdas

The lambda you pass must be safe when many threads run it at once.

Safe — read-only access, no shared mutation:

products.parallelStream()
    .map(Product::getName)
    .map(String::toUpperCase)
    .toList();

Unsafe — many threads append to the same ArrayList:

List<String> names = new ArrayList<>();

products.parallelStream()
    .forEach(p -> names.add(p.getName())); // Race condition!

Fix: collect into a new list instead of mutating a shared one:

List<String> names = products.parallelStream()
    .map(Product::getName)
    .toList(); // Thread-safe collect

Unsafe — shared mutable counter without atomics:

int[] count = {0};

products.parallelStream()
    .forEach(p -> count[0]++); // Wrong — lost updates

Fix: use stream reduction:

long count = products.parallelStream()
    .filter(p -> p.getPrice() < 10.0)
    .count();

Or use AtomicInteger if you truly need a side counter — but reduction is cleaner:

import java.util.concurrent.atomic.AtomicInteger;

AtomicInteger counter = new AtomicInteger(0);
products.parallelStream()
    .forEach(p -> counter.incrementAndGet()); // Safe, but prefer .count()

Unsafe — modifying shared fields from the lambda:

// BAD: Race condition on result list
List<String> result = new ArrayList<>();
list.parallelStream()
    .forEach(e -> result.add(transform(e)));

// GOOD: Use map + collect
List<String> result = list.parallelStream()
    .map(e -> transform(e))
    .toList();

Any field your lambda writes to must be thread-safe, or the lambda must not write to shared state at all. Prefer pure functions: input in, output out, no shared writes.

Reduction — combining results safely

Reduction folds many values into one result. The stream API handles thread safety for you:

int sum = numbers.parallelStream()
    .reduce(0, Integer::sum);

QuickCart wholesale order total:

public class CartCalculator {
    public double totalValue(List<LineItem> items) {
        return items.parallelStream()
            .mapToDouble(item -> item.getPrice() * item.getQuantity())
            .sum();
    }
}

Grouping still works in parallel:

Map<String, List<Product>> byCategory = products.parallelStream()
    .collect(Collectors.groupingBy(Product::getCategory));

Ordered vs unordered

Parallel streams may process elements in any order. Side-effect order in forEach is unpredictable:

List<Integer> nums = List.of(1, 2, 3, 4, 5);

nums.parallelStream()
    .forEach(System.out::println); // Order may differ each run

If order matters for output, use forEachOrdered:

nums.parallelStream()
    .forEachOrdered(System.out::println); // Prints 1, 2, 3, 4, 5

Note: forEachOrdered may reduce parallelism because it forces ordering.

Operations like collect() and toList() preserve encounter order from the source list even in parallel — the output list matches the input order.

For a customer’s normal cart with three items, Priya keeps it sequential:

public double totalValueSmallCart(List<LineItem> items) {
    if (items.size() < 100) {
        return items.stream()
            .mapToDouble(item -> item.getPrice() * item.getQuantity())
            .sum();
    }
    return totalValue(items);
}

Simple rule: parallel for big + CPU-bound + independent. Sequential for everything else until benchmarks say otherwise.

Common operations in parallel

Filtering:

List<Integer> evens = numbers.parallelStream()
    .filter(n -> n % 2 == 0)
    .toList();

Mapping:

List<String> upper = names.parallelStream()
    .map(String::toUpperCase)
    .toList();

Collecting:

Map<String, List<Order>> byCustomer = orders.parallelStream()
    .collect(Collectors.groupingBy(Order::getCustomerId));

What not to do

Do not use parallel streams for stateful operations that assume single-thread order:

// Risky — distinct() and similar may behave unexpectedly in parallel
list.parallelStream().distinct().toList();

Do not assume parallel is always faster — profile first.

Do not block the common pool with I/O — it starves other parallel streams in the same JVM.

Do not modify shared mutable state inside lambdas — use map + collect.

What to remember

  • parallelStream() and .stream().parallel() split work across the common ForkJoin pool.
  • Use parallel for large lists and CPU-heavy, independent operations.
  • Do not use parallel for small lists, I/O, or when threads would fight over shared mutable state.
  • Prefer map + collect over mutating shared collections inside forEach.
  • Use reduce and count for safe aggregation across threads.
  • Use forEachOrdered when print/output order matters; otherwise expect shuffled processing.
  • The common pool is shared — heavy parallel work in one place affects the whole app.
  • Always measure — parallel is not free.

What Priya does next: parallel streams use a ForkJoin pool under the hood. She learns to write divide-and-conquer tasks directly with RecursiveTask.