MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java Stream API的高效使用与性能陷阱

2022-04-284.6k 阅读

Java Stream API 基础概述

Java 8 引入的 Stream API 为处理集合数据提供了一种全新的、函数式编程风格的方式。Stream 代表一个来自数据源的元素序列,并支持一系列操作,这些操作可以对元素序列进行过滤、映射、归约等处理。与传统的集合遍历方式不同,Stream API 采用声明式编程风格,让开发者关注于“做什么”,而不是“怎么做”。

例如,假设有一个整数列表,要筛选出所有偶数并计算它们的平方和。使用传统的 for 循环方式如下:

import java.util.ArrayList;
import java.util.List;

public class TraditionalLoopExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(3);
        numbers.add(4);
        numbers.add(5);

        int sumOfSquares = 0;
        for (int number : numbers) {
            if (number % 2 == 0) {
                sumOfSquares += number * number;
            }
        }
        System.out.println("Sum of squares of even numbers: " + sumOfSquares);
    }
}

而使用 Stream API 可以这样写:

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class StreamExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(3);
        numbers.add(4);
        numbers.add(5);

        int sumOfSquares = numbers.stream()
                .filter(n -> n % 2 == 0)
                .mapToInt(n -> n * n)
                .sum();
        System.out.println("Sum of squares of even numbers: " + sumOfSquares);
    }
}

从上述代码对比可以看出,Stream API 的代码更加简洁,更清晰地表达了业务逻辑。Stream API 操作分为中间操作和终端操作。中间操作返回一个新的 Stream,如 filtermap 等,这些操作是惰性求值的,即只有当终端操作执行时,中间操作才会真正执行。终端操作会触发流的处理并返回一个结果,如 sumcollect 等。

Stream 的创建

  1. 从集合创建 集合类(如 ListSet 等)都提供了 stream 方法来创建顺序流,parallelStream 方法来创建并行流。
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class CollectionToStream {
    public static void main(String[] args) {
        List<String> fruits = new ArrayList<>();
        fruits.add("apple");
        fruits.add("banana");
        fruits.add("cherry");

        // 创建顺序流
        Stream<String> sequentialStream = fruits.stream();
        // 创建并行流
        Stream<String> parallelStream = fruits.parallelStream();
    }
}
  1. 从数组创建 可以使用 Arrays.stream 方法将数组转换为 Stream。
import java.util.Arrays;
import java.util.stream.Stream;

public class ArrayToStream {
    public static void main(String[] args) {
        int[] numbers = {1, 2, 3, 4, 5};
        Stream<int[]> intArrayStream = Stream.of(numbers);
        // 基本类型特化流
        java.util.stream.IntStream intStream = Arrays.stream(numbers);
    }
}
  1. 使用 Stream.of 方法 可以直接通过 Stream.of 方法创建包含给定元素的 Stream。
import java.util.stream.Stream;

public class StreamOfExample {
    public static void main(String[] args) {
        Stream<String> stringStream = Stream.of("apple", "banana", "cherry");
    }
}
  1. 无限流 Stream.iterateStream.generate 可以创建无限流。Stream.iterate 基于种子值生成一个无限序列,Stream.generate 通过提供的 Supplier 生成无限序列。
import java.util.stream.Stream;

public class InfiniteStreamExample {
    public static void main(String[] args) {
        // 创建一个从 0 开始,每次加 1 的无限序列
        Stream<Integer> iterateStream = Stream.iterate(0, n -> n + 1);
        // 创建一个随机数的无限序列
        Stream<Double> generateStream = Stream.generate(Math::random);

        // 通常需要限制无限流的使用,例如只取前 10 个元素
        iterateStream.limit(10).forEach(System.out::println);
        generateStream.limit(10).forEach(System.out::println);
    }
}

高效使用 Stream API

  1. 正确使用中间操作
    • filter 操作:用于筛选出符合条件的元素。在使用 filter 时,应确保过滤条件尽可能简单高效。例如,在一个包含大量用户对象的列表中,要筛选出年龄大于 18 岁的用户。
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

class User {
    private String name;
    private int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public int getAge() {
        return age;
    }
}

