Android 相容 Java 8 的原理
本文譯自 Jake Wharton 的部落格Android's Java 8 Support .
我雖然在家辦公了幾年,但人們對 Android 不同 Java 版本的支援問題的吐槽我也有所耳聞。每年的 Google I/O 的提問環節你會發現我也在諮詢這個問題;在其他相關的會議中,也會或多或少地提及。顯然這也是一個複雜的問題,我們需要明確我們在討論什麼。對 Java 的支援可以有很多維度,例如語法糖、位元組碼、工具鏈、新的 API 、新的 JVM 實現等。
通常大家談論到 Android 對 Java 8 的支援,都是在說語法糖。所以我們就來看看 Android 的工具鏈是如何處理 Java 8 的語法糖的。
Lambda
Java 8 標誌性的語法糖就是加入了 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 直接執行會報錯:
$ javac *.java $ ls Java8.java Java8.class Java8$Logger.class $ $ANDROID_HOME/build-tools/28.0.2/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
這是因為 lambda 的實現使用到了 Java 7 新增加的位元組碼 invokedynamic. 正如報錯資訊提示的那樣,Android 對這個位元組碼的支援是在 API 26 以上才實現的 —— 這竟然有點不可思議。相反地,一個名為 desugaring 的編譯流程被用來將 lambda 轉換為所以 API 都相容的形式。
Desugaring 的歷史
Desugaring 的歷史可以說是非常精彩,它的目標始終如一:新的語法糖可以執行在所有裝置上。
最開始,我們使用一款名為Retrolambda 來實現相關的功能。它使用 JVM 的內建機制,在執行時而不是編譯時將 lambda 轉換為類的實現。生成新的類很容易增加方法數,但是如果權衡利弊這些成本還是可以接受的 (but work on the tool over time reduced the cost to something reasonable).
隨後 Android 的工具鏈團隊釋出了一款新的編譯器,稱其可以將 Java 8 的語法糖脫糖的同時還兼備更好的效能。這款編譯器是基於 Eclipse 的 Java 編譯器開發的,但是目標是 Dalvik 位元組碼而不是 Java 位元組碼。這個版本的 Java 8 的脫糖實現代價高昂,並且使用率低、效能差,與其他工具鏈不相容。
當上述的新的編譯器最終被棄用時(感謝),一款新的將 Java 位元組碼翻譯到 Java 位元組碼的脫糖轉換器被整合到了 Android Gradle Plugin 中 ,它實際上源自 Google 自己的構建工具 Bazel. 其脫糖過程挺高效的,但是效能表現仍然不是很理想。事實上它是一個漸進式的解決方案,不停地在尋找更好的解決方案。
隨後 D8 釋出了,被用來取代傳統的 dx 工具鏈,承諾在 dex 過程中脫糖而不是使用標準的 Java 位元組碼做轉換。相對於 dx 而言,D8 在效能上取得了巨大的成功,並且帶來了更高效的脫糖位元組碼。從 Android Gradle Plugin 3.1 版本開始,D8 成為了預設的 dex 工具,在 3.2 版本開始負責脫糖。
D8
使用 D8 將之前的例子編譯為 Dalvik 就成功了:
$ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --output . \ *.class $ ls Java8.java Java8.class Java8$Logger.class classes.dex
我們可以使用 Android SDK 中提供的 dexdump 來看看 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 還是其他什麼的)你也不用擔心 —— 大多數都很容易被理解。
在第一個程式碼塊中,我們的 main 方法,在名為 Java8$1 的類中,位元組序 0000 返回了一個靜態的 INSTANCE 引用。考慮到原始碼中並沒有包含名為 Java8$1 的類,我們可以推斷這是一個脫糖過程中生成的類。main 方法的位元組碼中也沒有包含任何 lambda 的痕跡,所以一定是在 Java8$1 中幹了些什麼。
位元組序 0002 接著呼叫到了 sayHi 和 INSTANCE. sayHi 需要一個 Java8$Logger 引數,看起來 Java8$1 實現了相關的介面。我們同樣可以在 D8 的輸出中進行驗證:
Class #2- Class descriptor: 'LJava8$1;' Access flags: 0x1011 (PUBLIC FINAL SYNTHETIC) Superclass: 'Ljava/lang/Object;' Interfaces- #0: 'LJava8$Logger;'
SYNTHETIC 標誌著相關的類是被生成的;並且在 Interfaces 也包含了 Java8$Logger.
於是 Java8$1 現在就代表了 lambda. 如果你去檢視它的 log 方法的實現,你可能會期望發現缺失的 lambda 程式碼塊:
… [00026c] Java8$1.log:(Ljava/lang/String;)V 0000: invoke-static {v1}, LJava8;.lambda$main$0:(Ljava/lang/String;)V 0003: return-void …
可是什麼都沒有哦。相反地,它呼叫了 Java8 這個類中的名為 lambda$main$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 標誌著這個方法是被生成的。並且它的位元組碼包含了 lambda 的程式碼塊:呼叫 System.out.println(). lambda 程式碼塊存在於原來的類的內部的原因在於,它可能需要訪問該類的私有成員變數,而生成的類卻是訪問不到的。
通過上述描述你應該就能理解脫糖的原理了。當然在 Dalvik 位元組碼中看到它們可能會有點密集而令人生畏(密集恐懼症)。
程式碼轉換
為了更好地理解脫糖的原理,我們可以在程式碼層面進行一層轉換。這並不意味著脫糖實際上是這樣工作的,但是有助於我們學習並理解位元組碼中發生的事情。
再一次地,我們從最初的例子開始:
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 同級的,package-private 的方法:
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); +}
接著,一個實現了目標介面的類被生成了,它擁有一個呼叫 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 不需要捕獲任何狀態 (because the lambda doesn’t capture any state), 一個儲存在 INSTANCE 中的靜態單例被建立了出來:
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) {
這樣就脫糖出了一份在所有 API 版本都能使用的程式碼:
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. 至於為什麼這樣命名,並且帶來的好處,就需要另寫一篇文章來解釋了...
原生的 Lambda
當我們使用 dx 去嘗試編譯包含 lambda 的 Java 位元組碼為 Dalvik 位元組碼時,報錯資訊會提示你說至少得在 API 26 及其以上版本才能使用:
$ $ANDROID_HOME/build-tools/28.0.2/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
如果你用 D8 配合 --min-api 26 引數編譯的話,它會假定你將使用原生的 lambda 實現而不會進行脫糖:
$ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --min-api 26 \ --output . \ *.class
但如果你去檢視生成的 .dex 檔案,你還是會發現類似 -$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY 的類被生成了,這也許是 D8 的 bug?
為了探究為什麼脫糖過程總是在執行,我們需要深入 Java8 這個類的位元組碼中一探究竟:
$ 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 方法中你會看到 invokedynamic 位於索引 0. 對應的位元組碼中的第二個引數 0 是與啟動引導的方法掛鉤的,該方法在程式碼被第一次執行時會定義一些行為。在輸出檔案的底部有列出這些引導方法的列表:
… 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 的方法。這個方法存在於 JDK 中 ,它的職責是在執行時中即時建立 lambda 相關的匿名類,這與 D8 在編譯時乾的事情類似。
如果你有看過 java.lang.invoke 相關的Android 官方文件 或AOSP 的原始碼,你會發現 Android 執行時裡並沒有這個類。Android VM 有與 invokedynamic 相同效果的位元組碼支援,但是 JDK 內建的 LambdaMetafactory 並不可用。
Method References
作為 lambda 的補充,方法引用這個語法糖同樣被引入到 Java 8 中,使得建立 lambda 去指向一個已有的方法的操作變得高效。
在我們的 logger 的例子中,lambda 表示式直接呼叫了已有的 System.out.println() 方法,我們可以簡單把它轉換為方法引用的形式簡化程式碼:
public static void main(String... args) { -sayHi(s -> System.out.println(s)); +sayHi(System.out::println); }
使用 javac 編譯後,再用 D8 處理,我們會發現與之前的 lambda 有一處顯著的不同。當我們檢視生成的 Dalvik 位元組碼時,會發現生成的 lambda 類的程式碼塊被改變了:
[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$main$0 類中包含 System.out.println() 的方法不同,log 的實現直接呼叫了 System.out.println().
生成的 lambda 類不再是靜態單例。位元組序 0000 直接讀取了 PrintStream 的例項引用,該引用即是 System.out, 它在 main 方法中被呼叫,並且被傳遞給相應的構造器(名為 <init> 的位元組碼)(This reference is System.out which is resolved at the call-site in main and passed into the constructor (which is named <init> in bytecode)):
[0002bc] Java8.main:([Ljava/lang/String;)V 0000: 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;)V 0008: 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); +} +}
Interface Methods
Java 8 另一個顯著的特性是可以在介面中定義靜態方法和預設方法。介面中的靜態方法可以被用來提供相關的工廠方法,或者其他有助於操作介面的方法。介面中的預設方法則允許你給已有介面中新增預設的方法實現,同時保持相容性(你不需要給所有實現了該介面的類再全部實現一個新的方法):
interface Logger { void log(String s); default void log(String tag, String s) { log(tag + ": " + s); } static Logger systemOut() { return System.out::println; } }
D8 同樣也支援這兩種語法糖。你可以按照上文提到的方式來看看 D8 是如何脫糖的。
值得注意的是,這兩種語法糖在 API 24 以上都是使用的原生實現。因此不像 lambda 和方法引用,--min-api 24 不會觸發 D8 的脫糖操作。
只使用 Kotlin?
到此為止,肯定有大量讀者開始考慮 Kotlin. 沒錯,Kotlin 的確在語言層面上直接提供了 lambda 和方法引用,的確在介面中提供了預設方法和類似靜態方法的語法糖。但這些特性都是由 kotlinc 做的實現,和 D8 支援 Java 8 的位元組碼乾的事情差不多(具體的實現細節可能有所不同)。
即時你完全使用 Kotlin 來寫程式碼,Android 的開發工具鏈和 VM 對新語言特性的支援還是非常重要的。新版本的 Java 在位元組碼和 VM 方面更加高效,才能釋放 Kotlin 更多的潛力。
Kotlin 在未來的某個時刻可能會放棄對 Java 6 和 Java 7 的支援。Intellij 平臺已經在 2016.1 遷移到了 Java 8 , Gradle 5.0 也遷移到了 Java 8. 執行在老 VM 上的平臺越來越少。如果不支援 Java 8 的位元組碼和 VM 提供的功能,那麼 Android 的生態系統就危險啦。感謝 D8 和 ART 讓這一切不會發生。
Desugaring APIs
到此為止,本文主要關注的是 Java 新版本語法糖和位元組碼。Java 8 其他的好處在於加入了一些新的 API, 如 streams, Optional, functional interfaces, CompletableFuture 和新的 date/time API.
回到先前的 logger 的例子上,我們可以使用新的 date/time API 得知我們是什麼時候打的 log:
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
你可以將其 push 到真機或者模擬器上進行驗證,這是我們在先前的例子中沒有做的:
$ 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 及其以上的環境中,你會看見一條包含時間戳的字串 "Hello!". 但在低版本環境中會報錯:
java.lang.NoClassDefFoundError: Failed resolution of: Ljava/time/LocalDateTime; at Java8.sayHi(Java8.java:13) at Java8.main(Java8.java:9)
D8 只是對部分語法糖例如 lambda 進行了脫糖,但是卻並沒有提供諸如 LocalDateTime 這類的新的 API. 這當然是令人失望的,因為我們沒法使用完整的 Java 8 新特性。
開發者當然可以自己實現一套 Optional 並且使用諸如 ThreeTenBP 這類 date/time 第三方庫。但是既然我們修改打包的程式碼,為什麼我們不能讓 D8 提供這些新的 APi 呢?
看起來 D8 的確做了類似的事,但是隻支援了一個 API: Throwable.addSuppressed(), 這個 API 允許 Java 7 的 try-with-resources 語法糖可以執行在全版本系統上。
我們需要的是能支援全版本系統的 Java 8 的新 API 的相容實現。看起來 Bazel 團隊已經在做這件事了 。他們重寫的程式碼無法直接使用,但是將其重新打包是可以的 (Their code that does the rewriting can’t be used, but the standalone repackaging of these JDK APIs can be). 我們需要的就是 D8 團隊將其新增到工具鏈中,你可以在這個連結進行投票進行支援 。
儘管對新語法糖的脫糖操作已經在多方面可用,但是新 API 的缺乏意味著還是與 Java 的生態系統有著巨大的差距。在大多數 App 的 minSdkVersion 26 之前,Android 的工具鏈只會阻礙 Java 生態系統的發展。需要同時支援 Android 和 JVM 的第三方庫不能使用 Java 8 的新 API 至少五年!
儘管對 Java 8 的脫糖操作已經成為 D8 的一部分,但這也不是預設的。開發者必須明確宣告他們的程式碼需要用到 Java 8. 第三方庫的開發者可以通過強制使用 Java 8 而提升這種趨勢(即時你沒有使用到它的任何特性)。
鑑於 D8 已經開始起到實際作用了,所以前途還是光明的。即使你是一個 Kotlin 使用者,你也有義務敦促 Android 支援新版本的 Java 位元組碼和 API 以獲取更好的效能。實際上,在某些情況下,D8 還是要領先於 Java 8 的,我們將在下一篇部落格中展現。
(這篇部落格是作為我的Digging into D8 and R8 的分享的一部分,該分享並沒有被直接公佈出來。你可以看看其中的視訊,並且關注我後續的部落格)
—— Jake Wharton