并行流是JDK 8引入的一项重要特性,只要在流上调用一个parallel()方法,原本串行执行的流水线就会被拆分成多个子任务,交给ForkJoinPool中的多个线程同时处理。对于数据量大的计算场景,这确实能显著缩短执行时间。但并行带来的不只是速度,还有并发环境下绕不开的线程安全问题。很多看似正确的代码在串行流下运行毫无问题,一旦切换到并行流就出现数据丢失、计数不准甚至抛异常的情况。与此同时,不少开发者习惯用synchronized同步块来补救,却发现有时加了锁依然出错,有时锁住了却又让性能退化为不如串行。这篇文章就来系统地分析这两类问题。

一、并行流的线程安全风险从哪里来
要理解并行流的风险,首先要明白它的执行模型。并行流底层依赖ForkJoinPool框架,默认使用公共池(ForkJoinPool.commonPool),线程数量等于CPU核心数减一。调用parallel()后,数据源会被递归地切分成多个子区间,每个子区间由不同线程独立执行流水线操作,最后再把各个部分的结果合并起来。
问题就出在这个“分而治之”的过程中。如果流水线中的某个操作依赖或修改了外部共享状态,那么多个线程会同时对同一个变量进行读写,而没有同步保护的共享变量在JMM(Java内存模型)下不具备可见性和原子性保证,数据竞争便随之而来。看一段典型的错误代码:
List<Integer> numbers = Arrays.asList(1, 2, 3, ..., 1000000); // 错误示范:多线程同时修改共享的ArrayList List<Integer> results = new ArrayList<>(); numbers.parallelStream().forEach(n -> results.add(n * 2)); System.out.println(results.size()); // 结果几乎必然小于1000000
上面这段代码的输出结果是不确定的,往往小于一百万。原因有两个层面:第一,ArrayList的add方法不是原子操作,内部包含检查容量、写入元素、更新size三个步骤,两个线程交错执行时可能覆盖彼此的写入;第二,即使碰巧没有覆盖,size++这类复合操作的丢失更新也会导致计数不准。更危险的是,在扩容临界点并发写入还可能触发ArrayIndexOutOfBoundsException,让程序直接崩溃。
类似的风险点还包括:在forEach中对共享的普通计数器累加、向非线程安全的HashMap写入键值、在过滤器中修改外部标志位、对共享的非线程安全对象做复合操作等。判断标准其实很简单:只要流水线中的lambda表达式引用了方法外部的可变对象,且存在写操作,就有数据竞争的隐患。
二、哪些流操作天然安全,哪些需要警惕
并不是所有并行流操作都有风险,关键要区分操作是否遵守了框架的约定。JDK文档对流操作有一个基本分类:无干扰(non-interfering)和无状态(stateless)的操作是安全的,而有状态的操作在并行环境下需要特别小心。
filter、map、flatMap这些操作如果lambda内部不碰外部变量,就是完全安全的,框架会保证每个元素只被一个线程处理。reduce、collect这两个归约操作也设计得很巧妙:它们允许每个线程在各自的局部缓冲区里累积结果,最后由框架统一合并,全程不需要用户加锁。比如用Collectors.toList()收集结果,即便并行执行也能得到正确且完整的数据:
// 正确写法:collect会在内部为每个线程分配独立缓冲区
List<Integer> results = numbers.parallelStream()
.map(n -> n * 2)
.collect(Collectors.toList());
System.out.println(results.size()); // 稳定输出1000000
// 正确写法:reduce通过无状态的累加器保证安全
int sum = numbers.parallelStream()
.reduce(0, Integer::sum);而forEach则是最危险的操作之一。它的文档明确说明不保证顺序,也不对共享状态提供任何保护,如果你在forEach里写共享变量,正确性完全靠运气。forEachOrdered虽然保证了顺序,但代价是失去并行度,通常得不偿失。另外要特别注意的是sorted、distinct、skip这类有状态操作,它们本身是线程安全的(框架会先收集再处理),但需要缓存全部元素,在并行下的内存开销和合并成本可能很高,性能上未必划算。
还有一个容易被忽视的坑:peek方法。很多人喜欢在peek里做日志记录或者调试输出,如果peek里还顺手修改了共享集合,同样会触发数据竞争。总结成一句话:并行流中lambda的最佳实践是完全无状态,需要累积结果时交给collect和reduce,而不是自己管理共享容器。
三、synchronized同步块对变量处理的冲击
既然出现了数据竞争,很多开发者的第一反应是加锁,用synchronized把不安全的代码段包起来。这个思路方向没错,但实际效果往往出乎意料,主要有三个层面的冲击需要理解。
第一层冲击是性能的坍塌。synchronized是互斥锁,同一时刻只允许一个线程进入临界区。并行流的意义在于让多个线程同时干活,而锁把“同时”变成了“排队”。如果临界区覆盖了整个处理逻辑,并行流就退化成了带上下文切换开销的串行执行,速度甚至比直接用串行流更慢:
List<Integer> results = Collections.synchronizedList(new ArrayList<>());
Object lock = new Object();
long start = System.nanoTime();
numbers.parallelStream().forEach(n -> {
synchronized (lock) { // 锁粒度过大,并行优势荡然无存
results.add(n * 2);
}
});
long cost = System.nanoTime() - start;
System.out.println("耗时: " + cost / 1_000_000 + " ms");上面代码在千万级数据量下,耗时可能达到无锁collect方案的十几倍。锁竞争越激烈,线程在等待锁上浪费的时间越多,ForkJoinPool的工作窃取机制也无法弥补这种串行化损失。这就是所谓的“锁粒度冲击”:临界区越大,并行收益被吞噬得越彻底。
第二层冲击是可见性保证的局限性。synchronized确实能建立happens-before关系,保证释放锁之前的写操作对后续获得锁的线程可见,这解决了可见性问题。但它不能改变复合操作需要整体原子的本质。如果读操作在锁外、写操作在锁内,检查与写入之间依然可能被其他线程插入,产生经典的“检查后行动”竞态。要么读写都加锁,要么干脆换用原子类,半加锁的状态最危险。
第三层冲击是对ForkJoinPool本身的干扰。如果在并行流的lambda中执行了长时间的阻塞操作(比如在锁上长时间等待、或者做IO),会导致公共池中的工作线程被占满。由于公共池是JVM全局共享的,其他使用并行流的地方会被连带拖慢,甚至出现饥饿现象。所以官方一直强调:不要在并行流中做阻塞调用。这一点在排查性能问题时非常值得留意。
四、比加锁更可靠的替代方案
理解了风险和锁的副作用之后,更推荐的思路是规避锁而不是依赖锁。下面几种方案按优先级排列。
方案一:优先使用collect归约。这是最符合流式编程思想的做法,让框架管理每个线程的局部缓冲区,合并阶段由框架内部完成,既安全又高效。需要自定义累积逻辑时,可以使用collect的三参数重载,显式提供supplier(创建局部容器)、accumulator(局部累积,单线程调用无需加锁)和combiner(合并两个局部容器):
// 三参数collect:accumulator只操作线程局部容器,无需任何锁
List<Integer> results = numbers.parallelStream()
.collect(ArrayList::new,
(list, n) -> list.add(n * 2),
(left, right) -> left.addAll(right));方案二:计数场景使用LongAdder或Atomic系列。简单的数值统计如果一定要在遍历中完成,用LongAdder替代 AtomicLong 在高并发写入下吞吐量更高,因为它内部采用分段累加的策略,多个线程先各自累加到不同Cell,读取时再求和:
LongAdder counter = new LongAdder();
numbers.parallelStream().forEach(n -> {
if (n % 3 == 0) {
counter.increment(); // 无锁化的CAS累加,高并发下表现优秀
}
});
System.out.println(counter.sum());方案三:确实需要共享可变结构时,选用并发容器。ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue都是为并发设计的,配合并行流使用不需要额外加锁。不过要注意并发容器只保证单个操作的线程安全,check-then-act这类复合操作仍需使用compute、putIfAbsent等原子方法来完成。
最后从架构层面总结一下:并行流的正确使用姿势是“数据分片、局部计算、最终合并”,让每个线程尽可能只触碰自己的数据。synchronized同步块在并行流中应当是最后手段,且必须满足锁粒度最小化、读写全覆盖、临界区内不阻塞三个条件。把状态设计成不可变或者收拢到框架管理之下,远比事后加锁补救来得稳妥。掌握了这些原则,你就能放心地享受并行流带来的性能红利,而不是被偶发的并发bug反复折磨。
并行流线程安全synchronized修改时间:2026-09-04 03:34:57