开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第11天,点击查看活动详情
一、类加载机制
1.定义:
把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
在Java语言里,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点来实现的。
2.类的生命周期:
加载,验证,准备,解析,初始化,使用和卸载。其中验证,准备,解析3个部分统称为连接。
这7个阶段发生顺序如下图:
加载,验证,准备,初始化,卸载这5个阶段的顺序是确定的,而解析阶段则不一定:它在某些情况下可以在初始化完成后在开始,这是为了支持Java语言的运行时绑定。
其中加载,验证,准备,解析及初始化是属于类加载机制中的步骤。注意此处的加载不等同于类加载。
3.触发类加载的条件:
①.遇到new,getstatic,putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候,读取或设置一个类的静态字段的时候(被final修饰,已在编译期把结果放入常量池的静态字段除外),以及调用一个类的静态方法的时候。
②.使用java.lang.reflect包的方法对类进行反射调用的时候。
③.当初始化一个类的时候,发现其父类还没有进行过初始化,则需要先出发父类的初始化。
④.当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
⑤.当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出发初始化。
4.类加载的具体过程:
加载:
①.通过一个类的全限定名来获取定义此类的二进制字节流
②.将这个字节流所代表的静态存储结构转换为方法区内的运行时数据结构
③.在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
验证:
是连接阶段的第一步,目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
包含四个阶段的校验动作
a.文件格式验证
验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
b.元数据验证
对类的元数据信息进行语义校验,是否不存在不符合Java语言规范的元数据信息
c.字节码验证
最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的。对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
d.符号引用验证
最后一个阶段的校验发生在虚拟机将符号引用转换为直接引用的时候,这个转换动作将在连接的第三个阶段——解析阶段中发生。
符号验证的目的是确保解析动作能正常进行。
准备:
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。这些变量所使用的内存都将在方法区中分配。只包括类变量。初始值“通常情况”下是数据类型的零值。
“特殊情况”下,如果类字段的字段属性表中存在ConstantValue属性,那么在准备阶段变量的值就会被初始化为ConstantValue属性所指定的值。
解析:
虚拟机将常量池内的符号引用替换为直接引用的过程。
“动态解析”的含义就是必须等到程序实际运行到这条指令的时候,解析动作才能进行。相对的,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有开始执行代码时就进行解析。
初始化:
类加载过程中的最后一步。
初始化阶段是执行类构造器<clinit>()方法的过程。
<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。
<clinit>()与类的构造函数不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。
简单地说,初始化就是对类变量进行赋值及执行静态代码块。
二、类加载器
通过上述的了解,我们已经知道了类加载机制的大概流程及各个部分的功能。其中加载部分的功能是将类的class文件读入内存,并为之创建一个java.lang.Class对象。这部分功能就是由类加载器来实现的。
Java类加载器分类:
不同的类加载器负责加载不同的类。主要分为两类。
启动类加载器(Bootstrap ClassLoader): 由C++语言实现(针对HotSpot),负责将存放在\lib目录或-Xbootclasspath参数指定的路径中的类库加载到内存中,即负责加载Java的核心类。
其他类加载器: 由Java语言实现,继承自抽象类ClassLoader。如:
扩展类加载器(Extension ClassLoader): 负责加载\lib\ext目录或java.ext.dirs系统变量指定的路径中的所有类库,即负责加载Java扩展的核心类之外的类。
应用程序类加载器(Application ClassLoader): 负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器,通过ClassLoader.getSystemClassLoader()方法直接获取。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。
以上2大类,3小类类加载器基本上负责了所有Java类的加载。下面我们来具体了解上述几个类加载器实现类加载过程时相互配合协作的流程。
三、双亲委派模型
双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
双亲委派模型的代码实现
ClassLoader中loadClass方法实现了双亲委派模型
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
//检查该类是否已经加载过
Class c = findLoadedClass(name);
if (c == null) {
//如果该类没有加载,则进入该分支
long t0 = System.nanoTime();
try {
if (parent != null) {
//当父类的加载器不为空,则通过父类的loadClass来加载该类
c = parent.loadClass(name, false);
} else {
//当父类的加载器为空,则调用启动类加载器来加载该类
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//非空父类的类加载器无法找到相应的类,则抛出异常
}
if (c == null) {
//当父类加载器无法加载时,则调用findClass方法来加载该类
long t1 = System.nanoTime();
c = findClass(name); //用户可通过覆写该方法,来自定义类加载器
//用于统计类加载器相关的信息
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
//对类进行link操作
resolveClass(c);
}
return c;
}
}
整个流程大致如下:
a.首先,检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回。
b.如果此类没有加载过,那么,再判断一下是否有父加载器;如果有父加载器,则由父加载器加载(即调用parent.loadClass(name, false);).或者是调用bootstrap类加载器来加载。
c.如果父加载器及bootstrap类加载器都没有找到指定的类,那么调用当前类加载器的findClass方法来完成类加载。
这样做有什么好处呢?
- 防止同一个.class文件重复加载;
- 对于任意一个类确保在JVM中的唯一性。一个类在JVM中的唯一性是由加载它的ClassLoader和这个类的全名共同确立的;
- 保证系统类.class文件不能被篡改,通过双亲委派机制可以保证系统类的加载逻辑不会被篡改。
四、 Andorid类加载机制
Android平台整个应用层代码是基于Java语言编写。前文有提到,作为移动端平台,考虑到机器资源、性能的问题,它不能直接把Java原生的JVM照搬使用。具体来说,Android平台在对Java运行时环境所做的优化主要有两点:
- 1.定义dex文件格式,使字节码文件的结构更加紧凑,减少代码编译产物大小
- 2.开发Android平台定制JVM:Dalvik(包括后续的ART),以加载运行dex格式的Java程序
可以简单理解:Android平台的dex文件就像PC端的jar包一样,是一组.class文件的集合。从前文的背景介绍可知:既然Android平台对字节码的二进制文件有定制,其JVM的类加载器必然也有定制。
区别于标准JVM以Class文件为输入的字节码文件,Android虚拟机采用更为紧凑的Dex文件作为输入文件,无论是Dalvik VM还是ART VM。这样一来,类加载器自然也会有所改变。
Android系统类加载器主要有PathClassLoader、DexClassLoader、BootClassLoader、 BaseDexClassLoader等,最终都继承自ClassLoader
如图:
ClassLoader
ClassLoader中重要的方法是loadClass(String name),其他的子类都继承了此方法且没有进行复写。
从上图可以看出在加载类时首先判断这个类是否之前被加载过,如果有则直接返回,如果没有则首先尝试让parent ClassLoader进行加载,加载不成功才在自己的findClass中进行加载,这和java虚拟机中常见的双亲委派模型一致的。
BootClassLoader
和java虚拟机中不同的是BootClassLoader是ClassLoader内部类,由java代码实现而不是c++实现,是Android平台上所有ClassLoader的最终parent,这个内部类是包内可见,所以我们没法使用。
URLClassLoader
只能用于加载jar文件,但是由于 dalvik 不能直接识别jar,所以在 Android 中无法使用这个加载器。
PathClassLoader
PathClassLoader它通常被系统用来加载Apk中自带的Dex文件,它的构造函数中少了一个参数:optimizedDirectory,这是因为PathClassLoader定义了默认的optimizedDirectory参数:/data/dalvik-cache/,因此,我们无法自定义Dex文件的解压路径,所以我们加载类时,一般都使用DexClassLoader。
DexClassLoader
可以在磁盘中加载.dex或者是.apk文件,但是本质上都是加载属于Android 的字节码文件:Dex文件。
加载流程
Android中使用的类加载器主要包括PathClassLoader和DexClassLoader两个,这两个加载器在Android8以后已经完全一样了,他们都是继承自BaseDexClassLoader。
我们看看父类的加载代码
// BaseDexClassLoader
//创建
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(parent);
this.originalPath = dexPath;
this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}
首先在构造的时候初始化了DexPathList文件列表,文件列表如何生成的?进来看看
DexPathList初始化过程,主要功能是收集以下两个变量信息:
- dexElements: 根据多路径的分隔符“;”将dexPath转换成File列表,记录所有的dexFile
- nativeLibraryPathElements: 记录所有的Native动态库, 包括app目录的native库和系统目录的native库。
//加载
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class clazz = pathList.findClass(name);
if (clazz == null) {
throw new ClassNotFoundException(name);
}
return clazz;
}
接下来看看pathList的findClass方法是怎么实现的。
//DexPathList的findClass
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;
}
在findClass方法中遍历数组dexElements获取到里面的dex返回,可以知道dexElements里面保存的是apk中所有的dex,具体的保存过程这里就不贴代码里,保存过程是在上面创建DexPathList对象的构造方法中进行的。
BaseDexClassLoader中有个DexPathList实例pathList,pathList中包含一个DexFile的数组dexElements,dexElements数组就是这些dex文件的集合,dex文件就是内部存储路径optimizedDirectory缓存起来的。如果不分包一般这个数组只有一个Element元素,也就只有一个DexFile文件,而对于类加载呢,就是遍历这个集合,通过DexFile去寻找。简单来说就是:
- 在
DexClassLoader的findClass方法中通过一个DexPathList对象findClass()方法来获取class - 在
DexPathList的findClass方法中,对之前构造好dexElements数组集合进行遍历,一旦找到类名与name相同的类时,就直接返回这个class,找不到则返回null。
五、 热修复和加载第三方插件原理
ClassLoader在加载class的时候,要是有两个dex中包含了相同的.class文件,ClassLoader会按照顺序优先的原则加载class,即真正被加载的class是位置在前面的那个dex中的class文件。基于这一点可以实现Android代码热修复,我们可以将需要修复的class文件整体打包成一个dex文件,并放置到dexElements数组的最前面,这样就实现了类的替换加载的功能。
如何放置?当然是Hook,通过Hook的方式我们的dex插入到宿主apk的 dexElements数组中,这样系统在加载宿主的dex的时候就会加载插件的dex,通过这样的方式就骗过了系统。具体细节知识后续文章更新。
源码获取地址安卓源码官方地址