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:
- The list is large (thousands of items, not ten).
- The work per item is CPU-heavy (math, parsing, complex rules).
- 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+collectover mutating shared collections insideforEach. - Use
reduceandcountfor safe aggregation across threads. - Use
forEachOrderedwhen 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.