JVM和ClassLoader
2019-11-08
目录
1 JVM架构整体架构
1.1 类加载器子系统
1.1.1 加载
1.1.2 链接
1.1.3 初始化
1.2 运行时数据区(Runtime Data Area)
1.3 执行引擎
1.4 示例
2 classloader加载class文件的原理和机制
2.1 Classloader 类结构分析
2.2 实现类的热部署
2.3 类加载器的双亲委派模型
2.4 类加载的三种方式
2.5 自定义类加载器的两种方式
参考
1 JVM架构整体架构
图1 JVM整体架构图
JVM被分为三个主要的子系统:
- 类加载器子系统
- 运行时数据区
- 执行引擎
1.1 类加载器子系统
图2 类加载器
Java的动态类加载功能是由类加载器子系统处理。当它在运行时(不是编译时)首次引用一个类时,它加载、链接并初始化该类文件。
1.1.1 加载
加载器:类由此组件加载。启动类加载器 (BootStrap class Loader)、扩展类加载器(Extension class Loader)和应用程序类加载器(Application class Loader) 这三种类加载器帮助完成类的加载。
- 启动类加载器 – 负责从启动类路径中加载类,无非就是rt.jar。这个加载器会被赋予最高优先级。
- 扩展类加载器 – 负责加载ext 目录(jrelib)内的类.
- 应用程序类加载器 – 负责加载应用程序级别类路径,涉及到路径的环境变量等etc.上述的类加载器会遵循委托层次算法(Delegation Hierarchy Algorithm)加载类文件,这个在后面进行讲解。
加载过程:
- 通过类的全限定名来获取定义此类的二进制字节流
- 将这个类字节流代表的静态存储结构转为方法区的运行时数据结构
- 在堆中生成一个代表此类的java.lang.Class对象,作为访问方法区这些数据结构的入口。
1.1.2 链接
校验: 字节码校验器会校验生成的字节码是否正确,如果校验失败,我们会得到校验错误。
- 文件格式验证:基于字节流验证,验证字节流符合当前的Class文件格式的规范,能被当前虚拟机处理。验证通过后,字节流才会进入内存的方法区进行存储。
- 元数据验证:基于方法区的存储结构验证,对字节码进行语义验证,确保不存在不符合java语言规范的元数据信息。
- 字节码验证:基于方法区的存储结构验证,通过对数据流和控制流的分析,保证被检验类的方法在运行时不会做出危害虚拟机的动作。
- 符号引用验证:基于方法区的存储结构验证,发生在解析阶段,确保能够将符号引用成功的解析为直接引用,其目的是确保解析动作正常执行。换句话说就是对类自身以外的信息进行匹配性校验。
准备:分配内存并初始化默认值给所有的静态变量。
public static int value=33;
这据代码的赋值过程分两次,一是上面我们提到的阶段,此时的value将会被赋值为0;而value=33这个过程发生在类构造器的<clinit>()方法中。
解析:所有符号引用被方法区(Method Area)的直接引用所替代。
举个例子来说明,在com.sbbic.Person类中引用了com.sbbic.Animal类,在编译阶段,Person类并不知道Animal的实际内存地址,因此只能用com.sbbic.Animal来代表Animal真实的内存地址。在解析阶段,JVM可以通过解析该符号引用,来确定com.sbbic.Animal类的真实内存地址(如果该类未被加载过,则先加载)。
主要有以下四种:类或接口的解析,字段解析,类方法解析,接口方法解析
解析理解:
常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近于Java层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用总结起来则包括了下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
其中:
- 全限定名:就是完整类名把.改为/。
- 描述符:字段的类型,方法的返回类型和参数列表(参数列表又包含每个参数的类型)。
符号引用和直接引用的区别与关联:
- 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。
- 直接引用:直接引用可以是直接指向目标的指针、相对偏移量(看下图6)或是一个能间接定位到目标的句柄(看下图5)。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那说明引用的目标必定已经存在于内存之中了。
1.1.3 初始化
这是类加载的最后阶段,这里所有的静态变量会被赋初始值, 并且静态块将被执行。
java中,对于初始化阶段,有且只有以下五种情况才会对要求类立刻初始化:
- 使用new关键字实例化对象、访问或者设置一个类的静态字段(被final修饰、编译器优化时已经放入常量池的例外)、调用类方法,都会初始化该静态字段或者静态方法所在的类;
- 初始化类的时候,如果其父类没有被初始化过,则要先触发其父类初始化;
- 使用java.lang.reflect包的方法进行反射调用的时候,如果类没有被初始化,则要先初始化;
- 虚拟机启动时,用户会先初始化要执行的主类(含有main);
- jdk 1.7后,如果java.lang.invoke.MethodHandle的实例最后对应的解析结果是 REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄,并且这个方法所在类没有初始化,则先初始化;
1.2 运行时数据区(Runtime Data Area)
- The 运行时数据区域被划分为5个主要组件:
- 方法区 (线程共享) 常量 静态变量 JIT(即时编译器)编译后代码也在方法区存放
- 堆内存(线程共享) 垃圾回收的主要场地
- 程序计数器 当前线程执行的字节码的位置指示器
- Java虚拟机栈(栈内存) :保存局部变量,基本数据类型以及堆内存中对象的引用变量
- 本地方法栈 (C栈):为JVM提供使用native方法的服务
图4 运行时数据区
1.3 执行引擎
分配给运行时数据区的字节码将由执行引擎执行。执行引擎读取字节码并逐段执行。
解释器:解释器能快速的解释字节码,但执行却很慢。 解释器的缺点就是,当一个方法被调用多次,每次都需要重新解释。
编译器:JIT编译器消除了解释器的缺点。执行引擎利用解释器转换字节码,但如果是重复的代码则使用JIT编译器将全部字节码编译成本机代码。本机代码将直接用于重复的方法调用,这提高了系统的性能。
- 中间代码生成器– 生成中间代码
- 代码优化器– 负责优化上面生成的中间代码
- 目标代码生成器– 负责生成机器代码或本机代码d. 探测器(Profiler) – 一个特殊的组件,负责寻找被多次调用的方法。
垃圾回收器: 收集并删除未引用的对象。可以通过调用"System.gc()"来触发垃圾回收,但并不保证会确实进行垃圾回收。JVM的垃圾回收只收集哪些由new关键字创建的对象。所以,如果不是用new创建的对象,你可以使用finalize函数来执行清理。Java本地接口 (JNI): JNI会与本地方法库进行交互并提供执行引擎所需的本地库。本地方法库:它是一个执行引擎所需的本地库的集合。
1.4 示例
通过以下代码看JVM类加载执行过程
package com.example.demo.classloader;
/**
* 从JVM调用的角度分析java程序堆内存空间的使用:
* 当JVM进程启动的时候,会从类加载路径中找到包含main方法的入口类HelloJVM
* 找到HelloJVM会直接读取该文件中的二进制数据,并且把该类的信息放到运行时的Method内存区域中。
* 然后会定位到HelloJVM中的main方法的字节码中,并开始执行Main方法中的指令
* 此时会创建Student实例对象,并且使用student来引用该对象(或者说给该对象命名),其内幕如下:
* 第一步:JVM会直接到Method区域中去查找Student类的信息,此时发现没有Student类,就通过类加载器加载该Student类文件;
* 第二步:在JVM的Method区域中加载并找到了Student类之后会在Heap区域中为Student实例对象分配内存,
* 并且在Student的实例对象中持有指向方法区域中的Student类的引用(内存地址);
* 第三步:JVM实例化完成后会在当前线程中为Stack中的reference建立实际的应用关系,此时会赋值给student
* 接下来就是调用方法
* 在JVM中方法的调用一定是属于线程的行为,也就是说方法调用本身会发生在线程的方法调用栈:
* 线程的方法调用栈(Method Stack Frames),每一个方法的调用就是方法调用栈中的一个Frame,
* 该Frame包含了方法的参数,局部变量,临时数据等 student.sayHello();
*/
public class HelloJVM {
//在JVM运行的时候会通过反射的方式到Method区域找到入口方法main
public static void main(String[] args) {//main方法也是放在Method方法区域中的
/**
* student(小写的)是放在主线程中的Stack区域中的
* Student对象实例是放在所有线程共享的Heap区域中的
*/
Student student = new Student("spark");
/**
* 首先会通过student指针(或句柄)(指针就直接指向堆中的对象,句柄表明有一个中间的,student指向句柄,句柄指向对象)
* 找Student对象,当找到该对象后会通过对象内部指向方法区域中的指针来调用具体的方法去执行任务
*/
student.sayHello();
}
}
class Student {
// name本身作为成员是放在stack区域的但是name指向的String对象是放在Heap中
private String name;
public Student(String name) {
this.name = name;
}
//sayHello这个方法是放在方法区中的
public void sayHello() {
System.out.println("Hello, this is " + this.name);
}
}
对象的访问定位
java程序需要通过引用(ref)数据来操作堆上面的对象,那么如何通过引用定位、访问到对象的具体位置。
对象的访问方式由虚拟机决定,java虚拟机提供两种主流的方式
- 句柄访问对象
- 直接指针访问对象。(Sun HotSpot使用这种方式)
句柄访问优点:引用中存储的是稳定的句柄地址,在对象被移动【垃圾收集时移动对象是常态】只需改变句柄中实例数据的指针,不需要改动引用【ref】本身。
直接指针访问优点:优势很明显,就是速度快,相比于句柄访问少了一次指针定位的开销时间。【可能是出于Java中对象的访问时十分频繁的,平时我们常用的JVM HotSpot采用此种方式】
图5 通过句柄访问对象
图6 通过指针访问对象
2 classloader加载class文件的原理和机制
2.1 Classloader 类结构分析
主要由四个方法,分别是 defineClass , findClass , loadClass , resolveClass
Class defineClass(String name,byte[] b,int len):将类文件的字节数组转换成JVM内部的java.lang.Class对象。字节数组可以从本地文件系统、远程网络获取。参数name为字节数组对应的全限定类名。
Class findClass(String name),通过类名去加载对应的Class对象。当我们实现自定义的classLoader通常是重写这个方法,根据传入的类名找到对应字节码的文件,并通过调用defineClass解析出Class独享
Class loadClass(String name) :name参数指定类装载器需要装载类的名字,必须使用全限定类名,如:com.smart.bean.Car。该方法有一个重载方法 loadClass(String name,boolean resolve),resolve参数告诉类装载器时候需要解析该类,在初始化之前,因考虑进行类解析的工作,但并不是所有的类都需要解析。如果JVM只需要知道该类是否存在或找出该类的超类,那么就不需要进行解析。
resolveClass手动调用这个使得被加到JVM的类被链接(解析resolve这个类?)
实现自定义 ClassLoader 一般会继承 URLClassLoader 类,因为这个类实现了大部分方法。
2.2 实现类的热部署
- 同一个classLoader的两个实例加载同一个类,JVM也会识别为两个
- 不能重复加载同一个类(全名相同,并使用同一个类加载器),会报错
- 不应该动态加载类,因为对象被引用后,对象的属性结构被修改会引发问题
注意:使用不同classLoader加载的同一个类文件得到的类,JVM将当作是两个不同类,使用单例模式,强制类型转换时都可能因为这个原因出问题。
2.3 类加载器的双亲委派模型
图3 类加载器双亲委派模型
类加载器双亲委派模型加载顺序:java的三种类加载器存在父子关系,子 加载器保存着附加在其的引用,当一个类加载器需要加载一个目标类时,会先委托父加载器去加载,然后父加载器会在自己的加载路径中搜索目标类,父加载器在自己的加载范围中找不到时,才会交给子加载器加载目标类。
采用双亲委托模式可以避免类加载混乱,而且还将类分层次了,例如java中lang包下的类在jvm启动时就被启动类加载器加载了,而用户一些代码类则由应用程序类加载器(AppClassLoader)加载,基于双亲委托模式,就算用户定义了与lang包中一样的类,最终还是由应用程序类加载器委托给启动类加载器去加载,这个时候启动类加载器发现已经加载过了lang包下的类了,所以两者都不会再重新加载。当然,如果使用者通过自定义的类加载器可以强行打破这种双亲委托模型,但也不会成功的,java安全管理器抛出将会抛出java.lang.SecurityException异常。
2.4 类加载的三种方式
- 通过命令行启动应用时由JVM初始化加载含有main()方法的主类。
- 通过Class.forName()方法动态加载,会默认执行初始化块(static{}),但是Class.forName(name,initialize,loader)中的initialze可指定是否要执行初始化块。
- 通过ClassLoader.loadClass()方法动态加载,不会执行初始化块。
//1 由new关键字创建一个类的实例,在由运行时刻用 new 方法载入
Person person = new Person();
//2 使用Class.forName() 通过反射加载类型,并创建对象实例
Class clazz = Class.forName("Person");
Object person =clazz.newInstance();
//3 使用某个ClassLoader实例的loadClass()方法,通过该 ClassLoader 实例的 loadClass() 方法载入。应用程序可以通过继承 ClassLoader 实现自己的类装载器。
Class clazz = classLoader.loadClass("Person");
Object person =clazz.newInstance();
其中:
- 1和2使用的类加载器是相同的,都是当前类加载器(即:this.getClass.getClassLoader)。
- 3由用户指定类加载器。如果需要在当前类路径以外寻找类,则只能采用第3种方式。即第3种方式加载的类与当前类分属不同的命名空间。
- 1是静态加载,2、3是动态加载
-
Class.forName(className)方法,内部实际调用的方法是 Class.forName(className,true,classloader);
第2个boolean参数表示类是否需要初始化, Class.forName(className)默认是需要初始化。
一旦初始化,就会触发目标对象的 static块代码执行,static参数也也会被再次初始化。
-
ClassLoader.loadClass(className)方法,内部实际调用的方法是 ClassLoader.loadClass(className,false);
第2个 boolean参数,表示目标对象是否进行链接,false表示不进行链接,由上面介绍可以,
不进行链接意味着不进行包括初始化等一些列步骤,那么静态块和静态对象就不会得到执行
2.5 自定义类加载器的两种方式
- 遵守双亲委派模型:继承ClassLoader,重写findClass()方法。
- 破坏双亲委派模型:继承ClassLoader,重写loadClass()方法。
通常我们推荐采用第一种方法自定义类加载器,最大程度上的遵守双亲委派模型。 自定义类加载的目的是想要手动控制类的加载,那除了通过自定义的类加载器来手动加载类这种方式,还有其他的方式么?
利用现成的类加载器进行加载:
- 利用当前类加载器
Class.forName(); - 通过系统类加载器
Classloader.getSystemClassLoader().loadClass(); - 通过上下文类加载器
Thread.currentThread().getContextClassLoader().loadClass();
参考
[1] classloader加载class文件的原理和机制
[2] java 类加载器双亲委派模型