從Java類加載初始化到Android熱修復

近日筆者在研讀 Java Language Specification ,對 Java 類的加載過程略有所得。又聯想到最近公司同事分享的一個QZone的Android熱修復的技術,正是利用Android的類加載機制來完成的。故寫文分享,如有不當之處,還請大家指正。

類加載過程

一個類的加載過程,經歷了加載、驗證、準備、解析(可選) 這幾個階段,其中驗證、準備、解析合稱為連接階段。

加載

那什么是加載,簡單的說就是根據一個類名,去尋找這個類的二進制信息,并轉化為Class對象。一個類何時被加載,Java 虛擬機規范沒有明確的指明,但是何時初始化,是有嚴格的要求的,這個后面會說。而初始化時某個類時,若此類尚未加載,便會觸發其加載的過程。

而所有的 Java 類都是通過 ClassLoader 對象來加載的,這是一個老生常談的話題了。有人會有些疑惑,既然所有的類都是 ClassLoader 加載的,那么 ClassLoader 這個類是誰加載的。自然也是 ClassLoader , 不過這個 ClassLoader 是由 JVM 實現的(通常是C++,不過像MRP這種虛擬機本身就是由 Java 編寫的就另外一說了),在 Java 層的表現形式一般為 null 。

Java提供了3個基本的 ClassLoader :

  • BootstrapClassLoader,又稱為啟動類加載器,是 Java 類加載層次中最頂層的類加載器,負責加載 JAVA_HOME/lib 目錄或者是被-Xbootclasspath參數所指定的路徑下的jar,上文所述的 ClassLoader 便是由 Bootstrap ClassLoader 所加載。
  • ExtClassLoader,又稱為擴展類加載器,負責加載Java的擴展類庫,默認加載 JAVA_HOME/jre/lib/ext 目錄或者是 java.ext.dirs 系統變量所指定的路徑下的所有jar。
  • AppClassLoader,又稱為系統類加載器,負責加載用戶類路徑(classpath)指定的所有 jar 和目錄下的 .class 文件,這個 ClassLoader 便是負責加載我們開發者所寫的代碼的。

ClassLoader 是使用雙親委派的模型來加載類的,簡而言之的話就是每次加載時,先委托給父親加載,如果父親沒有找到才會由自己加載。而上述三個 ClassLoader 從上至下依次為父子關系。注意這里的父子不是繼承,而是使用組合的。值得注意的是,雙親委派是 Java 推薦的類加載方式,而不是強制的。簡單看一下load調用的代碼就一目了然了。

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                long t1 = System.nanoTime();
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

通過這種雙親委派的模型,就確保了系統的穩定性,不用擔心系統類被用戶惡意替代,因為最終都是從上至下查找的,就給類先天帶上了優先級的層次關系。同時每個類加載器都有一個獨立的命名空間,所以同一個類被不同的類加載器加載也會認為是不一樣的。

ClassLoader還有其他的妙用,比如動態替換類,熱部署,SPI相關的服務等,這里不再展開講述。

驗證

驗證做的工作,就是驗證class的二進制代碼的結構是否完整且正確的。

如果驗證過程中出現了錯誤,就會拋出VerifyError。

準備

準備階段負責創建類靜態字段,并把它初始化成默認值。這里的初始化,并不是調用代碼的靜態字段賦值語句和靜態代碼塊,而是把每個靜態字段初始化成 JVM 給定的初始值,具體的見 JLS 4.12.5

我大概列一下:

  • byte = 0
  • short = 0
  • int = 0
  • float = 0.0f
  • double = 0.0d
  • char = '\u0000'
  • boolean = false
  • reference = null

當然也有例外,字段被認為是 constant variable 時,也會在準備階段被賦值。

這里有個簡單的小例子:

package me.ele.test;

/**
 * Created by gengwanpeng on 16/8/12.
 */
public class Main {

    public static void main(String[] args) {
        System.out.println(A.j);
    }

    static class A {

        static int i = 2;

        static final int j = 3;

        static final int k;

        static {
            i = 3;
            k = 3;
            System.out.println("hello world");
        }
    }
}

main函數執行后,輸出的結果是

3

可見類 A 是已經被準備過了,但是尚未初始化。隨后,我們將 j 換成 i 或者 k ,都會輸出:

