一篇文章学会 Java 8 新特性 —— Stream 流


先看这样一个问题:

定义一个数组或者集合(source),包含 0 ~ 9 十个数字,筛选大于 5 的数组,返回一个新的数组或者集合(result)

当你熟练的打开编辑器,噼里啪啦一顿操作猛如虎:

List<Integer> result = new ArrayList<>();
for(Integer i : source){
	if(i > 5)
		result.add(i);
}
return result;

小小嘚瑟一下,简单,五六行代码,十秒钟搞定。

回头看看左边,python 程序员一脸嫌弃的看过来,偌大的屏幕上,函数中只有孤零零的一行代码

np.where(source > 5)

What ??? 这就可以了?

仔细一想,也难怪,Python一向以语法简单著称,可这,还是有点太简单了……郁闷中 ╮(╯﹏╰)╭

回头看一眼右边, 目光正好碰上 C# 程序员鄙视的眼神投来,顿时有点忐忑,难道他也……,弱弱的瞄一眼屏幕,也是一行!!! 瞬间 …(⊙_⊙)…

source.Where(t => t > 5);

当他告诉你,他还有一种语法也可以一行实现该功能

from i in source where i > 5 select i;

天哪, 有木有一种要崩溃的感觉 ~

这… 这到底是是什么语法? SQL 的风格直接一行代码处理数据, 一分钟之内连续被两个同行鄙视,Java ,你怎可如此 low

事实上,你还真错怪 Java

low 吗? 不,一点也不!
它没有类似的语法吗? 不,只是你不会而已! 兄弟,该学习了!!!


Java 8 的新特性 —— Stream 流。

上面的问题,我们同样可以避免冗余的 for 循环, 乏味的 if 判断,利用一行代码轻松解决。

source.stream().filter( i -> i > 5).collect(toList());

在 Java 8 中,数据集合只需要调用 stream 方法转换为 stream流,就可以轻松使用各种 声明性方式 处理数据集合。

何为声明性方式?

**声明性方式:**即只需要说明想要完成的动作,而不需要关心实现动作的具体操作。如上例,我们只需要说明我们的需求(过滤数组,以 i > 5 为条件),而不需要具体的操作(遍历数组,如果 i > 5,添加到新数组 …)

流的简介和特点

其实就是从一组数据源中生成的元素序列。流与集合最大的不同在于流的作用是来表达计算。简单来说,通常情况下集合是用来存储数据的,流是用来处理数据的。

  • 链式操作

    大多数流操作的返回值依然是流,所以这样的多个操作就可以链接起来,形成一个流水线式操作,也称之为链式操作。

  • 顺序保留

    一个数据源转换为流时,如果数据源是有序的,那么生成流的时候也会保留原有顺序。流在概念上是固定的数据结构,因此不能进行增删等操作。

  • 数据处理

    数据处理是流的核心功能,不仅支持顺序操作,还支持并行操作。这种类 SQL 的声明式操作,极大的方便了集合数据的处理。

  • 内部迭代

    流和迭代和迭代器的显式迭代类似,都只能遍历一次,但是流的迭代操作是在背后进行的,这使得流操作相对于显式迭代操作来说,安全性和性能都有了很大的提升。

流的基本操作