public class FilterExample {
    public static void main(String[] args) {
        List<User> users = new ArrayList<>();
        users.add(new User("Alice", 20));
        users.add(new User("Bob", 15));
        users.add(new User("Charlie", 25));

        List<User> adults = users.stream()
                .filter(user -> user.getAge() > 18)
                .collect(Collectors.toList());
    }
}
- **`map` 操作**:用于将流中的每个元素映射到一个新的元素。比如将字符串列表中的每个字符串转换为其长度。
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class MapExample {
    public static void main(String[] args) {
        List<String> words = new ArrayList<>();
        words.add("apple");
        words.add("banana");
        words.add("cherry");

        List<Integer> lengths = words.stream()
                .map(String::length)
                .collect(Collectors.toList());
    }
}
- **`flatMap` 操作**:当流中的元素本身又是一个流时,`flatMap` 可以将这些内部流扁平化为一个单一的流。例如,有一个列表,每个元素是一个字符串数组,要将所有字符串合并成一个流。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class FlatMapExample {
    public static void main(String[] args) {
        List<String[]> lists = new ArrayList<>();
        lists.add(new String[]{"a", "b"});
        lists.add(new String[]{"c", "d"});

        List<String> flatList = lists.stream()
                .flatMap(Arrays::stream)
                .collect(Collectors.toList());
    }
}
  1. 终端操作的优化
    • collect 操作collect 方法可以将流中的元素收集到各种集合中,或者进行复杂的归约操作。Collectors 类提供了许多静态方法来辅助收集。例如,将流中的元素收集到一个 Set 中以去除重复元素。
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

public class CollectToSetExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(2);
        numbers.add(3);

        Set<Integer> uniqueNumbers = numbers.stream()
                .collect(Collectors.toSet());
    }
}
- **`reduce` 操作**:用于将流中的元素归约为一个值。它有不同的重载形式。例如,计算整数流的总和。
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

public class ReduceExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(3);

        Optional<Integer> sumOptional = numbers.stream().reduce((a, b) -> a + b);
        sumOptional.ifPresent(sum -> System.out.println("Sum: " + sum));

        // 使用带初始值的 reduce
        int sumWithInitial = numbers.stream().reduce(0, (a, b) -> a + b);
        System.out.println("Sum with initial: " + sumWithInitial);
    }
}
  1. 并行流的合理运用 并行流利用多线程来处理流中的元素,在处理大数据量时可以显著提高性能。但并非所有场景都适合使用并行流。例如,在一个简单的小数据量列表求和场景中,并行流的线程创建和管理开销可能会超过其带来的性能提升。
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class ParallelStreamExample {
    public static void main(String[] args) {
        List<Integer> largeList = new ArrayList<>();
        for (int i = 0; i < 1000000; i++) {
            largeList.add(i);
        }

        long startTime = System.currentTimeMillis();
        int sequentialSum = largeList.stream().mapToInt(Integer::intValue).sum();
        long endTime = System.currentTimeMillis();
        System.out.println("Sequential sum time: " + (endTime - startTime) + " ms");

        startTime = System.currentTimeMillis();
        int parallelSum = largeList.parallelStream().mapToInt(Integer::intValue).sum();
        endTime = System.currentTimeMillis();
        System.out.println("Parallel sum time: " + (endTime - startTime) + " ms");
    }
}

在上述代码中,当数据量较大时,并行流的求和操作通常会比顺序流更快。但要注意,并行流中的操作需要满足线程安全的要求,例如在 mapfilter 等操作中使用的函数不能有副作用,否则可能导致结果不准确。

Java Stream API 的性能陷阱

  1. 过度使用中间操作 虽然中间操作使得代码更加灵活和可读,但过多的中间操作会增加流处理的复杂性和性能开销。每一个中间操作都会在流的处理管道中添加一个步骤,并且由于惰性求值,所有中间操作会在终端操作执行时链式执行。例如,在一个流处理中连续使用多个 mapfilter 操作,而其中一些操作可以合并或简化。
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class OverIntermediateOpsExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(3);
        numbers.add(4);
        numbers.add(5);

        // 不必要的复杂操作
        List<Integer> result1 = numbers.stream()
                .filter(n -> n % 2 == 0)
                .map(n -> n * 2)
                .filter(n -> n > 5)
                .collect(Collectors.toList());

        // 优化后的操作
        List<Integer> result2 = numbers.stream()
                .filter(n -> n % 2 == 0 && n * 2 > 5)
                .map(n -> n * 2)
                .collect(Collectors.toList());
    }
}

在上述代码中,优化后的版本将两个 filter 操作合并,减少了中间步骤,从而提高了性能。

  1. 并行流的线程安全问题 并行流使用多线程处理元素,这就要求在流操作中使用的函数必须是线程安全的。例如,在 map 操作中修改共享变量就会导致线程安全问题。
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

public class ParallelStreamThreadSafetyExample {
    private static AtomicInteger counter = new AtomicInteger(0);

    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(3);

        // 错误示范,共享变量在并行流中修改
        List<Integer> result = numbers.parallelStream()
                .map(n -> {
                    counter.incrementAndGet();
                    return n * counter.get();
                })
                .collect(Collectors.toList());
        System.out.println("Result: " + result);
        System.out.println("Counter value: " + counter.get());
    }
}

