Java虚拟机夯实之路——虚拟机类加载机制

47 阅读16分钟

虚拟机类加载机制

概述

在java语言中,类的加载、连接、初始化都是在程序执行期间完成的。

这样的好处是灵活性会更高,可拓展性也会提高。

java程序可以在执行的时候再去规定其实现的类。

这样会导致提前编译变得困难,是因为不知道一些依赖关系只有运行的时候才能知道。也会让类加载的时候多一些开销。

类加载的时机

类从加载到内存到卸出内存要经历7个阶段:加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析称之为连接。

加载、验证、准备、初始化、卸载这五个步骤的顺序是不变的。

对于初始化来说,有六种情况是必须要初始化的:

1、出现new,getstatic,putstatic或invokestatic这四条指令的时候。具体的java场景有:1、new一个新的对象 2、读取或设置一个static方法的时候(final 、以及在编译期就已经把结果放入常量池的静态字段除外) 3、调用一个类型的static方法的时候

2、使用java.lang.reflect方法对类型进行反射调用的时候,如 class.forName、getdeclaremethod

3、初始化类的时候,其父类还没有初始化的先初始化其父类

4、虚拟机启动的时候,含main()方法那个主类初始化

5、如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,如果这个句柄对应的类没有进行初始化,那么先对其进行初始化。

6、如果有接口中定义了default的方法,那么如果有这个接口的实现类进行了初始化,那么这个接口也要初始化

这六个类型被称为对一个类型进行主动引用,只有这六种类型会发生类的初始化。

剩下的称为被动引用,不会发生初始化:

这里子类subclass不会发生初始化,所以不会输出subclass init

-XX: +TraceClassLoading参数 可以通过这个参数观察到子类是否被加载

通过数组定义来引用类不会发生初始化,

触发了另一个名为Lorg.fenixsoft.classloading.SuperClass的类的初始化阶段,这是由虚拟机生成的,直接继承于java.lang.object。这个类代表了元素类型为org.fenixsoft.classloading.SuperClass的一维数组,类里有数组里有的属性和方法

HELLOWORLD在编译的时候已经放到了NotInitialization的常量池里,后面的引用就只需要在自身的常量池里找就行,所以不会输出ConstClass init,此时NotInitialization的Class文件里没有ConstClass类的符号引用入口。

接口在首次使用的时候也需要初始化,如果接口里有静态变量和静态代码块,会在接口初始化的时候执行;类中可以通过static来编写静态初始化代码,但接口不可以,但是在初始化的时候,虚拟机会生成一个静态初始化方法来初始化接口中的静态变量。

类要求在初始化的时候其父类已经完成了初始化,但接口不需要,在用到父类的时候再去初始化其父类。

类加载的过程

1 加载

加载的过程:首先根据这个类的全限定名来获取定义此类的二进制字节流,然后把二进制字节流表示的静态储存结构转换成方法区运行时的数据结构,然后在内存中生成代表该类的java.lang.class的文件,作为这个类在方法区各种数据的访问入口。

二进制字节流不一定要靠class文件获得,还有别的途径:1、可以从压缩文件中获得 2、可以从网络中获得 3、在运行时计算获得,例如动态代理 4、可以由其他文件生成,例如JSP应用,JSP文件生成class文件 5、在数据库中读取 6、在加密文件中获取

非数组类型的加载阶段的的获取字节流这个过程是自由度最高的过程,开发人员可以通过修改findclass()或loadclass()方法来定义自己的类加载器。

数组类本身不通过类加载器进行加载,由java虚拟机在内存中动态构建出来。

2 验证

这个阶段是确保class文件里的字节码描述的信息符合《java虚拟机规范》的要求,这样在运行的时候不会对虚拟机产生危害。

验证主要分四个步骤:

1、文件格式验证

在这一阶段的验证中,查看字节流是否符合规范并被当前版本的虚拟机处理,包括不限于以下几个过程:查看前面是不是cafebabe;主副版本号是不是适配当前虚拟机版本;常量池中的常量是不是都被支持(查tag);指向常量是不是指向了不存在的常量或者不符合类型的常量;constant_utf8_info是否由不符合utf8规范的数据;class文件中各个部分或整个文件有没有删除或者被加了什么新的东西......

