【Java】Java8特性官网学习之Stream的前世今生

41 篇文章 0 订阅
16 篇文章 0 订阅

前言

最近看了《数据密集型应用系统设计》,间断介绍了

  • 命令式语言和声明式语言的区别
  • UNIX的 设计哲学
  • MapReduce编程框架
  • 数据流引擎
  • 函数运算符(函数式编程)

能发现Java8 Stream的语法都有上述内容的影子,本文旨在了解了流式编程技术发展和迭代关系,把零散的信息整合成网状信息,加深对Java8 Stream的理解。

1. 命令式语言和声明式语言

  • 命令式语言
	public static List<String> getSharks(List<String> animalList) {
        List<String> result = new ArrayList<>();
        for (int i = 0; i < animalList.size(); i++) {
            String animal = animalList.get(i);
            if (isSharksFamily(animal)) {
                result.add(animal);
            }
        }
        return result;
    }
  • 等价的声明式语言
	select * from animals where family = 'Sharks';

1.1 介于命令式和声明式之间的语言

MangoDB(文档型数据库,暂时可以理解为存整个json)中的MapReduce语法:
需求:仅处理Sharks家族,并按照他们被观察的月份进行分组,最后求出将该月所有观察的动物数量

db.observations.mapReduce(
 	// 2. 符合1条件的所有数据都会执行该函数
	function map() {
		var year = this.observationTimestamp.getFullYear();
		var month= this.observationTimestamp.getMonth() + 1;
		// 发送一个键值对,map函数标准的处理方式
		emit(year + "-" + month, this.numAnimals);
	},
	
	// 3. 接收map()逻辑的键值对,这里相当于sql的聚合函数sum(),只不过是动态累加;
	// 简单来说,就是对map()产生相同键的处理;换言之,map() 做的是对指定数据生成键值对,把聚合工作交给reduce
	function reduce(key, values) {
		return Array.sum(values);
	},
	
	// 1. 查询语句, 文档数据库类型的查询风格
	{
		query: {family: "Sharks"}, // 条件
		out: "monthlySharkReport" // 输出集合
	}
);
  • 上述等价的PostgreSQL语句
select date_trunc('month',observation_timestamp ) as observation_month, sum(num_animals) as total_animals
from observations
where family = 'Sharks'
group by  observation_month
  • 认识
    • map 和 reduce 的不同作用
      map是对输入数据的匹配及键值对提取,reduce是对提取的value进行定义聚合操作。这个聚合操作可以是求和、求平均值、最大值最小值等,对比起SQL的声明式风格,MapReduce会把聚合的详细过程放开给用户自定义
    • map 和 reduce 的共同特点
      MapReduce查询是命令式语言和声明式语言的结合体,map和reduce函数必须是纯函数,只能使用 传进去的的数据作为输入,而不能执行额外的数据库查询,也不能有任何副作用。这是一句比较抽象的话,先看后面再回头看,就能理解了。

2. UNIX工具进行简单日志分析

cat /var/log/nginx/access.log |  # 读取日志
	awk '{print $7}' |			 # 按空格切分内容,只输出第七个部分(这里是URL)
	sort |			   			 # 按字符顺序对URL列表进行排序	
	uniq -c |		  			 # 过滤重复的行,并用计数器记录重复次数
	sort -r -n |				 # 按每行的起始数据(记录重复次数)倒序排序
	head -n 5					 # 只输出结果的前5行,丢弃其他数据

2.1. UNIX设计哲学

