HuKai's Blog HuKai's Blog
首页
  • Java核心技术

    • Java基础
    • Java并发编程
    • JVM
    • Java新特性
  • Spring生态

    • Spring5
    • SpringMVC
    • SpringBoot
  • 开源框架

    • MyBatis
  • 计算机网络
  • 操作系统
  • 数据结构与算法
  • 设计模式
  • SQL数据库

    • MySQL
    • Oracle
  • NoSQL数据库

    • Redis
    • MongoDB
  • 页面样式

    • HTML
    • CSS
  • JavaScript

    • JavaScript基础
    • ECMAScript6教程
    • TypeScript
  • 前端框架

    • Vue
    • Webpack
  • NIO
  • Netty
  • RabbitMQ
  • 技术文档

    • GitHub技巧
    • 博客搭建
    • 技术笔记
  • 优质文章

    • 小技巧
    • 解决方案
GitHub (opens new window)

HuKai

梦想成为全栈的保安
首页
  • Java核心技术

    • Java基础
    • Java并发编程
    • JVM
    • Java新特性
  • Spring生态

    • Spring5
    • SpringMVC
    • SpringBoot
  • 开源框架

    • MyBatis
  • 计算机网络
  • 操作系统
  • 数据结构与算法
  • 设计模式
  • SQL数据库

    • MySQL
    • Oracle
  • NoSQL数据库

    • Redis
    • MongoDB
  • 页面样式

    • HTML
    • CSS
  • JavaScript

    • JavaScript基础
    • ECMAScript6教程
    • TypeScript
  • 前端框架

    • Vue
    • Webpack
  • NIO
  • Netty
  • RabbitMQ
  • 技术文档

    • GitHub技巧
    • 博客搭建
    • 技术笔记
  • 优质文章

    • 小技巧
    • 解决方案
GitHub (opens new window)
  • Java基础

  • Java并发编程

  • JVM

    • 初识JVM
    • 类文件结构详解上篇
    • 类文件结构详解下篇
    • 虚拟机类加载机制
      • 概述
      • 类加载过程
        • 加载(Loading)
        • 验证(Verification)
        • 准备(Preparation)
        • 解析
        • 初始化
      • 类加载器
      • 双亲委派模型
      • 自定义类加载器
    • JVM内存结构
    • JVM对象模型
    • GC基础
    • GC算法与收集器
    • GC日志详解
  • Java新特性

  • Spring5

  • SpringMVC

  • SpringBoot

  • MyBatis

  • Java
  • JVM
HuKai
2022-02-13
目录

虚拟机类加载机制

# 概述

什么是虚拟机类加载机制呢?我们先来看Java程序运行图:

Java的类加载机制所做的工作就是将经编译器编译后的.class文件中的二进制数据读入到内存中,并对数据进行校验、转换解析和初始化,最终形成可以被JVM直接使用的Java类型。

# 类加载过程

Java 的类加载过程可以分为5个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)和初始化(Initialization)。其中验证、准备、解析3个部分统称为链接(Linking)。

加载、验证、准备、初始化这4个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。

接下来我们详细讲解一下Java虚拟机中类加载的全过程所执行的具体动作。

# 加载(Loading)

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

  • 通过一个类的全限定名来获取定义此类的二进制字节流。但它并没有指明二进制字节流要从一个Class文件中获取,也可能是 jar 包,甚至网络
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

用一句话来说,该阶段的主要目的是:将字节码从不同的数据源转化为二进制字节流加载到内存中,并生成一个代表该类的java.lang.Class 对象。

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

# 验证(Verification)

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

Java语言本身是相对安全的语言,但Class文件并不一定要求用Java源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写来产生Class文件。虚拟机如果不检査输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。

从整体上看,验证阶段大致上会完成下面4个阶段的检验动作:

  • 文件格式验证

这一阶段主要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。

验证点:是否以魔数OxCAFEBABE开头;主、次版本号是否在当前虚拟机处理范围之内;Class文件中各个部分及文件本身是否有被删除的或附加的其他信息等。

只有通这了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。

  • 元数据验证

这一阶段是对字节码描述的信息进行语义分析.以保证其描述的信息符合Java语言规范的要求。

验证点:这个类是否有父类(除java.lang.Object之外,所有的类都应当有父类);这个类的父类是否继承了不允许被继承的类(final修饰);如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法等

  • 字节码验证

这一阶段主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。

验证点:保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作;保证跳转指令不会跳转到方法体以外的字节码指令上;保证方法体中的类型转换是有效的等。

