JVM 学习笔记——JVM 类加载机制

用户头像
王海
关注
发布于: 2020 年 06 月 13 日

1 概述

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

2 类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(loading)验证(verification)准备(preparation)解析(resolution)初始化(initialization)使用(using)卸载(unloading)7个阶段。其中,验证、准备、解析3个部分统称为连接(Linking),这7个阶段的发生顺序如下图所示。

类的生命周期

什么情况下需要开始类加载的第一个阶段?Java虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但对于初始化阶段,虚拟机规范则是严格规定了有且仅有5种情况必须立即对类进行初始化(而加载、验证、准备自然要在此之前开始):

  1. 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果类没有进行初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候,读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。

  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。

  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

  4. 虚拟机启动时,用户需要指定一个要执行的类(包括main方法那个类)、虚拟机会先初始化这个类。

  5. 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先触发其初始化。



这5种场景种的行为称为:对一个类进行主动引用。

除此之外,所有引用类的方式都不会触发初始化。



被动引用的例子:

(1) 通过子类引用父类的静态字段,不会导致子类初始化。

对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

(2) 通过数组定义来引用类,不会触发此类的初始化。

(3) 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。



总结:只有真正直接使用一个类的时候,如果该类还没初始化,则会触发该类的初始化。如果该类的父类还没初始化,则先触发父类的初始化。

3 类加载的过程

3.1 加载

“加载”是“类加载”过程的一个阶段,不要混淆。

在加载阶段,虚拟机需要完成以下3件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流

  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

3.2 验证

验证是连接阶段的第一步,这一步的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

验证阶段大致上会完成下面四个阶段的校验动作:

  • 文件格式验证

第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。主要目的是保证输入的字节流能正确解析并存储在方法区之内,格式上符合描述一个Java类型信息的要求。该阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。

  • 元数据验证

第二阶段是对字节码描述的信息进行语义分析,以确保其描述的信息符合Java语言规范的要求。主要目的是对类的元数据信息进行语义分析,保存不存在不符合Java语言规范的元数据信息。

  • 字节码验证

第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确保程序语义是合法的,符合逻辑的。这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。

  • 符号引用验证

发生在虚拟机将符号引用转化为直接引用的时候,这个转换动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用过)的信息进行匹配性校验。符号引用验证的目的是确保解析动作能正常执行。

3.3 准备

准备阶段是正式为类变量分配内存并设置类变量初始值(默认值)的阶段。

3.4 解析

解析阶段是将常量池内的符号引用替换为直接引用的过程。

符合引用:符号引用是一组符号来描述被引用的目标(类、接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符)

直接引用:直接指向目标的指针、相对偏移量或者一个能间接定位到目标的句柄

3.5 初始化

初始化阶段对类的静态变量初始化为指定的值,执行静态代码块

类初始化阶段是类加载过程中的最后一步,前面的类加载的过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行Java类中的定义的Java代码(或者说是字节码)

4 类加载器

实现类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”的代码模块成为“类加载器”。

4.1 类加载器的分类

4.1.1 启动类加载器(Bootstrap ClassLoader)

加载JAVA_HOME/lib目录下的jar文件中的类

4.1.2 扩展类加载器(Extension ClassLoader)

加载JAVA_HOME/lib/ext目录下的jar文件中的类

4.1.3 应用程序类加载器(Application ClassLoader)

加载ClassPath下的类

4.2 类加载器初始化过程

  1. 创建JVM虚拟机

  2. 创建JVM启动器实例sun.misc.Launcher

  3. sun.misc.Launcher初始化使用了单例设计模式,保证一个JVM虚拟机内只有一个

  4. 在sun.misc.Launcher构造方法内部,创建了两个类加载器,分别是sun.misc.launcher.ExtClassLoader(扩展类加载器)和sun.misc.launcher.AppClassLoader(应用类加载器),JVM默认使用Laucher的getClassLoader()方法返回的类加载器AppClassLoader的实例来加载我们的应用程序。

4.3 双亲委派模型

4.3.1 双亲委派模型

我们的应用程序是由上文中提到的3种类加载器互相配合进行加载的,如果有必要,还可以加入自定义的类加载器。这些类加载器之间的关系一般如下图所示。

类加载器双亲委派模型

双亲委派模型除了要求顶层的启动类加载器除外,其余的类加载器都应该由自己的父类加载器。这里类加载器的关系一般不会以继承的关系实现,而是都是使用组合关系来复用父加载器的代码。



双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己求尝试加载这个类,而是把这个请求委派给你父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。



代码清单1 双亲委派模型的实现

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) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
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) {
resolveClass(c);
}
return c;
}
}



4.3.2 为什么要设计双亲委派模型

  • 沙箱安全机制,防止核心API库被随意篡改

可以尝试去编写一个与rt.jar类库中已有类重名的Java类,将会发现可以正常编译,但是永远无法被加载运行。即使自定义了自己的类加载器,强行打破双亲委派机制去加载一个以“java.lang”开头的类也不会成功。如果尝试这样做的话,将会收到一个有虚拟机自己抛出的“java.lang.SecurityException:Prohibited package name:java.lang”异常。

  • 避免类的重复加载

4.4 打破双亲委派模型

4.4.1 自定义类加载器

(1) 继承java.class.ClassLoader,重写loadClass方法。

(2) 重写findClass()方法,从指定目录读取.class文件,并转为Class对象。

(3) 如果加载自己的类时,需要打破双亲委派模型,需要覆写loadClass方法。loadClass方法默认是符合双亲委派模型的。

代码清单2 自定义类加载器

