从 Java 8 到 Java 17:你真的会用 Stream API 吗

自从 Java 8 引入 Stream API,Java 开发者可以更方便地对集合进行操作,比如过滤、映射、排序等。

Stream API 提供了一种声明式编程风格,让代码更简洁、可读性更高。不过,虽然 Stream API 看起来很优雅,实际使用中可能会遇到一些性能问题和常见陷阱。

今天,我们就聊聊在 Java 8 到 Java 17 之间,Stream API 的性能优化技巧,以及我们可能踩到的那些坑。

1. Stream API 的优势

Stream 是一个抽象化的数据管道,允许我们以声明式的方式处理数据集合。Stream 的两个主要功能是:中间操作终端操作

  • 中间操作 :如 filter(), map(),这些操作是惰性的(lazy),不会立即执行。
  • 终端操作 :如 collect(), forEach(),这些操作会触发 Stream 的实际执行。

Java 8 的 Stream 使代码看起来更清晰,但它在使用时也带来了一些需要注意的地方,尤其是在处理大数据集时的性能。

2. Stream API 常见的性能陷阱

2.1 多次创建 Stream 导致浪费

在开发中,如果对同一个集合多次创建 Stream,可能会导致重复计算。例如:

java 复制代码
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");

// 多次创建 Stream
long countA = names.stream().filter(name -> name.startsWith("A")).count();
long countB = names.stream().filter(name -> name.startsWith("B")).count();

在上面的代码中,names.stream() 被调用了两次,导致每次都从头开始扫描集合。可以优化为一次操作:

java 复制代码
Map<String, Long> result = names.stream()
        .collect(Collectors.groupingBy(name -> name.substring(0, 1), Collectors.counting()));

这样做的好处是只遍历一次集合,减少不必要的开销。

2.2 避免使用 forEach 进行数据聚合

forEach 是一个常见的终端操作,但它在很多场景下并不是最优解,尤其是在需要聚合数据时:

java 复制代码
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> result = new ArrayList<>();
numbers.stream().forEach(result::add);  // 这种方式不推荐

这里直接通过 forEach 操作来修改外部集合,会失去 Stream 的声明式风格,甚至可能出现线程安全问题。更好的做法是使用 collect

java 复制代码
List<Integer> result = numbers.stream().collect(Collectors.toList());

这种方式不仅代码更简洁,还能保证线程安全,特别是在并行流的场景下。

简单说说声明式命令式

Stream API 提供了一种声明式的编程风格,让你可以专注于"做什么",而不是"怎么做"。使用 forEach 来修改外部集合是一个命令式的做法,涉及了外部状态的修改,这样就打破了 Stream 的声明式优势。

相比之下在使用 collect 的例子中,代码更简洁且更易读,表达了你的意图是"收集这些元素",而不是"对每个元素进行操作"。

2.3 滥用并行流

Java 8 引入了并行流(Parallel Stream),它可以通过 stream().parallel() 方法来让 Stream 操作并行化。然而,并行流并不总是能带来性能提升:

java 复制代码
// 生成一个 0~999999 的数字列表
List<Integer> numbers = IntStream.range(0, 1000000).boxed().collect(Collectors.toList());

// 直接使用并行流
long start1 = System.currentTimeMillis();
long sum = numbers.parallelStream().mapToInt(Integer::intValue).sum();
long end1 = System.currentTimeMillis();
System.out.println("并行流执行时间:" + (end1 - start1) + "ms");
System.out.println(sum);

// 使用普通流
long start2 = System.currentTimeMillis();
long sum2 = numbers.stream().mapToInt(Integer::intValue).sum();
long end2 = System.currentTimeMillis();
System.out.println("普通流执行时间:" + (end2 - start2) + "ms");
System.out.println(sum2);

> 并行流的适用场景是计算量较大、数据量足够多的情况下。如果数据量较小,或者 Stream 操作较简单,使用并行流反而会带来线程切换的开销,导致性能下降。

2.4 limit()skip() 的误用

limit()skip() 可以限制 Stream 的数据量,但要注意它们的相对位置。如果在 filter() 之后使用 limit(),可能会带来不必要的性能消耗:

java 复制代码
List<Integer> numbers = IntStream.range(0, 1_000_000).boxed().collect(Collectors.toList());

// 过滤偶数,然后取前 10 个
List<Integer> result = numbers.stream()
    .filter(n -> n % 2 == 0)
    .limit(10)
    .collect(Collectors.toList());

这种情况下,filter() 会对 1,000,000 个元素逐个过滤,直到找到前 10 个符合条件的元素。更高效的方式是先 limit(),再进行其他操作:

java 复制代码
List<Integer> result = numbers.stream()
    .limit(20)  // 先取出前 20 个
    .filter(n -> n % 2 == 0)  // 再进行过滤
    .collect(Collectors.toList());

这样,Stream 只会处理有限的元素,性能会更好。

3. Stream API 性能优化技巧

3.1 使用 toArray() 而不是 collect(Collectors.toList())

如果我们只需要将 Stream 转换为数组,使用 toArray() 是更快的选择:

java 复制代码
String[] array = names.stream().toArray(String[]::new);

相比 collect(Collectors.toList())toArray() 在实现上更直接,尤其在处理大量数据时可以减少内存分配的开销。

