深入理解类加载机制
Klass模型
Java的每个类,在JVM中都有一个对应的Klass类实例与之对应,存储类的元信息如:常量池、属性信息、方法信息…从继承关系上也能看出来,类的元信息是存储在元空间的。普通的Java类在JVM中对应的是InstanceKlass(C++)类的实例,再来说下它的三个子类:
- 1.InstanceMirrorKlass:用于表示java.lang.Class,Java代码中获取到的Class对象,实际上就是这个C++类的实例,存储在堆区,学名镜相类
- 2.InstanceRefKlass:用于表示java/lang/ref/Reference类的子类
- 3.InstanceClassLoaderKlass:用于遍历某个加载器的类
Java中的数组不是静态数据类型,而是动态数据类型,即是运行期生成的,Java数组的元信息用ArrayKlass的子类来表示:
- 1.TypeArrayKlass:用于表示基本类型的数组
- 2.ObjArrayKlass:用于表示引用类型的数组
总结:
非数组:
InstanceKlass -> 普通的类在JVM中对应的C++类 方法区
InstanceMirrorKlass -> 对应的是Class对象 镜像类 堆区
数组:
基本类型数组
boolean、byte、char、short、int、float、long、double -> TypeArrayKlass
引用类型数组: ObjArrayKlass
为什么还要有镜像类?
是为了安全,由JVM控制可以将哪些参数返回给用户
实操:
public class Hello {
public static void main(String[] args) {
int[] a = new int[] {1,2,3};
Hello[] hello = new Hello[2];
Hello h = new Hello();
while(true);
}
}
利用HSDB查看main线程的调用栈,由于栈的规则是先进后出,也就是说意味着,当前方法栈的栈底存放的是当前方法的参数args,其次是int数组,Hello对象数组,我们可以查看它们的内存地址中都包含了哪些内容
基本数据类型的klass模型,还可以看到数组的内容
引用类型数组的klass模型,我们在代码中创建的Hello数组对象引用都是空的
也就是_java_mirror,这里c++上的注解也是说明了这个InstanceMirroKlass的存在
类加载的过程
类的加载由7个步骤完成,如图所示。类的加载说的是前5个阶段。
加载
- 1.通过类的全限定名获取存储该类的class文件(没有指明必须从哪获取)
- 2.解析成运行时数据,即instanceKlass实例,存放在方法去
- 3.在堆区生成该类的Class对象,即instanceMirrorKlass实例
程序随便你怎么写,随便你用什么语言,只要能达到这个效果即可。就是说你可以改写openjdk源码,你写的程序能达到这三个效果即可。
预加载:包装类、String、Thread
因为没有指明必须从哪获取class文件,脑洞大开的工程师们开发了这些:
- 1.从压缩包中读取。如jar、war
- 2.从网络中获取,如Web Applet
- 3.动态生成,如动态代理、CGLIB
- 4.由其他文件生成,如JSP
- 5.从数据库读取
- 6.从加密文件中读取
验证
- 1.文件格式验证。如验证class文件中是否包含魔数(CAFE BABE)、主次版本号是否在当前虚拟机处理范围之内
- 2.元数据验证。如这个类是否有父类、这个类的父类是否继承了不允许被继承的类(如被final修饰的类)
- 3.字节码验证。整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析确定程序语义是合法的,如保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作数栈放置了一个int类型的数据,使用时却按long类型来加载如本地变量表中
- 4.符号引用验证。最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看作是堆类自身以外的(常量池中的各种符号引用)的信息匹配性校验,如符号引用中通过字符串描述的全限定名是否能找到对应的类、符号引用中的类和字段一级方法的访问性是否可以被当前类访问(比如调用静态方法,检查调用的方法是否可以被当前类调用)
准备
为静态变量分配内存、赋初值。实例变量是在创建对象的时候完成赋值的,没有赋初值这一说。如果是被final修饰,在编译的时候会给属性添加ConstantValue属性,准备阶段直接完成赋值,即没有赋初值这一步
public class MyClassLoadHello {
public static int v = 10;
public static final int b = 11;
public static void main(String[] args) {
int a = 1;
int b = 2;
System.out.println(a + b);
}
}
可以看到变量b多出了一个ConstantValue的属性,这个属性指向了常量池中11这个数值。准备阶段就会直接赋值
反观变量v则是在类的初始化方法块中
为什么要在准备阶段赋初值?为何不直接赋值?
(C++对象)InstanceMirrorKlass对象只是创建出来,并没有属性,把这个变量写入到Class对象中去,如果这个静态变量没有使用到,也没有赋初值,字节码指令中将不包含该变量.
如图所示,变量m并没有在字节码指令中,因为没有赋初值也没有进行使用
通过HSDB可以发现InstanceMirrorKlass对象是有变量m这个属性的,但是InstanceKlass对象却显示只有两个静态属性。是不是很奇怪?字节码指令中都没有这个变量,InstanceMirrorKlass对象中却有这个属性。其实也不难理解,InstsanceKlass对象是存储在方法区中的,可以表示类的静态属性信息。由于这个属性没有赋值也没有使用,字节码层面就直接优化掉了,我们知道反射的时候可以获取到这个类的所有信息所有属性以及所有方法不管其作用域的范围是什么,如果不给InstanceMirrorKlass对象赋值这个属性,那么在反射的时候就会拿不到,这其实违背了反射的规则。所以要有静态属性赋初值这个动作,来给InstasnceMirrorKlass对象赋上这个属性。
解析
将常量池中的符号引用转为直接引用。解析后的信息存储在ConstantPoolCache类实例中。其中会涉及到如下:
- 1.类或接口的解析
- 2.字段解析
- 3.方法解析
- 4.接口方法解析
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。可以理解为静态常量池的索引
直接引用(Direct References): 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的.某个变量的内存地址
解析的时机?
1.加载阶段解析常量池时(类加载以后马上解析 resolve的参数需要改为 => true)
2.用的时候
解析什么?只要是直接引用都需要解析
+1.继承的类、实现的接口
- 2.属性
- 3.方法
如何避免重复解析:
借助缓存,ConstantPoolCache(运行时常量池的缓存) if (klass -> is_resolved()) {}如图所示
常量池缓存:
key: 常量池的索引 2
value: String -> ConstantPoolEntry
静态属性是存储在堆区中的,静态属性的访问:
- 1.去缓存中去找,如果有直接返回
- 2.如果没有就触发解析
底层实现: - 1.会找到直接引用
- 2.会存储到常量池缓存中
openjdk是第二种思路,在执行特定的字节码指令之前进行解析:anewarray、checkcase、getfield、instanceof、invokeddynamic、invokeinterface、invokesepcial、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield.
拓展知识:编译时常量池和运行时常量池
在Java中,常量池是class文件的一部分,它用于存储关于类和接口的常量以及一些符号引用。常量池分为两种:编译时常量池和运行时常量池。
- 1.编译时常量池(Constant Pool)
编译时常量池是在编译器生成的,它包含了类文件中的字面量(Literal)和符号引用(Symbolic References).
字面量:如文本字符串、final常量值等
符号引用:包括类和接口的全限定名、字段名称和描述符、方法名称和描述符。这些符号引用在类加载阶段或第一次使用时会被解析为直接引用。
编译时常量池时.class文件的一部分,它随着类文件的生成而生成,每个.class文件都有一个自己的编译时常量池 - 2.运行时常量池(Runtime Constant Pool)
运行时常量池是类或接口在JVM运行时的一部分,当类被JVM加载时,JVM会根据.class文件中的编译时常量池来创建运行时常量池。运行时常量池是方法区中的一部分。
动态性:运行时常量池具有动态性,它可以在运行期间想其中添加新的常量。例如,String的intern()方法可以将字符串常量添加到运行时常量池中
解析:运行时常量池中的符号引用会在类加载过程中或第一次使用时被解析为直接引用。
简而言之,编译时常量池是静态的,是.class文件的一部分,而运行时常量池是动态的,是JVM运行时数据区的一部分。运行时常量池在JVM的规范中是方法区的一部分,但在不同的JVM实现中可能会有所不同,如在HotSpot虚拟机中,它被放在了堆(Heap)中。
初始化
执行静态代码块,完成静态变量的赋值。类初始化阶段时类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制,到了初始化阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度表达:初始化阶段时执行类构造器()方法的过程。
- 1.()方法时由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,可以在前面的静态语句块中赋值,但是不能访问,如图所示
- 2.()方法与类的构造函数(或者说实例构造器()方法)不同,它不需要显示地调用父类构造器,虚拟机会保证在子类地()方法执行之前,父类的()方法已经执行完毕。因此在虚拟机中第一个被执行的()方法的类肯定是java.lang.Object
- 3.由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作
- 4.()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成()方法
- 5.虚拟机会保证一个类的()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,知道活动线程执行()方法完毕。如果在一个类的()方法中有耗时很长的操作,就可能造成多个进程阻塞(需要注意的是,其他线程虽然会被阻塞,但如果执行()方法的那条线程退出()方法后,其他线程唤醒之后不会再次进入()方法。同一个类加载器下,一个类型只会初始化一次)。
何时初始化?主动使用时
- 1.new、getstatic、putstatic、invokestatic
- 2.反射
- 3.初始化一个类的子类会去加载其父类
- 4.启动类(main函数所在类)
- 5.当使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic、REF_put_static、REF_invoke_Static的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先触发其初始化。
实例
- < clinit>()方法执行死锁示例1:
public class DeadLoopClass {
static {
if (true) {
System.out.println(Thread.currentThread() + "init DeadLoopClass");
while (true) {
}
}
}
public static void main(String[] args) {
Runnable script = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread() + "start");
DeadLoopClass dlc = new DeadLoopClass();
System.out.println(Thread.currentThread() + "end");
}
};
Thread thread1 = new Thread(script);
Thread thread2 = new Thread(script);
thread1.start();
thread2.start();
}
}
Thread[main,5,main]init DeadLoopClass
一条线程在死循环模拟长时间操作,另外一条线程在阻塞等待执行clinit方法执行完毕后触发唤醒,但是一直等不到,所以就发生了死锁
- < clinit >()方法执行死锁示例2:
public class InitDeadLock {
public static void main(String[] args) throws InterruptedException {
new Thread(() -> new A()).start();
new Thread(() -> new B()).start();
}
}
class A {
static {
System.out.println("class A init");
new B();
}
}
class B {
static {
System.out.println("class B init");
new A();
}
}
一个线程创建A对象,进而触发A的初始化,但是A的clinit方法中又创建B,又触发B的初始化,另一个线程的初始化则反过来,资源获取顺序不当造成了死锁
卸载
判定一个类是否是"无用的类"的条件相对一个实例对象或者"废弃常量"要苛刻很多。类需要同时满足下面3个条件才能算是"无用的类":
- 1.该类的所有实例都已经被回收,也就是Java堆中不存在该类的任何实例。
- 2.加载该类的ClassLoader已经被回收
- 3.该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
虚拟机可以堆满足上述3个条件的无用类进行回收,这里说的仅仅是"可以",而并不是和对象一样,不使用了,就必然会回收。
这也造成了很多人认为方法区(或者HotSpot虚拟机中的永久代)是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区中实现垃圾收集,而且在方法区中进行垃圾收集"性价比"一般比较低:在队中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70~95%的空间,而永久代的垃圾收集效率远低于此