public class MyClassLoader extends ClassLoader {
private String path="d:\\";
private final String fileType = ".class";
// 类加载器名字
private String name = null;
public MyClassLoader(String name){
super();
this.name = name;
}
public MyClassLoader(ClassLoader parent,String name){
super(parent);
this.name = name;
}
// 调用getClassLoader()时返回此方法,如果不重载,则显示MyClassLoader的引用地址
public String toString(){
return this.name;
}
// 设置文件加载路径
public void setPath(String path){
this.path = path;
}
protected Class findClass(String name) throws ClassNotFoundException{
byte[] data = loadClassData(name);
// 参数off代表什么?
return defineClass(name,data,0,data.length);
}
// 将.class文件读入内存中,并且以字节数形式返回
private byte[] loadClassData(String name) throws ClassNotFoundException{
FileInputStream fis = null;
ByteArrayOutputStream baos = null;
byte[] data = null;
try{
// 读取文件内容
name = name.replaceAll("\\.","\\\\");
System.out.println("加载文件名:"+name);
// 将文件读取到数据流中
fis = new FileInputStream(path+name+fileType);
baos = new ByteArrayOutputStream();
int ch = 0;
while ((ch = fis.read()) != -1){
baos.write(ch);
}
data = baos.toByteArray();
}catch (Exception e){
throw new ClassNotFoundException("Class is not found:"+name,e);
}finally {
// 关闭数据流
try {
fis.close();
baos.close();
}catch (Exception e){
e.printStackTrace();
}
}
return data;
}
public static void main(String[] args) throws Exception {
MyClassLoader loader1 = new MyClassLoader("loader1");
// 获取MyClassLoader加载器
System.out.println("MyClassLoader 加载器:" + MyClassLoader.class.getClassLoader());
// 设置加载类查找文件路径
loader1.setPath("D:\\workspace\\bac5\\java\\");
loader(loader1);
}
private static void loader(MyClassLoader loader) throws Exception {
// MyClassLoader 由系统加载器加载,跟test是不同的加载器,会出现NOClassDefFoundError
// 如果类中有package,则加载类名时,需要写全,不然找不到该类,会出现NOClassDefFoundError
Class test = loader.loadClass("com.ocean.test");
Object test1 = test.newInstance();
// test test2 = (test) test1;
// 如果MyClassLoader与test非同一个加载器,访问时,需要用到反射机制
Field v1 = test.getField("v1");// java反射机制,取test中的静态变量
System.out.println("被加载出来的类是:"+v1.getInt(test1));
// 卸载,将引用置空
test = null;
test1 = null;
// 重新加载
test = loader.loadClass("com.ocean.test");
test1 = test.newInstance();
System.out.println("test1 hashcode:"+test1.hashCode());
}
}



4.4.2 Tomcat打破双亲委派机制分析

4.4.2.1 为什么Tomcat 要违背默认的双亲委派类加载机制?

主流的Java Web服务器,如Tomcat、Jetty、WebLogic、WebSphere等,都实现了自己定义的类加载器(一般都不止一个)。因为一个功能健全的Web服务器,要解决如下几个问题:

  1. 部署在同一各个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离。这是最基本的需求,两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求一个类库在一个服务器中只有一份,服务器应该保证两个应用程序的类库可以互相独立使用。

  2. 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以互相共享。类库在使用时都要被加载到服务器内存,如果类库不能共享,虚拟机的方法区就会很容易出现过度膨胀的风险。

  3. 服务器需要尽可能地保证自身的安全不受部署的Web程序影响。一般来说,基于安全考虑,服务器所使用的类库应该与应用程序的类库互相独立。

  4. 支持JSP应用的Web服务器,大多数都需要支持热替换功能。JSP文件最终要编译成Java Class文件才能够由虚拟机执行,Web服务器需要支持jsp修改后不用重启。JSP热替换实现思路:jsp本质上还是class文件,如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。那么怎么办呢?每个jsp文件对应一个唯一的类加载器,那么一个jsp文件修改了,就直接卸载这个jsp类加载器,重新创建类加载器,重新加载jsp文件。

4.2.2.2 Tomcat 自定义类加载器详解



Tomcat的几个重要类加载器

CommonClassLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本省及各个WebApp访问

CatalinaClassLoader:Tomcat容器私有的类加载器,加载路径中的class对于WebApp不可见

SharedClassLoader:各个WebApp共享的类加载器,加载路径中的class对于所有WebApp可见,但是对于Tomcat容器不可见

WebAppClassLoader:各个WebApp私有的类加载器,加载路径中的class只对当前WebApp可见。WebAppClassLoad实现相互隔离

JasperClassLoader:加载范围仅仅是这个jsp文件所编译出的那个class文件,它出现的目的就是为了别丢弃。当Web容器检测到JSP文件被修改时,会替换掉目前的JasperClassLoader的实例,并通过在建立一个新的JSP类加载器来实现JSP文件的热加载功能



从图中的委派模型可以看出

  • CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用

  • CatalinaClassLoader和SharedClassLoader自己能加载的类与对方相互隔离。

  • WebAppClassLoader可以使用SharedClassLoader加载到的类,但是各个WebAppClassLoader实例之间相互隔离

  • JasperClassLoader:加载范围仅仅是这个jsp文件所编译出的那个class文件,它出现的目的就是为了别丢弃。当Web容器检测到JSP文件被修改时,会替换掉目前的JasperClassLoader的实例,并通过在建立一个新的JSP类加载器来实现JSP文件的热加载功能



4.5 模拟实现Tomcat的WebAppClassLoader加载war包应用内不同版本类库实现相互隔离

继承ClassLoader,重写findClass和loadClass方法。

发布于: 2020 年 06 月 13 日 阅读数: 56
用户头像

王海

关注

还未添加个人签名 2018.06.17 加入

还未添加个人简介

评论

发布
暂无评论
JVM学习笔记——JVM类加载机制