摘录了书中的一些话,并附上理解。

  1. 每个程序只做好一件事(如上面的单行命令行)。如果要做新的工作,则建立一个全新的程序,而不是通过增加新的 “特性” 使旧程序变得更加复杂。
    1.1 解释: 通过增加新的 “特性” 使旧程序变得更加复杂

    public static List<String> getSharks(List<String> animalList) {
        List<String> result = new ArrayList<>();
        for (int i = 0; i < animalList.size(); i++) {
            String animal = animalList.get(i);
            if (isSharksFamily(animal)) {
                result.add(animal);
            }
        }
        return result;
    }
    // 在上面的基础,修改为同时统计不同观察年龄的鲨鱼的数量
    public static Result getSharks(List<String> animalList) {
        List<String> result = new ArrayList<>();
        Map<String, Integer> map = new HashMap<>();
        Result result = new Result();
        for (int i = 0; i < animalList.size(); i++) {
            String animal = animalList.get(i);
            if (isSharksFamily(animal)) {
                result.add(animal);
                Integer current = map.get(animal);
                if (current  == null) {
                	map.put(animal, 1);
                } else {
                	map.put(animal, current+1);
    			};
            }
        }
        result.setList(list);
        result.setMap(map);
        return result;
    }
    
  2. 期待每个程序的输出成为另一个尚未确定的程序的输入。不要使用交互式输入。
    上下文不进行互相询问,下游只拿到确定的数据,上游不需要知道下游拿这些数据去干什么

  3. 需要扔掉哪些笨拙的部分时不要犹豫,并立即进行重建。

  4. 优先使用工具来减轻编程任务,即使你不得不花费时间去构建工具,甚至预期在使用完成后会将其中一些工具扔掉。

2.2. UNIX 所作出的突破

  • 统一接口。 在UNIX中,这个接口就是文件,文件只是一个有序的字节序列
    • 文件系统上的实际文件
    • 设备驱动程序
    • TCP链接的套接字

以上等内容都被抽象成统一的接口,对于这些差异明显的不同的事物通过共享统一的接口,使他们能够轻松连接在一起,确实不可思议。


  • 如何统一?(这跟Java的实现近乎一致)
    1. 首先定义标准输入(默认是接收键盘输入),标准输出(模式是输出到屏幕)
    2. 也可以将文件作为输入,或将输出重定向到文件
    3. 管道允许将一个进程的输出附加到一个进程的输入

以上的内容,阐述了个事实,不同的进程通过管道进行通信,一个进程的处理结果(文件)可以成为另外个进程的处理输入(文件)。

2.3 UNIX批处理输出的哲学

  • 什么是批处理?
    区分于:事务处理(OLTP)、分析处理(基于数据仓库的只读操作)
    批处理更靠近分析处理,但是于分析处理中执行SQL产生的报告不同,批处理的输出是其他类型的数据结构。
  • 具体思想
    UNIX设计哲学倡导明确的数据流:一个程序读取输入并写回输出。在这个过程中,输入保持不变,任何以前的输出都被新输出完全替换,并且没有其他副作用(比如生成短信提醒、生成支付记录)。这意味着可以随心所欲地重新运行一个命令,进行调整或调试,而不会扰乱系统状态。具体好处:
    1. 易于回滚
    2. 重试不会产生副作用,是安全的
    3. 一个团队可以专注于实现“做好一件事”,而其他团队决定何时何地运行该作业

3. 数据流

数据流在不同文章的上下文,可能承担着不同的角色。本篇文章的数据流可以视为编程中的Stream
数据流的存在是引用UNIX的设计哲学解决MapReduce的卡点,MapReduce不同作业的主要联系方式是文件系统上的输入和输出目录,那么中间状态的数据都会被写入实体化,这个过程会耗费较多IO资源。而UNIX中的管道并不是完全的中间状态,而是只使用一个小的内存缓冲区,逐渐将输出转化成输入。

  • 数据流引擎
    Spark 、Tez、Flink 设计上的共同点:把整个工作流作为一个作业来处理,而不是把它分解为多个独立的子作业(如上文提到的UNIX做日志处理,整个命令就是一个作业)
  • 函数运算符
    1. 不需要严格交替map和reduce的角色,而是以更灵活的方式组合。
    2. 排序等计算代价昂贵的任务只在实际需要的地方进行,而不是每个map和reduce运算符中进行
    3. 数据流不需要将自己所有中间状态写入文件系统