collect(Collectors.toList()) :这个方法首先创建一个 ArrayList,然后将所有元素添加到这个列表中。在这个过程中,ArrayList 可能会经历多次扩容,每次扩容都需要新建一个更大的数组,并将现有元素复制到新数组中。这种重复的内存分配和数组复制操作在处理大量数据时会增加开销。

toArray() :这个方法直接生成一个数组,避免了 ArrayList 的扩容过程。

3.2 避免不必要的装箱与拆箱

在处理基本数据类型时,使用 mapToInt()mapToDouble() 这样的基本类型专用方法,可以避免不必要的装箱和拆箱操作,提高性能:

java 复制代码
List<Integer> numbers = IntStream.range(0, 10000000).boxed().collect(Collectors.toList());
long start1 = System.currentTimeMillis();
// 使用 map 导致装箱和拆箱
int sumWithMap = numbers.stream()
        .map(n -> n)  // 装箱
        .reduce(0, Integer::sum);  // 拆箱
long end1 = System.currentTimeMillis();
System.out.println("sumWithMap: " + sumWithMap + " time: " + (end1 - start1));

long start2 = System.currentTimeMillis();
// 使用 mapToInt 避免装箱和拆箱
int sumWithMapToInt = numbers.stream()
        .mapToInt(n -> n)  // 直接处理基本类型
        .sum();
long end2 = System.currentTimeMillis();
System.out.println("sumWithMapToInt: " + sumWithMapToInt + " time: " + (end2 - start2));

如果直接使用 `map()` 会导致频繁的装箱和拆箱,降低性能。

3.3 尽量使用 forEachOrdered()

在并行流中,forEach() 的执行顺序是非确定性的,如果我们希望按原来的顺序处理数据,使用 forEachOrdered() 可以保证顺序,但会稍微影响性能。

java 复制代码
numbers.parallelStream().forEachOrdered(System.out::println);

3.4 减少链式调用中的中间操作

每个中间操作都会产生一个新的 Stream 实例,如果链式调用过多,会增加调用栈的深度,影响性能。尽量合并中间操作来减少链条长度:

java 复制代码
// 原始链式调用
List<String> result = names.stream()
    .filter(name -> name.length() > 3)
    .map(String::toUpperCase)
    .filter(name -> name.startsWith("A"))
    .collect(Collectors.toList());

// 优化后的调用
List<String> resultOptimized = names.stream()
    .filter(name -> name.length() > 3 && name.startsWith("A"))
    .map(String::toUpperCase)
    .collect(Collectors.toList());

通过合并 filter 的条件,可以减少 Stream 的中间操作,提升性能。

4. 从 Java 8 到 Java 17 的改进

Java 9 到 Java 17 中,Stream API 进行了多次优化和功能增强:

  • Java 9 引入了 takeWhile()dropWhile() 方法,这些方法允许我们基于条件对 Stream 进行分割,性能上比过滤操作更高效。

    java 复制代码
    List<Integer> limitedNumbers = numbers.stream()
        .takeWhile(n -> n < 100)
        .collect(Collectors.toList());
  • Java 10 开始,Collectors.toUnmodifiableList() 提供了一种方法来创建不可修改的集合,适用于需要更严格集合控制的场景。

  • Java 16 增加了对 Stream.toList() 的支持,方便直接将流转换为不可变的 List

    java 复制代码
    List<String> immutableList = names.stream().filter(n -> n.length() > 3).toList();
  • Java 17 进一步优化了 Stream 的性能,特别是在并行流的实现上,使其在多核环境下能够更高效地利用硬件资源。

5. 总结

Stream API 在 Java 8 引入后,可以说是极大地提高了代码的可读性和简洁性,但也带来了性能优化和陷阱需要注意。从 Java 8 到 Java 17 的不断优化中,我们可以看到 Stream API 逐渐变得更强大和高效。

要想充分利用 Stream API,开发者需要意识到 Stream 的惰性求值特点,避免重复计算和不必要的装箱、拆箱操作。同时,并行流的使用应在充分评估场景后进行,避免反而拖累性能。

希望这篇文章能帮助你更好地掌握 Java Stream API 的优化技巧,在开发中写出更高效、更优雅的代码!

若有勘误,烦请不吝赐教。

相关推荐
程序员爱钓鱼23 分钟前
Go语言实战案例-项目实战篇:新闻聚合工具
后端·google·go
IT_陈寒24 分钟前
Python开发者必须掌握的12个高效数据处理技巧,用过都说香!
前端·人工智能·后端
一只叫煤球的猫9 小时前
写代码很6,面试秒变菜鸟?不卖课,面试官视角走心探讨
前端·后端·面试
bobz9659 小时前
tcp/ip 中的多路复用
后端
bobz9659 小时前
tls ingress 简单记录
后端
皮皮林55110 小时前
IDEA 源码阅读利器,你居然还不会?
java·intellij idea
你的人类朋友10 小时前
什么是OpenSSL
后端·安全·程序员
bobz96511 小时前
mcp 直接操作浏览器
后端
前端小张同学13 小时前
服务器部署 gitlab 占用空间太大怎么办,优化思路。
后端
databook13 小时前
Manim实现闪光轨迹特效
后端·python·动效