前言:在目前的市面项目开发中普遍的在使用一些大牛或者团队开源的框架可以更加保证项目产品的高效稳定迭代,比如之前说过的apk增量更新、热修复等等。在这些框架的原理里都没有绕过Android的类加载这一块,或者说也都是通过对类加载的流程做了一定的干预和插入才最终实现了想要的效果。所以今天我也抽时间再次回头学习学习类加载这块的知识。
先简单介绍一下相关的概念吧。
类的加载是由类加载器完成的,类加载器包括:启动类加载器(BootStrap)、扩展类加载器(ExtClassLoader)、应用程序类加载器(AppClassLoader)和自定义类加载器(java.lang.ClassLoader的子类)。
为什么需要自定义类加载器呢?
一方面是由于java代码很容易被反编译,如果需要对自己的代码加密的话,可以对编译后的代码进行加密,然后再通过实现自己的自定义类加载器进行解密,最后再加载。
另一方面也有可能从非标准的来源加载代码,比如从网络来源,那就需要自己实现一个类加载器,从指定源进行加载。
当一个类加载器负责加载某个 Class 时,该 Class 所依赖的和引用的其他 Class 也将由该类加载器负责载入,除非显式指定另外一个类加载器来载入。
如果一个类加载器收到了 Class 加载的请求,它首先不会自己去尝试加载这个 Class ,而是把请求委托给父加载器去完成,依次向上。因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的 Class 时,即无法完成该加载,子加载器才会尝试自己去加载该 Class 。
这样做的好处是:
1. 可以避免同一个类被多次加载 ;
2. 更加安全,Java 核心 API 中定义的类不会被随意替换 ;
3. 可以保证每个加载器只能加载自己范围内的类;
所有加载过的 Class 都会被缓存,当程序中需要使用某个 Class 时,类加载器先从缓存区寻找该 Class ,只有当缓存区不存在时,系统才会去读取该 Class 对应的二进制数据,并将其转换成 Class 对象,存入缓存区。
这就是为什么修改了 Class 后,必须重启JVM,程序的修改才会生效的原因。
1、loadClass(String name, boolean resolve)
protected Class> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 先从缓存查找该class对象,找到就不用重新加载
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) {
// 如果都没有找到,则通过自定义实现的findClass去查找并加载
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;
}
}
代码执行流程是:读取缓存 -> 调用父类加载器 -> 如果没有父类 -> 自己启动类加载器 -> 调用自己的 findClass() 方法。
2、findClass(String name)
findClass是由自己负责加载类的方法,在自定义类加载器时,需要重写该方法并编写加载规则,取得要加载类的字节码后转换成流,然后调用defineClass()方法生成类的 Class 对象。
3、defineClass(byte[] b, int off, int len)
defineClass 是负责将获取到的 byte 字节流解析成 JVM 能够识别的 Class 对象。
4、resolveClass(Class≺?≻ c)
resolveClass 是负责解析 Class 对象,即将字节码文件中的符号引用转换为直接引用。
符号引用与直接引用的区别是什么?
类从文件加载到内存再到从内存中卸载,它的生命周期包括7个阶段: 加载–>验证–>准备->解析->初始化–>使用–>卸载 。
其中 验证–>准备->解析 三个阶段合称 连接 阶段,copy下图:
加载、验证、准备、初始化、卸载,这5个阶段的先后顺序是确定的,但解析阶段不一定,它某些情况下可以在初始化之后执行,这是为了支持Java的动态绑定。
加载阶段主要做3件事情:
1、通过一个类的全限定名来获取定义此类的二进制字节流(并没有指明要从一个Class文件中获取,可以从其他渠道,譬如:网络、动态生成、数据库等)。
2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3、在内存中生成一个代表这个这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
友情提示:加载阶段未完成时,连接阶段可能已经开始(如一部分字节码文件格式验证),后续这个两个阶段交叉进行,但加载阶段会在连接阶段结束前结束。
验证是连接阶段的第一步,目的是确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害当前虚拟机的安全。 它主要完成以下4个动作:
友情提示:验证阶段很重要,但不是必须的,它对程序运行期间没有影响,如果引用的类经过反复验证,可以通过-Xverifynone参数来关闭大部分验证措施,以缩短虚拟机类加载时间。
准备阶段是正式为类变量分配内存和设置类变量初始值(各类型的0值)的阶段,内存分配在方法区。
这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值,
友情提示:
public static int value=123;
静态变量 value 在准备阶段过后,value的值为0,而不是123,这是因为准备阶段,还不会执行任何java方法。 把value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。
但是看下面:
public static final int value=123
如果使用了static和final关键字,将出现宏替换效果,准备阶段过后,value的值为123。
解析阶段是虚拟机将常量池里的符号引用替换为直接引用的过程。 主要针对7类符号引用进行:
初始化阶段是类加载的最后一步,到这里,才开始执行类中定义的java代码。 在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序猿通过程序制定的主观计划去初始化类变量和其他资源。
或者说:初始化阶段是执行类构造器< clinit>()方法的过程。
< clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。示例如下:
public class Test
{
static
{
i=0;
System.out.println(i);//这句编译器会报错:Cannot reference a field before it is defined(非法向前应用)
}
static int i=1;
}
对代码做如下修改:
public class Test
{
static
{
i=0;
// System.out.println(i);
}
static int i=1;
public static void main(String args[])
{
System.out.println(i);
}
}
输出结果是1,原因:在准备阶段我们知道i=0,然后类初始化阶段按照顺序执行,首先执行static块中的i=0,接着执行static赋值操作i=1,最后在main方法中获取i的值为1。
在执行程序时 父类构造器< clinit>先执行,即父类的静态成员先于子类执行。
类构造器< clinit>和实例构造器< init>方法不同,它不会显示调用父类的构造器,虚拟机会保证在子类< cinit>()方法执行之前,父类的< clinit>()方法已经执行完毕。且< clinit>()方法对于类或者接口来说并不是必需的,如果类中没有静态语句块或静态成员赋值动作,编译器可以不为这个类生产< clinit>构造器。
接口不能定义静态语句块,但有变量赋值操作,所以会生成< clinit>构造器。 但接口的< clinit>构造器不会强制规定要调用父接口的< clinit>构造器,除非父接口定义的变量被使用时父接口的< clinit>构造器才会被调用,从而初始化。另外,接口的实现类在初始化时,一样不会执行接口的< clinit>构造器。
虚拟机会保证一个类的< clinit>()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的< clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行< clinit>()方法完毕。 所以如果在一个类的< clinit>()方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的。
需要注意的是,其他线程虽然会被阻塞,但如果执行< clinit>()方法的那条线程退出< clinit>()方法后,其他线程唤醒之后不会再次进入< clinit>()方法。同一个类加载器下,一个类型只会初始化一次。
虚拟机规范严格规定了有且只有5中情况(jdk1.7)必须对类进行“初始化”(而加载、验证、准备自然需要在此之前开始)):
1、 遇到new,getstatic,putstatic,invokestatic这失调字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:
2、使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
3、当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4、当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
5、当使用jdk1.7 动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。
到此,类的加载流程就结束了,之后就是实例为对象进行使用了。
将类实例化为对象时,对象的加载机制是下面几步:
1、JVM会先到方法区查找是否有目标类的.Class对象,有就直接使用,没有,执行上述的类加载过程。
2、把.Class中的所有非静态变量及非静态代码,加载到方法区下的非静态区域内。
3、在堆内存中开辟一块空间。
4、给开辟的控件分配一个地址。
5、把对象所有的非静态成员加载到所开辟的空间下。
6、对所有非静态成员进行默认初始化。
7、构造函数入栈,调用构造函数(先执行隐式3步,再执行咱们在构造函数里写的代码)。
① 执行super()语句 ;
② 显示初始化(非静态成员) ;
③ 执行构造代码块;
8、构造函数出栈,把分配的空间地址赋给引用对象。
友情提示:构造函数的第一行代码 是this()语句时,不执行隐式3步,直到调用的构造方法第一行不是this()方法,执行隐式3步;是super()语句时,调用父类的构造方法;不是this() 也不是super()时,执行隐式3步。
该篇文章是从执行流程的角度梳理了一下加载机制,如果想要梳理一下源码的执行可以看看其他文章。