深刻Android對Java8支持的實現

本文內容來自 Jake wharton Android's Java8 Support一文,從這篇文章中你將瞭解Android對Java8語言特性的支持的歷程;並分析瞭解Android在字節碼層面是如何實現支持Java8語法的html

一個新的Java版本發佈可能會帶來諸多方面的變動,好比:新的語法、字節碼變化、工具支持、API、JVM等,一般Android開發者關注的Android的Java8支持方面更多的是語法特性這部分,Java8的其中一個重大變動就是 引入了 lamda表達式,那麼接下來咱們來看下Android是如何處理支持Java8新的語法的。java

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工具拋出異常了android

$ 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才支持。shell

能夠經過 javap -verbose Java8 查看Java8.class的字節碼bootstrap

那麼Android要實現對全部設備api版本的 lambda函數的支持呢?目前,Android是經過脫糖的方式來實現api

注:dx工具是負責將 輸入的java字節碼文件合併轉換爲android的dex文件;bash

Desugaring的歷史

脫糖 即在編譯階段將在語法層面一些底層字節碼不支持的特性轉換爲基礎的字節碼結構,(好比List上的泛型脫糖後在字節碼層面實際爲Object); Android工具鏈對Java8語法特性脫糖的過程可謂豐富多彩,固然他們的最終目的是一致的:使更新的語法能夠在全部的設備上運行。app

Retrolamda

最初支持Lamda語法的第三方工具如 Retrolamda是經過JVM的一些機制(premain Agent ASM)在編譯器把lamda表達式轉換爲內部類實現,可是生成的類會使得方法數極具增長。ide

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
相關文章
相關標籤/搜索