这一阶段是保证字节流可以保存在方法区之内,只有经过了这个验证,字节流才能被允许进入java虚拟机内存的方法区进行存储,后三个验证步骤都是在方法区的存储结构上进行的。

2、元数据验证

验证字节流描述的信息进行语义分析,检测是否符合《java虚拟机规范》的要求。

可能包括的验证点如下:

是否包含父类(除了java.lang.object以外所有类都应包含父类);这个类的父类是否继承了不允许继承的类(final修饰的类);如果这个类不是抽象类,是否实现了父类或接口的所有方法;类中的字段或方法是否与父类产生矛盾......

3、字节码验证

通过数据流分析和控制流分析,对类的方法体进行校验

可能包括的验证如下:

操作树栈的数据类型和指令代码能配合工作,例如不会出现操作栈放一个int类型的但是却把一个long类型的加载到本地变量表中;任何跳转指令不会跳到方法外的字节码指令上;保证方法体的类型转换是有效的,可以把一个子类对象赋值给父类数据类型。

把尽可能多的校验辅助措施挪到Javac编译器里进行,给方法体Code属性的属性表中增加了StackMapTable的属性,在字节码验证期间虚拟机就只需要看这个属性的记录是否合法即可。

-XX:-UseSplitVerifier用这个来关闭上面的优化

-XX:+FailOverToOldVerifier在类型校验失败的时候退回到旧的类型推导方式进行校验。

4、符号引用验证

发生在虚拟机将符号引用转为直接引用的时候。

通常需要校验下面的内容:

根据符号引用的字符串是否能找到对应的类;在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段;符号引用中的字段、类方法是否可被当前类访问

3 准备

为静态变量分配内存并设置初始值。

该过程不包括实例变量。实例变量是等到对象实例化的时候和对象一起分配到堆中。

初始值指0值。

上表是基本数据类型的0值

下面这种情况初始化为ConstantValue属性指定的值,static final或者string类型的在声明赋值的时候会生成constantvalue属性

4 解析

解析是把符号引用转换为直接引用的过程。

符号引用:用符号来描述引用目标,与虚拟机里的内存布局无关,引用的目标不一定加载到了内存中,不同的虚拟机接受的符号可能是一样的,符号引用的字面量形式定义在《java虚拟机规范》的class文件格式里。

直接引用:能够直接指到引用目标的指针、相对偏移量以及间接定位到目标的句柄。这与虚拟机的内存布局有关,同一个符号引用在不同的虚拟机上翻译出来的会不同。引用目标需要是加载到虚拟机内存里的对象。

解析执行的时间不明确,可以在加载的时候就对符号引用进行解析,也可以在符号引用被使用的时候去解析。

对字段或方法进行访问的时候也会注意其可访问性。

除了invokedynamic指令外,可能会对一个符号引用进行多次解析;会对第一次解析的结果进行缓存,第一次解析的结果会影响后面的解析;

invokedynamic指令是动态语言支持:程序运行到这条指令之后解析才能执行;

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点这7类符号引用来解析;

1、类或接口的解析

代码所处类为D,一个未解析过的符号引用N解析为一个类或接口C的直接引用;虚拟机完成解析需要下面三个步骤:

1)如果C不是数组类型,就把N的全限定名传递给D的类加载器去加载C。

2)如果C是一个数组类型,而且数组的元素类型为对象,N的描述会是类似[Ljava/lang/Integer的形式,按上面的步骤来加载数组元素类型。上面加载的数组元素类型就为java/lang/Integer,然后由虚拟机生成代表该数组元素和维度的数组对象。

3)如果前两步没有出现异常,那么虚拟机已经有C这个类或接口了,此时还需要进行符号验证,判断D是否对C有访问权限,如果没有,会抛出java.lang.IllegalAccessError异常

如果说D有C的访问权限,需要满足以下三点

1)C是public而且与D处于同一个模块

2)C是public,但不与D处于同一个模块,但D的模块具有C的模块的访问权限

3)C不是public,但D和C在一个包里

2、字段解析

对字段表内class_index项中索引的CONSTANT_class_info来解析,也就是字段所属的类或端口的符号引用;解析完成后把这个字段所属的类或端口用C表示,下面是4个步骤:

1)如果C本身就包含了简单名称与字段描述符都与目标相匹配的字段,直接返回这个字段的直接引用,查找结束。

