本文主要阐述了Lambda表达式及其底层实现(invokedynamic指令)的原理、Android第三方插件RetroLambda对其的支持过程、Android官方最新的dex编译器D8对其的编译支持。通过对这三个方面的跟踪分析,以Java 8的代表性特性——Lambda表达式为着眼点,将Android如何兼容Java8的过程分享给大家。
Java 8是Java开发语言非常重要的一个版本。Oracle从2014年3月18日发布Java 8,从该版本起,Java开始支持函数式编程。特别是吸收了运行在JVM上的Scala、Groovy等动态脚本语言的特性之后,Java 8在语言的表达力、简洁性两个方面有了很大的提高。
Java 8的主要语言特性改进概括起来包括以下几点:
其中Lambda表达、函数式接口、方法引用三个特性为Java带来了函数式编程的风格;而Stream实现了map、filter、reduce等常见的高阶函数,数据源囊括了数组、集合、IO通道等,这些又为Java带来了流式编程或者说链式编程的风格,以上这些风格让Java变得越来越现代化和易用。
其实Java在Android的快速发展过程中扮演着非常重要的角色,无论是作为开发语言(Java)、开发Framework(Android-SDK引用了80%的JDK-API),还是开发工具(Eclipse or Android Studio)。这些都和Java有着千丝万缕的关系。不过可能是受到与Oracle的法律诉讼的影响,Google在Android上针对Java的升级一直都不是很积极:
可谓”历经坎坷“。特别是Rx大行其道的今天,Rx配合Java 8特性Lambda带来简洁、高效的开发体验,更是让Android Developer望眼欲穿。
接下来,本文将从技术原理层面,来分析一下Android是如何支持Java 8的。
想要更好的理解Android对Java 8的支持过程,Lambda表达式这一代表性的”语法糖“是一个非常不错的切入点。所以,我们首先需要搞清楚Lambda表达式到底是什么?其底层的实现原理又是什么?
Lambda表达式是Java支持函数式编程的基础,也可以称之为闭包。简单来说,就是在Java语法层面允许将函数当作方法的参数,函数可以当做对象。任一Lambda表达式都有且只有一个函数式接口与之对应,从这个角度来看,也可以说是该函数式接口的实例化。
通用格式:
简单范例:
说明:
针对实例中的代码,我们来看下编译之后的字节码:
javac J8Sample.java -> J8Sample.class javap -c -p J8Sample.class
从字节码中我们可以看到:
可见,Lambda表达式在虚拟机层面上,是通过一种名为invokedynamic字节码指令来实现的。那么invokedynamic又是何方神圣呢?
invokedynamic指令是Java 7中新增的字节码调用指令,作为Java支持动态类型语言的改进之一,跟invokevirtual、invokestatic、invokeinterface、invokespecial四大指令一起构成了虚拟机层面各种Java方法的分配调用指令集。区别在于:
那么,invokedynamic如何通过引导方法找到所属者及其类型?我们依然结合前面的J8Sample实例:
javap -v J8Sample.class
结合J8Sample.class字节码,并对invokedynamic指令调用过程进行跟踪分析。总结如下:
依据上图invokedynamic调用步骤,我们一步一步做一个分析讲解。
步骤1 选取J8Sample.java源码中Lambda表达式1:
Runnable runnable = () -> System.out.println("xixi"); // lambda表达式1
步骤2 通过 javac J8Sample.java
编译得到 J8Sample.class
之后,
Lambda表达式1变成:0: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
对应在 J8Sample.class
中发现了新增的私有静态方法:
步骤3 针对表达式1的字节码分析 #2 对应的是class文件中的常量池:
#2 = InvokeDynamic #0:#35 // #0:run:()Ljava/lang/Runnable;
注意,这里InvokeDynamic不是指令,代表的是 Constant_InvokeDynamic_Info
结构。
步骤4 结构后面紧跟的 #0 标识的是class文件中的BootstrapMethod区域中引导方法的索引:
步骤5 引导方法中的 java/lang/invoke/LambdaMetafactory.metafactory才是invokedynamic指令的关键:
该方法会在运行时,在内存中动态生成一个实现Lambda表达式对应函数式接口的实例类型,并在接口的实现方法中调用步骤2中新增的静态私有方法。
步骤6 使用 java -Djdk.internal.lambda.dumpProxyClasses J8Sample.class
运行一下,可以内存中动态生成的类型输出到本地:
步骤7 通过 javap -p -c J8Sample/$/$Lambda/$1.class
反编译一下,可以看到生成类的实现:
在run方法中使用了invokestatic指令,直接调用了 J8Sample.lambda$main$0
这个在编译期间生成的静态私有方法。
至此,上面7个步骤就是Lambda表达式在Java的底层的实现原理。Android 针对这些实现会怎么处理呢?
回到Android系统上,Java-Bytecode(JVM字节码)是不能直接运行在Android系统上的,需要转换成Android-Bytecode(Dalvik/ART 字节码)。
如图:
通过Lambda这节,我们知道Java底层是通过invokedynamic指令来实现,由于Dalvik/ART并没有支持invokedynamic指令或者对应的替代功能。简单的来说,就是Android的dex编译器不支持invokedynamic指令,导致Android不能直接支持Java 8。
既然不能直接支持,那就只能在Java-Bytecode转换到Android-Bytecode这一过程中想办法,间接支持。这个间接支持的过程我们统称为Desugar(脱糖)过程。
官方流程图:
当前,无论是RetroLambda,还是Google的Jack & Jill 工具,还是最新的D8 dex编译器:
下面我们逐个分析解读一下。
如图所示,RetroLambda 的Desugar过程发生在javac将源码编译完成之后,dx工具进行dex编译之前。
其实就是参照invokedynamic指令解读一节中的步骤5,根据 java/lang/invoke/LambdaMetafactory.metafactory
方法,直接将原本在运行时生成在内存中的 J8Sample/$/$Lambda/$1.class
,在javac编译结束之后,dx编译dex之前,直接生成到本地,并使用生成的 J8Sample/$/$Lambda/$1
类修改 J8Sample.class
字节码文件,将 J8Sample.class
中的invokedynamic指令替换成invokestatic指令。
将实例中的J8Sample.java放到一个配置了Retrolambda的Android工程中:
AndroidStudio -> Build -> make project 编译之后:
app:transformClassesWithRetrolambdaForDebug
任务发生在 app:compileDebugJavaWithJavac
(对应javac)之后,和 app:transformDexArchiveWithDexMergerForDebug
(对应dx)之前,同时在 build/intermediates/transforms/retrolambda
下面生产如图所示的class文件。
J8Sample.class和J8Sample$$Lambda$1.class反编译之后的代码如下:
通过反编译代码,可以看出J8Sample.class中Lambda表达式已经被我们熟悉的1.7or1.6的语句所替代。
注意:右图中 J8Sample.lambda$main$0()
方法在左图中没有显示出来,但是J8Sample.class字节码确实是存在的。
Jack是基于Eclipse的ecj编译开发的, Jill是基于ASM4开发的。Jack&Jill工具链是Google在Android N(7.0)发布的,用于替换javac&dx的工具链,并且在jack过程内置了Desugar过程。
但是在Android P(9.0) 的时候将Jack&Jill工具链废弃了,被 javac&D8工具链替代了。这里就不做Desugar具体分析了。
D8是Android P(9.0)新增的dex编译器。并在Android Studio 3.1版本中默认使用D8作为dex的默认编译器。
如图所示,Desugar过程放在了D8的内部,由Android Studio这个IDE来实现这个转换,原理基本和RetroLambda是一样。
本质上也是参照 java/lang/invoke/LambdaMetafactory.metafactory
方法直接将原本在运行时生成在内存中的 J8Sample/$/$Lambda/$1.class
,在D8的编译dex期间,直接生成并写入到dex文件中。
同样,将实例中的J8Sample.java放到支持D8的Android工程中:
同样,AndroidStudio -> Build -> make project编译之后:
javac编译之后的 J8Sample.class
还是使用invokedynamic指令,即这一步并没有Desugar:
在 app:transformDexArchiveWithDexMergerForDebug
(对应dx)任务之后,再对应 build/intermediates/transforms/dexMerger
目录找第0个classex.dex。
执行 $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex >> dexInfo.txt
拿到dex信息。
还是选取实例中Lambda表达式1 : Runnable runnable = () -> System.out.println("xixi");
来进行分析。
这个dexIno.txt文件非常大,有1.4M,我们通过 com.J8Smaple2.J8Sample
找到我们J8Sample在dex中位置。
新增方法:
J8Sample.main方法:
图中选中部分,对应就是Lambda表达式1 desugar之后的内容。
翻译成Java的话就变成了: new Lcom/j8sample2/-$$Lambda$J8Sample$jWmuYH0zEF070TKXrjBFgnnqOKc
这个生成类的一个对象。
类 Lcom/j8sample2/-$$Lambda$J8Sample$jWmuYH0zEF070TKXrjBFgnnqOKc
对应前面的生成的 J8Sample$$Lambda$1
类型,只不过数字1变成了Hash值。
实现Interface Ljava/lang/Runnable。 Lcom/j8sample2/-$$Lambda$J8Sample$jWmuYH0zEF070TKXrjBFgnnqOKc.run
方法:
到这里,是不是和前面RetroLambda就一样了。
至此,Lambda及其invokedynamic指令、RetroLambda插件、D8编译器各自的原理分析都已经结束了。
相比较Lambda在Java8自己内部的实现:即运行时,在内存中动态生成关联的函数式接口的实例类型,通过BSM-引导方法找到该内存类(字节码层面的反射)。
在Android上的其他三种Desugar方式,原理都是一样的,区别在于时机不同:
无论是RetroLambda,还是D8,对Java8的特性也不是全都支持。
Java8新增的许多API(例如:新的DataAPI),就D8编译器而言,只有在Android P(9.0)版本中能直接运行。低于9.0就不行了。如何能够全版本支持Java 8。D8还有很长的一段路要走。
如果我们在低版本需要使用新的API,目前可以采取将这些API打包进去的临时办法。
写到这里,肯定有人要提出,为什么不直接使用Kotlin呢?确实Kotlin对Lambda表达式、函数引用等特性都做了很好的支持,但是现实的情况中,Kotlin很难取代Android中的Java。新业务、新工程还相对容易,对老业务来说,尤其是经过多年沉淀,工程结构复杂,迁移改造带来的收益,往往远远小于迁移改造带来的成本和不可控之风险。Kotlin和Java同时存在的情况,长期来看是一个必然的结果。
至于Java 8的其他特性呢,D8是如何实现的,也可以按照上面类似的方式去分析,甚至可以结合Kotlin实现的方式,一探究竟。