Android相容Java 8語法特性的原理分析

美團技術團隊發表於2022-12-05

本文主要闡述了Lambda表示式及其底層實現(invokedynamic指令)的原理、Android第三方外掛RetroLambda對其的支援過程、Android官方最新的dex編譯器D8對其的編譯支援。透過對這三個方面的跟蹤分析,以Java 8的代表性特性——Lambda表示式為著眼點,將Android如何相容Java8的過程分享給大家。

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表示式

通用格式:

Android相容Java 8語法特性的原理分析

簡單範例:

Android相容Java 8語法特性的原理分析

Android相容Java 8語法特性的原理分析

說明:

  • Lambda表示式中 () 對應的是函式式介面-run方法的引數列表。

  • Lambda表示式中 System.out.println("xixi") / System.out.println("haha"),在執行時會是具體的run方法的實現。

Lambda表示式原理

針對例項中的程式碼,我們來看下編譯之後的位元組碼:

javac J8Sample.java  ->  J8Sample.class

javap -c -p J8Sample.class 
Android相容Java 8語法特性的原理分析

從位元組碼中我們可以看到:

  • 例項中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例項:

javap -v J8Sample.class
Android相容Java 8語法特性的原理分析

結合J8Sample.class位元組碼,並對invokedynamic指令呼叫過程進行跟蹤分析。總結如下:

Android相容Java 8語法特性的原理分析

依據上圖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中發現了新增的私有靜態方法:
Android相容Java 8語法特性的原理分析

步驟3 針對表示式1的位元組碼分析 #2 對應的是class檔案中的常量池:

#2 = InvokeDynamic      #0:#35         // #0:run:()Ljava/lang/Runnable;   

注意,這裡InvokeDynamic不是指令,代表的是Constant_InvokeDynamic_Info結構。

步驟4 結構後面緊跟的 #0 標識的是class檔案中的BootstrapMethod區域中引導方法的索引:

Android相容Java 8語法特性的原理分析

步驟5 引導方法中的java/lang/invoke/LambdaMetafactory.metafactory才是invokedynamic指令的關鍵:

Android相容Java 8語法特性的原理分析

Android相容Java 8語法特性的原理分析

該方法會在執行時,在記憶體中動態生成一個實現Lambda表示式對應函式式介面的例項型別,並在介面的實現方法中呼叫步驟2中新增的靜態私有方法。

步驟6 使用java -Djdk.internal.lambda.dumpProxyClasses J8Sample.class執行一下,可以記憶體中動態生成的型別輸出到本地:

Android相容Java 8語法特性的原理分析

步驟7 透過javap -p -c J8Sample\$\$Lambda\$1.class反編譯一下,可以看到生成類的實現:

Android相容Java 8語法特性的原理分析
在run方法中使用了invokestatic指令,直接呼叫了J8Sample.lambda$main$0這個在編譯期間生成的靜態私有方法。

至此,上面7個步驟就是Lambda表示式在Java的底層的實現原理。Android 針對這些實現會怎麼處理呢?

Android不能直接支援

回到Android系統上,Java-Bytecode(JVM位元組碼)是不能直接執行在Android系統上的,需要轉換成Android-Bytecode(Dalvik/ART 位元組碼)。

如圖:

Android相容Java 8語法特性的原理分析

透過Lambda這節,我們知道Java底層是透過invokedynamic指令來實現,由於Dalvik/ART並沒有支援invokedynamic指令或者對應的替代功能。簡單的來說,就是Android的dex編譯器不支援invokedynamic指令,導致Android不能直接支援Java 8。

Android間接支援

既然不能直接支援,那就只能在Java-Bytecode轉換到Android-Bytecode這一過程中想辦法,間接支援。這個間接支援的過程我們統稱為Desugar(脫糖)過程。

官方流程圖:

Android相容Java 8語法特性的原理分析

當前,無論是RetroLambda,還是Google的Jack & Jill 工具,還是最新的D8 dex編譯器:

  • 流程方面:都是按照如上圖所示的官方流程進行Desugar的。

  • 原理方面:卻是參照Lambda在Java底層的實現,並將這些實現移至到RetroLambda外掛或者Jack、D8編譯器工具中。

下面我們逐個分析解讀一下。

Android 間接支援之RetroLambda

Android相容Java 8語法特性的原理分析

如圖所示,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工程中:

Android相容Java 8語法特性的原理分析

AndroidStudio -> Build -> make project 編譯之後:

Android相容Java 8語法特性的原理分析

app:transformClassesWithRetrolambdaForDebug任務發生在app:compileDebugJavaWithJavac (javac)後,app:transformDexArchiveWithDexMergerForDebug (dx)之前,同時在build/intermediates/transforms/retrolambda下面生產如圖所示的class檔案。

J8Sample.class和J8Sample$$Lambda$1.class反編譯之後的程式碼如下:

Android相容Java 8語法特性的原理分析

Android相容Java 8語法特性的原理分析

透過反編譯程式碼,可以看出J8Sample.class中Lambda表示式已經被我們熟悉的1.7or1.6的語句所替代。

注意:右圖中J8Sample.lambda$main$0()方法在左圖中沒有顯示出來,但是J8Sample.class位元組碼確實是存在的。
Android間接支援之Jack&Jill工具
Android相容Java 8語法特性的原理分析

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

Android相容Java 8語法特性的原理分析

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工程中:

Android相容Java 8語法特性的原理分析

同樣,AndroidStudio -> Build -> make project編譯之後:

Android相容Java 8語法特性的原理分析

javac編譯之後的J8Sample.class還是使用invokedynamic指令,即這一步並沒有Desugar:

Android相容Java 8語法特性的原理分析

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中位置。
Android相容Java 8語法特性的原理分析

新增方法:

Android相容Java 8語法特性的原理分析

J8Sample.main方法:

Android相容Java 8語法特性的原理分析

圖中選中部分,對應就是Lambda表示式1 desugar之後的內容。

翻譯成Java的話就變成了:new Lcom/j8sample2/-$$Lambda$J8Sample$jWmuYH0zEF070TKXrjBFgnnqOKc這個生成類的一個物件。類Lcom/j8sample2/-$$Lambda$J8Sample$jWmuYH0zEF070TKXrjBFgnnqOKc對應前面的生成的J8Sample$$Lambda$1型別,只不過數字1變成了Hash值。
Android相容Java 8語法特性的原理分析

實現Interface Ljava/lang/Runnable。Lcom/j8sample2/-$$Lambda$J8Sample$jWmuYH0zEF070TKXrjBFgnnqOKc.run方法:

Android相容Java 8語法特性的原理分析

到這裡,是不是和前面RetroLambda就一樣了。

總結

至此,Lambda及其invokedynamic指令、RetroLambda外掛、D8編譯器各自的原理分析都已經結束了。

相比較Lambda在Java8自己內部的實現:即執行時,在記憶體中動態生成關聯的函式式介面的例項型別,透過BSM-引導方法找到該記憶體類(位元組碼層面的反射)。

在Android上的其他三種Desugar方式,原理都是一樣的,區別在於時機不同:

  1. RetroLambda將函式式介面對應的例項型別的生產過程,放在javac編譯之後,dx編譯之前,並動態修改了表示式所屬的位元組碼檔案。

  2. Jack&Jill是直接將介面對應的例項型別,直接jack過程中生成,並編譯進了dex檔案。

  3. 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實現的方式,一探究竟。

作者簡介

元合、朝旭,美團到店事業群前端工程師。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31559353/viewspace-2661175/,如需轉載,請註明出處,否則將追究法律責任。

相關文章