深入Android Runtime: 指令優化與Java方法呼叫
作者簡介:dc, 天天P圖AND工程師
做一個小試驗
先做一個小試驗:在apk的activity中放一個Button和一個TextView,點選Button讓結果顯示在TextView上。
apk的程式碼如下:
public class MainActivity extends AppCompatActivity { Button button; TextView textView;@Override protected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); textView = findViewById(R.id.text); button = findViewById(R.id.button); button.setOnClickListener(new View.OnClickListener() {@Override public void onClick(View v) { Test test = new Test(); String s = test.getValue(); textView.setText(s); } }); } }
其中Test類的程式碼如下:
public class Test { public String getValue() { return "this is method getValued"; } }
試著思考下,文字框顯示的結果會是什麼?
第1次結果:
如果執行正常,結果會如下(本次測試全部在Android AOSP N上執行):
this is method getValued
進一步試驗
接下來,再進一步試驗。 我們給apk的PathClassLoader的ClassPath最前面注入一個dex,這個dex僅包含一個class,和之前的Test的包名+類名一致,如下:
public class Test {public String getValue(){return "this is method getValue from dex"; }public String abc(){return "this is method abc !!!"; } }
這是最簡單的熱修復原理,猜想一下,這次的結果是什麼?
第2次結果
這次的結果會是什麼呢?
實際上,在debug版本上,我們能夠得到正確的結果:
而在release版本上,結果並不是我們想象的這樣,結果如下:
現象解釋
為什麼會出現這樣的現象:明明呼叫的是getValue方法,為什麼返回的是abc方法的結果呢? 要解釋這個現象,我們需要對Android虛擬機器執行程式碼的原理有一定的瞭解。
當我們將Java程式碼編譯成apk時,編譯器會用javac將java檔案轉成class檔案,再通過dx將class檔案轉成dex檔案(如果是jack&jill編譯器,不會有class生成的過程)。 apk安裝時候,PMS會通過installd喚起dex2oat程序對apk進行優化。 當我們啟動系統時候,虛擬機器先載入BootClassLoader,再載入SystemClassLoader,分別將BOOTCLASSPATH和SYSTEMSERVERCLASSPATH中對應jar包中的class載入起來,。
apk啟動時,將會建立一個PathClassLoader,將apk相關及其依賴的library中的class載入到記憶體。 如果我們往PathClassLoader的clssapath中最開始注入新的jar/dex,在執行時PathClassLoader就會優先載入前面的jar/dex,從而覆蓋apk本身的類實現類的替換。
但是我們通常不會注意到虛擬機器的機制。
在安裝apk時,如果apk是debug版本,會被強制以解釋方式執行,此時執行的是位元組碼,我們看到的位元組碼是這樣的:
即invoke-virtual+methodID的方式執行。這個methodID是儲存在apk自身的dex中的,每個dex中都有一個String表和Method表(當然還有Class表等其他表)。 通過String表,可以查到某個index對應的String是什麼;通過method表,可以拿到methodID對應的StringID,然後再到String表中查到方法名稱。 虛擬機器通過方法名稱,再從已載入cache中查詢方法,如果方法沒找到,就從classpath載入並resolve,最終找到對應的method。
那麼正常debug版本解釋執行時,這個過程是沒有任何問題的,包括使用新的類覆蓋了舊的類的時候,仍然可以通過自身編譯時就決定的methodID拿到正確的方法名,也就可以獲取到正確的method並執行。
但是release版本的時候,dex會被優化的。dex2oat根據系統prop中的配置決定進行何種程度的優化,在AOSP N上,預設配置如下:
interpret-only模式的優化,實際上只是dalvik指令級的優化,並不會生成機器碼(其他speed之類的優化模式會產生部分機器碼,everything模式是完全編譯,將所有位元組碼均優化成機器碼),而是會對invoke-virtual這樣的指令進行quicken優化,變成invoke-virtual-quick。 優化的目的,是將methodID的查詢變成vtable的查詢。methodID是dex全域性的查詢,相比vtable在class內部的查詢,效率要高很多,畢竟一個dex中很可能有幾萬個method,而一個class中的method通常只有幾個到幾十個。
interpret-only的優化,是基於一個前提,編譯時不僅能獲取到class的名稱,還能獲取到class的定義。 因為我們是動態載入了dex,這個dex只有在classloader載入dex時才會被發現,dex2oat編譯時只知道apk自身中的class的存在。
dex2oat進行interpret-only優化時,編譯依賴是原先的method,導致生成的vtable索引為原先Test類中的方法索引。但是執行的時候,新的Test類由於加上了一個abc的方法,android中的各種String表、method表、vtable等都是按照字母表順序進行排序,導致abc方法排在Test方法之前,這樣原先的vtable索引查到的method就變成了abc方法。
由於vtable索引的變化,就出現了明明是呼叫的Test方法,可結果跑的是abc方法的奇特現象。
如果我們進行verify-none模式的編譯(不進行quicken優化,或者其他能編譯成機器碼的模式),讓其以解釋模式執行,就不會有問題。但是如果apk在Manifest中設定了android:vmSafeMode=”true” ,那麼無論是否使用了其他模式進行強制編譯,apk會始終以interpret-only方式編譯,導致問題一直存在。 比如我們使用speed編譯,日誌中依然是interpret-only:
總結
在進行apk熱修復、外掛化、動態載入的時候,會經常多個jar/dex包含相同的class,如果class結構因為需要升級出現了變化,會隱藏一些很難解釋的坑在裡面,務必謹慎。