Java 8 概述
Java 8 是 Java 开发语言非常重要的一个版本。Oracle 从 2014 年 3 月 18 日发布 Java 8,从该版本起,Java 开始支持函数式编程。特别是吸收了运行在 JVM 上的 Scala、Groovy 等动态脚本语言的特性之后,Java 8 在语言的表达力、简洁性两个方面有了很大的提高。
Java 8 的主要语言特性改进概括起来包括以下几点:
Lambda 表达 (函数闭包)
函数式接口 (@FunctionalInterface)
Stream API (通过流式调用支持 map、filter 等高阶函数)
方法引用(使用::关键字将函数转化为对象)
默认方法(抽象接口中允许存在 default 修饰的非抽象方法)
类型注解和重复注解
其中 Lambda 表达、函数式接口、方法引用三个特性为 Java 带来了函数式编程的风格;而 Stream 实现了 map、filter、reduce 等常见的高阶函数,数据源囊括了数组、集合、IO 通道等,这些又为 Java 带来了流式编程或者说链式编程的风格,以上这些风格让 Java 变得越来越现代化和易用。
Android 和 Java 关系
其实 Java 在 Android 的快速发展过程中扮演着非常重要的角色,无论是作为开发语言(Java)、开发 Framework(Android-SDK 引用了 80%的 JDK-API),还是开发工具(Eclipse or Android Studio)。这些都和 Java 有着千丝万缕的关系。不过可能是受到与 Oracle 的法律诉讼的影响,Google 在 Android 上针对 Java 的升级一直都不是很积极:
Android 从 1.0 一直升级到 4.4,迭代了将近 19 个 Android 版本,才在 4.4 版本中支持了 Java 7。
然后从 Android 4.4 版本开始算起,一直到 Android N(7.0)共 4 个 Android 版本,才在 Jack/Jill 工具链勉强支持了 Java 8。但由于 Jack/Jill 工具链在构建流程中舍弃了原有 Java 字节码的体系,导致大量既有的技术沉淀无法应用,致使许多 App 工程放弃了接入。
最后直到 Android P(9.0)版本, Google 才在 Android Studio 3.x 中通过新增的 D8 dex 编译器正式支持了 Java 8,但部分 API 并不能全版本支持。
可谓“历经坎坷”。特别是 Rx 大行其道的今天,Rx 配合 Java 8 特性 Lambda 带来简洁、高效的开发体验,更是让 Android Developer 望眼欲穿。
接下来,本文将从技术原理层面,来分析一下 Android 是如何支持 Java 8 的。
Lambda 表达式
想要更好的理解 Android 对 Java 8 的支持过程,Lambda 表达式这一代表性的“语法糖”是一个非常不错的切入点。所以,我们首先需要搞清楚 Lambda 表达式到底是什么?其底层的实现原理又是什么?
Lambda 表达式是 Java 支持函数式编程的基础,也可以称之为闭包。简单来说,就是在 Java 语法层面允许将函数当作方法的参数,函数可以当做对象。任一 Lambda 表达式都有且只有一个函数式接口与之对应,从这个角度来看,也可以说是该函数式接口的实例化。
Lambda 表达式
通用格式:
简单范例:
说明:
Lambda 表达式中 () 对应的是函数式接口-run 方法的参数列表。
Lambda 表达式中 System.out.println(“xixi”) / System.out.println(“haha”),在运行时会是具体的 run 方法的实现。
Lambda 表达式原理
针对实例中的代码,我们来看下编译之后的字节码:
从字节码中我们可以看到:
实例中 Lambda 表达式 1 变成了字节码代码块中 Line 11 的 0: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable。
实例中 Lambda 表达式 2 变成了字节码代码块中 Line 20 的 21: invokedynamic #6, 0 // InvokeDynamic #1:run:()Ljava/lang/Runnable。
可见,Lambda 表达式在虚拟机层面上,是通过一种名为 invokedynamic 字节码指令来实现的。那么 invokedynamic 又是何方神圣呢?
invokedynamic 指令解读
invokedynamic 指令是 Java 7 中新增的字节码调用指令,作为 Java 支持动态类型语言的改进之一,跟 invokevirtual、invokestatic、invokeinterface、invokespecial 四大指令一起构成了虚拟机层面各种 Java 方法的分配调用指令集。区别在于:
后四种指令,在编译期间生成的 class 文件中,通过常量池(Constant Pool)的 MethodRef 常量已经固定了目标方法的符号信息(方法所属者及其类型,方法名字、参数顺序和类型、返回值)。虚拟机使用符号信息能直接解释出具体的方法,直接调用。
而 invokedynamic 指令在编译期间生成的 class 文件中,对应常量池(Constant Pool)的 Invokedynamic_Info 常量存储的符号信息中并没有方法所属者及其类型 ,替代的是 BootstapMethod 信息。在运行时, 通过引导方法 BootstrapMethod 机制动态确定方法的所属者和类型。这一特点也非常契合动态类型语言只有在运行期间才能确定类型的特征。
那么,invokedynamic 如何通过引导方法找到所属者及其类型?我们依然结合前面的 J8Sample 实例:
结合 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.lambda0 这个在编译期间生成的静态私有方法。
至此,上面 7 个步骤就是 Lambda 表达式在 Java 的底层的实现原理。Android 针对这些实现会怎么处理呢?
Android 不能直接支持
回到 Android 系统上,Java-Bytecode(JVM 字节码)是不能直接运行在 Android 系统上的,需要转换成 Android-Bytecode(Dalvik/ART 字节码)。
如图:
通过 Lambda 这节,我们知道 Java 底层是通过 invokedynamic 指令来实现,由于 Dalvik/ART 并没有支持 invokedynamic 指令或者对应的替代功能。简单的来说,就是 Android 的 dex 编译器不支持 invokedynamic 指令,导致 Android 不能直接支持 Java 8。
Android 间接支持
既然不能直接支持,那就只能在 Java-Bytecode 转换到 Android-Bytecode 这一过程中想办法,间接支持。这个间接支持的过程我们统称为 Desugar(脱糖)过程。
官方流程图:
当前,无论是 RetroLambda,还是 Google 的 Jack & Jill 工具,还是最新的 D8 dex 编译器:
流程方面:都是按照如上图所示的官方流程进行 Desugar 的。
原理方面:却是参照 Lambda 在 Java 底层的实现,并将这些实现移至到 RetroLambda 插件或者 Jack、D8 编译器工具中。
下面我们逐个分析解读一下。
Android 间接支持之 RetroLambda
如图所示,RetroLambda 的 Desugar 过程发生在 javac 将源码编译完成之后,dx 工具进行 dex 编译之前。
RetroLambda Desugar
参照 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$1.class 反编译之后的代码如下:
通过反编译代码,可以看出 J8Sample.class 中 Lambda 表达式已经被我们熟悉的 1.7or1.6 的语句所替代。
注意:右图中 J8Sample.lambda0()方法在左图中没有显示出来,但是 J8Sample.class 字节码确实是存在的。
Android 间接支持之 Jack&Jill 工具
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 具体分析了。
Android 间接支持之 D8
D8 是 Android P(9.0)新增的 dex 编译器。并在 Android Studio 3.1 版本中默认使用 D8 作为 dex 的默认编译器。
D8 Desugar
如图所示,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/-$J8SampleJ8Sample1 类型,只不过数字 1 变成了 Hash 值。
实现 Interface Ljava/lang/Runnable。Lcom/j8sample2/-$J8Sample$jWmuYH0zEF070TKXrjBFgnnqOKc.run 方法:
到这里,是不是和前面 RetroLambda 就一样了。
总结
至此,Lambda 及其 invokedynamic 指令、RetroLambda 插件、D8 编译器各自的原理分析都已经结束了。
相比较 Lambda 在 Java8 自己内部的实现:即运行时,在内存中动态生成关联的函数式接口的实例类型,通过 BSM-引导方法找到该内存类(字节码层面的反射)。
在 Android 上的其他三种 Desugar 方式,原理都是一样的,区别在于时机不同:
RetroLambda 将函数式接口对应的实例类型的生产过程,放在 javac 编译之后,dx 编译之前,并动态修改了表达式所属的字节码文件。
Jack&Jill 是直接将接口对应的实例类型,直接 jack 过程中生成,并编译进了 dex 文件。
D8 的过程是在 dex 编译过程中,直接在内存生成接口对应的实例类型,并将生成的类型直接写入生成的 dex 文件中。
探讨
无论是 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 实现的方式,一探究竟。
作者介绍:
元合、朝旭,美团到店事业群前端工程师。
本文转载自公众号美团技术团队(ID:meituantech)。
原文链接:
评论