在上述代码中,由于 counter 是共享变量,在并行流的 map 操作中对其进行修改,会导致结果不可预测。正确的做法是避免在并行流操作中修改共享状态,可以使用 Atomic 类型的变量或者局部变量来处理。

  1. 流操作的装箱和拆箱开销 Java 的基本类型和包装类型在 Stream API 中有不同的处理方式。当使用基本类型的特化流(如 IntStreamDoubleStream 等)时,可以避免装箱和拆箱的开销。例如,在对整数列表求和时,使用 IntStream 比使用普通的 Stream<Integer> 性能更好。
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class BoxingUnboxingExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(3);

        // 使用普通 Stream<Integer>
        long startTime1 = System.currentTimeMillis();
        int sum1 = numbers.stream()
                .mapToInt(Integer::intValue)
                .sum();
        long endTime1 = System.currentTimeMillis();
        System.out.println("Sum with Stream<Integer>: " + sum1 + " time: " + (endTime1 - startTime1) + " ms");

        // 使用 IntStream
        int[] intArray = numbers.stream().mapToInt(Integer::intValue).toArray();
        long startTime2 = System.currentTimeMillis();
        int sum2 = IntStream.of(intArray).sum();
        long endTime2 = System.currentTimeMillis();
        System.out.println("Sum with IntStream: " + sum2 + " time: " + (endTime2 - startTime2) + " ms");
    }
}

从上述代码的时间对比可以看出,使用 IntStream 避免了装箱和拆箱操作,性能更优。

  1. 终端操作的选择不当 不同的终端操作有不同的性能特点。例如,collect 操作在收集到不同类型的集合时性能也有所差异。收集到 ArrayList 通常比收集到 TreeSet 更快,因为 TreeSet 需要对元素进行排序。另外,findFirstfindAny 在并行流中的表现也不同。findFirst 会按照顺序查找第一个满足条件的元素,而 findAny 可以返回任意一个满足条件的元素,在并行流中 findAny 性能更好,因为它不需要等待所有元素都处理完,只要找到一个满足条件的元素即可返回。
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class TerminalOpSelectionExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        for (int i = 0; i < 1000000; i++) {
            numbers.add(i);
        }

        // 收集到 ArrayList
        long startTime1 = System.currentTimeMillis();
        List<Integer> listResult = numbers.stream()
                .filter(n -> n % 2 == 0)
                .collect(Collectors.toList());
        long endTime1 = System.currentTimeMillis();
        System.out.println("Collect to ArrayList time: " + (endTime1 - startTime1) + " ms");

        // 收集到 TreeSet
        long startTime2 = System.currentTimeMillis();
        Set<Integer> treeSetResult = numbers.stream()
                .filter(n -> n % 2 == 0)
                .collect(Collectors.toCollection(TreeSet::new));
        long endTime2 = System.currentTimeMillis();
        System.out.println("Collect to TreeSet time: " + (endTime2 - startTime2) + " ms");

        // findFirst 和 findAny 在并行流中的对比
        Stream<Integer> parallelStream = numbers.parallelStream();
        long startTime3 = System.currentTimeMillis();
        Optional<Integer> findFirstResult = parallelStream.filter(n -> n > 500000).findFirst();
        long endTime3 = System.currentTimeMillis();
        System.out.println("findFirst in parallel stream time: " + (endTime3 - startTime3) + " ms");

        Stream<Integer> parallelStream2 = numbers.parallelStream();
        long startTime4 = System.currentTimeMillis();
        Optional<Integer> findAnyResult = parallelStream2.filter(n -> n > 500000).findAny();
        long endTime4 = System.currentTimeMillis();
        System.out.println("findAny in parallel stream time: " + (endTime4 - startTime4) + " ms");
    }
}

性能优化建议

  1. 分析流操作的复杂性 在编写 Stream API 代码时,要分析流操作的复杂度。尽量减少中间操作的数量,合并可以合并的操作。对于复杂的操作,可以考虑将其分解为多个简单的步骤,并在必要时进行性能测试,以确保代码的高效性。
  2. 合理选择并行流 只有在数据量较大且操作是线程安全的情况下,才考虑使用并行流。在使用并行流之前,可以先对顺序流和并行流进行性能测试,根据实际数据量和操作类型来确定是否使用并行流。同时,要注意并行流中操作的原子性和线程安全问题。
  3. 避免装箱和拆箱 尽可能使用基本类型的特化流,如 IntStreamDoubleStream 等。这样可以避免装箱和拆箱带来的性能开销,特别是在处理大量数据时,这种优化效果更加明显。
  4. 选择合适的终端操作 根据业务需求选择最合适的终端操作。如果只需要获取一个满足条件的元素,在并行流中优先使用 findAny。在收集元素到集合时,根据集合的特性和业务需求选择合适的集合类型,如需要无序且不重复的集合,优先选择 HashSet;需要有序集合,优先选择 TreeSet 等,但要注意不同集合类型的性能差异。

通过深入理解 Java Stream API 的工作原理,避免性能陷阱,并采用合理的优化策略,可以在使用 Stream API 时充分发挥其优势,提高代码的效率和可读性。无论是处理小数据量的简单操作,还是大数据量的复杂计算,都能通过正确使用 Stream API 来实现高效的编程。