一. 概述
Android热更新技术诣在解决线上版本的BUG修复,以clasloader类加载机制为核心,在不发布新版本的情况下让线上应用有能力进行全量或者增量更新
本文浅析classloader类加载机制与其在于热修复中的应用
ART和Dalvik
什么是Dalvik
Dalvik 是 Google 公司自己设计用于 Android 平台的 Java 虚拟机,Android 工程师编写的 Java 或者 Kotlin 代码最终都是在这台虚拟机中被执行的。在 Android 5.0 之前叫作 DVM,5.0 之后改为 ART(Android Runtime)。
在整个 Android 操作系统体系中,ART 位于以下图中红框位置:
其实称 DVM/ART 为 Android 版的 Java 虚拟机,这种说法并不是很准确。虚拟机必须符合 Java 虚拟机规范,也就是要通过 JCM(Java Compliance Kit)的测试并获得授权,但是 DVM/ART 并没有得到授权。
Class 的来龙去脉
Java 能够实现"一次编译,到处运行”,这其中 class 文件要占大部分功劳。为了让 Java 语言具有良好的跨平台能力,Java 独具匠心的提供了一种可以在所有平台上都能使用的一种中间代码——字节码类文件(.class文件)。有了字节码,无论是哪种平台(如:Mac、Windows、Linux 等),只要安装了虚拟机都可以直接运行字节码。并且,有了字节码,也解除了 Java 虚拟机和 Java 语言之间的耦合。
其实,Java 虚拟机当初被设计出来的目的就不单单是只运行 Java 这一种语言。目前 Java 虚拟机已经可以支持很多除 Java 语言以外的其他语言了,如 Groovy、JRuby、Jython、Scala 等。之所以可以支持其他语言,是因为这些语言经过编译之后也可以生成能够被 JVM 解析并执行的字节码文件。而虚拟机并不关心字节码是由哪种语言编译而来的。如下图所示:
Dex 文件
传统 Class 文件是由一个 Java 源码文件生成的 .Class 文件,而 Android 是把所有 Class 文件进行合并优化,然后生成一个最终的 class.dex 文件。dex 文件去除了 class 文件中的冗余信息(比如重复字符常量),并且结构更加紧凑,因此在 dex 解析阶段,可以减少 I/O 操作,提高了类的查找速度。
dexopt与dexaot
- dexopt
在Dalvik虚拟机加载一个dex文件时,会对 dex 文件进行验证和优化,得到odex(Optimized dex) 文件。这个文件和 dex 文件很像,只是使用了一些优化操作码。 - dexaot
ART 预先编译机制,在安装时对 dex 文件执行dexopt优化之后,再将odex进行 AOT 提前编译操作,编译为OAT(实际上是ELF文件)可执行文件(机器码)。(相比做过odex优化,未做过优化的dex转换成OAT要花费更长的时间)
ART 和 Dalvik 对比
1、在Dalvik下,应用运行需要解释执行,常用热点代码通过即时编译器(JIT)将字节码转换为机器码,运行效率低。而在ART 环境中,应用在安装时,字节码预编译(AOT)成机器码,安装慢了,但运行效率会提高。
2、ART占用空间比Dalvik大(字节码变为机器码), “空间换时间"。
3、预编译也可以明显改善电池续航,因为应用程序每次运行时不用重复编译了,从而减少了 CPU 的使用频率,降低了能耗。
二.ClassLoader
一个完整的 Java 程序是由多个 .class 文件组成的,在程序运行过程中,需要将这些 .class 文件加载到 JVM 中才可以使用。而负责加载这些 .class 文件的就是类加载器(ClassLoader)。
Java 中 ClassLoader
JVM 中自带 3 个类加载器:
1.启动类加载器 BootstrapClassLoader
2.扩展类加载器 ExtClassLoader (JDK 1.9 之后,改名为 PlatformClassLoader)
3.系统加载器 APPClassLoader
以上 3 者在 JVM 中有各自分工,但是又互相有依赖。
APPClassLoader 系统类加载器
部分源码如下:
static class AppClassLoader extends URLClassLoader {
public static ClassLoader getAppClassLoader(final ClassLoader extcl)
throws IOException
{
final String s = System.getProperty("java.class.path");
final File[] path = (s == null) new File[0] : getClassPath(s);
return AccessController.doPrivileged(
new PrivilegedAction<AppClassLoader>() {
public AppClassLoader run() {
URL[] urls =
(s == null) new URL[0] : pathToURLs(path);
return new AppClassLoader(urls, extcl);
}
});
}
}
AppClassLoader 主要加载系统属性“java.class.path”配置下类文件,也就是环境变量 CLASS_PATH 配置的路径。因此 AppClassLoader 是面向用户的类加载器,我们自己编写的代码以及使用的第三方 jar 包通常都是由它来加载的。
ExtClassLoader 扩展类加载器
部分源码如下
static class ExtClassLoader extends URLClassLoader {
static {
ClassLoader.registerAsParallelCapable();
}
private static ExtClassLoader createExtClassLoader() throws IOException {
try {
return AccessController.doPrivileged(
new PrivilegedExceptionAction<ExtClassLoader>() {
public ExtClassLoader run() throws IOException {
final File[] dirs = getExtDirs();
int len = dirs.length;
for (int i = 0; i < len; i++) {
MetaIndex.registerDirectory(dirs[i]);
}
return new ExtClassLoader(dirs);
}
});
} catch (java.security.PrivilegedActionException e) {
throw (IOException) e.getException();
}
}
private static File[] getExtDirs() {
String s = System.getProperty("java.ext.dirs");
File[] dirs;
...
}
}
可以看出,ExtClassLoader 加载系统属性“java.ext.dirs”配置下类文件。
BootstrapClassLoader 启动类加载器
BootstrapClassLoader 同上面的两种 ClassLoader 不太一样。
首先,它并不是使用 Java 代码实现的,而是由 C/C++ 语言编写的,它本身属于虚拟机的一部分。因此我们无法在 Java 代码中直接获取它的引用。如果尝试在 Java 层获取 BootstrapClassLoader 的引用,系统会返回 null。
Android中ClassLoader
本质上,Android 和传统的 JVM 是一样的,也需要通过 ClassLoader 将目标类加载到内存,类加载器之间也符合双亲委派模型。但是在 Android 中, ClassLoader 的加载细节有略微的差别。
在 Android 虚拟机里是无法直接运行 .class 文件的,Android 会将所有的 .class 文件转换成一个 .dex 文件,并且 Android 将加载 .dex 文件的实现封装在 BaseDexClassLoader 中,而我们一般只使用它的两个子类:PathClassLoader 和 DexClassLoader。
PathClassLoader
PathClassLoader 用来加载系统 apk 和被安装到手机中的 apk 内的 dex 文件。它的 2 个构造函数如下:
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
}
public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
}
}
参数说明
- dexPath:dex 文件路径,或者包含 dex 文件的 jar 包路径;
- librarySearchPath:C/C++ native 库的路径。
DexClassLoader
对比 PathClassLoader 只能加载已经安装应用的 dex 或 apk 文件,DexClassLoader 则没有此限制,可以从 SD 卡上加载包含 class.dex 的 .jar 和 .apk 文件,这也是插件化和热修复的基础,在不需要安装应用的情况下,完成需要使用的 dex 的加载。
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
}
}
参数说明:
- dexPath:包含 class.dex 的 apk、jar 文件路径 ,多个路径用文件分隔符(默认是“:”)分隔。
- optimizedDirectory:用来缓存优化的 dex 文件的路径,即从 apk 或 jar 文件中提取出来的 dex 文件。该路径不可以为空,且应该是应用私有的,有读写权限的路径。
它们之间的继承关系
双亲委派模式
所谓双亲委派模式就是,当类加载器收到加载类或资源的请求时,通常都是先委托给父类加载器加载,也就是说,只有当父类加载器找不到指定类或资源时,自身才会执行实际的类加载过程。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
// 检查class是否有被加载
Class c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
//如果parent不为null,则调用parent的loadClass进行加载
c = parent.loadClass(name, false);
} else {
//parent为null,则调用BootClassLoader进行加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
// 如果都找不到就自己查找
long t1 = System.nanoTime();
c = findClass(name);
}
}
return c;
}
委托:如上所述,委托机制是指将加载一个类的请求交给父类加载器,如果这个父类加载器不能够找到或者加载这个类,那么再加载它。
可见性:可见性的原理是子类的加载器可以看见所有的父类加载器加载的类,而父类加载器看不到子类加载器加载的类。
单一性:单一性原理是指仅加载一个类一次,这是由委托机制确保子类加载器不会再次加载父类加载器加载过的类。
findClass
可以看到在所有父ClassLoader无法加载Class时,则会调用自己的findClass方法。findClass在ClassLoader中的定义为:
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
其实任何ClassLoader子类,都可以重写loadClass与findClass。一般如果你不想使用双亲委托,则重写loadClass修改其实现。而重写findClass则表示在双亲委托下,父ClassLoader都找不到Class的情况下,定义自己如何去查找一个Class。而我们的PathClassLoader会自己负责加载MainActivity这样的程序中自己编写的类,利用双亲委托父ClassLoader加载Framework中的Activity。说明PathClassLoader并没有重写loadClass,因此我们可以来看看PathClassLoader中的 findClass 是如何实现的。
public BaseDexClassLoader(String dexPath, File optimizedDirectory,String
librarySearchPath, ClassLoader parent) {
super(parent);
this.pathList = new DexPathList(this, dexPath, librarySearchPath,
optimizedDirectory);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
//查找指定的class
Class c = pathList.findClass(name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}
实现非常简单,从pathList中查找class。继续查看DexPathList:
public DexPathList(ClassLoader definingContext, String dexPath,
String librarySearchPath, File optimizedDirectory) {
//.........
// splitDexPath 实现为返回 List<File>.add(dexPath)
// makeDexElements 会去 List<File>.add(dexPath) 中使用DexFile加载dex文件返回 Element数组
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
suppressedExceptions, definingContext);
//.........
}
public Class findClass(String name, List<Throwable> suppressed) {
//从element中获得代表Dex的 DexFile
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
//查找class
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
Class 对象在执行引擎中的初始化过程
一个 class 文件被加载到内存中需要经过 3 大步:装载、链接、初始化。其中链接又可以细分为:验证、准备、解析 3 小步。因此用一张图来描述 class 文件加载到内存的步骤如下所示。
装载
装载是指 Java 虚拟机查找 .class 文件并生成字节流,然后根据字节流创建 java.lang.Class 对象的过程。
这一过程主要完成以下 3 件事:
1)ClassLoader 通过一个类的全限定名(包名 + 类名)来查找 .class 文件,并生成二进制字节流:其中 class 字节码文件的来源不一定是 .class 文件,也可以是 jar 包、zip 包,甚至是来源于网络的字节流。
2)把 .class 文件的各个部分分别解析(parse)为 JVM 内部特定的数据结构,并存储在方法区。
3)在内存中创建一个 java.lang.Class 类型的对象:
接下来程序在运行过程中所有对该类的访问都通过这个类对象,也就是这个 Class 类型的类对象是提供给外界访问该类的接口。
加载时机
一个项目经过编译之后,往往会生成大量的 .class 文件。当程序运行时,JVM 并不会一次性的将这些 .class 文件全部加载到内存中。Java 虚拟机规范中并没有严格规定,不同的虚拟机实现会有不同实现。不过以下两种情况一般会对 class 进行装载操作。
- 隐式装载:在程序运行过程中,当碰到通过 new 等方式生成对象时,系统会隐式调用 ClassLoader 去装载对应的 class 到内存中;
- 显示装载:在编写源代码时,主动调用 Class.forName() 等方法也会进行 class 装载操作,这种方式通常称为显示装载。
链接
链接过程分为 3 步:验证、准备、解析。
验证:
验证是链接的第一步,目的是为了确保 .class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危及虚拟机本身的安全。主要包含以下几个方面的检验。
1.文件格式检验:检验字节流是否符合 class 文件格式的规范,并且能被当前版本的虚拟机处理。
2.元数据检验:对字节码描述的信息进行语义分析,以保证其描述的内容符合 Java 语言规范的要求。
3.字节码检验:通过数据流和控制流分析,确定程序语义是合法、符合逻辑的。
4.符号引用检验:符号引用检验可以看作是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。
准备
准备是链接的第 2 步,这一阶段的主要目的是为类中的静态变量分配内存。
解析
解析是链接的最后一步,这一阶段的任务是把常量池中的符号引用转换为直接引用,也就是具体的内存地址。在这一阶段,JVM 会将常量池中的类、接口名、字段名、方法名等转换为具体的内存地址。
初始化
class 加载的最后一步,这一阶段是执行类构造器<clinit>方法的过程,并真正初始化类变量。
初始化的时机
对于装载阶段,JVM 并没有规范何时具体执行。但是对于初始化,JVM 规范中严格规定了 class 初始化的时机,主要有以下几种情况会触发 class 的初始化:
1.虚拟机启动时,初始化包含 main 方法的主类;
2.遇到 new 指令创建对象实例时,如果目标对象类没有被初始化则进行初始化操作;
3.当遇到访问静态方法或者静态字段的指令时,如果目标对象类没有被初始化则进行初始化操作;
4.子类的初始化过程如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化;
5.使用反射 API 进行反射调用时,如果类没有进行过初始化则需要先触发其初始化;
6.第一次调用 java.lang.invoke.MethodHandle 实例时,需要初始化 MethodHandle 指向方法所在的类。
三.热修复
PathClassLoader中存在一个Element数组,Element类中存在一个dexFile成员表示dex文件,即:APK中有X个dex,则Element数组就有X个元素。
在PathClassLoader中的Element数组为:[patch.dex , classes.dex , classes2.dex]。如果存在Key.class位于patch.dex与classes2.dex中都存在一份,当进行类查找时,循环获得dexElements中的DexFile,查找到了Key.class则立即返回,不会再管后续的element中的DexFile是否能加载到Key.class了。
因此实际上,一种热修复实现可以将出现Bug的class单独的制作一份hotfix.dex文件(补丁包),然后在程序启动时,从服务器下载fix.dex保存到某个路径,再通过hotfix.dex的文件路径,用其创建Element对象,然后将这个Element对象插入到我们程序的类加载器PathClassLoader的pathList中的dexElements数组头部。这样在加载出现Bug的class时会优先加载hotfix.dex中的修复类,从而解决Bug。
实践
package com.yupaopao.hotfix;
public class MyTitle {
public String getTitle() {
return "I am original Title";
}
}
以上MyTitle类中getTitle方法存在bug,需要通过预先加载fix.dex补丁包加载正确的MyTitle类
以下为正确MyTitle类
package com.yupaopao.hotfix;
public class MyTitle {
public String getTitle() {
return "I am hotfix Title";
}
}
将MyTitle.java打包成MyTitle.jar,然后通过 dx 工具将生成的 MyTitle.jar 包中的 class 文件优化为 dex 文件。
dx --dex --output=MyTitle.jar
上述 MyTitle.jar 就是我们最终需要用作 hotfix 的 jar 包。
首先将 HotFix patch 保存到本地目录下。一般在真实项目中,我们可以通过向后端发送请求的方式,将最新的 HotFix patch 下载到本地中,使用 DexClassLoader 本地目录中的MyTitle类
package com.yupaopao.hotfix;
import android.app.Application;
import android.content.Context;
import java.io.File;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import dalvik.system.BaseDexClassLoader;
import dalvik.system.PathClassLoader;
public class HotfixApplication extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
File apk = new File(getCacheDir() + "/hotfix.dex");
if (apk.exists()) {
try {
ClassLoader classLoader = getClassLoader();
//默认类加载器
Class loaderClass = BaseDexClassLoader.class;
Field pathListField = loaderClass.getDeclaredField("pathList");
pathListField.setAccessible(true);
Object pathListObject = pathListField.get(classLoader);
Class pathListClass = pathListObject.getClass();
Field dexElementsField = pathListClass.getDeclaredField("dexElements");
dexElementsField.setAccessible(true);
//默认类加载器中dexElements数组
Object dexElementsObject = dexElementsField.get(pathListObject);
//新建类加载器加载hotfix.dex中的类
PathClassLoader newClassLoader = new PathClassLoader(apk.getPath(), null);
Object newPathListObject = pathListField.get(newClassLoader);
Object newDexElementsObject = dexElementsField.get(newPathListObject);
int oldLength = Array.getLength(dexElementsObject);
int newLength = Array.getLength(newDexElementsObject);
Object concatDexElementsObject = Array.newInstance(dexElementsObject.getClass().getComponentType(), oldLength + newLength);
for (int i = 0; i < newLength; i++) {
Array.set(concatDexElementsObject, i, Array.get(newDexElementsObject, i));
}
for (int i = 0; i < oldLength; i++) {
Array.set(concatDexElementsObject, newLength + i, Array.get(dexElementsObject, i));
}
//将hotfix.dex中的类追加进默认类加载器中
dexElementsField.set(pathListObject, concatDexElementsObject);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
}
应用重启后在Application初始化时类加载器便会先加载修复过后的MyTitle类,从而修复已有bug.