在 JDK8 中新加入了一个 流(Stream) 的概念,通过 流 的方式操作数据可以大大简化我们处理批量数据的方式。
Stream 使用了一种类似于 SQL 语句从数据库查询数据的只管方式来提供一种对 Java 集合运算和表达的高阶抽象。
Stream 风格会将批量数据/集合看成是一种流/管道,数据在流/管道中进行流动。在流经不同的操作节点时进行处理,比如数据的筛选、转换、排序、聚合等,并由最终操作得到前面的处理结果。
相较于传统的 for
循环语句操作批量数据,Stream
风格更关注于操作本身,使得开发者可以专注于开发业务逻辑而非区分和过滤不同中间数据。
Stream 流的特点
非数据结构
Stream 本身并不是数据结构,内部不会保存数据。在执行操作时也是对数据进行逻辑迭代处理
不修改数据源
对于 List
,Array
等数据结构而言,将他们转换成 Stream
并不会对 List
,Array
本身进行处理,而是通过 copy
的方式在copy
的对象上进行操作。对于基本数据类型为值拷贝,而对于引用数据类型如 JavaBean
等为引用拷贝,由于是引用拷贝,所以可以实现对引用对象的修改操作。
管道(Pipeling)与 链式操作
管道是流式操作的一个非常明显的特性,具体表现为多个中间操作如 filter
,map
等如同管道组件一样连接在一起,每个中间操作都会返回流对象本身。多个中间操作连接在一起组成了链式操作,使得代码逻辑变的更为清晰,在某些需要开关中间操作逻辑的场景下会管道的操作会变的非常有利。我们可以针对各个操作进行单独迭代优化,比如延迟执行和短路操作等。
以下面的代码为例,如果 filter
的处理逻辑发生变化的话,我只需要处理相对应的 filter
部分的代码即可,而无需考虑全部其他逻辑。
// Stream 中定义了几种通用的数据处理节点,.filter()负责过滤数据 .map()负责转换数据,
// 通过这些数据处理节点使得操作逻辑变的简单易读
// 在实际追踪数据状态时,由于封闭了中间数据状态,使得追踪中间状态变得复杂起来
IntStream.range(1, 100)
.filter(num -> num % 2 == 0)
.map(num -> num * 13)
.forEach(System.out::println);
虽然链式操作封闭了数据的处理过程,使得开发者在调试代码过程中追踪数据的处理状态变得复杂。这是由于Stream
风格中大量使用了高阶函数的特性导致的。
但是相比较而言从代码可读性提高了,操作逻辑也变的更为清晰。个人感觉这点问题也就不是问题了,在实际使用中可以参考场景进行匹配适用。
内部迭代
以前对集合遍历都是通过迭代器或者for
循环的方式,显示的在集合外部进行迭代,这个叫做外部迭代。而Stream 提供了内部迭代的方式,通过访问者模式(Visitor)将数据的流转封装在管道内部。这样做一方面减少了中间数据,另一方面由于数据封装在了操作过程中,使得调试过程中比较难以观察到数据流转的状态。
final Integer[] nums = {1, 2, 3, 4, 5, 6, 7, 8, 9};
// 外部迭代
for (int i = 0; i < nums.length; i++) {
// 通过 nums[i] 数组在单次迭代过程中可以访问 nums 数组中的全部数据,
// 这种迭代方式被成为 外部迭代 ,适用于窗口函数,或者需要在每次迭代过程中对全部数组进行访问的场景
System.out.printf("num is %d%n", nums[i]);
}
// 内部迭代
for(Integer num:nums){
// 在多数场景中,我们迭代过程中其实只需要取得当前需要迭代的值即可
// 即 内部迭代 ,这里其实还是能够访问到 nums 中的值并加以修改
System.out.printf("num is %d%n",num);
}
惰性求值
在 流 的中间处理过程中,只是对操作进行了记录,并不会立即执行,需要等到执行中止操作时通过链式调用的方式实现流水线式的操作。
Stream 的使用
Stream
的使用无疑是简化了我们的开发过程,能够将一些混合在一起的混合过滤,映射等操作拆分出来,使得逻辑变得更为清晰,系统动作更为明确。
创建 Stream
Stream 的创建有多种方式,但大部分的实现方式都是通过集合作为数据源操作的。我想这和集合所实现 Iterable
有关系。从某种角度来说 Stream
看起来就好像是一个拓展了的 Iterator
,使得Iterator
原本只能实现一个操作变成可以实现多个操作,并且将操作结果进行了向后传递。
// 通过 collection 集合创建流
List<String> strList = Lists.newArrayList("hello","world","my","name");
final String result = strList.stream()
.filter(str -> !"hello".equals(str))
.map(str -> str + " ")
.collect(Collectors.joining(","));
// 通过 Stream 创建流
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5, 6, 7, 9);
stream.filter(num -> num % 2 == 0).forEach(num -> System.out.printf("even num is %d%n", num));
// 通过 Arrays 工具创建 流
stream = Arrays.stream(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8, 9});
stream.filter(num -> num % 2 == 1).forEach(num -> System.out.printf("odd num is %d%n", num));
Stream
的另外一个特点就是可以使用并行流(parallerStream
)进行操作,配合 JVM 中的 forkJoinPool
线程池实现并发处理,在大数据处理的场景下拥有处理速度的优势。而定义的方式也很简单,只需要将 steam
方法修改为parallerStream
即可。
List<String> strList = Lists.newArrayList("hello","world","my","name");
final String result = strList.parallerStream()
.filter(str -> !"hello".equals(str))
.map(str -> str + " ")
.collect(Collectors.joining(","));
Stream 中可用的操作
Stream
中操作主要分为两类,分别是中间操作(Intermediate operations
)和结束操作(Termianl operations
)。一个流操作过程可以包含多个中间操作用于实现业务逻辑和一个结束操作用于收集处理结果。
对于 中间操作 而言,又分为有状态(Stateful)操作和无状态(Stateless)操作。其中 有状态操作 需要感知到流中的所有元素才能实现,比如去重操作distinct()
需要获取到流当中的全部元素才能够实现去重,排序sorted()
等操作同理。反之 无状态操作 则只需要针对当前元素进行处理,类似于观察者(Visitor)模式。
对于 结束操作 而言,又分为 短路操作(short-circuiting)和非短路操作。对于非短路操作,往往可以获取到流中最后处理后的全部元素;我们或者可以对这些元素通过迭代的方式进行一一处理,亦或着将这些元素收集起来以供之后的逻辑处理。而短路操作获取到的元素往往是包含条件的元素,如 findFirst()
操作只会收集流中的第一个元素,allMatch()
则是判断流中的元素是否符合某一逻辑。
计数 count()
通过 stream
我们可以实现 stream
中元素的个数的计数。
// 统计 1~100 内的偶数的个数
final IntStream stream = IntStream.rangeClosed(1, 100);
final long eventCnt = stream.filter(num -> num % 2 == 0).count();
System.out.println("event cnt "+eventCnt);
求和 sum()
这个比较简单,我们找一道高斯教授做过的题进行示例。
从1加到100,结果是多少?
final IntStream stream = IntStream.rangeClosed(1, 100);
final int sum = stream.sum();
System.out.println("from one to hundred is " + sum);
自定义聚合逻辑 reduce()
通过 reduce()
我们可以实现自定义的数据聚合操作,比如上面的 sum()
,通过 reduce()
实现版本如下:
从1加到100,结果是多少?
final OptionalInt sum = IntStream.rangeClosed(1, 100).reduce((left, right) -> left + right);
System.out.println("from one to hundred is " + sum);
或者更无聊一点:
从1乘到100,结果是多少?
final OptionalInt sum = IntStream.rangeClosed(1, 100).reduce((left, right) -> left * right);
System.out.println("from one to hundred the multiple result is " + sum);
亦或者实现count()
操作:
final int count = IntStream.rangeClosed(1, 100).reduce(0, (left, right) -> left + 1);
System.out.println("count num is " + count);
求最值 max()
/min()
final int max = IntStream.rangeClosed(1, 100).max();
System.out.println("max is " + max);
// 流是一次性的,不能够重复使用,一个流在获取到结果以后就无法重复使用了
final int min = IntStream.rangeClosed(1,100).min();
System.out.println("min is " + min);
寻找匹配 allMatch()
/anyMatch()
/noneMatch()
这类操作返回的结果为 boolean
,主要是对 Stream
内部数据进行判断,如果数据符合某些特征的话则返回内容。有点类似于 filter()
,但是 filter()
有点像管道的开关,而allMatch()
/anyMatch()
/noneMatch()
,返回的是匹配结果。
final IntStream stream = IntStream.rangeClosed(1, 100);
final int max = stream.max();
final int ret = IntStream.rangeClosed(1,100).allMatch(num->num%2==0);
System.out.println("from 1 to 100 are all even num :" + ret);
排序操作 sorted()
final Integer[] sorted = IntStream.rangeClosed(1, 10)
.boxed()
// 自定义实现了倒排序
.sorted((o1, o2) -> Integer.compare(o2, o1))
.toArray(Integer[]::new);
System.out.println(Arrays.toString(sorted));
去重操作 distinct()
这个中间操作会移除 Stream
中的重复元素,但在使用的时候要注意对于无序流,去重的元素是不稳定的。对于有序流而言,往往会保留重复元素中的第一个元素,并抛弃其余的元素,但是无序流无法保证这样的结果。
在并行流(parallelStream
)中去重的性能代价相对会比较昂贵,不仅需要用到一个屏障(barrier),而且还需要必要的缓存(buffer)开支。同时由于使用到了并发(多线程),导致流本身无序,所以会产生无序流去重无法保障稳定性的问题。因此如果并行流中有去重的需求场景的话,需要额外注意去重性能和额外的内存开销,必要情况下可以使用 sequential()
来尝试提高这种场景下的性能。
int[] nums = new int[100];
Arrays.fill(nums, 1);
Arrays.stream(nums).distinct().forEach(num -> System.out.println("num is " + num));
// num is 1
除此之外我们可以使用 filter()
自定义去重操作,根据我们需要的业务逻辑进行去重。
public static void main(String[] args) {
final Integer[] sorted = IntStream.rangeClosed(1, 50)
.boxed()
.filter(customDistinctFunc())
.toArray(Integer[]::new);
System.out.println(Arrays.toString(sorted));
}
/**
* 瞎写的自定义去重逻辑
* @return func
*/
private static Predicate<Integer> customDistinctFunc() {
// 去重 map 要写在返回的函数之外,相当于一个lambda函数的一个全局的 map 了
final ConcurrentMap<Integer, Integer> distinctMap = new ConcurrentHashMap<>();
return num -> {
int distinctNum = num / 4;
final Integer put = distinctMap.put(distinctNum, num);
return put == null;
};
}
元素映射 map()
通过 map()
方法我们可以实现将 流中的元素一一映射为新的元素,我会经常在 JavaBean
之间的转换使用到这个操作。往往是通过数据库查出来的数据通过映射从一个 list 转换成为另一个 list。
@Data
public class User {
public User(Integer id, String name, Integer age, CountryEnum country) {
this.id = id;
this.name = name;
this.age = age;
this.country = country;
}
private Integer id;
private String name;
private Integer age;
private CountryEnum country;
}
//--------------
@Data
public class UserDTO {
private String name;
private String namePinyin;
private String country;
}
//--------------
final List<User> users = Arrays.asList(
new User(1, "zhangsan", 23, CountryEnum.china),
new User(2, "lisi", 24, CountryEnum.china),
new User(3, "wangwu", 25, CountryEnum.africa),
new User(4, "zuanliu", 26, CountryEnum.america)
);
final List<UserDTO> dtoList = users.stream().map(user -> {
final UserDTO dto = new UserDTO();
dto.setName(user.getName());
dto.setNamePinyin(user.getName().toUpperCase(Locale.ROOT));
dto.setCountry(user.getCountry().name());
return dto;
}).collect(Collectors.toList());
元素过滤 filter()
通过 filter()
方法我们可以实现对元素的过滤操作,对于该中间操作而言,可以传入一个断言(Predicate<? super T> predicate
),传入 Stream 中的元素,并返回 boolean 类型的结果。filter()
会根据返回结果决定是否"放过"该元素,如果为 true
则允许该元素通过,反之则拒绝该元素通过,并过滤掉元素。
final int[] odds = IntStream.rangeClosed(0, 100)
// 过滤为偶数的值
.filter(num -> num % 2 == 0).toArray();
System.out.println(Arrays.toString(odds));
窥视 peek()
peek()
被翻译为窥视,偷看
。就这个操作的功能来看,也是十分的形象了。通过 peek()
操作我们可以获取到当前流中元素的值,并完成我们想要的操作。这个操作定位本身是不对流中元素进行处理,但是对于类型为对象(如 JavaBean)的元素,我们还是可以通过这个操作修改每个元素中的属性(不能操作元素本身)。
在开发阶段,我们可以通过这个操作将流中的元素打印出来以方便检查逻辑是否符合预期:
// 统计 1~100 内的偶数的个数
final IntStream stream = IntStream.rangeClosed(1, 100);
final long evenCnt = stream.filter(num -> num % 2 == 0)
// 用 peek() 看一下我们拿到的偶数
.peek(num -> System.out.print(num + "\t"))
.count();
System.out.println("even cnt " + evenCnt);
分组 Collectors.groupingBy()
可以使用 groupingBy()
对数据实现分组:
@Data
public class UserDTO {
private String name;
private String namePinyin;
private String country;
}
//--------------
final List<User> users = Arrays.asList(
new User(1, "zhangsan", 23, CountryEnum.china),
new User(2, "lisi", 24, CountryEnum.china),
new User(3, "wangwu", 25, CountryEnum.africa),
new User(4, "zuanliu", 26, CountryEnum.america)
);
users.stream().collect(Collectors.groupingBy(User::getCountry))
// 使用分组操作实现分组并获取国籍为 china 的数据
.get(CountryEnum.china)
.forEach(System.out::println);
合并为字符串 Collectors.joining()
使用 Collectors.joining()
可以实现对数据进行合并为字符串操作:
final List<User> users = Arrays.asList(
new User(1, "zhangsan", 23, CountryEnum.china),
new User(2, "lisi", 24, CountryEnum.china),
new User(3, "wangwu", 25, CountryEnum.africa),
new User(4, "zuanliu", 26, CountryEnum.america)
);
final String ret = users.stream()
.map(User::getName)
.collect(Collectors.joining("-"));
System.out.println(ret);
Stream 转换成 数组 Array toArray()
这个操作可以将 Stream 中的元素统一转换成数组类型,方便我们对结果以数组的方式进行操作处理。这里需要我们自定义 Array 的类型。
final Integer[] sorted = IntStream.rangeClosed(1, 10)
.boxed()
// 自定义实现了倒排序
.sorted((o1, o2) -> Integer.compare(o2, o1))
// stream 转换为 array
.toArray(Integer[]::new);
System.out.println(Arrays.toString(sorted));
List 转换成 Map
在一些场景下,我们并不希望通过遍历 List 去获取值,而是通过 Map 的方式获取值。一方面在 数据量较大的时候 Map 可以提供比 List 更高的访问速度;另一方面 Map 在取值的时候只需要使用 get
方法获取即可,无须进行其他操作。我们可以通过 Stream 实现将 List 快速迭代成为 Map。
@Data
@AllArgsConstructor
class User {
private Integer id;
private String name;
private Integer age;
}
public static void main(String[] args) {
final List<User> users = Arrays.asList(
new User(1, "zhangsan", 23),
new User(2, "lisi", 24),
new User(3, "wangwu", 25),
new User(4, "zuanliu", 26)
);
final Map<Integer, User> userMap = users
.stream()
// 使用 Collectors.toMap 实现将 stream 转成 Map,通过指定 key ,value 自动完成映射
// 映射结果为 HashMap
.collect(Collectors.toMap(User::getId, Function.identity()));
System.out.println(userMap.get(1));
}
注意这里我们的 key 不能重复,否则会抛出一个 key 重复异常。
如果存在key重复这种场景的话,我们需要指定 key 冲突时的逻辑。
final Map<Integer, User> userMap = users
.stream()
// 使用 Collectors.toMap 实现将 stream 转成 Map,通过指定 key ,value 自动完成映射
// 映射结果为 HashMap , 这里 (u1,u2)->u1 函数为当出现重复key时应该如何处理,
// 这里直接抛弃了u2,取了 u1,具体业务场景中应根据实际场景调整
.collect(Collectors.toMap(User::getId, Function.identity()),(u1,u2)->u1);
将多个 Stream 合并为一个 Stream flatMap()
在一些场景下,我们需要将遍历多个流,通过 flatMap
可以实现将多个流合并为一个流进行统一处理。
简单来讲,这是一种流中的元素是流的场景,我们可以通过flatMap
将最内层流中的元素抽出来,合成一个流进行统一的处理。
final long eventCnt = Stream.of(IntStream.rangeClosed(1, 100)
, IntStream.range(500, 700)
).flatMapToInt(stream -> stream)
.filter(num -> num % 2 == 0).count();
// 统计 1~100 内的偶数的个数
System.out.println("event cnt " + eventCnt);
flatMap
函数要求内部返回是一个 stream
,从而使 flatMap
进行去 stream
化。
map() 与 flapMap() 的区别
map()
操作可以使 Stream 内的元素一对一的映射为另一种元素,而 flatMap()
操作则是将多个 Stream
合并为一个 Stream
。
(flat) adj. (形容词)
flat的基本意思是“平的”“平坦的”,指表面没有显著的弯曲、突出、倾斜等。也可用于借喻,表示“平淡无味的”“无聊的”“无精打采的”,还可指“漏气的,气不足的”“扁平的,浅的”等,在句中可用作定语或表语。
flat也可指“(价格)固定的,(收费)统一的”,此时只用作定语。
由 flat
的翻译可以将 flatMap
看成是 扁平化map
操作。
总结
以上是我对 Stream 风格做的一丢丢总结,实际上 Stream 可以实现的效果要远比上面讲到的内容丰富。灵活运用的话可以大大简化我们的开发逻辑,希望能够对你有帮助。