深入Android对Java8支持的实现

3,921 阅读7分钟

本文内容来自 Jake wharton Android's Java8 Support一文,从这篇文章中你将了解Android对Java8语言特性的支持的历程;并分析了解Android在字节码层面是如何实现支持Java8语法的

一个新的Java版本发布可能会带来诸多方面的变更,比如:新的语法、字节码变化、工具支持、API、JVM等,通常Android开发者关注的Android的Java8支持方面更多的是语法特性这部分,Java8的其中一个重大变更就是 引入了 lamda表达式,那么接下来我们来看下Android是如何处理支持Java8新的语法的。

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!");
  }
}

例子中我们在main方法内部的sayHi方法调用时传入了一个lambda表达式。 接下来我们先使用javac将上面的源码编译成class文件,再通过 dx 工具尝试转换成dex文件时,此时dx工具抛出异常了

$ javac *.java
$ ls
Java8.java Java8.class Java8$Logger.class
$ $ANDROID_HOME/build-tools/28.0.02/dx --dex --output . *.class
Uncaught 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

这是因为lamda表达式在Java字节码层面使用了invokedynamic指令 ,而Android对 字节码指令 invokedynamic 在设备sdk 版本大于26才支持。

可以通过 javap -verbose Java8 查看Java8.class的字节码

那么Android要实现对所有设备api版本的 lambda函数的支持呢?目前,Android是通过脱糖的方式来实现

注:dx工具是负责将 输入的java字节码文件合并转换为android的dex文件;

Desugaring的历史

脱糖 即在编译阶段将在语法层面一些底层字节码不支持的特性转换为基础的字节码结构,(比如List上的泛型脱糖后在字节码层面实际为Object); Android工具链对Java8语法特性脱糖的过程可谓丰富多彩,当然他们的最终目的是一致的:使更新的语法可以在所有的设备上运行。

Retrolamda

最初支持Lamda语法的第三方工具如 Retrolamda是通过JVM的一些机制(premain Agent ASM)在编译器把lamda表达式转换为内部类实现,但是生成的类会使得方法数极具增加。

Jack编译器

随后Google 在Android SDK 21发布了新的编译器 Jack 来构建Android程序,但是他的实现机制是直接将Java源码转为 Dalvik字节码 而不是Java字节码。

随后Google发现这种实现方式带来的成本太大了。在Jack编译器之前 Androidn程序的编译链是

graph LR
Java_Source-.Javac.->Java_ByteCode
Java_ByteCode-.dx.->dex

现在呢,变成

graph LR
Java_Source-.Jack_Compiler.-dex

直接从Java源代码编译成dex文件了,这意味着Google要自己重新实现对Java生态所有语言特性的支持 比如 注解处理器的支持等;另一个严重的问题是,之前很多第三方库的实现依赖于Java字节码,比如Android-Gralde插件提供的TransformAPI提供的就是Java字节码,这样一改这些依赖Java字节码的第三方库在新的编译器体系下就不能使用了。

D8

使用Jack编译器的成本太大,于是Google就又弃用了Jack编译器,并在AS3.1引入了新的dex编译工具D8,因此目前Android的编译过程就变为了

graph LR
Java_Source-.Javac.->Java_ByteCode
Java_ByteCode-.D8.->dex

新的D8编译器相比之前的dx编译器性能更优,并且支持了一些Java8的语法,如lamda表达式等 我们用重新用D8编译上面的class文件并生成了Dalvik 字节码

$ java -jar $ANDROID_HOME/build-tools/28.0.3/lib/d8.jar \
    --lib $ANDROID_HOME/platforms/android-28/android.jar \
    --release \
    --output . \
    *.class

$ ls
Java8.java  Java8.class  Java8$Logger.class  classes.dex

我们可以使用Anroid SDK 提供的 dexdump 查看生成的dex文件内容字节码,看看D8设计如何脱糖支持 lambda的

