经典伴读系列文章,想写的不是读书笔记,也不是读后感,自己的理解加上实际项目中运用,让大家3-4天读懂这本书
预备知识:
- 方法引用,Lambda相关知识点,特别是函数描述符的概念和使用。
请参考 《经典伴读_Java8实战_Lambda》 - 归约操作reduce,数值流,构建流的使用。
请参考《经典伴读_java8实战_Stream基础》
六、用流收集数据
收集器
1、收集器Collector
问题:要求按照年份分组展示所有交易信息,
(1)准备实体和测试数据
/**
* 交易
*/
public static class Transaction {
private long id; //交易编号
private String trader; //交易员
private double value; //交易金额
private int year; //交易时间
public Transaction(long id, String trader, int year, double value) {
this.id = id;
this.trader = trader;
this.year = year;
this.value = value;
}
private static List getAllTransaction() { //测试数据
List transactions = Arrays.asList(
new Transaction(1, "brian", 2011, 300),
new Transaction(2, "raoul", 2012, 1000),
new Transaction(3, "raoul", 2011, 400),
new Transaction(4, "mario", 2012, 710),
new Transaction(5, "mario", 2012, 700),
new Transaction(6, "alan", 2012, 950)
);
return transactions;
}
(2)使用传统迭代
List trans = getAllTransaction();
Map> transMap = new HashMap<>();
for (Transaction tran : trans) {
List groupTrans = transMap.get(tran.getYear());
if (groupTrans == null) {
groupTrans = new ArrayList<>();
transMap.put(tran.getYear(), groupTrans);
}
groupTrans.add(tran);
}
System.out.println("分组结果:" + transMap);
如果突然拿到这段没有注释的程序,是不是只能在脑袋里执行循环代码,才能明白程序作用。有没有一眼就可以看出代码意图的方式?
(3)分组收集
静态导入所有收集器,可以更加简洁的体现出函数意图。
import static java.util.stream.Collectors.*;
只需向流提出按照交易年份分组的要求:groupingBy(Transaction::getYear)),不关心实现细节,就能得到分组结果。这下看起来可舒服多了。
List trans = getAllTransaction();
Map> transMap
= trans.stream().collect(groupingBy(Transaction::getYear));
System.out.println("分组结果:" + transMap);
分组结果:{2011=[Transaction{id=1, trader='brian', value=300.0, year=2011}, Transaction{id=3, trader='raoul', value=400.0, year=2011}], 2012=[Transaction{id=2, trader='raoul', value=1000.0, year=2012}, Transaction{id=4, trader='mario', value=710.0, year=2012}, Transaction{id=5, trader='mario', value=700.0, year=2012}, Transaction{id=6, trader='alan', value=950.0, year=2012}]}
收集(collect)是流的终端操作,用于将流中的每个元素按照一定规则汇总成一个对象,这不正是归约(reduce)么?不错,collect就是一种高级归约,它的参数Collector就是汇总的规则,被称为收集器。我们可以使用内置的收集器,处理预定义的汇总规则,如:Collectors.groupby将流分组,Collectors.toList流转为列表等。当然也可以自定义收集器。
2、收集collect
对收集器有了基本认识后,继续学习更多的内置收集器之前,有个无法绕开的问题,到底什么是收集collect?先看下collect方法签名。(觉得复杂同学可以先跳过这节)
R collect(Collector super T, A, R> collector);
这里发现一件有趣事情,收集器Collector并不是一个函数式接口,它不止有一个抽象方法,也就是说汇总的过程由多个步骤组成,和reduce果然不同。惊不惊喜,意不意外。
public interface Collector {
Supplier supplier();
BiConsumer accumulator();
Function finisher();
BinaryOperator combiner();
Set characteristics();
虽然无法立即知道这些方法的含义,但至少感觉到一丝模版模式的味道,接下来看下collect如何编排这些步骤,首先我们得知道流是可以并行汇总的,类似分布式计算(后面在讲),这里先说当流顺序汇总时,逻辑步骤如下:(只用到前三个函数)
参考上图,我们查看收集器Collector的抽象方法:
public interface Collector {
T:流中要收集的元素类型
A:累加器类型,CPU中累加器是一种寄存器,用来储存计算产生的中间结果,这里的累加器是一个结果容器,在收集过程中存放部分结果的对象。A就是这个结果容器的类型。
R:收集操作最后要返回的对象类型。
Supplier supplier();
创建结果容器(累加器),返回值的函数描述符是() -> A
BiConsumer accumulator();
将元素添加到结果容器,到底以什么规则汇总就在这里,是收集的核心步骤,
返回值的函数描述符是(A a, T t) -> void
Function finisher();
将结果容器类型转换为返回值类型,返回值的函数描述符是(A a) -> R
BinaryOperator combiner();
并行汇总时,合并两个结果容器(累加器),返回值的函数描述符是(A a1, A a2) -> A
Set characteristics();
返回收集器的特征集,这些特征Characteristics是枚举类型,包括三个:
- CONCURRENT表示accumulator函数支持多线程调用,且收集器可以并行归约。
- UNORDERED表示归约结果无序。注意支持并行的收集器必须支持无序或者是无序数据源 。
- IDENTITY_FINISH表示finisher函数返回的是恒等函数identity function,输入参数是什么,返回值就是什么。也就是说可以不执行finisher函数,直接跳过,将最后一次累加器中的值当做结果返回。
Collectors(注意带s)中有现成的特征集常量,可惜不是public,无法直接使用,但是可以过来拷贝呀。
static final Set CH_CONCURRENT_ID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.CONCURRENT,
Collector.Characteristics.UNORDERED,
Collector.Characteristics.IDENTITY_FINISH));
static final Set CH_CONCURRENT_NOID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.CONCURRENT,
Collector.Characteristics.UNORDERED));
static final Set CH_ID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.IDENTITY_FINISH));
static final Set CH_UNORDERED_ID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.UNORDERED,
Collector.Characteristics.IDENTITY_FINISH));
static final Set CH_NOID = Collections.emptySet();
到此收集器Collector总算弄明白了,我们模仿Collector.toList()方法创建一个自定义收集器,将流转换为列表。(暂不支持并发)
List traders = trans.stream()
.map(Transaction::getTrader) //映射交易员
.collect(new Collector, List>() {
@Override
public Supplier> supplier() {
return ArrayList::new; //构造方法引用,等同于 new ArrayList();
}
@Override
public BiConsumer, String> accumulator() {
return List::add; //内部方法引用,等同于 (a, t) -> a.add(t)
}
@Override
public BinaryOperator> combiner() {
return (a1, a2) -> {
//注意抛异常的位置,不在combiner中,而是在返回值里。
throw new UnsupportedOperationException("暂不支持并发流");
};
}
@Override
public Function, List> finisher() {
return Function.identity(); //等同于 t -> t
}
@Override
public Set characteristics() {
//如果返回Collections.unmodifiableSet(
// EnumSet.of(Collector.Characteristics.IDENTITY_FINISH));
// 则finisher函数跳过不执行
return Collections.emptySet();
}
});
System.out.println("交易员:" + traders);
交易员:[brian, raoul, raoul, mario, mario, Alan]
内置收集器
Collectors(注意带s)就像一个工厂,里面有一堆静态方法,用于创建各种各样的内置收集器Collector,让我们先将常用方法分类:(暂时不包含并发相关的收集器)
- 汇总到集合:toList,toSet,toMap等
- 归约为一个值:joining,counting,minBy,maxBy,summingInt,averagingInt,summarizingInt,reduce等
- 分组和分区:groupingBy,partitioningBy
注意:书中将summingInt和averagingInt称为汇总,实际网上对于规约和汇总并没有明确区分,为了容易理解,这里我将返回集合的操作称为汇总,返回一个值的操作叫做归约。
汇总到集合
需要记忆,实际项目中非常常用。
List traderList = trans.stream()
.map(Transaction::getTrader).distinct()
.collect(Collectors.toList());
System.out.println("转List:" + traderList);
Set traderSet = trans.stream()
.map(Transaction::getTrader)
.collect(toSet());
System.out.println("转Set(去重且无序):" + traderSet);
Map transMap = trans.stream()
.collect(toMap(Transaction::getId, Function.identity())); //identity是恒等函数等同于t->t
System.out.println("转Map(值为对象):" + transMap);
Map transTraderMap = trans.stream()
.collect(toMap(Transaction::getId, Transaction::getTrader)); //identity是恒等函数等同于t->t
System.out.println("转Map(值为对象属性):" + transTraderMap);
转List:[brian, raoul, mario, alan]
转Set(去重且无序):[raoul, alan, mario, brian]
转Map(值为对象):{1=Transaction{id=1, trader='brian', value=300.0, year=2011}, 2=Transaction{id=2, trader='raoul', value=1000.0, year=2012}, 3=Transaction{id=3, trader='raoul', value=400.0, year=2011}, 4=Transaction{id=4, trader='mario', value=710.0, year=2012}, 5=Transaction{id=5, trader='mario', value=700.0, year=2012}, 6=Transaction{id=6, trader='alan', value=950.0, year=2012}}
转Map(值为对象属性):{1=brian, 2=raoul, 3=raoul, 4=mario, 5=mario, 6=alan}
归约为一个值
在上一篇讲解归约reduce操作时,介绍过内置归约操作stream().xxx(),如:sum(),count(),max(),min(),average(),这一篇的内置收集器Collectors.xxx()中也有它们,如:counting(),minBy(),maxBy(),summingInt(),averagingInt(),甚至还有归约reducing()收集器。注意和stream().reduce()方法名不同。如果不考虑并行流情况下,它们都没有太大区别,
double sum = trans.stream().map(Transaction::getValue)
.collect(reducing(0.0, Double::sum));
System.out.println("规约操作求和:" + sum);
sum = trans.stream().map(Transaction::getValue)
.reduce(0.0, Double::sum);
System.out.println("收集操作求和:" + sum);
summarizingInt是它们的合体。joining用于拼接列表较常用。
DoubleSummaryStatistics statistics = trans.stream().collect(summarizingDouble(Transaction::getValue));
System.out.println("统计:" + statistics);
String traderNames = trans.stream().map(Transaction::getTrader)
.collect(Collectors.joining(", "));
System.out.println("List转String(逗号分隔):" + traderNames);
List转String(逗号分隔):brian, raoul, raoul, mario, mario, alan
统计:DoubleSummaryStatistics{count=6, sum=4060.000000, min=300.000000, average=676.666667, max=1000.000000}
分组
groupby收集器可以复合(嵌入)多种收集器,进行多级分组,分区和归约。
1、复合summingDouble,用于分组求和。
问题:要求按照年份统计交易销售额。
使用SQL:
SELECT year, sum(value) FROM transactions GROUP BY year
使用Stream:
Map valuesByYearMap = trans.stream()
.collect(groupingBy(
Transaction::getYear,
summingDouble(Transaction::getValue))
);
System.out.println("按年份统计营业额:" + valuesByYearMap);
按年份统计营业额:{2011=700.0, 2012=3360.0}
这里使用两个参数的groupby,第2个参数为Collector,嵌入summingDouble在已收集的交易列表List
public static
Collector> groupingBy(Function super T, ? extends K> classifier,
Collector super T, A, D> downstream)
2、复合mapping,用于映射后再收集。
问题:按年份统计交易员。
Map> traderByYearMap = trans.stream().collect(
groupingBy(Transaction::getYear,
mapping(Transaction::getTrader, toSet())));
System.out.println("按照年份统计交易员:" + traderByYearMap);
按照年份统计交易员:{2011=[raoul, brian], 2012=[raoul, alan, mario]}
groupby嵌入mapping,mapping方法第2个参数还是Collector,说明映射之后可以再收集,如:映射交易员后,可以再次收集到Set后返回。
public static
Collector mapping(Function super T, ? extends U> mapper,
Collector super U, A, R> downstream)
3、复合collectingAndThen,用于收集完之后做类型转换。
问题:按照年份统计最大交易额。
Map maxValueByYearMap = trans.stream().collect(
groupingBy(Transaction::getYear,
collectingAndThen(
maxBy(Comparator.comparingDouble(Transaction::getValue)),
t -> t.getValue())));
System.out.println("按照年份统计最大交易额:" + maxValueByYearMap);
按照年份统计最大交易额:{2011=400.0, 2012=1000.0}
groupby嵌入collectingAndThen,collectingAndThen第2个参数是Function,说明了收集后可以执行一步操作,如:选出的最大值是Optional
public static Collector collectingAndThen(Collector downstream,
Function finisher)
4、复合groupby,用于多级分组。
问题:要求同时按照年份和交易员统计交易销售额。
使用SQL:
SELECT year,trader, sum(value) FROM transactions GROUP BY year, trader
使用Stream:
Map> valuesByYearAndTraderMap
= trans.stream().collect(groupingBy(
Transaction::getYear,
groupingBy(
Transaction::getTrader,
summingDouble(Transaction::getValue))));
System.out.println("按年份,交易员统计营业额:" + valuesByYearAndTraderMap);
按年份,交易员统计营业额:{2011={raoul=400.0, brian=300.0}, 2012={raoul=1000.0, alan=950.0, mario=1410.0}}
groupby嵌入groupby,再次分组,Map中还有Map,如:先按照年份分组,如果年份相同,再按照交易员分组。
分区
问题:以2012年为界,选出2012以前和以后(包含2012)的最高交易额。
Map partitioningMap =
trans.stream().collect(
partitioningBy(t -> t.getYear() >= 2012,
collectingAndThen(maxBy(
Comparator.comparingDouble(Transaction::getValue)),
Optional::get)));
System.out.println("2012年前后分区选出最大的交易:"partitioningMap);
2012年前后分区选出最大的交易:{false=Transaction{id=3, trader='raoul', value=400.0, year=2011}, true=Transaction{id=2, trader='raoul', value=1000.0, year=2012}}
partitioningBy分区收集器是一种特殊的分组收集器,区别在于结果Map的key值为Boolean,也就是只能将一个列表分为是或不是两组。
public static
Collector> partitioningBy(Predicate super T> predicate,
Collector super T, A, D> downstream)
七、并行数据处理与性能
终于到并行流,不知道你现在是不是也这么想。
顺序流执行过程
long sum = Arrays.asList(1,2,3,4,5).stream()
.reduce(0, (a, t) -> {
System.out.print(a + "+" + t + "=" + (a + t) + ", ");
return a + t;
});
System.out.println("sum:" + sum);
0+1=1, 1+2=3, 3+3=6, 6+4=10, 10+5=15, sum:15
其中a为累加器,t为流中的元素,顺序流执行过程如下:
并行流执行过程
long sum = Arrays.asList(1,2,3,4,5).stream().parallel()
.reduce(0, (a, t) -> {
System.out.print(a + "+" + t + "=" + (a + t) + ", ");
return a + t;
});
System.out.println("sum:" + sum);
0+2=2, 0+4=4, 0+5=5, 0+1=1, 0+3=3, 1+2=3, 4+5=9, 3+9=12, 3+12=15, sum:15
顺序流调用parallel()就变成了并行流,简洁的背后封装了并行算法,并行流执行过程如下:
(1)首先将流中的元素分成很多数据块,这个过程叫做Fork。
(2)然后用不同线程分别处理每个数据块获取块结果。
(3)最后将所有的块结果合并起来得到最终的结果,这个过程叫做Join。
测试流性能
我们使用了并行流后,性能是否真的变快了,变快了多少?
准备测试框架:
public static void test(Function f, T n, String title) {
new Thread(() -> {
long begin = System.nanoTime();
T t = f.apply(n);
System.out.println("结果:" + t); //输出方法返回,如求和sum
long used = System.nanoTime() - begin;
System.out.println(title + "耗时:" + used + "ns");
}).start();
}
开始测试:
//顺序流求和
public static long sequentialSum(long n) {
long sum = 0;
for (long i = 0; i < n; i++) {
sum += i;
}
return sum;
}
//并行流iterate求和
public static long parallelIterateSum(long n) {
return LongStream.iterate(0, t -> t+1).limit(n)
.parallel()
.reduce(0l, Long::sum);
}
//并行求range求和
public static long parallelRangeSum(long n) {
return LongStream.range(0, n)
.parallel()
.reduce(0l, Long::sum);
}
public static void main(String[] args) {
long n = 100000l;
//ParallelStreamTest是当前类名
test(ParallelStreamTest::sequentialSum, n, "sequentialSum");
test(ParallelStreamTest::parallelIterateSum, n, "parallelIterateSum");
test(ParallelStreamTest::parallelRangeSum, n, "parallelRangeSum");
}
随着n的增大,测试结果有所不同,可以看到两个现象:
(1)无论n如何变化,parallelRangeSum都小于parallelIterateSum耗时。同样使用数值流,都没有装箱拆箱的过程,为什么速度相差很大?
答:这是因为在使用iterator方法生成的流是一个无法确定大小的流spliteratorUnknownSize。分成了8个数据块n[1024]到n[8192]生成,然后再合并在一起。由于不知道limit方法需要截取多少元素,所以只能先尽量多的生成数据。相比起来,使用range或rangeClosed方法则是一次性生成固定数量的元素,自然要快很多。(建议不要在实际项目中生成无限流,如:Stream.iterator,Stream.generate)
(2)只有当计算次数n上亿时,parallelRangeSum才会小于sequentialSum耗时,由此可知并行流大多数情况下都不如顺序流快,如果只做简单运算确实如此,但如果我们把单次计算耗时增加50ms时,会发现n就算只有5,parallelRangeSum也会小于sequentialSum耗时。说明并行流适用于单次计算时间稍长的场景。
正确使用并行流
并行流内部默认使用ForkJoinPool实现,它默认线程数就是处理器的数量,既然是多线程就有线程安全的问题,最常见的就是多线程访问共享变量。
//自定义累加器对象
public static class Accumulator {
private long total = 0; //非线程安全
public void add(long value) {
total += value;
}
}
//错误并行求和
public static long wrongParallelSum(long n) {
Accumulator accumulator = new Accumulator();
LongStream.range(0l, n).parallel().forEach(accumulator::add);
return accumulator.total;
}
public static void main(String[] args) {
long n = 100;
//两次求和结果不同
test(ParallelStreamTest::wrongParallelSum, n, "wrongParallelSum1");
test(ParallelStreamTest::wrongParallelSum, n, "wrongParallelSum2");
}
结果:4939
结果:4837
wrongParallelSum1耗时:8269332ns
wrongParallelSum2耗时:7755148ns
为什么每次并行执行结果都不同?
多线程间共享类的实例变量(private也一样共享),并且+=,++,--等需要多步的操作都不是原子操作,编译时会生成多条字节码(或汇编命令),在多线程情况下,有可能交错执行。导致出现非预期的结果。
如何解决?
(1)加锁synchronized
public synchronized void add(long value) {
total += value;
}
(2)使用原子类AtomicLong
//自定义累加器对象
public static class Accumulator {
private AtomicLong total = new AtomicLong(0);
public void add(long value) {
total.addAndGet(value);
}
public long get() {
return total.get();
}
}
加锁会降低并行流的效率,因此要么使用原子类,要么最好避免修改共享变量。
并行流中的reduce和collect
1、reduce
double s = trans.stream().parallel() //并行流
.map(Transaction::getValue)
.reduce(0.0, Double::sum, (u1, u2) -> u1 + u2); //等同于Double::sum
System.out.println("销售总额:" + s);
reduce归约操作三参方法:
U reduce(U identity,
BiFunction accumulator,
BinaryOperator combiner);
2、collect
List traders = trans.stream().parallel()
.map(Transaction::getTrader)
.collect(ArrayList::new, (li, t) -> li.add(t), (l1, l2) -> {
l1.addAll(l2);
});
System.out.println("交易人员:" + traders);
collect收集操作三参方法:
R collect(Supplier supplier,
BiConsumer accumulator,
BiConsumer combiner);
注意:这两个方法看起来像,实际相差很大,如:
- 第1个参数都是初始值(或容器),区别在于reduce的U identity是一个实际的初始值,而collect的Supplier
supplier则是一个用来获取初始值的函数。 - 第2个参数都是累加器,区别在于reduce中是BiFunction的函数类型是(U u, T t) -> U,而collect中是BiConsumer的函数类型是(R r, T t)-> void。后者没有返回值。
- 第3个参数都是合并函数,区别在于reduce中是BinaryOperator的函数描述符是 (U u1, U u2) -> U,collect中是BiConsumer的函数描述符是 (R r1, R r2) -> void。后者没有返回值。
3、同是累加器,当使用并行流修改共享变量时,会不会也有线程安全问题?
//重写累加器对象
public static class Accumulator {
private long total = 0; //非线程安全
//为了满足recude参数BIFunction,BinaryOperator,添加返回值
public Accumulator add(long value) {
total+=value;
return this;
}
public long get() {
return total;
}
}
//并行流reduce
public static long parallelReduce(long n) {
Accumulator s = LongStream.rangeClosed(1, n).parallel().boxed().reduce(new Accumulator(),
(Accumulator a, Long t) -> {
System.out.println("累加器地址:" + System.identityHashCode(a));
a.add(t);
return a;
},
(Accumulator a1, Accumulator a2) -> {
a1.add(a2.get());
return a1;
});
return s.get();
}
//并行流collect
public static Long parallelCollect(long n) {
Accumulator s = LongStream.rangeClosed(1, n).parallel().boxed()
.collect(Accumulator::new, (Accumulator a, Long t) -> {
System.out.println("累加器地址:" + System.identityHashCode(a));
a.add(t);
},(Accumulator a1, Accumulator a2) -> {
a1.add(a2.get());
});
return s.get();
}
测试代码1:
long n = 100;
test(ParallelStreamTest::parallelReduce, n, "parallelReduce");
累加器地址:1251915859
累加器地址:1251915859
......
累加器地址:1251915859
累加器地址:1251915859
累加器地址:1251915859
结果:55868495446784
parallelReduce耗时:17425825ns
测试代码2:
long n = 100;
test(ParallelStreamTest::parallelCollect, n, "parallelCollect");
累加器地址:1479325164
累加器地址:531367754
......
累加器地址:1596367378
累加器地址:1978686776
累加器地址:1596367378
结果:5050
parallelCollect耗时:12979936ns
从输出结果得出结论:
(1)如果并行流修改的是容器(内部有共享变量),如List,Set,Map等,适合用collect。在同一个数据块内使用同一个累加器(顺序执行),不同的数据块之间会生成新的累加器(并行执行),如此保证线程安全。
(2)如果并行流修改的是值(不可变类型),如String,Integer,Long,Double等,适合用reduce。所有数据块共用一个累加器(返回之后再传入),但由于是不可变类型的值,参数传递会生成新值,也做到相对线程安全。
至此流的高级应用基本讲完,还有关于并行流的实现原理,如:Fork/Join框架以及Spliterator的使用,以及如何利用他们模拟实现一个并行流的内容,放在最后一篇。《经典伴读java8实战一网打尽》
未完待续。