流的基本操作主要分三个步骤:初始操作、中间操作、终端操作。

  • 初始操作

    • stream
      • 方法一:Collection 提供的 stream 方法,将数据源转换成顺序流返回
        List<String> list = new ArrayList<>();
        Stream<String> stream = list.stream();
        
      • 方法二:Collection 提供的 parallelStream 方法,将数据源转换成并行流返回
        List<String> list = new ArrayList<>();
        Stream<String> parallelStream = list.parallelStream();
        
      • 方法三:数组创建流 —— Arrays 的 stream 静态方法获取数据流
        int[] array = new int[] {};
        IntStream intStream = Arrays.stream(array);
        IntStream intStream1 = Arrays.stream(array, 0, array.length);
        
        /* 与 Java 8 提供的函数式接口同样,为了避免频繁的拆装箱引起不必要的开销,Stream 也扩展了原始类型的流:如 IntStream, DoubleStream 等 */
        
      • 方法四:值创建流 —— Stream 的 of 静态方法获取数据流
        Stream stream = Stream.of("hello", "every one", "good", "morning");
        
      • 方法五:文件创建流 —— Files 的 lines 方法
        Stream<String> lines = Files.lines(Paths.get("123.txt"), Charset.defaultCharset());
        
      • 方法六:空流 —— Stream 的 empty 方法
        Stream<Object> empty = Stream.empty();
        
      • 方法七:函数创建流(无限流)
        1. 迭代函数

          该流接收一个初始值和一个依次应用在每个新值上的 UnaryOperator 类型的 Lambda 表达式,这种流没有结尾,可以永远计算下去,因此需要 limit 截断

          Stream<Integer> stream = Stream.iterate(0, n -> n + 2).limit(10);
          
        2. 生成函数
          与迭代函数不同,生成函数不是对每个新生成的值应用函数的,它接收一个 Supplier 类型的 Lambda 表达式来提供新值,但同样它也是无限的,因此需要截断

          Stream<Double> stream = Stream.generate(Math::random).limit(10);
          
  • 中间操作

    • filter
      • 作用:过滤
      • 参数类型: Predicate<T>
      • 返回类型: Stream<T>
      • 示例:
        List<Integer> nums = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 0);
        nums.stream().filter(t-> t % 2 == 0)
        //nums.stream().filter(t-> t % 2 == 0).forEach(System.out::println); //forEach 为了将结果打印到控制台上
        //结果:2 4 6 8 0
        
    • map
      • 作用:映射
      • 参数类型: Function<T, R>
      • 返回类型: Stream<R>
      • 示例:
        List<Integer> nums = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 0);
        nums.stream().map(t -> t * 2);
        //结果:2 4 6 8 10 12 14 16 18 0
        
    • sorted
      • 作用:排序
      • 参数类型: Comparator<T>
      • 返回类型: Stream<T>
      • 示例:
        List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
        nums.stream().sorted();//默认排序	4  5  7  7  7  8  9  10  11  12
        nums.stream().sorted(Comparator.reverseOrder());// 逆序排序  12  11  10  9  8  7  7  7  5  4
        nums.stream().sorted(Comparator.comparing(i -> i % 5));  //自定义排序(除以 5 的余数大小排序)  10  5  11  12  7  7  7  8  9  4
        
    • distinct
      • 作用:去重
      • 返回类型: Stream<T>
      • 示例:
        List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
        nums.stream().distinct();  // 10  12  9  5  7  4  8  11
        
    • limit
      • 作用:截取
      • 参数类型: long
      • 返回类型: Stream<T>
      • 示例:
        List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
        nums.stream().limit(3);  // 10  12  9 
        
    • skip
      • 作用:跳过
      • 参数类型: long
      • 返回类型: Stream<T>
      • 示例:
        List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
        nums.stream().skip(3);  // 5  7  4  7  8  11  7
        
    • flatMap
      • 作用:扁平化处理(一个流中的每个值都需要换成另一个流时,将所有流链接成一个流)
      • 参数类型: Function<T, Stream<R>>
      • 返回类型: Stream<R>
      • 示例:
        List<Integer> num1 = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
        List<Integer> num2 = Arrays.asList(3, 4, 5);
        Stream <int[]> stream = num1.stream().flatMap(i -> 
        							num2.stream().filter(j -> i != j && i % j == 0).map(j -> new int[]{i, j}));
        
        List<int[]> list = stream.collect(Collectors.toList());
        //list:  [[6, 3], [8, 4], [9, 3]]
        
  • 终端操作

    • collect

      • 作用:规约
      • 扩展:规约操作还可以自定义收集器,实现更为复杂的规约,这里暂用预定义的收集器来写两个最常用的示例,
      • 参数类型: Collector<T, A, R>
      • 返回类型: R
      • 示例:
        1. 规约成集合
          List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
          List<Integer> result = nums.stream().skip(3).collect(Collectors.toList()); 
          
        2. 规约成分组
          private List<User> source = new ArrayList<>();
          source.add(new User("张", "三", "male", 18));
          source.add(new User("李", "四", "female", 14));
          source.add(new User("王", "五", "female", 24));
          source.add(new User("赵", "六", "male", 16));
          
          Map<String, List<User>> groupByGender = source.stream().collect(groupingBy(User::getGender));
          /* 根据性别分组,返回一个 Map 类型集合 */
          /* 结果类似如下结构: {"male" : ["张三", "赵六"], "female" : ["李四", "王五"]} */
          
          /* 如果需要多级分组,groupingBy 还可以传递第二个参数,为内层 groupingBy 
             语法: groupingBy(一级分组表达式, groupingBy(二级分组表达式, groupingBy(三级分组表达式))) */
          
    • count

      • 作用:返回流中元素的个数
      • 返回类型: long.
      • 示例:
        List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
        long count = nums.stream().count();
        
    • forEach

      • 作用:使用对应的表达式消费流中的每个元素
      • 参数类型: Consumer<T>
      • 返回类型: void
      • 示例:
        List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
        nums.stream().skip(3).forEach(System.out::print);
        
    • reduce

      • 作用:规约
      • 参数类型: BinaryOperator<T>
      • 返回类型: Optional<T>
      • 示例:

        reduce 中 Lambda 表达式是一步一步将上一次计算的结果返回,然后继续和下一个元素运算,并产生一个新的结果返回,继续和下一个元素运算,直到流结束。

        1. 无初始值
          List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
          Optional<Integer> sum = nums.stream().reduce((a, b) -> a + b);
          //无初始值,默认 0
          // 0 + 10   返回  10
          // 上一次返回结果和流的下一个元素传入 Lambda 表达式 : 10 + 12  返回  22
          // 上一次返回结果和流的下一个元素传入 Lambda 表达式 : 22 + 9   返回  31
          // ......
          // sum: 80
          
        2. 有初始值
          List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
          Integer sum = nums.stream().reduce(10, Integer::sum); // Integer::sum 在这里与 (a, b) -> a + b 等价
          //无初始值,默认 10
          // 10 + 10   返回  20
          // 上一次返回结果和流的下一个元素传入 Lambda 表达式 : 20 + 12  返回  32
          // 上一次返回结果和流的下一个元素传入 Lambda 表达式 : 32 + 9   返回  41
          // ......
          // sum: 90
          
    • allMatch

      • 作用:检查谓词是否匹配所有元素
      • 参数类型: Predicate<T>
      • 返回类型: boolean
      • 示例:
        List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
        boolean isAllOdd = nums.stream().allMatch(i -> i % 2 == 1);
        // false
        
    • anyMatch

      • 作用:检查谓词是否至少匹配一个元素
      • 参数类型: Predicate<T>
      • 返回类型: boolean
      • 示例:
        List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
        boolean hasOdd = nums.stream().anyMatch(i -> i % 2 == 1);
        // true
        
    • noneMatch

      • 作用:检查谓词是否不匹配所有元素(与allMatch相对)
      • 参数类型: Predicate<T>
      • 返回类型: boolean
      • 示例:
        List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
        boolean isAllEven = nums.stream().noneMatch(i -> i % 2 == 1);
        // true
        
    • findAny

      • 作用:返回流中任意元素,将利用短路找到结果后立即结束
      • 返回类型: Optional<T>
      • 示例:
        List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
        Optional<Integer> firstOdd = nums.stream().filter(i -> i % 2 == 1).findAny();
        // 9
        
    • findFirst

      • 作用:返回流中第一个元素

      • 返回类型: Optional<T>

      • 示例:

        List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
        Optional<Integer> firstOdd = nums.stream().filter(i -> i % 2 == 1).findFirst();
        // 9
        

        一眼望去,findAny 和 findFirst 似乎没有什么区别。

        实际上,findAny 其返回的结果是不确定的。如上例,如果是并行情况,或者数据量比较大的时候,多调用几次,可能返回 9,也可能返回 5、 7 或者 11。

        而 findFirst 返回的一定是第一个元素。

        所以,如果侧重于效率,只需要一个满足条件的任意结果,那么建议使用 finaAny ,因为并行搜索效率更高;

        如果侧重于元素,必须要满足条件的第一个元素,那么就要使用 findFirst。

      • 再看两个例子:

        List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
        while (true)
        	System.out.println(nums.parallelStream().filter(i -> i % 2 == 1).findFirst().get());
        
        //  输出结果一直为 9 
        
        List<Integer> nums = Arrays.asList(10, 12, 9, 5, 7, 4, 7, 8, 11, 7);
        while (true)
        	System.out.println(nums.parallelStream().filter(i -> i % 2 == 1).findAny().get());
        
        //  输出结果不唯一。7  9  5  11 
        
  • 最后再看一组完整的基本使用示例代码:

    public class Test {
    	//先初始化一组数据
        private List<User> source = new ArrayList<>();
        {
            source.add(new User("张", "三", "male", 18));
            source.add(new User("李", "四", "female", 14));
            source.add(new User("王", "五", "female", 24));
            source.add(new User("赵", "六", "male", 16));
        }
    
    	/* 流的基本三步骤: 创建、中间操作、终端操作 */
        @Test
        public void test1() {
    		// 初始操作
    		Stream<User> stream =  source.stream();
    		// 中间操作
    		Stream<User> men = stream.filter(t -> "male".equals(t.getGender()));
            Stream<String> menNamesStream = men.map(t -> t.getFirstName() + t.getLastName());
    		//终端操作
            List<String> menNames = menNamesStream.collect(Collectors.toList());// ["张三", "赵六"]
    
    		/**  流只能被消费一次, 如果再次调用将会异常  **/
    		List<User> women = stream.filter(t -> "female".equals(t.getGender())).collect(Collectors.toList());
    		/**  java.lang.IllegalStateException: stream has already been operated upon or closed  **/
        }
    
    	/* 流的链式操作 */
    	@Test
    	public void test2(){
            List<String> names = source.stream()
    			.filter(p -> p.getAge() >= 18)			//年龄大于 18 的人
    			.map(p-> p.getFirstName())				//获取姓氏
    			.limit(3)								//截取前 3 个
    			.collect(Collectors.toList());			//终端操作:返回集合
    
    	}
    }
    