4. 迭代和数据流的选择

  • 用迭代
    “重复直到完成” 的需求,但是每次重复都会为下一轮提供利好条件,如同图的遍历。非连通图,每次都从一个顶点开始,找不到边后就随机从为遍历的顶点开始遍历,已经遍历过的则不会再遍历。如果用流,则每一次遍历都会从头开始,因为以下步骤一的流是不可变的:

    1. 外部调度程序运行批处理来执行算法的一个步骤
    2. 当批处理过程完成时,调度器检查图是否遍历完整
    3. 如果尚未完成,则调度程序返回到步骤1并允许另一批批处理
  • 用数据流(批处理)
    除了上述“用迭代”的场景,其他场景均可以替换为数据流。但是数据流更强大的特性,在于它可重试、不产生副作用的安全机制。Google曾将数据流用于以下场景:

在线生成服务和离线批处理作业在同一台机器上运行, 批处理的优先级较低。当有足够主机资源时,批处理运行,但是在线服务随时都可以抢占,批处理允许失败且能自动重试(完成的流数据不可变,重试的逻辑不会产生副作用)。
架构层面的优点:批处理任何工作可以有效地利用高优先级进程剩下的任何可用资源

5. Java中的部分新特性

上述对数据流的理解上升到架构层面,难免有点空中楼阁的感觉,现在回归到实际工作上,趁热打铁记录Stream API的使用。

5.1. 前置知识 Lambda 表达式

	// 使用 java 7 排序
   private void sortUsingJava7(List<String> names){   
      Collections.sort(names, new Comparator<String>() {
         @Override
         public int compare(String s1, String s2) {
            return s1.compareTo(s2);
         }
      });
   }
   
   // 使用 java 8 排序
   private void sortUsingJava8(List<String> names){
      Collections.sort(names, (s1, s2) -> s1.compareTo(s2));
   }
  • 延伸一下,介绍下函数式编程,直观得表现为:表达式可以作为参数传入
	// Predicate<Integer> predicate 定义了个函数式的入参(可以理解为回调逻辑)
   public static void eval(List<Integer> list, Predicate<Integer> predicate) {
      for(Integer i: list) {
         if(predicate.test(i)) {
            System.out.println(n + " ");
         }
      }
   }
	
	// 测试
	public static void main(String args[]) {
      List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
        
      // 传递参数 n
      eval(list, n->true);
        
      // Predicate<Integer> predicate1 = n -> n%2 == 0
      // n 是一个参数传递到 Predicate 接口的 test 方法
      System.out.println("输出所有偶数:");
      eval(list, n-> n%2 == 0 );
   }

5.2. Java 8 Stream

Oracle 对Java8新特性描述

  • 摘录对集合特性的描述
    在这里插入图片描述
  • 这个Collections 是对集合的阐述,而不是指java.util.Collections
  • Stream API 被继承到集合体系中,可用于(顺序或者并行的)map-reduce 转换

又看到了熟悉的MapReduce的阐述,接下来看详细的lambda、Stream官网教程

5.3. Stream 聚合操作前言

Oracle 对Java8 Stream聚合操作的教程,其中有三个子目录,依次展开

  • 对比java7以前的操作

The following example prints the name of all members contained in the collection roster with a for-each loop:

for (Person p : roster) {
    System.out.println(p.getName());
}

The following example prints all members contained in the collection roster but with the aggregate operation forEach:

