为什么要支持函数式编程
java在1.8以后支持函数式编程
理由:让代码更简洁、易读、高效
简洁:可以取代部分匿名内部类的创建,利用lambda表达式让代码更简洁
易读:声明式取代命令式
高效:利用Stream流对数据进行高效的处理
函数式接口
很多编程语言都支持函数式编程,但是不同的语言是有一些区别的,Java的函数式编程需要函数式接口的支持。
函数式接口:接口中只有一个抽象方法。通常函数式接口中只定义一个抽象方法,但这不代表函数式接口中只能有一个方法。如下图:
public interface Function<T, R> {
R apply(T t);
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> apply(before.apply(v));
}
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(apply(t));
}
static <T> Function<T, T> identity() {
return t -> t;
}
}
在这个接口中apply()是抽象接口(接口中的抽象方法,关键字abstract可以省略)。但是除了apply()之外还定义了其他方法,这还是函数式接口吗?答案是肯定的,这是因为其他的方法都不是抽象的,如果抽象方法超过一个那他就不是函数式接口。静态方法和默认方法数量不限。
默认方法(java8之后加入的)
加入这一方法的作用就是:指定接口方法的默认实现。
java8之前如果要在一个接口中声明一个方法,需要在所有的子类实现中都显示声明,默认方法解决了这个问题。除了这个好处以外,还能实现c++中的多继承特性。
默认方法实现多继承:
默认方法允许有自己的实现,在Java中是不支持多继承的,但是可以实现多个接口,在接口中的default中实现具体的功能实际上是完成了多继承的功能。
public interface Collection<E> extends Iterable<E> {
default boolean removeIf(Predicate<? super E> filter) {
Objects.requireNonNull(filter);
boolean removed = false;
final Iterator<E> each = iterator();
while (each.hasNext()) {
if (filter.test(each.next())) {
each.remove();
removed = true;
}
}
return removed;
}
}
这个removeIf方法是1.8新加入的,如果之前想在collection中声明一个removeIf方法需要修改所有的实现类。但是现在这个不用这样做了,因为他是默认方法,默认方法子类默认实现,就和无参构造一个道理。而且接口中的默认方法可以有具体的实现,多个接口又同时能被多个类实现,那这不就是多继承吗。
为什么只能声明一个抽象方法
lambda表达式允许你直接以内联的形式为函数式接口的抽象方法提供实现,并把整个表达式作为函数式接口的实例。
通俗解释:lambda表达的就是函数式接口中的抽象方法,如果超过一个抽象声明,lambda会不知道去执行哪个实现。
Lambda表达式:(parameters) -> expression
注意:Lambda表达式就是用在函数式接口上的
Lambda表达式的结构分为:(参见上面的Function接口中的apply)
1.参数列表:抽象函数的参数
2.箭头:lambda的标志,无实际意义
3.Lambda主体:抽象函数的具体实现
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
}
public static <T> List<T> filter(List<T> list, Predicate<T> p) {
List<T> results = new ArrayList<>();
for (T s : list) {
if (p.test(s)) {
results.add(s);
}
}
return results;
}
Predicate<String> nonEmptyStringPredicate = (s) -> s.length() > 10;
List<String> nonEmpty = filter(strList, nonEmptyStringPredicate);
单纯从这个功能上来说,为了从list中找出长度大于10的字符串,代码好像变的更加复杂了,但是如果现在需求加了一个比如说内容不包含’A’的字符串,那是不是需要重新再遍历一次呢,这样就只需要在lambda的expression做修改就行了。
注意:Lambda表达式中的的实际类型是编译器从上下文中推断出来的
方法引用:
根据已有的方法实现来创建Lambda表达式,但是是显示指定方法的名称,这样可以增加可读性。是Lambda的一种语法糖。下表对比两种等效的书写方式。
Lambda | 等效的方法引用 |
---|---|
(Apple a) -> a.getWeight() | Apple::getWeight |
() -> Thread.currentThread().dumpStack() | Thread.currentThread()::dumpStack |
(str, i) -> str.substring(i) | String::substring |
(String s) -> System.out.println(s) | System.out::println |
Lambda延迟执行的特点
由于lambda的执行是在函数式接口被调用时执行,所以在调用这个抽象方法之前,我们可以做其他处理,想处理的时候再调用抽象方法即可。
解释:这个其实很好理解,就是说当代码运行到lambda那一行的时候并没有立即执行那一行的逻辑,lambda真正执行时机是在调用函数式方法的时刻。
Lambda表达式和字节码
两个问题:java编译器是如何实现Lambda表达式的?Java虚拟机又是如何对他们进行处理?
由于lambda表达式提供了函数式接口中的抽象方法的实现,这像是在编译过程中让java编译器直接将Lambda表达式转换为匿名类,实际情况不是这样。
匿名类的编译过程:
1.编译器会为每个匿名类生成一个新的.class文件。这样如果匿名类多了之后会生成大量的类文件,每个类在使用之前都需要加载和验证,会影响应用的启动性能。
2.每个新的匿名类都会为类或者接口产生一个新的子类型。
下面用两种不同的方式实现一个Function接口
public class InnerClass {
//匿名类方式
Function<Object, String> f = new Function<Object, String>() {
@Override
public String apply(Object obj) {
return obj.toString();
}
};
}
//生成的字节码
0: aload_0
1: invokespecial #1
4: aload_0
5: new #2 // new操作实例化InnerClass$1
8: dup
9: aload_0
10: invokespecial #3 // Method InnerClass$1."<init>":(LInnerClass;)V
13: putfield #4
16: return
--------------------------------------------------------------------
public class Lambda {
//用Lambda的方式
Function<Object, String> f = obj -> obj.toString();
}
//生成的字节码
0: aload_0
1: invokespecial #1
4: aload_0
5: invokedynamic #2, 0 // InvokeDynamic
10: putfield #3 // Field f:Ljava/util/function/Function;
13: return
字节码指令说明:
Invokeinterface:调用接口方法
invokeSpecial:调用构造方法
invokeStatic:调用类方法
Invokevirtual:调用实例方法
invokeDynamic:延迟确认具体调用哪个方法,调用决定权交给代码层来实现
区别:invokedynamic 支持动态类型语言,jvm在调用这种方法时不用关心方法在哪个类中,只需要知道参数是什么返回是什么。因为不用去实例化类,所以会减少一些性能的开销。
函数式处理数据的方法-Stream
为什么要用流来处理数据?看一个例子
//统计程序部门中山西来的人数
int count = 0;
for(People people: yyList){
if(people.isFrom("shanxi")){
count++;
}
}
感觉很简洁,但本质是通过迭代遍历每个元素来获取的,而且需要阅读整个循环体才能明白,如果面对的是一个嵌套的集合迭代肯定比这个还要复杂。
这种迭代方式称为外部迭代,这种需要我们手动的对这个集合进行种种操作才能得到想要的结果。
那什么是内部迭代?内部迭代是集合内部通过流进行处理之后,存到某个地方,你只需要声明要干什么就可以了。
这张图说明了内部迭代与外部迭代的区别。
举一个例子,如果你去超市买牙膏,外部迭代就是你把每个商品都看一下,如果是牙膏就装起来,否则放回原处。内部迭代就是你告诉超市你要牙膏,然后超市直接把牙膏都给你。
下面是内部迭代的方式:
//内部迭代的方式
long count = yyList.stream()//进行流操作
.filter(people-> people.isFrom("shanxi"))//选出来自山西的人员
.count();//统计他们的数量
流操作的分类:
中间操作:将一个流转化为另一个流。这让多个操作可以连接起来形成一个查询,在执行终端之前这些流不会被消耗
终端操作:从流的流水线生成结果,其结果是任何不为流的值,比如void,List等。
Stream接口中定义各种中间操作与终端操作,下面列举几个:
中间操作 | 终端操作 |
---|---|
filter | findAny |
map | count |
limit | collect |
流水线:
很多流操作本身会返回一个流,这样多个操作就可以连接起来,形成一个大的流水线,流水线的操作可以看成是进行数据库式的查询
如上图,集合中的元素在流中保存,filter筛选出了一部分符合条件的流数据,然后通过map选取理想的映射,通过limit截取数量,最后通过collect终端操作将流数据转化为集合类型。终端操作执行后流就被永久的消耗掉了。
Stream接口定义的操作流方式
1.筛选filter
2.去重distinc
3.截取limit
4.跳过skip
5.映射map
6.查找findFirst,findAny
7.匹配allMatch,anyMatch
4.归约reduce
这些操作和操作数据库的方式很相似,在这不一一的讨论了,讲一下归约
归约:把一个流中的元素组合起来,使用reduce表达更复杂的查询。将流中所有元素反复结合起来得到一个值。
reduce接受两个参数:
1.一个初始值,这里是0;
2.一个BinaryOperator来将两个元素结合起来产生一个新值,这里用的是lambda.
//假设存放列表的变量名称为numbers
//列表元素求和
int sum = numbers.stream().reduce(0, (a, b) -> a + b);
//列表元素乘积
int product = numbers.stream().reduce(1, (a, b) -> a * b);
//如果换成传统的方式,两种都需要分别遍历。
用规约的方式作用于一个流,Lambda会反复结合每个元素,直到流被规约成一个值。
归约的过程
收集器Collectors:
可以将收集器看做高级规约,比如对数据进行多级分组,指令式的方式对来做这些显得很啰嗦,更加难维护。但是对函数式版本的收集器来很简单。
//按照省份来给程序部门人员分组
Map<String, List<People>> =yyList.stream()
.collect(groupingBy(People::getFromCity));
//解释说明:这里返回的是一个map,键就是分组函数返回的值,值就是流中所有具有这个分类值的项目的列表。
这个收集器类中定义了很多类似的静态工厂方法,比如maxBy计算最大值,counting计算数量等。
总之,几乎一切对于集合的操作都可以通过流处理的方式来替代。
并行使用Stream
java7之前并行处理集合的方法:
1.明确地把包含数据的结构分成若干子部分
2.给每个子部分分配一个独立的线程
3.在恰当的时候对它们进行同步来避免不希望出现的竞争条件
4.等所有线程都完成,最后把这些部分结果合并起来
并行流parallelStream:并行流就是一个把内容分成多个数据块,并用不同的线程分别处理每个数据块的流。这样就可以把工作分配给多核处理器的所有内核。
通过举例的方法说明:
//给定一个参数n,计算从1到n的合
public static long sequentialSum(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);
}
//Stream在内部将其分为几块,然后每块都独立的进行并行的操作,最后再归纳合并得到整个流
线程从何而来?使用了多少个线程?
并行流内部使用了默认的ForkJoinPool,它默许线程的数量就是你处理器的数量,这个值从系统拿到。是可以配置的。
//设置8个线程数
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism","8");
将这几种分别测量一下性能,结果如下
//普通的遍历计算
public static long sum(long n) {
int sum = 0;
for (int i = 1; i <= n; i++) {
sum += i;
}
return sum;
}
//串行流计算
public static long sequentialSum(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);
}
//测试环境:n = 10000000,Intel Core i5-6500U 3.2GHz
//运行结果
串行方式总耗时:230毫秒
并行方式总耗时:2564毫秒
普通遍历方式总耗时:6毫秒
Process finished with exit code 0
这个结果很意外,单纯只看并行和串行执行的结果,并行要慢很多。
这里实际上有两个问题
1.iterate生成的是装箱的对象,必须拆箱才能求和
2.Stream很难把iterate分成多个独立块来并行执行,因为每次应用这个函数都要依赖前一次应用的结果,用并行处理其实是增加了开销,它还要把每次求和的操作分到一个不同的线程
这些说明了并行流如果用的不对只会让整体的程序性能变得更差,比如iterate不容易并行化,所以我们需要进一步的了解
使用更有针对性的方法
1.不进行装箱拆箱的操作,为了解决这个问题Stream提供了对应的方法。对于上面那个问题可以采用LongStream.rangeClosed,这个方法不进行装箱拆箱的步骤,直接操作原始类型,此外它会生成范围数字,这些数字很容易拆分成独立的小块。比如1-20,分为1-5,6-10,11-15,16-20
测试一下:
/串行计算(不装箱拆箱)
public static long rangedSum(long n) {
return LongStream.rangeClosed(1, n)
.reduce(0L, Long::sum);
}
//并行计算(不装箱拆箱)
public static long parallelRangedSum(long n) {
return LongStream.rangeClosed(1, n)
.parallel()
.reduce(0L, Long::sum);
}
/普通的遍历计算
public static long sum(long n) {
int sum = 0;
for (int i = 1; i <= n; i++) {
sum += i;
}
return sum;
}
//运行结果
串行方式总耗时:72毫秒
并行方式总耗时:5毫秒
普通遍历方式总耗时:8毫秒
Process finished with exit code 0
这是一个比正常遍历操作要快的方法,如果处理器更好的话效果比这应该更好。
如何正确使用并行流:避免共享可变状态,确保并行Stream得到正确的结果
如何高效的使用并行流:
1.测试。如果不确定使用并行流还是顺序流就进行测试,看哪种效果更好
2.留意装箱操作。自动装箱拆箱操作会大大降低性能java8以后提供的(IntStream,LongStream,DoubleStream)可以解决这个问题。
3.有些操作本身在并行流上的性能比顺序流要差。比如limit,findFirst等依赖元素顺序的操作
4.较小的数据量一般不适合用并行流,因为并行化本身就会造成一定的开销
5.考虑流背后的数据结构是否易于分解。比如ArrayList的拆分效率比LinkedList高很多。
上面只分析了迭代的效率对比,想要对比其他的效率,测试方式一样。下面是网上一些对于这几种方式性能的对比图
映射处理测试结果:
过滤处理测试结果:
自然排序测试结果:
归约统计测试结果:
字符串拼接测试结果:
混合操作测试结果:
结论:
1.简单的迭代逻辑,可以直接使用 iterator。对于有多步处理的迭代逻辑,可以使用 stream,损失一点几乎没有的效率,换来代码的高可读性是值得的;
2.数据量小的情况下,性能差别不大。所以不管数据量大小,在流处理更高效的情况下坚决使用流处理可以让增加代码的灵活性和刻度性。
参考:
《java8实战》