写点什么

[译] Android 的 Java 8 支持

用户头像
Antway
关注
发布于: 2021 年 06 月 14 日

本文原文出自 jakewharton 关于 D8R8 系列文章第一篇。



我在家办公已经有几年了,在此期间,我听到周围的人抱怨 AndroidJava 不同版本的支持力度。在每年的 Google I/O 大会上,你都会发现我针对这个问题在 fireside chats 环节提问或直接问负责人。但是这是一个复杂的话题,因为讨论 AndroidJava 能支持到什么程度我们也不清楚,每一个 Java 版本中涉及到:语言特性(the language features)、字节码(the bytecode)、工具(the tools)、APIsJVM 以及其它方面。


当人们谈论起 AndroidJava 8 的支持通常指的是语言特性,所以接下来让我们一起开始看看 Android 的工具链是如何处理支持 Java 8 语言特性的。

1. Lambda

Java 8 中最大的语言特性变动是增加了 Lambda,相比以前使用更冗长的构造(如匿名类),lambda 带来了一个更简洁的代码格式。


class Java8 {  interface Logger {    void log(String s);  }
public static void main(String... args) { sayHi(s -> System.out.println(s)); }
private static void sayHi(Logger logger) { logger.log("Hello!"); }}
复制代码


通过 javac 指令编译为字节码后,然后通过 dx 工具编译打包为 dex 文件,但是出错了。