流的性能问题

我们可以在初始流的时候通过 stream 方法将集合转换为顺序流,也可以通过 parallelStream 方法将集合直接转换为并行流。

当然,我们也可以在操作中使用 parallel 方法将流转换为并行流,使用 sequential 方法将流转换为顺序流。

但是,切记,不要妄想使用者两个方法随时切换流的状态去控制每一个中间操作,因为,最后一次调用会影响整个流水线。

关于性能这个问题,我们潜意识会认为并行流的性能肯定是优于顺序流的,因为是并行执行嘛,但事实上真的如此吗?

来做一组测试

首先我们定义一个测试求和函数性能的方法:

/* 求前 n 个自然数的和,返回执行时间 */
public long sumPerfTesting(Function<Long, Long> sumFun, long n){
	long fastest = Long.MAX_VALUE;

	/* 
		这里重复执行 5 次,返回最快的用时。 
		为什么呢?因为编译器在某些情况下需要预热,简单来说,就是因为某些时候,第一次执行速度会比较偏慢,后面的执行速度才会正常。
		所以我们一般做性能测试是不取第一次运行结果的,因为误差较大,参考价值不大,
		另外,多运行,取平均(有时候也会取最小或最大,根据情况而定)也是做性能测试的一个原则。
	 */
	for(int i = 0; i < 5; i++){
		long start = System.nanoTime();
		long sum = sumFun.apply(n);
		long duration = (System.nanoTime() - start) / 1_000_000;
		System.out.println("Result: " + sum);
		fastest = fastest > duration ? duration : fastest;
	}
	return fastest;
}

