从 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 的优化技巧,在开发中写出更高效、更优雅的代码!

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

相关推荐
m0_571957581 小时前
Java | Leetcode Java题解之第543题二叉树的直径
java·leetcode·题解
魔道不误砍柴功3 小时前
Java 中如何巧妙应用 Function 让方法复用性更强
java·开发语言·python
NiNg_1_2343 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
闲晨3 小时前
C++ 继承:代码传承的魔法棒,开启奇幻编程之旅
java·c语言·开发语言·c++·经验分享
Chrikk5 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*5 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue5 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man5 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
测开小菜鸟5 小时前
使用python向钉钉群聊发送消息
java·python·钉钉
P.H. Infinity6 小时前
【RabbitMQ】04-发送者可靠性
java·rabbitmq·java-rabbitmq