$ javac *.java
$ lsJava8.java Java8.class Java8$Logger.class
$ $ANDROID_HOME/build-tools/28.0.2/dx --dex --output . *.classUncaught translation error: com.android.dx.cf.code.SimException: ERROR in Java8.main:([Ljava/lang/String;)V: invalid opcode ba - invokedynamic requires --min-sdk-version >= 26 (currently 13)1 error; aborting
复制代码


这是因为 lambda 使用了 invokedynamic 字节码指令,invokedynamic 是在 Java 7 中引入的。上面的错误信息提示,Android 支持这种字节码的最低版本是 26。与此同时 Android 使用 desugaring(脱糖)兼容所有 API 版本上使用 lambda 表达式。

2. Desugaring(脱糖)的历史

脱糖工具的发展史非常出彩,但是它的核心目标却是一致的:让所有的 Java 语言新特性都能运行在所有设备上。


Retrolambda 是最初支持 lambda 表达式的第三方工具库,它通过在编译时利用 JVM 指令将 lambda 转换为内部类来实现。然而生成的类会使方法数激增,但是随着时间的推移,使用该工具的成本降低到了合理的水平。


然后,Android 工具团队宣布了一个新的编译器,它将提供 Java 8 语言特性的支持,以及更好的性能。该工具是建立在 Eclipse Java 编译器上的,而不是 Dalvik Java 字节码之上的。虽然处理 Java 8 效率很高,但是它的体验很差以及无法与别的工具兼容。


最终新的编译器被舍弃,同时在 Android Gradle plugin 中引入了谷歌定制的字节码构建系统,因为脱糖是增量式的,所以脱糖的输出效率仍然不是很理想,与此同时,正在进行的工作有了更好的方案。


D8 编译工具问世了。D8 编译工具用来替代老的 dx 工具,同时在 D8 中集成了脱糖,以此取代脱糖作为一个独立的字节码转换模块的方式。D8 相比较 dx 有很大的提升,带来了更有效率的字节码转换。同时在 Android Gradle Plugin 3.1 中作为默认 dex 编译器,然后在 3.2 版本中 D8 又集成了脱糖。

3. D8

通过 D8 工具编译上面的例子成功了。


$ java -jar d8.jar \    --lib $ANDROID_HOME/platforms/android-28/android.jar \    --release \    --output . \    *.class
$ lsJava8.java Java8.class Java8$Logger.class classes.dex
复制代码


同时我们可以通过 Android 提供的 dexdump 工具来查看 dex 文件内容,看看 D8 是如何脱糖的,由于 dexdump 会产生很多代码,我们只截取一部分。


$ $ANDROID_HOME/build-tools/28.0.2/dexdump -d classes.dex[0002d8] Java8.main:([Ljava/lang/String;)V0000: sget-object v0, LJava8$1;.INSTANCE:LJava8$1;0002: invoke-static {v0}, LJava8;.sayHi:(LJava8$Logger;)V0005: return-void
[0002a8] Java8.sayHi:(LJava8$Logger;)V0000: const-string v0, "Hello"0002: invoke-interface {v1, v0}, LJava8$Logger;.log:(Ljava/lang/String;)V0005: return-void
复制代码


main 方法中,对应 0000 位置创建了一个 Java8$1 类对象 INSTANCE 实例,但是我们的源文件中并不包含这个类,所以猜测这个类是由脱糖产生的。同时 main 方法的字节码中也没有包含任何 lambda 的实现,所以很可能是在 Java8$1 中实现的。在 0002 位置,INSTANCE 调用了 sayHi 方法,同时可以看到 sayHi 方法的参数是 LJava8$Logger ,所以基本可以确定 Java8$1 类实现了 lambda 中的接口。我们可以输出字节码进行验证。


Class #2            -  Class descriptor  : 'LJava8$1;'  Access flags      : 0x1011 (PUBLIC FINAL SYNTHETIC)  Superclass        : 'Ljava/lang/Object;'  Interfaces        -    #0              : 'LJava8$Logger;'
复制代码


SYNTHETIC 字节码标签代表着这个类是由系统产生,通过 Interfaces 可以看到 LJava8$1 类实现了 LJava8$Logger 接口。


现在 LJava8$1 的实现已经替代了 lambda,我们可以通过查看 sayHi 方法的字节码实现。


[00026c] Java8$1.log:(Ljava/lang/String;)V0000: invoke-static {v1}, LJava8;.lambda$main$0:(Ljava/lang/String;)V0003: return-void
复制代码


sayHi 的字节码实现中,它调用了 Java8 类中的静态方法 lambda$main$0,但是我们并没有在类中定义这个方法,所以我们只能查看下 Java8 类对应的字节码。


    #1              : (in LJava8;)      name          : 'lambda$main$0'      type          : '(Ljava/lang/String;)V'      access        : 0x1008 (STATIC SYNTHETIC)[0002a0] Java8.lambda$main$0:(Ljava/lang/String;)V0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;0002: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V0005: return-void
复制代码


在这里我们通过 SYNTHETIC 标签可以确定 lambda$main$0 方法是由系统自动生成的,并且看到了 lambda 实现的方法体 System.out.println


通过上面的流程分析,我们可以推测出:lambda 的实现保持在原来的主类中,并且是私有的,别的类无法直接访问。

4. Source Transformation(源码模拟实现)

为了更好的理解脱糖是如何工作的,我们可以在源码的层面模拟实现,注意这里的模拟仅仅是为了加深理解,脱糖实际工作比这个要复杂的多。


class Java8 {  interface Logger {    void log(String s);  }
public static void main(String... args) { sayHi(s -> System.out.println(s)); }
private static void sayHi(Logger logger) { logger.log("Hello!"); }}
复制代码


第一步lambda 表达式移到同级的包私有方法。


   public static void main(String... args) {-    sayHi(s -> System.out.println(s));+    sayHi(s -> lambda$main$0(s));   }++  static void lambda$main$0(String s) {+    System.out.println(s);+  }
复制代码


第二步生成一个内部类实现 Logger 接口,并且它的方法体调用刚才实现的 lambda 方法。


   public static void main(String... args) {-    sayHi(s -> lambda$main$0(s));+    sayHi(new Java8$1());   }@@ }++class Java8$1 implements Java8.Logger {+  @Override public void log(String s) {+    Java8.lambda$main$0(s);+  }+}
复制代码


最后,因为 lambda 方法并没有依赖外部的任何类,所以我们在 Java8$1 内部创建一个单例对象来避免每次调用 lambda 方法都生成一个新对象。


   public static void main(String... args) {-    sayHi(new Java8$1());+    sayHi(Java8$1.INSTANCE);   }@@ class Java8$1 implements Java8.Logger {+  static final Java8$1 INSTANCE = new Java8$1();+   @Override public void log(String s) {
复制代码


最终我们经过脱糖生成的文件适用与所有 APIs


class Java8 {  interface Logger {    void log(String s);  }
public static void main(String... args) { sayHi(Java8$1.INSTANCE); }
static void lambda$main$0(String s) { System.out.println(s); }
private static void sayHi(Logger logger) { logger.log("Hello!"); }}
class Java8$1 implements Java8.Logger { static final Java8$1 INSTANCE = new Java8$1();
@Override public void log(String s) { Java8.lambda$main$0(s); }}
复制代码


实际上你在查看 lambda 表达式生成的 Dalvik 字节码时可能看到不是类似 Java8$1 的名称,而是像这样的 -$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY 名称,这是由于命名规范不恰当引起的。

5. Native Lambdas

在上面我们通过 dx 工具编译 dex 文件时,错误信息提示我们最低的支持版本是 API 26。


$ $ANDROID_HOME/build-tools/28.0.2/dx --dex --output . *.classUncaught translation error: com.android.dx.cf.code.SimException:  ERROR in Java8.main:([Ljava/lang/String;)V:    invalid opcode ba - invokedynamic requires --min-sdk-version >= 26    (currently 13)1 error; aborting
复制代码


所以如果我们在使用 D8 的时候指定 --min-api 26 版本,应该就不会报错了。


$ java -jar d8.jar \    --lib $ANDROID_HOME/platforms/android-28/android.jar \    --release \    --min-api 26 \    --output . \    *.class
复制代码


同样为了查看 D8 如何工作,我们还是查看 Java8 类的字节码。


$ javap -v Java8.classclass Java8 {  public static void main(java.lang.String...);    Code:       0: invokedynamic #2, 0   // InvokeDynamic #0:log:()LJava8$Logger;       5: invokestatic  #3      // Method sayHi:(LJava8$Logger;)V       8: return}
复制代码


为了阅读方便我只截取了部分代码,但是我们同样可以在 main 方法中看到这里使用了 InvokeDynamic 指令,在 Code 表的 0 位置上,我们可以看到第二个参数是 0,对应着 bootstrap method(引导方法)bootstrap method(引导方法)是当字节码第一次执行时首先被执行的一小段代码。


BootstrapMethods:  0: #27 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(                        Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;                        Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;                        Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)                        Ljava/lang/invoke/CallSite;    Method arguments:      #28 (Ljava/lang/String;)V      #29 invokestatic Java8.lambda$main$0:(Ljava/lang/String;)V      #28 (Ljava/lang/String;)V
复制代码


在上面的代码中,bootstrap method(引导方法)对应的是 java.lang.invoke.LambdaMetafactory 类中的 metafactory 方法。LambdaMetafactory 类在运行时为 lambda 表达式生成匿名类,而 D8 是在编译时生成。


如果我们查看 Android documentation for java.lang.invoke AOSP source code for java.lang.invoke 的文档,我们可以注意到这个类在 Android Runtime 中不存在,这也是为什么脱糖在编译时要求最小版本的原因。VM 环境支持 invokedynamic 指令,但是 JDK 在编译 LambdaMetafactory 中却不可用。

6. Method References(方法引用)

除了 lambda 表达式,方法引用也是 Java 8 的语言特性,当 lambda 的实现是一个已经存在的方法,此时使用方法引用会很方便。


   public static void main(String... args) {-    sayHi(s -> System.out.println(s));+    sayHi(System.out::println);   }
复制代码


这与 javacdexesD8 的编译是相同的,与 lambda 版本有一个显著的区别。在编译为 dalvik 字节码时,生成的 lambda 类的主体已更改。


[000268] -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM.log:(Ljava/lang/String;)V0000: iget-object v0, v1, L-$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM;.f$0:Ljava/io/PrintStream;0002: invoke-virtual {v0, v2}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V0005: return-void
复制代码


不是通过生成 Java8.lambda$main$0 方法然后调用 System.out.println 的方式实现,而是直接调用 System.out.println 方法。lambda 表达式调用类也不是一个静态单例,而是直接使用 PrintStream 类实例引用,即 System.out,它的调用如下。


[0002bc] Java8.main:([Ljava/lang/String;)V0000: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;0003: new-instance v0, L-$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM;0004: invoke-direct {v0, v1}, L-$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM;.<init>:(Ljava/io/PrintStream;)V0008: invoke-static {v0}, LJava8;.sayHi:(LJava8$Logger;)V
复制代码


同样我们也可以在源码级层面进行模拟。


   public static void main(String... args) {-    sayHi(System.out::println);+    sayHi(new -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM(System.out));   }@@ }++class -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM implements Java8.Logger {+  private final PrintStream ps;++  -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM(PrintStream ps) {+    this.ps = ps;+  }++  @Override public void log(String s) {+    ps.println(s);+  }+}
复制代码

7. Interface Methods(接口中的方法)

Java 8 中新增了接口方法中的 defaultstatic 修饰符。接口中的 static 方法允许直接操作调用。接口中的 default 方法允许你为接口添加默认实现方法。


interface Logger {  void log(String s);
default void log(String tag, String s) { log(tag + ": " + s); }
static Logger systemOut() { return System.out::println; }}
复制代码


D8 中的脱糖都已经支持了这两个接口的新特性。通过上面的方法同样可以分析出脱糖是如何进行优化工作的,具体的分析就留给读者了。

8. Just Use Kotlin?

这个时候肯定有很多读者猜想 Kotlin 是否也具备这种能力。当然,Kotlin 同样提供了 lambda 和接口中的 staticdefault 方法。这些特性都被 kotlinc 以相同的方式实现。


Android 工具和 VM 的开发者肯定会 100% 支持 Kotlin 实现 Java 语言的新特性。因为每次的 Java 新版本都会在字节码构建和 VM 上带来新的优化体验。


在未来和可能 Kotlin 不会支持 Java 6Java 7Intellij 开发工具已经在在 2016 年 1 月迁移至 Java 8

9. Desugaring APIs

上面的分析中,我们一直关注的是 Java 语言新特性,其它还有一些主要的方面没有提及,比如新的 APIs。在 Java 8 转给你带来了很多新的 APIs,比如 streamOptionalCompletableFuture 以及新的 date/time API 等等。


回到上面的例子,我们使用新的 date/time API 来输出日志打印的时间。


import java.time.*;
class Java8 { interface Logger { void log(LocalDateTime time, String s); }
public static void main(String... args) { sayHi((time, s) -> System.out.println(time + " " + s)); }
private static void sayHi(Logger logger) { logger.log(LocalDateTime.now(), "Hello!"); }}
复制代码


我们同样使用 javac 指令和 d8 指令进行编译:


$ javac *.java
$ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --output . \ *.class
复制代码


当编译完成后,我们可以将它运行在一个手机或模拟器中。


$ adb push classes.dex /sdcardclasses.dex: 1 file pushed. 0.5 MB/s (1620 bytes in 0.003s)
$ adb shell dalvikvm -cp /sdcard/classes.dex Java82018-11-19T21:38:23.761 Hello
复制代码


如果我们的设备运行在 API26 或更高的版本上我们会得到一个带有时间戳的日志。但是在一个低于 API26 的机器上,得到确实异常信息。


java.lang.NoClassDefFoundError: Failed resolution of: Ljava/time/LocalDateTime;  at Java8.sayHi(Java8.java:13)  at Java8.main(Java8.java:9)
复制代码


显然,D8 通过脱糖使 lambda 表达式能够运行在所有的 API 版本机器上,但是却没有对新 API 做任何处理,所以我们无法使用 LocalDateTime 类。也说明我们仅仅能够利用部分的 Java 8 新特性,而不是全部。


针对这种情况,开发者可自行编译组件引用或使用相关的第三方实现库来解决,但是退一步讲,既然开发者可以自己编译或实现,为什么 D8 不能在脱糖中为我们做这些呢?


其实 D8 现在仅仅针对 Throwable.addSuppressed 这个 API 进行实现,这个 API 是用于 Java 7 引入的语言特性 try-with-resources


我们需要 Java 8 API 在所有设备上工作,我们所需要的只是 D8 团队在他们的脱糖工具中添加支持来进行重写。您可以在 Android 问题跟踪程序上添加 D8 功能请求,以传达您的支持。

10. 总结

虽然一段时间以来,语言特性的脱糖已经以各种形式出现,但是缺乏对新 API 的适配仍然是我们生态系统中的一个巨大缺陷。不然直到绝大多数应用程序能够指定最小 API 26 的那一天,Android 工具链缺少 API 的缺陷才算停止阻碍 Java 库生态系统的发展。尽管现在 Java 8 语言特性脱糖是 D8 的一部分,但默认情况下它没有启用。开发人员必须明确地选择它们的源代码和目标兼容性到 Java 8Android 库的作者可以通过使用 Java 8 字节码来构建和发布它们的库(即使你不使用语言特性)。


D8 正在积极工作,因此 AndroidJava 语言和 API 支持的前景仍然光明。即使你仅仅是一个 KOTLIN 用户,重要的是要保持对 Android 的活力,以支持更好的字节码和新 APIJava 新版本。在某些情况下,D8 实际上是超越 Java 8 版本的,我们将在下一篇文章中进行探索。

用户头像

Antway

关注

持续精进,尽管很慢 2019.05.27 加入

专注开源库

评论

发布
暂无评论
[译] Android 的 Java 8 支持