近日笔者在研读 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虚拟机