1. Not everything that can be parallelized should be parallelized
There are many ways to parallelize tasks in Java. But “parallelism = always faster” is like thinking that if you add more salt to soup, it will taste better: up to a point—yes, and beyond that—it’s better not to try.
ExecutorService is a great fit when you have explicit tasks to launch and control: request processing, asynchronous data loading, independent computations. You decide how many threads are in the pool and control the lifecycle of tasks.
parallelStream is a quick way to parallelize collection processing when operations are independent and side‑effect free. It pays off for “heavy” collections (tens of thousands of elements or more).
ForkJoinPool is the choice for tasks that split well into subtasks (divide & conquer): sorting, searching, aggregating large arrays. It’s used under the hood by parallelStream, but you can also manage it directly.
Do not use parallelism “just in case”. If the task is small, the overhead of scheduling, context switching, and synchronization can eat up all the gains.
Example: when parallelism is not needed
List<Integer> smallList = List.of(1, 2, 3, 4, 5);
int sum = smallList.parallelStream()
.mapToInt(x -> x)
.sum(); // Parallelizing for 5 numbers is overkill!
2. Thread-safety: avoid shared mutable state
In the parallel world, the main threat is data races. If multiple threads change the same variable, the result can be unexpected.
- Avoid shared mutable variables. Even an expression like counter++ is not atomic.
- Use thread-safe collections and atomic operations. For example, ConcurrentHashMap, CopyOnWriteArrayList, AtomicInteger, AtomicLong.
- No side effects in parallel streams. Do not mutate external structures from parallelStream.
Bad code example
List<Integer> numbers = Arrays.asList(1,2,3,4,5);
List<Integer> result = new ArrayList<>();
numbers.parallelStream().forEach(n -> result.add(n * 2)); // DANGEROUS!
Here, result.add() is not thread-safe. The outcome is lost elements or exceptions.
How to do it right?
List<Integer> result = numbers.parallelStream()
.map(n -> n * 2)
.collect(Collectors.toList());
3. Performance: more threads is not always better
Small tasks are not worth parallelizing. If the work takes milliseconds, parallel execution often only slows it down due to overhead.
Measure performance. For quick measurements, System.nanoTime() will do:
long start = System.nanoTime();
// ... your code ...
long end = System.nanoTime();
System.out.println("Elapsed time: " + (end - start) + " ns");
For serious microbenchmarks, use JMH (Java Microbenchmark Harness).
Example: comparing sequential and parallel stream
List<Integer> bigList = IntStream.range(0, 1_000_000)
.boxed().collect(Collectors.toList());
long t1 = System.nanoTime();
long sum1 = bigList.stream().mapToLong(x -> x).sum();
long t2 = System.nanoTime();
long sum2 = bigList.parallelStream().mapToLong(x -> x).sum();
long t3 = System.nanoTime();
System.out.println("Sequential: " + (t2 - t1) / 1_000_000 + " ms");
System.out.println("Parallel: " + (t3 - t2) / 1_000_000 + " ms");
Try it on your machine—the benefit is noticeable on truly large collections and heavy operations.
4. Error handling: do not ignore exceptions in threads
Future and exception handling
If you launched a task via ExecutorService.submit(), exceptions will not “bubble up” automatically—you need to handle them via Future.get():
Future<Integer> future = executor.submit(() -> {
if (Math.random() > 0.5) throw new RuntimeException("Oops!");
return 42;
});
try {
Integer result = future.get(); // may throw ExecutionException
} catch (ExecutionException e) {
System.err.println("Error in task: " + e.getCause());
}
ForkJoin and exception handling
In ForkJoinPool, exceptions are “wrapped” in the task. When calling join()/get(), they will surface:
ForkJoinPool pool = new ForkJoinPool();
RecursiveTask<Integer> task = new MyTask();
try {
int result = pool.invoke(task);
} catch (Exception e) {
System.err.println("Error in ForkJoin: " + e);
}
Do not forget to handle InterruptedException
Many methods (for example, Future.get(), Thread.sleep()) can throw InterruptedException. Do not “swallow” it—react properly: set the interrupt flag or finish the task.
5. Debugging and testing parallel code
Logging and debugging
Parallel bugs are insidious and often manifest non‑deterministically. Log with the thread name: Thread.currentThread().getName(). This helps you understand who runs the code and when.
In complex cases, use a debugger with multithreading support (for example, IntelliJ IDEA). Temporary Thread.sleep() calls sometimes help “catch” a rare data race.
Testing multithreaded scenarios
Create separate tests for parallel operations and use condition‑waiting utilities, for example Awaitility. Run such tests many times: some issues surface only on the 100th or 1000th run.
6. Useful nuances and tips
Readability and maintainability: write clear parallel code
- Document. Comment on complex parts and the choice of tools.
- Use high‑level abstractions. Prefer ExecutorService, parallelStream, ForkJoinPool instead of manual thread management.
- Avoid “magic”. Do not overcomplicate synchronization if a simpler approach exists.
Table: when to use which tool
| Scenario | Recommended tool |
|---|---|
| Many independent tasks | |
| Processing a large collection | parallelStream or ForkJoin |
| Divide‑and‑conquer task | |
| Simple asynchronous task | |
| Many small tasks | Sequential stream |
| Tasks with side effects | Only thread-safe collections! |
The “commandments” of a parallel programmer
- Do not have shared mutable variables—unless you are sure they are thread-safe.
- Do not parallelize for the sake of parallelism: assess the potential gain.
- Do not forget to shut down pools: shutdown()/shutdownNow().
- Do not use parallelStream for operations with side effects.
- Do not forget to handle exceptions from Future and ForkJoinTask.
- Do not “swallow” InterruptedException—finish the task properly.
7. Common mistakes in parallel programming
Error No. 1: Parallelizing small tasks. Beginners often parallelize everything—even when the work takes microseconds. The result is slower due to overhead.
Error No. 2: Side effects in streams. In parallelStream, you must not mutate external variables or collections—you will get data races and unpredictable bugs.
Error No. 3: Ignoring exceptions. If you do not handle errors from Future.get() or ForkJoinTask, you will not know why a task failed.
Error No. 4: Forgot shutdown() on ExecutorService. Without explicit termination, the application can “hang” on exit.
Error No. 5: Using non‑thread‑safe collections. Writing from multiple threads to a regular ArrayList is a direct path to errors.
Error No. 6: “Swallowing” InterruptedException. If a thread was interrupted—respect it and terminate correctly.
Error No. 7: Overly complex synchronization logic. Excessive synchronized blocks lead to deadlock/livelock. Prefer high‑level abstractions.
GO TO FULL VERSION