1. 能并行的不一定都该并行
在 Java 中有很多并行任务的实现方式。但“并行 = 一定更快”的想法,就像往汤里一直加盐就会更好喝一样:在某个点之前可能是的,再往后——最好别试。
ExecutorService 非常适合有明确生命周期、需要启动与控制的任务:请求处理、异步数据加载、彼此独立的计算。你可以自己决定线程池的大小,并管理任务的生命周期。
parallelStream 是在操作彼此独立且无副作用的集合时实现并行的快捷方式。对于“重”的集合(几十万元素及以上)才更划算。
ForkJoinPool 适用于易于拆分为子任务的场景(divide & conquer):排序、搜索、大数组聚合。它被 parallelStream 内部使用,但也可以直接控制。
不要“以防万一”就使用并行。如果任务很小,调度、上下文切换和同步的开销可能会吞掉所有收益。
示例:什么时候不需要并行
List<Integer> smallList = List.of(1, 2, 3, 4, 5);
int sum = smallList.parallelStream()
.mapToInt(x -> x)
.sum(); // 为了 5 个数字而并行——大材小用!
2. 线程安全:避免共享可变数据
在并行世界里,最大的威胁是数据竞争(race conditions)。当多个线程修改同一个变量时,结果可能超出预期。
- 避免共享的可变变量。 即便像 counter++ 这样的表达式也不是原子操作。
- 使用线程安全的集合与原子操作。 例如,ConcurrentHashMap、CopyOnWriteArrayList、AtomicInteger、AtomicLong。
- 在并行流中避免副作用。 不要在 parallelStream 中修改外部结构。
反例
List<Integer> numbers = Arrays.asList(1,2,3,4,5);
List<Integer> result = new ArrayList<>();
numbers.parallelStream().forEach(n -> result.add(n * 2)); // 危险!
这里的 result.add() 不是线程安全的。结果可能是丢失元素或抛出异常。
正确做法?
List<Integer> result = numbers.parallelStream()
.map(n -> n * 2)
.collect(Collectors.toList());
3. 性能:线程更多并不总是更好
小任务并行不划算。 如果工作仅耗时毫秒级,并行启动往往会因为开销而使执行更慢。
要测量性能。 进行快速测量可使用 System.nanoTime():
long start = System.nanoTime();
// ... 你的代码 ...
long end = System.nanoTime();
System.out.println("Execution time: " + (end - start) + " 纳秒");
进行严肃的微基准测试请使用 JMH(Java Microbenchmark Harness)。
示例:比较顺序流与并行流
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 + " 毫秒");
System.out.println("Parallel: " + (t3 - t2) / 1_000_000 + " 毫秒");
在自己的电脑上试试——对确实很大的集合和“重”操作,收益会更加明显。
4. 错误处理:不要忽略线程中的异常
Future 与异常处理
如果通过 ExecutorService.submit() 启动任务,异常并不会自动“冒泡”——需要通过 Future.get() 来处理:
Future<Integer> future = executor.submit(() -> {
if (Math.random() > 0.5) throw new RuntimeException("哎呀!");
return 42;
});
try {
Integer result = future.get(); // 可能抛出 ExecutionException
} catch (ExecutionException e) {
System.err.println("任务中的错误: " + e.getCause());
}
ForkJoin 与异常处理
在 ForkJoinPool 中,异常会被“封装”在任务里。调用 join()/get() 时它们会抛出:
ForkJoinPool pool = new ForkJoinPool();
RecursiveTask<Integer> task = new MyTask();
try {
int result = pool.invoke(task);
} catch (Exception e) {
System.err.println("ForkJoin 中的错误: " + e);
}
不要忘记处理 InterruptedException
很多方法(例如 Future.get()、Thread.sleep())可能抛出 InterruptedException。不要“吞掉”它——要正确响应:设置中断标志或结束任务。
5. 并行代码的调试与测试
日志与调试
并发 bug 难以捉摸,且经常不稳定地出现。记录包含线程的信息:Thread.currentThread().getName()。这有助于弄清是谁在何时执行了代码。
在复杂情况下,使用支持多线程的调试器(例如 IntelliJ IDEA)。临时的 Thread.sleep() 有时可以帮助“捕捉”罕见的数据竞争。
多线程场景的测试
为并行操作编写独立测试,并使用条件等待工具,例如 Awaitility。多次运行这些测试:有些问题只有在第 100 次或第 1000 次运行时才会暴露。
6. 实用细节与建议
可读性与可维护性:写清晰的并行代码
- 要有文档。 对复杂部分和工具选择进行注释与说明。
- 使用高层抽象。 比起手动管理线程,更推荐 ExecutorService、parallelStream、ForkJoinPool。
- 避免“魔法”。 如果可以更简单,就不要把同步搞得过于复杂。
表格:在什么场景使用哪种工具
| 场景 | 推荐工具 |
|---|---|
| 大量相互独立的任务 | |
| 处理大型集合 | parallelStream 或 ForkJoin |
| “分而治之”的任务 | |
| 简单的异步任务 | |
| 大量小任务 | 顺序流 |
| 带有副作用的任务 | 只能使用线程安全的集合! |
并行程序员的“守则”
- 不要使用共享可变变量——除非确定它们是线程安全的。
- 不要为并行而并行:先评估潜在收益。
- 不要忘记关闭线程池:shutdown()/shutdownNow()。
- 不要把 parallelStream 用于带副作用的操作。
- 不要忘记处理来自 Future 和 ForkJoinTask 的异常。
- 不要“吞掉” InterruptedException——要正确结束任务。
7. 并行编程中的常见错误
错误 1:对小任务进行并行化。 新手常常把一切都并行化,哪怕工作仅是微秒级。结果——由于开销更慢。
错误 2:在流中引入副作用。 在 parallelStream 中不能修改外部变量或集合——会引发数据竞争和不可预测的 bug。
错误 3:忽略异常。 如果不处理来自 Future.get() 或 ForkJoinTask 的错误,你就不知道任务为何失败。
错误 4:忘记对 ExecutorService 调用 shutdown()。 没有显式关闭,应用在退出时可能“挂住”。
错误 5:使用非线程安全的集合。 在多个线程中往普通 ArrayList 写入——是直奔错误之路。
错误 6:“吞掉” InterruptedException。 如果线程被中断——请尊重它并正确结束工作。
错误 7:过度复杂的同步逻辑。 过多的 synchronized 块会导致 死锁/活锁。优先选择高层抽象。
GO TO FULL VERSION