接下来,我们在 DoSum 类中定义三种求和的方法:分别是传统循环、顺序流、并行流,来做性能测试。

public class DoSum{
    /* 迭代求和 */
    public static long iteratorSum(long n) {
        long result = 0;
        for (long i = 1L; i <= n; i++) {
            result += i;
        }
        return result;
    }

    /* 顺序流求和 */
    public static long sequentSum(long n) {
        return Stream.iterate(1L, i -> i + 1).limit(n).reduce(0L, Long::sum);
    }

    /* 并行流求和 */
    public static long parallelSum(long n) {
        return Stream.iterate(1L, i -> i + 1).limit(n).parallel().reduce(0L, Long::sum);
    }
}

最后,来分别输出三中求和方法的执行时间

System.out.println("Iterator Sum : " + sumPerfTesting(DoSum::iteratorSum, 10_000_000));
System.out.println("Sequent Sum : " + sumPerfTesting(DoSum::sequentSum, 10_000_000));
System.out.println("Parallel Sum : " + sumPerfTesting(DoSum::parallelSum, 10_000_000));

输出结果如下:

Iterator Sum : 6
Sequent Sum : 119
Parallel Sum : 500

有没有觉得很不可思议?

迭代版本速度最快,是可以理解的,因为迭代是最底层的操作。但是,并行版本的耗时居然是顺序版本的近 5 倍,迭代版本的 80 倍之多。