2)否则,如果C实现了接口,那么就会按照继承关系从下到上递归搜索其接口和父接口,哪个接口包含了简单名称和字段描述符都与目标匹配的字段,返回这个字段的直接引用,查找结束。

3)否则如果C不是java.lang.object,就按照继承关系从下到上递归搜索其父类,哪个父类包含了简单名称和字段描述符都与目标匹配的字段,返回这个字段的直接引用,查找结束。

4)否则,查找失败,抛出java.lang.NoSuchFieldError异常。

如果有同一个名字的字段同时出现在一个类的父类和接口中,javac就会拒绝编译。

例如这个情况,如果注释掉public static int A=4,就会提示The field Sub.A is ambiguous,从而拒绝编译

最后也要进行权限验证😊♥

3、方法解析

第一个步骤和字段解析一样,也是解析方法表里的class_index项中索引方法的所属类或接口的符号引用。按以下步骤来解析,用C来表示这个类:

1)class文件格式里的类的方法或接口的方法的符号引用的常量类型是分开定义的,所以先看方法表中class_index发现C是个接口的话那就直接抛出java.lang.IncompatibleClassChangeError异常

2)过了第一步,就在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

3)否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

4)否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出java.lang.AbstractMethodError异常。

5)否则,查找失败,抛出java.lang.NoSuchMethodError。

最后也要进行权限验证

4、接口方法解析

接口方法解析也需要解析接口方法表的class_index项中索引的方法所属的类或接口的符号引用。解析步骤如下,用C来表示这个类

1)与方法解析刚好相反,先看接口方法表中class_index中C是接口还是类,如果是类抛出java.lang.IncompatibleClassChangeError异常

2)过了第一步,就在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

3)否则,在接口C的父类中递归查找直到有java.lang.object类为止,看看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

4)java的接口允许多重继承,如果父接口中有好多都存在简单名称和描述符与目标相匹配的方法,就选一个方法返回。

5)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

最后也要进行访问权限验证

5 初始化

初始化时虚拟机真正的去执行类中编写的java程序代码。

准备阶段已经给变量赋予了初值,初始化阶段就是根据程序员制定的主观计划去初始化类变量和其他资源。

初始化阶段就是执行类构造器()方法的过程,他是javac编译器自动生成物。

()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static)中的语句合并产生的。

静态语句块只能访问到定义在其之前的变量,对于定义在其之后的变量,静态语句块可以赋值但是不能访问,例子如下:

Java虚拟机会保证在子类的()方法执行前,父类的()方法已经执行完毕,所以java虚拟机里第一个被执行的类就是java.lang.object。

父类的静态语句块要优先于子类的赋值操作,例子如下:

B的值为2

()方法不是必须的,如果类里没有赋值动作和静态语句块,编译器就不为这个类生成()方法。

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作;执行接口的()方法不需要先执行父接口的()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。

Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步,如果在一个类的()方法中有耗时很长的操作,那就可能造成多个进程阻塞。

用死循环来代替耗时很长的操作,另一条线程阻塞等待。🤯

类加载器

通过一个类的全限定名获取描述该类的二进制字节流,放到Java虚拟机外部去实现,让应用程序自己决定如何去获取所需的类。

1 类与类加载器

类加载器只用于实现类的加载动作。

比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义。

这里所指的相等,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等各种情况。

2 双亲委派模型

存在两种类加载器:1是启动类加载器,这个加载器由C++语言实现。2是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。

三层类加载器:

1、启动类加载器:存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的;无法被java程序直接引用;用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。

2、扩展类加载器:在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的;这是一种Java系统类库的扩展机制;开发者可以直接在程序中使用扩展类加载器来加载Class文件。

3、应用程序类加载器:这个类加载器由sun.misc.Launcher$AppClassLoader来实现;负责加载用户类路径上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

这些类加载器之间的协作关系图,为双亲委派模型。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载。

上图是双亲委派模型的实现。

双亲委派模型对于保证Java程序的稳定运作来说很重要。

能保证一个类是由同一个类加载器去加载,因此在程序的各种类加载器环境中都能够保证是同一个类。

3 破坏双亲委派模型

引导用户编写的类加载逻辑时尽可能去重写findclass()这个方法,如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则。

线程上下文类加载器:通过java.lang.Thread类的setContext-ClassLoader()方法进行设置;