tips:在JDK 1.6之后的Javac编译器和Java虚拟机中进行了一项优化,给方法体的Code属性的属性表中增加了一项名为“StackMapTable”的属性,这项属性描述了方法体中所有的基本块(Basic Block,按照控制流拆分的代码块)开始时本地变量表和操作数栈应有的状态,在字节码验证期间,就不需要根据程序推导这些状态的合法性,只需要检査StackMapTable属性中的记录是否合法即可,这样将字节码验证的类型推导转变为类型检査从而节省一些时间。而在jdk1.7后,使用类型检査来完成数据流分析校验则是唯一的选择,不允许再退回到类型推导的校验方式。

  • 符号引用验证

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

验证点:符号引用中通过字符串描述的全限定名是否能找到对应的类;在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段;符号引用中的类、字段、方法的访问性是否可被当前类访问等。

对于虚拟机的类加载机制来说,验证阶段是一个非常重要的、但不是一定必要(因为对程序运行期没有影响)的阶段。如果所运行的全部代码(包括自己编写的及第三方包中的代码)都已经被反复使用和验证过,那么在实施阶段就可以考虑使用-Xverity:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

# 准备(Preparation)

准备阶段是正式为类变量(也称为静态变量,static 关键字修饰的)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

需要注意的是这里并不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值,如0、0L、null、false等。如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值。

举个栗子,假如有这样一段代码:

public String test;

public static String hello = "hello";

public static final String world = "world";

public static final int a = 3;
1
2
3
4
5
6
7

test不会被分配内存,因为它不是类变量。hello会被分配内存,但其初始值不是“hello”而是 null。world和a也会被分配内存,其初始值为“world”和3,因为我们知道这两个字段的属性表中是会生成ConstantValue属性的。

# 解析

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

符号引用在讲解Class文件格式的时候已经出现过多次,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieidref_info、CONSTANT_Methodref_info等类型的常世出现,它以一组符号(任何形式的字面量,只要在使用时能够无歧义的定位到目标即可)来描述所引用的目标。

在编译时,Java 类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。而直接引用通过对符号引用进行解析,找到引用的实际内存地址。如果有了直接引用,那引用的目标必定已经在内存中存在。

# 初始化

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

在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,类变量将被赋值为代码期望赋的值。换句话说,初始化阶段是执行类构造器方法(<clinit>()方法)的过程。

<clinit>方法是由编译器自动收集类中的所有类变量的陚值动作和静态语句块(static代码块)中的语句合并产生的。它与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。

# 类加载器

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类的加载阶段。对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在就Java虚拟机中的唯一性,也就是说,即使两个类来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。这里的“相等”包括了代表类的Class对象的equals()、isAssignableFrom()、isInstance()等方法的返回结果,也包括了使用instanceof关键字对对象所属关系的判定结果。

站在Java虚拟机的角度来讲,只存在两种不同的类加载器:

  • 启动类加载器:它使用C++实现是虚拟机自身的一部分
  • 所有其他的类加载器:这些类加载器都由Java语言实现,独立于虚拟机之外,并且全部继承自抽象类java.lang.ClassLoader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类

站在Java开发人员的角度来看,类加载器可以大致划分为以下三类:

  • 启动类加载器:BootstrapClassLoader,跟上面相同。它负责加载存放在${JAVA_HOME}\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的;

  • 扩展类加载器:Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载${JAVA_HOME}\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器;

  • 应用程序类加载器:Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

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

除了启动类加载器,每一个加载器都有一个父加载器,注意父加载器并不是父类,ExtClassLoader的父加载器是BootstrapClassLoader,由C/C++编写的,它本身是虚拟机的一部分,所以它并不是一个JAVA类,也就是无法在java代码中获取它的引用,JVM启动时通过Bootstrap类加载器加载rt.jar等核心jar包中的class文件,之前的int.class,String.class都是由它加载。

我们来看这样一段代码:

public class ClassLoaderDemo {
    public static void main(String[] args) {
        // 获取Person类的加载器AppClassLoader
        ClassLoader personClassLoader = Student.class.getClassLoader();
        System.out.println("Student类的加载器:" + personClassLoader);

        // 获取父加载器ExtClassLoader
        ClassLoader parentClassLoader = personClassLoader.getParent();
        System.out.println("Student类的父加载器:" + parentClassLoader);

        // ExtClassLoader的父加载器是BootstrapClassLoader,但是为什么是null
        parentClassLoader = parentClassLoader.getParent();
        System.out.println("Student类的父加载器的父加载器:" + parentClassLoader);

        // int类的加载器为什么是null?
        ClassLoader intClassLoader = int.class.getClassLoader();
        System.out.println("int类的加载器:" + intClassLoader);

        // String类的加载器为什么是null?
        ClassLoader stringClassLoader = String.class.getClassLoader();
        System.out.println("String类的加载器:" + stringClassLoader);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

这段代码的输出结果如下:

Person类的加载器:sun.misc.Launcher$AppClassLoader@18b4aac2
Person类的父加载器:sun.misc.Launcher$ExtClassLoader@1540e19d
Person类的父加载器的父加载器:null
int类的加载器:null
String类的加载器:null
1
2
3
4
5

由此我们看出AppClassLoader的父加载器为ExtClassLoader。ExtClassLoader的父加载器应当是BootstrapClassLoader,但由于它并不是一个JAVA类,无法在java代码中获取它的引用,所以输出结果为null。

tips:-verbose:class 输出所有的类加载日志

# 双亲委派模型

上面展示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型。它要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器,使用使用组合(Composition)关系来复用父加载器的代码而非继承。

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

使用双亲委派模型来组织类加栽器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。比如java.lang.String类,无论哪一个类加载器要加栽这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,即使你自己编写了一个路径为java.lang.String的类,并放在程序的ClassPath中,但被加载的永远是rt.jar中的String类。这对保证Java程序的稳定运作很重要。

双亲委派模型的实现非常简单,代码都集中java.lang.ClassLoader的loadClass()方法之中,我们来看一下这段代码:

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);

