前言
JDK1.8是自JDK1.5以来最具革命性的版本。JDK1.8为Java语言、编译器、类库、开发工具与JVM(Java虚拟机)带来了大量新特性。所做的改变,在许多方面比Java历史上任何一次改变都深远。它们会让你编起程来更容易。本文主要介绍JDK1.8最重要的新特性之一:Lambda表达式。
ps:博主的公司也切换为JDK1.8了,CTO让我进行一次JDK1.8的分享,本文也是记录一下。
什么是Lambda表达式
Lambda表达式可以理解为简洁地表示可传递的匿名函数的一种方式。
是不是很抽象的一句话?那让我们看一下它长什么样。
1 | (String s) -> s. isEmpty() |
什么鬼,长得也这么抽象。
我们看一下表达式结构中最特别的是->
,而这个箭头的左边是Lambda的参数列表,右边是Lambda的函数主体。所以Lambda表达式的结构表示为:(parameters) -> expression
或 (parameters) ->{ statements; }
。
如下:
1 | Comparator<String> sort = (String s1, String s2) -> (s1.compareTo(s2)); |
这样看来是不是有所明白了?
如何使用Lambda表达式
我们使用以前的方法创建一个新的线程,代码如下:
1
2
3
4
5
6Thread thread = new Thread(new Runnable() {
public void run() {
System.out.println("匿名内部类实现Runnable");
}
});接下来使用Lambda表达式创建一个新的线程,代码如下:
1
Thread thread = new Thread(() -> System.out.println("Lambda实现Runnable" ));
通过上面代码的对比,我们看到Lambda表达式实现了Runnable
接口。
那所有的接口都可以用Lambda表达式来实现吗?
答案当然是不行的,只有函数式接口才能使用Lambda表达式。什么是函数式接口?让我们继续看下一节。
函数式接口
所谓的函数式接口,就是接口中只定义了一个抽象方法(SAM:Single Abstract Method)。Java8接口中的默认方法和静态方法,都不算是抽象方法。接口默认继承Object
,所以如果接口显示声明覆盖了Object
中方法,那么也不算抽象方法。
在Java8中,使用@FunctionalInterface
标记一个函数式接口 。该注解不是必须的,如果一个接口符合函数式接口的定义,那么加上该注解能够更好地让编译器进行检查。如果不是函数式接口,但是加上了@FunctionalInterface
,那么编译器会报错。
Java8中新增的函数式接口
Predicate<T>
Consumer<T>
Supplier<T>
Function<T,R>
BinaryOperator<T>
UnaryOperator<T>
- 还有很多是通过上面六个衍化出来的,具体可以查看JDK1.8的API
类型推断
这一节,我们说一下类型推断~
看一下下面的代码:
1 | Callable<String> c = () -> "done"; |
同一个Lambda表达式() -> "done"
,但是表示了不同的类型。
当且仅当下面所有条件均满足时,Lambda 表达式才可以被赋给目标类型 T:
- T 是一个函数式接口
- Lambda 表达式的参数和 T 的方法参数在数量和类型上一一对应
- Lambda 表达式的返回值和 T 的方法返回值相兼容(Compatible)
- Lambda 表达式内所抛出的异常和 T 的方法 throws 类型相兼容
而类型推断可以通过如下的规则进行推断:
- 变量声明
- 赋值
- 返回语句
- 数组初始化器
- 方法和构造方法的参数
- Lambda 表达式函数体
- 条件表达式(? :)
- 转型(Cast)表达式
特殊的void兼容规则:如果一个Lambda的主体是一个语句表达式,它就和一个返回void的函数描述符兼容(当然需要参数列表也兼容)。例如,以下两行都是合法的:
1 | Predicate<String> p = list::add; |
默认方法和静态方法
上一节说到,接口的默认方法和静态方法并不是抽象方法。这好像跟我们平时认识的接口,有所不同,接口里面的方法都是没有具体实现的。怎么还会有不是抽象方法的方法?
默认方法
我们先看一下默认方法,代码如下:
1 | default Stream<E> stream() { |
在接口的方法名前面加个default
关键字即可实现默认方法 。
ps:default
关键字,博主之前还是在用switch
的时候用到了。
引入默认方法最主要的作用是:为了解决接口的修改与现有的实现不兼容的问题,不需要逐个修改实现类。因为实现类默认携带接口的默认方法。
说到这里,有细心的读者就会想到:Java语言中一个类只能继承一个父类,但是一个类可以实现多个接口。随着默认方法在Java 8中引入,有可能出现一个类继承了多个方法而它们使用的却是同样的函数签名。这种情况下,类会选择使用哪一个函数?
解决该问题的规则如下:
- 类中的方法优先级最高。 类或父类中显式声明的方法,其优先级高于所有的默认方法
- 如果无法依据第一条进行判断,那么子接口的优先级更高
- 如果2规则也失效,则需要显式指定接口,
X.super.m(…)
静态方法
Java 8 的另一个特性是接口可以声明(并且可以提供实现)静态方法 ,代码如下:
1 | public static <T, U extends Comparable<? super U>> Comparator<T> comparing( |
Java8之后的接口和抽象类对比
从上面可以看到Java8之后,抽象类和接口越来越接近了,是接口向抽象类靠近,剥夺抽象类的生存空间。
- 与抽象类相比,接口不能搞定的:
- 抽象类能够定义非 static final 的属性(field) ,而接口不能。接口的属性都是static final的。
- 抽象类能够定义非public方法,而接口不能。接口的方法都是public的。
- 与接口相比,抽象类不能搞定的:
- 接口可以多继承(实现),而抽象类不能。抽象类只能单继承。
方法引用
类型推断这一节的最后,我们看到了Lambda表达式的结构变了。它的->
不见了,取而代之的是::
,::
左右两边也不是参数列表和方法主体,其实这是用了方法引用。
什么是方法引用?
方法引用简单地说,就是一个Lambda表达式 。方法引用的标准形式是:
类名::方法名
。为什么会有方法引用?
因为,我们有时使用Lambda表达式可能仅仅调用一个已存在的方法,而不做任何其它事。对于这种情况,通过一个方法名字来引用这个已存在的方法会更加清晰。
怎么使用方法引用?
首先,我们定义一个list:
1
List<String> list = Arrays.asList("c", "A", "C");
然后使用Lambda表达式进行排序:
1
list.sort((s1, s2) -> s1.compareTo(s2));
这里的
compareTo
方法是已存在的方法,所以我们可以用方法引用代替它:1
list.sort(String::compareTo);
是不是看上去很简洁?
下面说一下方法引用的种类:
方法引用种类 | 示例 |
---|---|
静态方法引用:ClassName::methodName | IntBinaryOperator staticMethod = Integer::sum; |
实例上的实例方法引用:instanceReference::methodName | Predicate |
类型上的实例方法引用:ClassName::methodNam | Function<String, String> function = String::toString; |
构造方法引用:Class::new | Supplier
|
变量捕获
这一节,我们说一下初学者比较常见的错误。代码如下:
1 | List<Integer> list = new ArrayList<>(); |
可能有一部分初学者会用这样的代码进行累加,但是这段代码是报编译错误的。
这是什么原因呐?
由于Java只允许在其中捕获那些符合有效只读(Effectively final)的局部变量。
有效只读是指:如果一个局部变量在初始化后从未被修改过,那么它就符合有效只读的要求,换句话说,加上 final后也不会导致编译错误的局部变量就是有效只读变量。
序列化Lambda表达式
我们看一下序列化的接口,代码如下:
1 | public interface Serializable { |
序列化接口被称为ZAM(Zero Abstract Method),即该接口中没有声明任何方法 。Serializable接口一般认为是标记性的接口。
为了能序列化,java8引入了所谓的类型关联(TypeIntersection)。
序列化Lambda表达式的具体代码如下:
1 | Runnable r1 = () -> System.out.println(this); |
Lambda实现原理
最后,我们来看一下Lambda表达式的实现原理。
由于Lambda表达式提供了函数式接口中抽象方法的实现,这让人有一种感觉,似乎在编译过程中让Java编译器直接将Lambda表达式转换为匿名类更直观。那jvm是不是直接把Lambda编译成匿名内部类?
并不是,匿名类有着种种不尽如人意的特性,会对应用程序的性能带来负面影响:
- 编译器会为每个匿名类生成一个新的.class文件。这些新生成的类文件的文件名通常以 ClassName$1这种形式呈现,其中ClassName是匿名类出现的类的名字,紧跟着一个美元符号和一个数字。生成大量的类文件是不利的,因为每个类文件在使用之前都需要加载和验证,这会直接影响应用的启动性能。如果将Lambda表达式转换为匿名类,每个 Lambda表达式都会产生一个新的类文件,这是我们不期望发生的。
- 每个新的匿名类都会为类或者接口产生一个新的子类型。如果你为了实现一个比较器,使用了一百多个不同的Lambda表达式,这意味着该比较器会有一百多个不同的子类型。 这种情况下,JVM的运行时性能调优会变得更加困难。
我们看一下下面这段代码:
1 | Function<Object, String> f = new Function<Object, String>() { |
这段代码的对应的字节码:
- 可以看到,通过字节码操作new,一个TestFunction$1类型的对象被实例化了。与此同时,一个指向新创建对象的引用会被压入栈。
- dup操作会复制栈上的引用。
- 接着这个值会被invokespecial指令处理,该指令会初始化对象。
- 栈顶现在包含了指向对象的引用,该值通过putfield指令保存到了LambdaBytecode类 的f1字段。
我们将这段代码使用Lambda表达式实现:
1 | Function<Object, String> f = Object::toString; |
对应的字节码:
通过两段字节码的对比,我们可以看到,Lambda表达式是通过invokedynamic
字节码指令创建额外的类。
invokedynamic
最初被JDK7引入,用于支持运行于JVM上的动态类型语言。执行方法调用时,invokedynamic添加了更高层的抽象,使得一部分逻辑可以依据动态语言的特征来决定调用目标。
使用invokedynamic
,可以将实现Lambda表达式的这部分代码的字节码生成 推迟到运行时。这种设计选择带来了一系列好结果 :
- Lambda表达式的代码块到字节码的转换由高层的策略变成了纯粹的实现细节。它现在可以动态地改变,或者在未来版本中得到优化和修改,并且保持了字节码的后向兼容性。
- 没有带来额外的开销,没有额外的字段,也不需要进行静态初始化,而这些如果不使用Lambda,就不会实现。
- 对无状态非捕获型Lambda,我们可以创建一个Lambda对象的实例,对其进行缓存,之后对同一对象的访问都返回同样的内容。
- 没有额外的性能开销,因为这些转换都是必须的,并且结果也进行了链接,仅在Lambda 首次被调用时需要转换。其后所有的调用都能直接跳过这一步,直接调用之前链接的实现。
总结
Lambda表达式,语法简单,有更灵活的语义。它是Java8最重要的新特新之一,大家可以多尝试一下,毕竟Java8已经发布四年多了。不要落后咯~
欢迎关注博主其他的文章。
参考资料
《Java8 实战》