深入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結構因爲需要升級出現了變化,會隱藏一些很難解釋的坑在裏面,務必謹慎。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章