                // 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;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

上述代码大致流程如下:

  • 检查类是否已加载,如果是则不用再重新加载了;
  • 如果未加载,则通过父类加载(依次递归)或者启动类加载器(bootstrap)加载;
  • 如果还未找到,则调用本加载器的findClass方法;

以上可知,类加载器先通过父类加载,父类未找到时,才有本加载器加载。

# 自定义类加载器

因为自定义类加载器是继承ClassLoader,而我们再看findClass方法:

protected Class<?> findClass(String name) throws ClassNotFoundException {
    throw new ClassNotFoundException(name);
}
1
2
3

可以看出,它直接返回ClassNotFoundException。因此,自定义类加载器必须重写findClass方法。

一般情况下不建议重写loadClass()方法,在loadClass()方法的逻辑里如果父类加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派规则的。

我们来定义一个自己的ClassLoader,从D盘的demo文件夹下加载一个class文件:

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.lang.reflect.Method;

/**
 * 自定义类加载器
 *
 * @author hukai
 */
public class CustomerClassLoader extends ClassLoader {

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String path = name.replace('.', '/').concat(".class");
        File file = new File("D:\\demo", path);
        System.out.println("加载:" + file.getAbsolutePath());
        try {
            // 读取二进制流
            FileInputStream is = new FileInputStream(file);
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            int len = 0;
            try {
                while ((len = is.read()) != -1) {
                    bos.write(len);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            byte[] data = bos.toByteArray();
            is.close();
            bos.close();

        } catch (IOException e) {
            e.printStackTrace();
        }
        //调用父类的findClass, 父类内部直接抛出ClassNotFoundException异常
        return super.findClass(name);
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

测试类:

public static void main(String[] args) throws ClassNotFoundException {
    CustomerClassLoader classLoader = new CustomerClassLoader();
    //查看父加载器
    ClassLoader parentClassLoader = classLoader.getParent();
    System.out.println("parentClassLoader:" + parentClassLoader);

    Class<?> clazz = classLoader.loadClass("com.hukai.demo.classloader.CustomerDemo");
    if (clazz != null) {
        try {
            // 初始化对象
            Object obj = clazz.newInstance();
            // 获取test方法
            Method method = clazz.getDeclaredMethod("test", null);
            // 通过反射调用test方法
            method.invoke(obj, null);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

父加载器输出为:sun.misc.Launcher$AppClassLoader@18b4aac2。正常情况下,只要我们继承ClassLoader并且没有指定父加载器,默认的父加载器都为AppClassLoader。

即使自定义了自己的类加载器,强行用defineClass方法去加载一个以“java.lang”开头的类也不会成功,如果尝试这样做的话,将会收到一个由虚拟机自己抛出的“java.lang.SecurityException”。

对于自定义类加载器,哪里可以用到呢?

主流的Java Web服务器,比如Tomcat,都实现了自定义的类加载器。因为它要解决几个问题:

  • Tomcat上可以部署多个不同的应用,但是它们可以使用同一份类库的不同版本。这就需要自定义类加载器,以便对加载的类库进行隔离,否则会出现问题;
  • 对于非.class的文件,需要转为Java类,就需要自定义类加载器。比如JSP文件;

自定义类加载器的应用远远不止这些,当你需要以非常规的方式加载一个类时,都可以使用自定义类加载器完成。

编辑 (opens new window)
#JVM
上次更新: 2022/03/20, 11:17:00
类文件结构详解下篇
JVM内存结构

← 类文件结构详解下篇 JVM内存结构→

最近更新
01
MyBatisPlus
03-20
02
MyBatis源码剖析-延迟加载
03-20
03
MyBatis源码剖析-二级缓存
03-20
更多文章>
Theme by Vdoing | Copyright © 2021-2022 HuKai | 赣ICP备17016768号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式