hello world
3

可見此時類A才真正初始化完成。

我們借助 JDK 的 javap 工具輸入如下命令:javap -c -sysinfo -constants me.ele.test.Main.A,可以看到輸出的結果:

Classfile /Users/gengwanpeng/dev/AndroidStudioProject/SimpleTest/build/classes/main/me/ele/test/Main$A.class
  Last modified 2016-8-12; size 645 bytes
  MD5 checksum afd7f1dfd37b11f3f3f6d555ea4f7770
  Compiled from "Main.java"
class me.ele.test.Main$A {
  static int i;

  static final int j = 3;

  static final int k;

  me.ele.test.Main$A();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  static {};
    Code:
       0: iconst_2
       1: putstatic     #2                  // Field i:I
       4: iconst_3
       5: putstatic     #2                  // Field i:I
       8: iconst_3
       9: putstatic     #3                  // Field k:I
      12: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
      15: ldc           #5                  // String hello world
      17: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      20: return
}

我們看到,只有 j 這個字段被認為是常量3, 其他兩個字段都是在類初始化時在<clinit>函數內被賦值的。根據初始化的規則:靜態非常量被使用會觸發初始化。因此有了上面的結果。

解析(可選)

當前的類可能會引用其他的類,因此需要把符號引用進行解析。如果被引用的類,尚未被加載,會把該類名傳遞給調用者的 ClassLoader 進行類加載的過程,這是一個遞歸的過程。

為什么說這個過程是可選的呢,因為虛擬機沒有明確規定到底什么時候進行解析的過程,但是必須在被16個操作符號引用的字節碼指令調用前完成,比如 GETSTATIC, GETFIELD, NEW 等。

因此激進的做法,是在準備階段完成后就進行解析,解析又會遞歸的觸發類加載,因此這種做法有點類似于多年前C語言的靜態鏈接

而另外一種實現就是選擇在它實際被調用時才進行解析。如果所有類都是這么實現的話,就是類似于一種懶加載的解析策略。在這種情況下,哪怕依賴的類并不存在,只要不調用它,就不會引起問題。現在的商用虛擬機大多數的實現都是這種。

類初始化

初始化就是真正執行初始化的代碼,對于類而言是兩部分,由靜態字段的賦值語句和靜態語句塊組成。而對接口而言就僅僅是靜態字段的賦值語句。

那么上面提到,對于初始化虛擬機是有明確的規范的,當且僅當以下場景第一次發生時,會觸發一個類T的初始化。

  • T是一個類,創建T的一個實例時
  • T的靜態方法被調用時
  • T的靜態字段被賦值時
  • T的靜態非常量字段被使用時
  • T是一個頂層類,在T內部嵌套的斷言被執行時
  • Class 和 java.lang.reflect 包中某些反射T的方法被調用時

這里就引發了很多有意思的面試題,這是 JLS 里的一段代碼:

class Super {
    static int taxi = 1729;
}
class Sub extends Super {
    static { System.out.print("Sub "); }
}
public class Test {
    public static void main(String[] args) {
        System.out.println(Sub.taxi);
    }
}

輸出的結果是:

1729

這里只有Super.taxi被使用了, 雖然字面上有Sub這個類,但是因為不滿足初始化條件,所以沒有被初始化。

來自 QZone 的 Android 熱修復方案

原理

前面我們提到了我們的代碼,其實是通過 ClassLoader 加載到內存中去的, Android 的類加載和 Java 是相似的,不過 Android 沒有 ExtClassLoader, 而且 SystemClassLoader 的實例不再是 AppClassLoader, 而是 PathClassLoader。

之前我們說過 SystemClassLoader 是負責加載我們寫的代碼的,也就是說當App運行時,某處的代碼操作了符號引用,導致新類被加載,便會調用 PathClassLoader 的 loadClass。

我們追蹤一下代碼,發現 PathClassLoader 是繼承于 BaseDexClassLoader:

public BaseDexClassLoader(String dexPath, File optimizedDirectory,
        String libraryPath, ClassLoader parent) {
    super(parent);

    this.originalPath = dexPath;
    this.pathList =
        new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    Class clazz = pathList.findClass(name);

    if (clazz == null) {
        throw new ClassNotFoundException(name);
    }

    return clazz;
}