$ $ANDROID_HOME/build-tools/28.0.2/dexdump -d classes.dex
[0002d8] Java8.main:([Ljava/lang/String;)V
0000: sget-object v0, LJava8$1;.INSTANCE:LJava8$1;
0002: invoke-static {v0}, LJava8;.sayHi:(LJava8$Logger;)V
0005: return-void

[0002a8] Java8.sayHi:(LJava8$Logger;)V
0000: const-string v0, "Hello"
0002: invoke-interface {v1, v0}, LJava8$Logger;.log:(Ljava/lang/String;)V
0005: return-void
…

如果,你之前没有接触过字节码(Dalvik字节码 或者其他的比如Java字节码)也没有关系,我们只需要理解这些字节码大概的内容。 在字节码对应我们的主函数Java8.main中: 字节码位置

[0000] 通过 sget-object 操作码 获取 LJava8$1类的静态成员INSTANCE

[0002] 调用该静态实例调用sayHi方法

因为在我们的源代码中并不存在Java81类,因此我们可以推断这个类是由d8工具在脱糖处理过程中生成的。
**sayHi**方法需要传入一个 **Java8Logger类型对象的参数,因此Java8$Logger**类应该实现 了这个接口。 我们可以在输出的字节码内容得到验证

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

Interferfaces列出了 Java81**实现的接口,包含了**Java8Logger。 flagSYNTHETIC 代表了这个类是在编译过程中自动生成的。

查看Java8Logger**类的log方法实现,发现它调用了Java8类的**lambdamain$0方法,同样的在源码中我们并没有定义这个方法,因此可以推断这个类同样是编译过程中自动生成的。

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

通过SYNTHETIC标志再次验证了这个方法是自动生成的。查看该方法的字节码具体内容,可以看到该方法的实现为:

[0000]获取System类的静态成员变量out

[0002]调用PrintStream类型out对象的println方法

以上完成了字节码内部对lamda方法的脱糖,尽管对与一个简单的lamda表达式在脱糖过程生成的字节码数量看上去比较多,但是逻辑还是很好理解的。

实际上,如果你真的运行并查看dex字节码内容,lamdba表达式生成的类名实际上并不会是Java1,生成的类名实际上会带有类似hashcode的符号,比如 **LambdaJava8$QkyWJ8jlAksLjYziID4cZLvHwoY**

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表达式的方法体从main函数内部移动到Java8类内部

   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);
+  }

第二步,生成一个内部类并实现Java8.Logger接口,接口方法的内部调用了上述Java8类的lambda$main$0方法

   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);
+  }
+}

最后,因为我们的lamda方法并没有使用外部的任何对象,因此我们在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) {

最终,我们在源代码层级脱糖实现了lambda表达式,并且这个代码可以在所有的Java版本中使用

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) {

Native Lambdas

上面,我们说到如果使用dx工具尝试编译包含 lambda语法的字节码文件,会抛出异常,并提示

Uncaught 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

那么,是不是就意味着只要我们指定了min-sdk-version为26,就会采用原生的字节码指令来直接支持lambda而不是在脱糖处理中采用生成一个中间类这种的方式来支持。但是实际的结果是:编译器依旧是采用脱糖的方式来支持的。

这是为什么呢,我们首先看下lamda表达式生成的Java字节码内容

$ javap -v Java8.class
class 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方法内部,在index 0的位置字节码为 invokedynamic #2,0; 这里操作码的第二个参数为 0,表示的是 bootstrap method方法表的第0个位置,当第一次调用invokedyanmic 指定时,Java虚拟机将执行它所对应的Bootstrap Method,而lambda表达式所链接的bootstrap method会通过ASM生成一个适配器类来支持lambda。

有兴趣可以了解下Java8字节码层级对lambda的实现方式,主要的部分就是invokedynamic 和 bootstrapmethod。

…
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

可以看到这个启动方法调用了java.lang.invoke.LambdaMetafactory类的metafactory方法。 上述字节码所做的工具就类似于D8编译器对lamda的支持,只不过JVM的实现是在运行阶段生成中间类来支持,而D8是在编译阶段就完成的.

如果我们查看Android documentation for java.lang.invoke 或者是 AOSP source code for java.lang.invoke ,会发现Android VM虽然支持与invokedynamic的同等作用的字节码操作符,但是目前Android运行时SDK中并不存在LambdaMetafactory这个类,因此其实无论指定的minimum API是多少,都会采用提前在编译期对lambda做脱糖处理的方式来支持lambda。

方法引用

Java8的lamda表达式还提供了方法引用的特性,如果一个Lambda表达式仅仅调用一个已经存在的方法,如System.out.println,那么我们就允许通过方法名来引用这个已经存在的方法。

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

我们再次用 javac+D8工具链编译上面的代码,并打印Dalvik字节码,此时生成的lambda body 字节码发生了变化。

[000268] -?Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM.log:(Ljava/lang/String;)V
0000: 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;)V
0005: return-void

可以看到,改成方法引用的写法后,不会在在Java8类内部生成一个中间方法lambda$main0来调用System.out.println了,而是改为直接调用

