注册

从 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,可能会导致重复计算。例如:


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() 被调用了两次,导致每次都从头开始扫描集合。可以优化为一次操作:


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

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

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


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


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

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


List<Integer> result = numbers.stream().collect(Collectors.toList());

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



简单说说声明式命令式


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


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



2.3 滥用并行流


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


// 生成一个 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);

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

2.4 limit()skip() 的误用


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


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(),再进行其他操作:


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() 是更快的选择:


String[] array = names.stream().toArray(String[]::new);

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



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


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



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


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


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));

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

3.3 尽量使用 forEachOrdered()


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


numbers.parallelStream().forEachOrdered(System.out::println);

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


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


// 原始链式调用
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 进行分割,性能上比过滤操作更高效。
    List<Integer> limitedNumbers = numbers.stream()
    .takeWhile(n -> n < 100)
    .collect(Collectors.toList());


  • Java 10 开始,Collectors.toUnmodifiableList() 提供了一种方法来创建不可修改的集合,适用于需要更严格集合控制的场景。
  • Java 16 增加了对 Stream.toList() 的支持,方便直接将流转换为不可变的 List
    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 的优化技巧,在开发中写出更高效、更优雅的代码!


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


作者:奔跑的毛球
来源:juejin.cn/post/7419984211144736808

0 个评论

要回复文章请先登录注册