写目录
Stream API的介绍
Stream API 的出现显著提升了Java对函数式编程的支持,允许开发人员使用声明式的方式处理数据集合,例如列表、数组等。同时还能有效的使用多核处理器进行并行操作,提升应用程序的性能,代码更加简洁。
为什么要引入Stream API
我们在pojo包下创建一个Person实体
private String name;
private int age;
private String country
并在主入口处创建一个Person类型的List
List<Person> people = Arrays.asList(
new Person("Noe", 45, "USA"),
new Person("Stan", 12, "JPA"),
new Person("Noe", 45, "USA"),
new Person("Stan", 12, "JPA"),
new Person("Grace", 5, "UK"),
new Person("Alex", 18, "USA"),
new Person("Albert", 91, "FR")
);
如果我们要从people中取出age大于18的人员应该怎么做呢?
常规方法是我们设置一个循环遍历people中的每一个元素在去判断它们的age
List<Person> adults = new ArrayList<>();
for (Person person : people) {
if(person.getAge()>18)
{
adults.add(person);
}
}
System.out.println(adults);
}
这种方式虽然直观,但是当操作变得复杂时,代码就会变得冗余,难以维护。有没有更加简洁的方式去实现呢,那就是使用Stream API:
List<Person> adults = people.stream()
.filter(person -> person.getAge() > 18)
.collect(Collectors.toList());
System.out.println(adults);
我们发现当我们使用Stream API后代码变得更加的简洁方便。值得一提的是Stream本身不是一种数据结构,它不会存储数据或改变数据源,他只定义对数据的处理方式。
流的操作
想要熟练掌握Stream API ,关键在于三个核心步骤:
创建流:可以通过集合、数组、I/O通道等方式创建流。例如,可以通过Collection.stream() 方法将集合转换为流,或者通过Files.lines()方法将文件的每一行作为流的元素。
中间操作:对流进行中间操作,可以对流进行过滤、映射、排序等操作,以生成一个新的流。中间操作是惰性的,只有当执行终端操作时,中间操作才会被触发执行。
终端操作:对流进行终端操作,可以对流进行计算、收集结果、打印输出等操作。终端操作会触发流的处理,产生最终的结果。常见的终端操作包括forEach()、count()、**collect()**等。
值得注意的是,执行终端操作后,流就被消费掉不能再使用。
创建流
创建流的方式有很多,对于实现了Collection接口的集合,都可以通过Stream()方法直接创建Stream流。下面展示List、数组直接通过Stream()方法创建流:
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream = list.stream();
stream.forEach(System.out::println);
String[] array = {"a", "b", "c"};
Stream<String> stream = Arrays.stream(array);
stream.forEach(System.out::println);
通过Stream.of方法直接创建流:
IntStream intStream = IntStream.of(1, 2, 3);
合并两个流:
Stream<String> stream1 = Stream.of("a", "b", "c");
Stream<String> stream2 = Stream.of("d", "e", "f");
Stream<String> concat = Stream.concat(stream1, stream2);
concat.forEach(System.out::println);
通过Stream.builder() 创建流:
Stream.Builder<String> builder = Stream.builder();
builder.add("a");
builder.add("b");
if(Math.random()>0.5)
{
builder.add("c");
}
Stream<String> stream = builder.build();
///调用build就不能再add
stream.forEach(System.out::println);
文件创建流,通过Java的files类的lines方法实现。
首先我们拿到文件的路径,在进行lines方法时他会逐行读取文件的内容,每一行文本都会被当成一行字符串处理。
需要注意,通过此方式打开的文件需要进行妥善关闭,所以下述代码进行了try with resource操作。
Path path = Paths.get("file.txt");
try(Stream<String> lines = Files.lines(path)) {
lines.forEach(System.out::println);
} catch (IOException e) {
e.getStackTrace();
}
对基本数据类型 StreamAPI 提供了IntStream、LongStream 和DoubleStream。通过使用range() 和rangeClosed 等方法可以和方便的创建这些类型的流。range 左闭右开、rangeClosed 左闭右闭。也可以使用基本类型流转化为对象流通过boxed()方法。
// IntStream intStream = IntStream.range(0,4);
IntStream intStream = IntStream.rangeClosed(0,4);
Stream<Integer> boxed = intStream.boxed();
boxed.forEach(System.out::println);
创建无限流,使用Stream的generate方法创建,通过limit来限制创建的个数。
Stream<String> stringStream = Stream.generate(() -> "Mizuki").limit(5);
stringStream.forEach(System.out::println);
Stream.generate(Math::random).limit(5).forEach(System.out::println);
通过Stream的iterate方法可以实现生成数学序列或者迭代算法,下述代码实现一个起始为 0 的有界等差数列:
Stream.iterate(0,n->n+2).limit(10).forEach(System.out::println);
创建并行流,通过已有的顺序流通过调用parallel方法或者直接使用parallelStream方法即可将其转化为并行流。
Arrays.asList("a","b","c").parallelStream().forEach(System.out::println);
中间操作
中间操作 用于对流中元素的处理,根据操作的性质可以分为以下两个功能类别:筛选和切片(filter、distinct、limit,skip …)、映射(map、flatMap、mapToInt …),排序(sorted)
筛选与切片
文章开头展示Stream时使用了筛选操作filter来筛选处理大于18岁的人员,文章往下会依次展示各类功能的方法示例。
List<Person> adults = people.stream()
.filter(person -> person.getAge() > 18)
.collect(Collectors.toList());
System.out.println(adults);
去除重复元素distinct:
Stream.of("apple","orange","apple","orange","cherry")
.distinct()
.forEach(System.out::println);
控制台输出:
依旧使用文章开始的例子,不过增加两个重复元素:
List<Person> people = Arrays.asList(
new Person("Noe", 45, "USA"),
new Person("Stan", 12, "JPA"),
new Person("Noe", 45, "USA"),
new Person("Stan", 12, "JPA"),
new Person("Grace", 5, "UK"),
new Person("Alex", 18, "USA"),
new Person("Albert", 91, "FR")
);
使用对people进行去重,使用limit输出前两个元素:
people.stream()
.distinct()
.forEach(System.out::println);
System.out.println("----------------------------------");
people.stream()
.limit(2)//skip
.forEach(System.out::println);
控制台输出:
需要注意的时需要在Person类中重写equals() 和hasCode() 方法。
limit()从前开始取,若接收数大于本身元素个数,则会输出全部
skip()从前开始省略,若接收数大于本身元素个数,则返回空的流
映射
映射实际上就一个数据转换的过程,加入我们要把例子中包含人员对象得流转换为仅包含人名得流应该怎么做呢?使用map方法可以轻松实现:
people.stream()
.map(Person::getName)
.forEach(System.out::println);
控制台输出:
通过提示我们知道map中首先传入的 T 是原始流中的元素类型,再传入的 R 是经过转换后返回的类型
总言之 map通过传入的信息将流中的元素转换成新的元素
对于单层结构的集合,map可以轻松的实现一对一转换,但是对多层结构或者嵌套的处理起来就没有那么的简便了,所以映射功能中提供了flatMap来处理这类数据。
首先我们创建一个多层结构的数据:peopleGroups
List<List<Person>> peopleGroups = Arrays.asList(
Arrays.asList(
new Person("Noe", 45, "USA"),
new Person("Stan", 12, "JPA")
),
Arrays.asList(
new Person("Noe", 45, "USA"),
new Person("Stan", 12, "JPA")
),
Arrays.asList(
new Person("Grace", 5, "UK"),
new Person("Alex", 18, "USA")
),
Collections.singletonList(
new Person("Albert", 91, "FR")
)
);
创建流并输出:
peopleGroups.stream()
.forEach(System.out::println)
可以看出此时的流是多层的
我们再使用flatMap对数据进行处理,并查看输出:
peopleGroups.stream()
.flatMap(Collection::stream)
.forEach(System.out::println);
可以以发现,再使用了flatMap后数据又变成单层结构了
flatMap不仅能够执行map的转换功能,还能使多层的数据结构扁平化,将他们转换合并成一个单层流,执行过程如下图所示:
至此 我们来实现多层结构数据 输出他们的人名:
peopleGroups.stream()
.flatMap(Collection::stream)
.distinct()
.map(Person::getName)
.forEach(System.out::println);
控制台输出:
-----------------------------插入一个注意点-----------------------------
(一个流进行操作过后返回的使一个新的流,原始流在被操作过后就会被标记 “已操作” ,就不能再进行操作,否则会报错。所以建议使用链式操作。)
-------------------------------插入一个注意点-----------------------------
另外我们还可以通过mapToInt、mapToLong、mapToDouble将流转化为对应的数值流,如果流中的元素是字符串或者包装类的数字(Integer、Long 等),可以直接调用无参数的sorted方法按照自然顺序进行排序,倒序使用reversed()。也可以通过sorted的Comparator.comparing() 方法自定义排序。下述代码实现根据字符串长度排序:
Stream.of("apple","orange","apex")
.sorted(Comparator.comparing(String::length))
.forEach(System.out::println);
控制台输出:
现在我们来实现一个综合实列,将文章多层数据结构的例子进行age>18的人员进行按age降序并只打印人名:
peopleGroups.stream()
.flatMap(Collection::stream)
.filter(person -> person.getAge()>18)
.distinct()
.sorted(Comparator.comparing(Person::getAge).reversed())
.map(Person::getName)
// .limit(1) 年纪第一大
.forEach(System.out::println);
控制台输出:
终端操作
终端操作是流处理的最终步骤,一旦执行了终端操作,流就会被消费,再不能使用。
终端操作包括:查找与匹配、聚合操作、规约操作、收集操作、迭代操作,如下图所示:
查找与匹配
通过anyMatch() 方法可以判断流中是否有符合条件的,如果有至少一个,则会返回布尔值true:
System.out.println(people.stream().anyMatch(person -> person.getAge() > 18));
控制台输出:
此外还有一些类似方法,noneMatch() :如果不存在一个符合条件则返回true 、allMatch():全符合条件则返回true
通过findFirst() 方法可以找到流中的第一个元素(直接调用,不做演示)。
聚合操作
通过集合操作提供的方法,我们可以完成如:计数、求和、最大最小值,平均值等。下述代码依次展示:
System.out.println(people.stream().count());//计数
System.out.println(people.stream().max(Comparator.comparingInt(Person::getAge))); // min()
也可以通过映射mapToInt将对象流转换为数值流再进行操作:
System.out.println(people.stream().mapToInt(Person::getAge).sum());
OptionalDouble average = people.stream().mapToInt(Person::getAge).average();//average
average.ifPresent(System.out::println);
归约操作
stream规约操作是指对数据流进行约束和操作的一种方式。下面介绍reduce的使用方法:
由idea的提示可知reduce接收两个参数(初始值,累积函数)
以下演示reduce的实际使用:
求和:
IntStream ageStream = people.stream().mapToInt(Person::getAge);
int sum = ageStream.reduce(0, Integer::sum);
System.out.println(sum);
连接字符串通过 " , " :
String nameStream = people.stream().map(Person::getName)
.reduce("", (a,b)->a+b+",");
System.out.println(nameStream);
收集操作
在流编程中,收集操作是指对流中的元素进行聚合、合并或分组的操作。Java 8引入了Collectors类,该类提供了一套丰富的收集器(Collector)方法,可以方便地对流进行收集操作。
合并操作:可以通过joining方法将流中的元素拼接为一个字符串,也可以通过reducing方法将流中的元素进行二元操作。
-> 连接字符串通过 " , " :
String joinedName = people.stream()
.map(Person::getName)
.collect(Collectors.joining(","));
System.out.println(joinedName);
分组操作:可以通过groupingBy方法对流中的元素进行分组,可以按照一个属性或者多个属性进行分组。
-> 通过 “国家” 分组:
Map<String, List<Person>> peopleByCountry = people.stream()
.collect(Collectors.groupingBy(Person::getCountry));
peopleByCountry.forEach((k,v)-> System.out.println(k +"="+v));//分组
统计操作:可以通过summarizingInt、summarizingDouble、summarizingLong方法对流中的元素进行一系列统计操作。
IntSummaryStatistics ageSum = people.stream()
.collect(Collectors.summarizingInt(Person::getAge));
System.out.println(ageSum.getSum()); //getAverage getMax getMin ...
自定义收集操作:可以通过Collector接口的of方法自定义收集操作,实现自己的accumulator、combiner、finisher、characteristics等方法。
在收集操作使用最多的莫过于 collect(Collectors.toList()) 他将流收集为一个List,接下来我们手动实现以下toList() 方法
通过idea提示:返回一个新的Collector,由给定的supplier,accumulator和combiner函数描述。生成的Collector具Collector.Characteristics.IDENTITY_FINISH特性。
自定义实现toList():
ArrayList<Object> collect = people.stream() //.parallel()
.collect(Collector.of(
ArrayList::new,//supplier
(list, person) -> {
System.out.println("Accumulator: " + person);//监控
list.add(person);
},
(left, right) -> {
System.out.println("Combiner: " + left);//监控
left.addAll(right);
return left;
},
Collector.Characteristics.IDENTITY_FINISH
));
System.out.println(collect);
控制台输出:
由控制台输出可知,Combiner 部分并没有执行。因为我们创建的是顺序流,并没有涉及到线程数据合并的情况,所以Combier不会被执行。
自定义实现toMap():
HashMap<String, List<Person>> collect = people.stream()
.collect(Collector.of(
HashMap::new,
(map, person) -> {
System.out.println("Accumulator: "+person);
map.computeIfAbsent(person.getCountry(), k -> new ArrayList<>()).add(person);
//computeIfAbsent 如果指定的键尚未与值关联(即 Map 中不存在该键),则使用给定的计算函数来计算其值,并将键和计算出的值添加到 Map 中。
},
(left, right) -> {
System.out.println("Combiner: "+ System.lineSeparator()+"left: "+left
+ System.lineSeparator()+"right: "+right
);
right.forEach((k, v) -> left.merge(k, v, (list, newList) -> {
//如果指定的键已经存在于 Map 中,则使用给定的 remappingFunction 来计算新值,否则直接插入键值对。
list.addAll(newList);
return list;
}));
return left;
}
));
collect.forEach((k,v)-> System.out.println(k+" = "+v));
控制台输出:
由控制台输出可知,Combiner 部分由于是顺序流也没有执行。
我们再来看看并行流的情况,将上方自定义的toList()转换为并行流:
ArrayList<Object> collect = people.stream().parallel()
.collect(Collector.of(
ArrayList::new,
(list, person) -> {
System.out.println("Accumulator: " + person);
list.add(person);
},
(left, right) -> {
System.out.println("Combiner: " + left);
left.addAll(right);
return left;
},
Collector.Characteristics.IDENTITY_FINISH
));
System.out.println(collect);
查看控制台输出:
通过打印结果看到在并行流情况下 Combiner 的监控语句被打印了。
迭代操作
forEach 是一个终端操作中的迭代操作。通过idea提示可知:forEach 方法接受一个 Consumer,用于定义如何处理流中的每个元素。(Consumer 是 Java 中的一个函数式接口)
并行流介绍
以上的流操作都是在顺序流的基础上进行的,顺序流它是单线程的,Stream API提供了一种更高效的解决方式,那就是并行流(Parallel Stream) ,它能够借助多核处理器的并行计算能力,加速数据的处理,适用于密集型数据处理场景。
并行流具体执行过程如下图所示:
并行流开始时,Spliterator 分割迭代器将数据分割成多个部分,以平衡子任务的工作负载。然后Fork/Join 框架会将这些数据片段分配到多个线程和处理器核心上进行并行处理,处理完成功后,结果将会被汇总合并。其核心是任务的分解和fork结果的合并Join。在操作上无论是并行流还是顺序流,两者都提供相通的中间操作和终端操作。
并行流顺序问题
举一个简单的列子,下方代码将一个List 转换为一个流,并进行一个map中间操作将字母全部转为小写,观察控制台输出
Arrays.asList("A","L","B","E","R","T").parallelStream()
.map(String::toLowerCase)
.forEach(System.out::println);// forEachOrdered
}
控制台输出:
可以发现控制台打印的字母并没有按照原来的顺序。
我们可以使用forEachOrdered来实现顺序输出
Arrays.asList("A","L","B","E","R","T").parallelStream()
.map(String::toLowerCase)
.forEachOrdered(System.out::println);
使用forEachOrdered 之所以能够保持这个顺序输出结果,这得益于Spliterator 分割器与Fork/Join框架的协作。在处理并行流时,对于有序数据源,Spliterator会对数据源进行递归分割,这个分割是逻辑上的,而非物理上的,通过划分数据源的索引范围来实现,每一次分割都会生成一个新的Spliterator实列,该实例维护了指向原数据的索引范围,这种分割机制让原来的数据顺序得以保持。
然后Fork/Join框架接手,将分割的数据块分配个每个子任务进行,对于forEachOrdered,Fork/Join框架依据Spliterator维护的顺序来进行线程的调度,这意味着,即使某一个子任务率先完成了任务,但是其关联的方法执行顺序还未到来,那么系统将缓存数据,并暂停执行该方法,直到前置顺序都已完成并执行了各自的方法。这种机制确保了,即使是在并行的情况下,也能按照原始数据的顺序执行。但这种方式(forEachOrdered)可能会损失一些并行执行效率。
forEach操作虽然具有相同的Spliterator分割策略,但是在Fork/Join框架执行时会忽略这些顺序信息,但是能够提供更高的执行效率。
为我们可以自定义一个收集器来观察内部执行方式,下面代码自定义了一个toList() 并在其中添加了一些监控语句:
ArrayList<Object> collect = Arrays.asList("A", "B", "C", "D", "E").parallelStream()
.map(String::toLowerCase)
.collect(Collector.of(
() -> {
System.out.println("Supplier: new ArrayList " + "Thread: " + Thread.currentThread().getName());
return new ArrayList<>();
},
(list, item) -> {
System.out.println("Accumulator: " + item + " Thread: " + Thread.currentThread().getName());
list.add(item);
},
(left, right) -> {
System.out.println("Combiner: " + left + " + " + right + " Thread: " + Thread.currentThread().getName());
left.addAll(right);
return left;
},
Collector.Characteristics.IDENTITY_FINISH
));
System.out.println(collect);
观察控制台打印的结果:
可以看到left和right的合并操作,虽然不是在同一个线程执行的,但任然按照原数据逻辑先后顺序依次组合。值得注意的是如果是toSet()方法,那么依旧会无序输出,这并不是因为内部执行出现了问题,而是set本身就是无序的数据结构。
另外,关于Characteristics中的 UNORDERED , CONCURRENT 两个特性值得一提。
在有序源中加入 UNORDERED 特性,处理结果仍然会是有序的。如下示例:
ArrayList<Object> collect = Arrays.asList("A", "B", "C", "D", "E").parallelStream()
.map(String::toLowerCase)
.collect(Collector.of(
() -> {
System.out.println("Supplier: new ArrayList " + "Thread: " + Thread.currentThread().getName());
return new ArrayList<>();
},
(list, item) -> {
System.out.println("Accumulator: " + item + " Thread: " + Thread.currentThread().getName());
list.add(item);
},
(left, right) -> {
System.out.println("Combiner: " + left + " + " + right + " Thread: " + Thread.currentThread().getName());
left.addAll(right);
return left;
},
Collector.Characteristics.IDENTITY_FINISH,
Collector.Characteristics.UNORDERED
));
System.out.println(collect);
控制台打印结果:
可以发现任然是有序的。
关于 CONCURRENT特性,在并行流处理数据的过程中,每个线程处理数据的一个子集维护自己的结果容器,在所有的数据处理完成后,通过Combiner函数进行汇总。使用 CONCURRENT特性后所有的线程将共享一个结果容器,从而减少了合并操作,带来性能提升。此外共享容器必须是线程安全的,比如ConcurrentHashMap,否则不会生效。
下面代码示例:
ConcurrentHashMap<String,String> collect = Arrays.asList("A", "B", "C", "D", "E").parallelStream()
.map(String::toLowerCase)
.collect(Collector.of(
() -> {
System.out.println("Supplier: new ConcurrentHashMap " + "Thread: " + Thread.currentThread().getName());
return new ConcurrentHashMap<>();
},
(map, item) -> {
System.out.println("Accumulator: " + item + " Thread: " + Thread.currentThread().getName());
map.put(item.toUpperCase(),item);
},
(left, right) -> {
System.out.println("Combiner: " + left + " + " + right + " Thread: " + Thread.currentThread().getName());
left.putAll(right);
return left;
},
Collector.Characteristics.IDENTITY_FINISH,
Collector.Characteristics.CONCURRENT
));
System.out.println(collect);
控制台打印结果:
这里我们发现,Supplier函数依然创建了多个ConcurrentHashMap实例。这是因为在处理有序流的情况下,如果多个线程同时并发更新一个共享容器,那么元素更新的顺序将变得不稳定,为了避免这种情况,框架通常会忽略有序源的CONCURRENT特性,除非同时还指定了UNORDERED特性。
下方代码示例:
ConcurrentHashMap<String,String> collect = Arrays.asList("A", "B", "C", "D", "E").parallelStream()
.map(String::toLowerCase)
.collect(Collector.of(
() -> {
System.out.println("Supplier: new ConcurrentHashMap " + "Thread: " + Thread.currentThread().getName());
return new ConcurrentHashMap<>();
},
(map, item) -> {
System.out.println("Accumulator: " + item + " Thread: " + Thread.currentThread().getName());
map.put(item.toUpperCase(),item);
},
(left, right) -> {
System.out.println("Combiner: " + left + " + " + right + " Thread: " + Thread.currentThread().getName());
left.putAll(right);
return left;
},
Collector.Characteristics.IDENTITY_FINISH,
Collector.Characteristics.CONCURRENT,
Collector.Characteristics.UNORDERED
));
System.out.println(collect);
控制台打印结果:
这里发现Supplier函数只创建了一个ConcurrentHashMap实例。
并行流与顺序流一致形的问题
对于并行流,通过系统内部精确的执行策略,绝大多数的终端操作,都能够产生与顺序流一致的结果,比如下图中的一些方法:
比如聚合操作,并不依赖与元素的出现顺序,只需要将各各子任务的计算结果合并到最终结果。
再比如一些短路操作也能保证一致形,当子任务发现不满足条件时就会立即停止执行,并通知其他子任务停止执行。
即便是有distinct和sorted两个中间操作也不会影响结果的一致形,系统会在分片中进行中间操作,再到合并中再执行一次,最后完成最终的中间操作。
值得一提的是,并行流reduce是否可以保持与顺序流的一致性呢?我们可以通过以下代码验证:
顺序流:
System.out.println(Stream.of(1, 2, 3, 4)
.reduce(0, (a, b) -> a - b));
控制台输出:
并行流:
System.out.println(Stream.of(1, 2, 3, 4).parallel()
.reduce(0, (a, b) -> a - b));
控制台输出:
对比顺序流与并行流的控制台输出,可以发现,两者的打印结果并不相同。其实reduce是否能保持一致,取决于使用的操作是否关联,比如上述代码的减法操作是关联不一致的,a-b不等于b-a。我们可以详细观察一下:
System.out.println(Stream.of(1, 2, 3, 4).parallel()
.reduce(0, (a, b) ->
{
System.out.println(a+" - "+b +" Thread: "+Thread.currentThread().getName());
return a-b;
}));
如果将关联操作换成加法或者是乘法,它们是满足交换律和结合律的,故他们的结果是一致的。
总言之,并行流能够充分发挥多核处理器的优势,特别适用于大数据量和密集型任务,对于数据量小,或者涉及到IO操作的时候,顺序流可能更加合适,因为线程管理涉及到线程管理协调的额外开销,这些开销可能超过并行执行带来的性能提升。所以在决定是否使用并行操作时,应该考虑到任务的性质和数据的规模。