并且生成的lambda类也不会再创建一个静态单例对象;在main方法字节码0000处,会直接获取PrintStream引用并作为生成的Lambda类的构造函数的参数,来创建该lambda对象。 同样的,我们可以模拟在源码层级上述字节码的变化;大概是这样的

   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);
+  }
+}

接口方法

Java8的另一个语法特性是,抽象接口类中可以定义静态方法和默认方法。

interface Logger {
  void log(String s);

  default void log(String tag, String s) {
    log(tag + ": " + s);
  }

  static Logger systemOut() {
    return System.out::println;
  }
}

同样这两个特性的支持,也是由D8的脱糖处理支持的,读者可以根据上述使用到的这些工具自己分析接口方法在字节码中的脱糖具体实现

值得注意的是,接口方法这个特性在Android 24的VM 提供了原生实现,因此不像lambdas 和 method references ,如果指定了 min-api为24及以上,将不会使用语法脱糖的方式实现。

Just Use Kotlin

也许你会想到,那么为什么直接上Kotlin了,上面提到的大部分特性Kotlin都提供了类似的支持。事实上,Kotlin对这些特性的支持是也是通过在自己的编译工具kotlinc在编译阶段来实现的,就类似于D8的处理工作。

即使你的项目100%使用Kotlin语言来开发,了解Android的工具链 以及Android VM对新的Java语言特性的支持也是十分重要。

Java8 API

Java新版本的发布除了在语言特性和字节码做出变动外,还提供新的API。Java8提供了很多新的API,比如:streams、Optional、function interfaces, CompletableFunter和新的日期处理的api。

回到最开始的例子,我们可以尝试下使用这个新的DateTime 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将它转成Dalvik字节码

$ javac *.java

$ java -jar d8.jar \
    --lib $ANDROID_HOME/platforms/android-28/android.jar \
    --release \
    --output . \
    *.class

然后在手机或者模拟器上,尝试运行这个程序

$ adb push classes.dex /sdcard
classes.dex: 1 file pushed. 0.5 MB/s (1620 bytes in 0.003s)

$ adb shell dalvikvm -cp /sdcard/classes.dex Java8
2018-11-19T21:38:23.761 Hello

如果你的设备API是26或者以上,它可以正常工作,但是如果你的设备API小于26,运行这段程序将会抛出异常

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

可以看到虽然D8可以通过脱糖处理来支持Java8新的语法特性,但是对于新的API的却没有做出处理。这或许有点令人失望,因为这样的haul我们只能使用Java8的一部分功能而不是全部。

其实开发者可以自己创建Optional类并打包到程序中,或者是使用一些第三方类库 如 ThreeTenBp这个时间库来使用上述Time Api。但是为什么D8工具不能帮我们完成这些工作呢,其实D8内部也做了类似的工具,但是目前只对Throwable.addSuppressed这个API做了支持。这个API支持了 Java7才引入的try-with-resouce语法特性。

因此想做到所有的新JAVA版本的API在低版本的Android设备上也能使用似乎比较简单,只需要我们手动把这些API对应的类一起打包到APK中就可以了。Google的 Bazel小组已经做了类似的工作,但是对大部分开发者来说还是更期待由D8来默认提供这些支持,你可以在google的issuetracker上面start你想要得到的api支持


尽管Android对语法特性的脱糖支持有一段时间了,但是对API的脱糖支持还十分不完善。Android工具链对新的API的脱糖支持的缺失会阻碍Android的Java生态的libary库的发展。

虽然,Java8语法特性脱糖是D8编译工具的一部分,而D8 已经是目前AS默认的编译工具了,但是除非开发者显示指定了sourceCompatibilitytargetCompatibility为1.8,否则脱糖工作依旧不会执行。因此为了更好的推动真个生态对Java8语法的支持,及时开发者目前不使用Java8语法特性,最好也在开发库中指定一下这两个参数配置。

D8还在被不断完善中,因此未来对Java语言和API的支持前景还是光明的;及时你是一个纯粹使用的Kotlin语言开发者,了解Android对Java新版本的支持还是十分重要的。并且在某些方面,D8还走在Java的前面(毕竟Kotlin最终还是编译成Java字节码)

总结

  • Android目前对Java8的支持是在编译阶段采用脱糖的方式支持
  • Dalivk字节码是在Java字节码之上编译生成的
  • Dalvik字节码并不复杂,能看懂Java字节码就能看懂大部分的Dalivk字节码
  • 由于Android设备SDK中对新的API的缺失,大部分Java8的api并不能使用

image