@Override
protected URL findResource(String name) {
    return pathList.findResource(name);
}

我們著重關注以上的代碼,發現資源文件的讀取和類的查找都委托給了一個叫 DexPathList 的類:

/**
 * Finds the named class in one of the dex files pointed at by
 * this instance. This will find the one in the earliest listed
 * path element. If the class is found but has not yet been
 * defined, then this method will define it in the defining
 * context that this instance was constructed with.
 *
 * @return the named class or {@code null} if the class is not
 * found in any of the dex files
 */
public Class findClass(String name) {
    for (Element element : dexElements) {
        DexFile dex = element.dexFile;

        if (dex != null) {
            Class clazz = dex.loadClassBinaryName(name, definingContext);
            if (clazz != null) {
                return clazz;
            }
        }
    }

    return null;
}

/**
 * Finds the named resource in one of the zip/jar files pointed at
 * by this instance. This will find the one in the earliest listed
 * path element.
 *
 * @return a URL to the named resource or {@code null} if the
 * resource is not found in any of the zip/jar files
 */
public URL findResource(String name) {
    for (Element element : dexElements) {
        URL url = element.findResource(name);
        if (url != null) {
            return url;
        }
    }

    return null;
}

這里就是最有趣的部分,從代碼和注釋我們都可以看到,無論是找類還是資源文件, DexPathList 都是在 Element 數組內按順序查找,找到第一個結果就返回。

這也就是我們熱修復技術的基礎,當我們線上的代碼發現了 BUG 的時候, 我們可以利用Android 類加載的機制, 生成一個同包名同名的新類做成一個補丁包,并通過接口發到客戶端,在下次啟動時,通過反射修改系統的 PathClassLoader 實例的 DexPathList ,將我們的補丁包放在列表的最前面, 當需要加載這個類的時候便會優先取到我們補丁包里的類,而不再是有 BUG 的類。

同樣,資源文件也可以通過這種方式修復。但是要注意這種方式只能在下次啟動的時候才能熱修復代碼。

要解決的問題

當然,這件事不僅僅是上面說的那么簡單的。
這種熱修復方式,要解決3個問題。

混淆

現在的項目中的代碼,基本上都被混淆過了, 因此如果混淆后的類出了 BUG, 那么我們的新類也要被混淆成同名類。

其實這個問題不算麻煩,只要保留下 mapping 文件,在以后打包混淆的時候都 apply mapping 即可。

資源

上面我們說了,Android的類加載機制也可以修復資源的錯誤。但是資源文件的查找,大多是通過 R 文件的 id 指定的,最后會在 resources.arsc 生成每個 id 也就是 int 值對應的資源的索引。

所以,如果我們要熱修復資源,就要保證2點:

  • 如果是替換已存在的資源,那么新舊的資源 id 要保持一致
  • 如果是新增加的資源,那么資源 id 不能與已有的沖突

這一點,我們部門的同事提出了一個解決方案,就是 aapt 支持直接指定 name 對應的 id。
通過創建一個 public.xml, 第一次通過腳本將 R 文件中的 int 值導出到 public.xml中。這樣做有一個問題,就是后續每次新增資源,都要手動添加 name 對應的 R 文件的 id。

當然對于熱修復帶來的好處,這點付出還是值得的。

CLASS_ISPREVERIFIED

上面這個小標題是什么意思呢,其實在 dex 轉換成 odex 時虛擬機做的優化。如果某個類的方法直接引用到的類和該類都在一個dex中,就會被打上該標志,用來提高性能。

這個問題也是可以解決的,QZone的解決方案,就是通過字節碼工具,在每個類的構造函數,引用了一個專門的類。 然后這個類會被打包到一個單獨的dex中,這樣所有的類都會引用不同 dex 中的類,也就不會被打上這個標志

當然,這么做就破壞了虛擬機的優化,可能會導致性能有所損失。

結語

本文到這里就告一段落了。一行代碼的執行,虛擬機在背后默默地做了很多工作。了解這些流程,有助于我們寫出優質的代碼。

這里是筆者的個人博客地址: dieyidezui.com

也歡迎關注筆者的微信公眾號,會不定期的分享一些內容給大家


參考文獻

The Java Language Specification

深入理解Java虛擬機

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容