roster
    .stream()
    .forEach(e -> System.out.println(e.getName());

  • java8,引入的新概念——管道操作:

A pipeline is a sequence of aggregate operations. A pipeline contains the following components:

  1. 资源:可以来自collection, an array, a generator function, or an I/O channel.(来自UNIX的哲学)
  2. 零个或多个中间操作,会产生新的Stream(上文提到的数据流):过滤完成的数据将成为新的对象而不会改变旧数据(来自UNIX的哲学),如
    2.1 filter(条件)——符合条件的元素收集进新的Stream
    2.2 mapToInt()——一个只包含int元素的Stream
  3. 终末操作:产生一个非Stream的返回值,如:
    3.1. forEach() —— 无返回值
    3.2 getAsDouble()——返回基础数据类型

用上述内容,写一段基于Stream的代码

double average = roster
    .stream()
    .filter(p -> p.getGender() == Person.Sex.MALE)
    .mapToInt(Person::getAge)
    .average()
    .getAsDouble();

看懂这段代码,有点类似UNIX的命令行操作,这个操作中产生了三个Stream,每个Stream都是不可变的,如果部署作为代码的作者,我们也只是关心average 这个数值,中间的逻辑也一目了然,很符合自然语言的表达。

5.4. Stream 终末操作 Reduction

The JDK contains many terminal operations (such as average, sum, min, max, and count) that return one value by combining the contents of a stream. These operations are called reduction operations.

However, the JDK provides you with the general-purpose reduction operations reduce and collect, which this section describes in detail.

ReductionMapReduce中的reduce是一脉相承的,是对map匹配出来的值进行聚合操作。对于语言层面的支持,Java的Reduction被分为collect 类型reduce 类型

The reduce operation always returns a new value. However, the accumulator function also returns a new value every time it processes an element of a stream. Suppose that you want to reduce the elements of a stream to a more complex object, such as a collection. This might hinder the performance of your application. If your reduce operation involves adding elements to a collection, then every time your accumulator function processes an element, it creates a new collection that includes the element, which is inefficient. It would be more efficient for you to update an existing collection instead. You can do this with the Stream.collect method, which the next section describes.

简单来说,聚合过程中只维护一个值的用reduce, 聚合过程中维护多个值的用collect。原则上多次reduce也能代替collect,至于为什么用collect,是多个流计算多个值会产生多个Stream,中间的Stream的创建和遍历十分耗费性能,则用collect方法代替。

5.5. reduce

Integer totalAge = roster
    .stream()
    .mapToInt(Person::getAge)
    .sum();
Integer totalAgeReduce = roster
   .stream()
   .map(Person::getAge)
   .reduce(
       0,
       (a, b) -> a + b);

上述两段代码是等价的作用。对于reduce(0, (a, b) -> a + b) 有如下解释:
1. 0 返回值的初始值,尽管Stream中没有任何一个成员
2. (a, b) -> a + b a代表Stream中原来的数据,b代表过程中逐步流入的数据。把sum()的内部过程可视化了。有着动态规划的思想,缓缓流入管道中的b数据都会累加进a里面。

5.6. 普通collect

Unlike the reduce method, which always creates a new value when it processes an element, the collect method modifies, or mutates, an existing value.

一个适合用collect的需求:求平均值

class Averager implements IntConsumer
{
    private int total = 0;
    private int count = 0;
        
    public double average() {
        return count > 0 ? ((double) total)/count : 0;
    }
        
    public void accept(int i) { total += i; count++; }
    
    public void combine(Averager other) {
        total += other.total;
        count += other.count;
    }
}
Averager averageCollect = roster.stream()
    .filter(p -> p.getGender() == Person.Sex.MALE)
    .map(Person::getAge)
    .collect(Averager::new, Averager::accept, Averager::combine);
                   
System.out.println("Average age of male members: " +
    averageCollect.average());

上述官网出现了方法引用的内容,查询了overstackflow后,现在替换成容易理解的样子,第三个参数后文会补充

Averager  averageCollect = roster.stream()
    .filter(p -> p.getGender() == Person.Sex.MALE)
    .map(Person::getAge)
    .collect(
    	new Supplier<Averager>() {
       	 		@Override
        		public ArrayList<String> get() {
            		return new ArrayList<>();
        		}
    		},

    	new BiConsumer<Averager, Integer>() {
       			@Override
        		public void accept(Averager result, Integer item) {
            		result.accept(item);
        		}
    		},

    	new BiConsumer<Averager, Averager>() {
       			 @Override
        		 public void accept(Averager av, Averager other) {
            		av.combine(other);
        		}
    		}
	);
  • collect 的三个参数
    1. supplier 提供结果容器的工厂方法:上例是创建了一个Averager对象用于接收返回值
    2. accumulator 聚合器:上列是声明了一个聚合列的输入,但是定义了两个逻辑
      2.1 输入值(上例是年龄)累加
      2.2 输入的次数累加
    3. combiner 叠加器:允许流参与到并行执行的逻辑,属于一种强制的声明,具体原因摘自网路:
      简单来说就是并行和串行要准守同一套设计原则,不能我专门设计一个方法给你串行,然后我的并行没法用,反之同理.所以我们要看看,并行是如何进行reduce 的,拿一下overstackflow的图
      在这里插入图片描述

5.7. collect(Collectors.xxxx)

  • Collectors 出现的意义

This version of the collect operation takes one parameter of type Collector. This class encapsulates the functions used as arguments in the collect operation that requires three arguments (supplier, accumulator, and combiner functions).

简言之,Collectors 封装了collect()函数中所需要的三个参数。

  • Collector.toList()
List<String> namesOfMaleMembersCollect = roster
    .stream()
    .filter(p -> p.getGender() == Person.Sex.MALE)
    .map(p -> p.getName())
    .collect(Collectors.toList());
  • Collectors.groupingBy()
Map<Person.Sex, List<Person>> byGender =
    roster
        .stream()
        .collect(
            Collectors.groupingBy(Person::getGender));
  • Collectors.groupingBy(key, Collectors.mapping())
Map<Person.Sex, List<String>> namesByGender =
    roster
        .stream()
        // .collect(Collectors.groupingBy(Person::getGender)); 展开为下列参数
        .collect(
            Collectors.groupingBy(
                Person::getGender,  // 第一个参数表示Map的key 也就是分组依据                     
                Collectors.mapping(
                    Person::getName, // 第二个参数表示Map中的数据如何组织,这里表示同一组的name收集成一个List											
                    Collectors.toList())));
  • Collectors.groupingBy(key, Collectors.reducing())
Map<Person.Sex, Integer> totalAgeByGender =
    roster
        .stream()
        .collect(
            Collectors.groupingBy(
                Person::getGender,                      
                Collectors.reducing( // 同reduce的语义
                    0, 
                    Person::getAge, // 另一个角度讲,发生哈希冲突时,所有冲突值的values是什么行为
                    Integer::sum)));
  • Collectors.groupingBy(key, averagingInt())
Map<Person.Sex, Double> averageAgeByGender = roster
    .stream()
    .collect(
        Collectors.groupingBy(
            Person::getGender,                      
            Collectors.averagingInt(Person::getAge)));

5.8. 理解 mapping reducing averagingInt

MapReduce 思想一脉相承,如果分组的逻辑中只需要map的进行分组,用Collector.mapping()就够用了,如果还需要其他聚合操作,那么就需要使用Collector.reducing(),更高级的聚合操作如求平均值 Collectors.averagingInt()。

5.9. parallelStream()

在上文中 5.3.2.2 普通collect已经介绍了collect() 的第三个参数的作用,该参数是作为parallelStream()功能强有力的保证,提供了Stream并行map-reduce的能力,面对大数据上处理会缩短时长

ConcurrentMap<Person.Sex, List<Person>> byGender =
    roster
        .parallelStream() // 这里改为 .Stream()结果是一致的
        .collect(
            Collectors.groupingByConcurrent(Person::getGender));

后记

周末两天时间整理了这文章,结合了《数据密集型应用系统设计》与Oracle官网的内容进行梳理,感觉到异常畅快!一脉相承的设计哲学仍称得上是经典!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值