这是为什么呢?

原因主要有两点:

  1. 频繁的拆装箱耗时过多。

    iterator 生成的是装箱对象,每一个都需要拆箱后才可以求和,上千万次拆箱装箱的动作,耗时非常严重

  2. 数据的依赖造成并行的优势无法体现。

    类似这种顺序累加的求和功能,每次都是使用上一次求和的结果与下一个元素相加并返回,也就是说每次的执行都依赖上一次的结果,所以无法体现出并行的优势。

综合所述,这个并行流相当于不仅始终在顺序执行,而且还有频繁的拆装箱耗时。

分析出以上两点原因,我们来做两个大胆的猜测

猜测一: 使用之前提到的 Stream 原始类型流 LongStream 避免拆装箱操作,得到的最终耗时应该与顺序执行差不多。

猜测二: 使用 LongStream 避免拆装箱,并且使用 LongStream.rangeClosed 生成范围数字,并行的优势就会体现出来,耗时会大大减小。

接下来,验证一下刚才的猜测:

验证一:
修改 parallelSum 方法,使用 LongStream 生成流,返回 long 类型数字,避免拆装箱

/* 并行流求和 */
public static long parallelSum(long n) {
    return LongStream.iterate(1L, i -> i + 1).limit(n).parallel().reduce(0L, Long::sum);
}

测试结果如下:

Iterator Sum : 6
Sequent Sum : 140
Parallel Sum : 167

可以看到,耗时 167 与 顺序指定 140 仅相差 27 ms,说明我们的猜测是正确的。

验证二:
修改 parallelSum 方法,使用 LongStreamrangeClosed 方法,生成独立数字范围返回,有利于并行执行。

/* 并行流求和 */
public static long parallelSum(long n) {
    return LongStream.rangeClosed(1L, n).reduce(0L, Long::sum);
}

测试结果如下:

Iterator Sum : 6
Sequent Sum : 114
Parallel Sum : 6

终于,并行流的速度远快于顺序流了,甚至有时候还快于迭代速度。

通过上述例子可以看出,并行流的性能,还是很优秀的。但是,一定要注意正确的使用并行流。

并行流一旦使用的场景不对,轻则影响性能,耗时更慢(如上);重则影响数据,不仅耗时慢,还会导致返回错误的数据。

同样以求和为例,这次,我们使用一个共享累加器:

class Accumulator{
    public long total = 0;
    public void add(long value) {total += value;}
}

在 DoSum 中创建求和方法

public static sideEffectSum(long n){
    Accumulator accumulator = new Accumulator();
    LongStream.rangeClosed(1, n).forEach(accumulator::add);
    return accumulator.total;
}

调用:

System.out.println("sideEffect Sum : " + sumPerfTesting(DoSum::sideEffectSum, 10_000_000));

结果:

Result: 50000005000000
Result: 50000005000000
Result: 50000005000000
Result: 50000005000000
Result: 50000005000000
sideEffect Sum : 7

貌似没有问题,来,将其修改为并行,再测试一次,

public static sideEffectSum(long n){
    Accumulator accumulator = new Accumulator();
    LongStream.rangeClosed(1, n).parallel().forEach(accumulator::add);
    return accumulator.total;
}

调用:

System.out.println("sideEffectSum Sum : " + sumPerfTesting(DoSum::sideEffectSum, 10_000_000));

结果:

Result: 27850481869918
Result: 25948841007030
Result: 7263318432858
Result: 23731343106789
Result: 17107187037081
sideEffect Sum : 2

惊喜吗?速度是够快,可却反回了一堆毫无意义的错误数字,这是因为,多个线程同时访问累加器
这并不是一个原子操作,所以一定要避免这种情况。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章