JVM之类加载子系统

本文以Java8为主探讨虚拟机。下图是JVM整个构造简图,由类加载子系统、运行时数据区、即时编译器、JIT编译器、垃圾收集器、本地方法接口、本地方法库等组成。
在这里插入图片描述

1、类的生命周期

在Java中数据类型分为基本数据类型和引用数据类型。基本数据类型由虚拟机预先定义,引用数据类型则需要进行类的加载
按照Java虚拟机规范,从class文件到加载到内存中的类,到类卸载出内存为止,它的整个生命周期包括如下7各阶段
而类加载子系统只涵盖类生命周期的前5个阶段,类的使用就是我们程序员平常经常要用的,类的卸载条件极为严苛,文章后面会提到
在这里插入图片描述
其中,验证、准备、解析 3个部分统称为链接(Linking)。
执行引擎需要该类时,先在方法区中寻找,没有找到——就使用该类的对应的加载器加载该类;如果该类的加载器没有加载到类,会对应抛出相应的异常,如常见的:ClassNotFondException。如果加载到了就正常使用,如实例化等等。
注:方法区其实是JVM虚拟机规范中提到的概念,HotSpot虚拟机的落地实现,JDK1.8之前:永久代;JDK1.8及之后:元空间。

2、类加载子系统

如下是类加载子系统简图:
在这里插入图片描述

1、加载(Loading):

通过一个类的全限定名(如:com.example.jvm.Student)获取定义此类的二进制数据流;将这个二进制数据流所代表的静态存储结构转化为方法区的运行时数据结构(Java类模板信息);在堆中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口;(在此多一句,反射机制即基于这一基础【Class实例】。如果JVM没有将Java类的声明信息存储在堆中,则JVM在运行期也无法反射)
注:Class类的构造方法是私有的,只有JVM能够创建。java.lang.Class实例是访问类型元数据的接口,也是实现反射的关键数据、入口。通过Class类提供的接口,可以获得目标类所关联的。class文件中具体的数据结构:方法、字段等信息。
加载方式如下情形:

从本地系统中直接加载;
通过网络获取,经典场景:Web Applet;
从zip压缩包中读取,成为日后jar、war格式的基础;
运行时计算生成,使用最多的是:动态代理技术;
由其他文件生成,经典场景:JSP应用;
从专有数据库中提取.class文件,比较少见;
从加密文件中获取,典型的防Class文件被反编译的保护措施;

如加载String类:

public class LoadingTest {

    public static void main(String[] args) {
        try {
            Class<?> clazz = Class.forName("java.lang.String");
            Method[] ms = clazz.getDeclaredMethods();
            for (Method m : ms) {
                // 获取方法的修饰符
                String mod = Modifier.toString(m.getModifiers());
                System.out.println(mod + " ");
                // 获取方法的返回值类型
                String returnType = m.getReturnType().getSimpleName();
                System.out.println(returnType + " ");
                // 获取方法名
                System.out.println(m.getName() + "(");
                // 获取方法的参数列表
                Class<?>[] ps = m.getParameterTypes();
                if (ps.length == 0){
                    System.out.println(')');
                }
                for (int i = 0; i < ps.length; i++) {
                    char end = (i == ps.length - 1) ? ')' : ',';
                    // 获取参数的类型
                    System.out.println(ps[i].getSimpleName() + end);
                }
                System.out.println();
            }
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

补充:数组类的加载
创建数组类的情况稍微有些特殊,因为数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要而直接创建的,但数组的元素类型任然需要依靠类加载器去创建。创建数组类(下述简称A)的过程:
1、如果数组的元素类型是引用类型,那么就遵循定义的加载过程递归加载和创建数组A的元素类型;
2、JVM使用指定的元素类型和数组维度来创建新的数组类。
如果数组的元素类型是引用类型,数组类的可访问性就由元素类型的可访问性决定。否则数组类的可访问性将被缺省定义为public。

2、验证(Verification):

当类加载到系统后,就开始链接操作,验证是链接操作的第一步。它的目的是保证加载的字节码是合法、合理并符合规范的。在于确保class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。主要包括四种验证,文件格式验证,语义检查、字节码验证,符号引用验证。

具体说明
1)格式验证:是否以魔数 0xCAFEBABE开头,主版本和副版本号是否在当前Java虚拟机的支持范围内,数据中每一个项是否都拥有正确的长度等。
2)语义检查:
  • 是否所有的类都有父类的存在(在Java里,除了Object外,其他类都应该有父类);
  • 是否一些被定义为final的方法或者类被重写或继承了;
  • 非抽象类是否实现了所有抽象方法或者接口方法;
  • 是否存在不兼容的方法(比如方法的签名除了返回值不同,其他都一样,这种方法会让虚拟机无从下手调度;abstract情况下的方法,就不能是final的了);
3)字节码验证
  • 在字节码的执行过程中,是否会跳转到一条不存在的指令;
  • 函数的调用是否传递了正确类型的参数;
  • 变量的赋值是不是给了正确的数据类型等;
4)符号引用验证

Class文件在其常量池会通过字符串记录自己将要使用的其它类或者方法。因此,在验证阶段,虚拟机就会检查这些类或者方法确实是存在的,并且当前类有权限访问这些数据,如果一个需要使用类无法在系统中找到,则会抛出NoClassDefFoundError,如果一个方法无法被找到,则会抛出NoSuchMethodError

注意:加载和验证是同时进行的,

3、准备(Preparation):

为类的静态变量分配内存并且为其初始值默认值,即零值。(不同的类变量赋不同的默认值,如引用类型赋null,double赋0.0,boolean赋false等等);这里不包含final修饰的static的基本数据类型,因为final在编译的时候就会分配了默认值,准备阶段会显示赋;而引用数据类型看情况可能在准备阶段赋值,也有可能在其后的初始化阶段赋值;这里不会为实例变量分配初始值,类变量会分配在方法区中,而实例变量会随着对象一起分配到Java堆中。
注:在这个阶段并不会像初始化阶段中那样会有初始化或者代码被执行。

4、解析(Resolution):

将常量池内的符号引用转换为直接引用的过程;事实上,解析操作往往会伴随着JVM在执行完初始化之后在执行;符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info、CONSTANT_Fielderf_info、CONSTANT_Methodref_info等。

5、初始化(Initialization)

到了初始化阶段,才真正开始执行类中定义的Java程序代码;初始化阶段就是执行类构造器方法<clinit>()的过程。此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。构造方法中语句在源文件中出现的顺序执行。()不同于类的构造器。(关联:构造其实虚拟机视角下的<init>());若该类具有父类,JVM会保证子类<clinit>()执行前,父类的<clinit>()已经执行完毕。虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁。
口诀:由父及子,静态先行。

哪些场景下,java编译器就不会生成<clinit>()方法?
场景1:对应非静态的字段,不管是否进行了显式赋值,都不会生成<clinit>方法;public int num = 1;
场景2:静态的字段,没有显示的赋值,也不会生产<clinit>()方法; public static int num1;
场景3: 比如对于声明为static final的基本数据类型的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法 public static final int num2 = 1;

public class InitializationTest {

    // 场景一:对应非静态的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
    public int num = 1;
    // 场景二:静态的字段,没有显示的赋值,也不会生成<clinit>()方法
    public static int num1;
    // 场景三:声明为static final的基本数据类型的字段,不管是否进行了现实赋值,
    // 都不会生成<clinit>方法
    public static final int num2 = 2;
    // 场景四:
//    public static final int num3 = 3;

}
/**
 * @Author George
 * @Date 2023/1/15 5:21
 * @Desc
 *
 * 使用static + final修饰字段的显式赋值操作,到底是在那个阶段进行的?
 * 情况一:在链接阶段的准备环节赋值
 * 情况二:在初始化阶段<clinit>()中赋值
 *
 * 结论:
 * 1.对于基本类型的字段来说,如果使用static final修饰,则显示赋值(直接赋常量,而非调用方法)通常是链接阶段的准备环节进行
 * 2.对于String来说,如果使用字面量的方式赋值,使用static final修饰的话,则显示赋值通常在链接阶段的准备环节进行
 *
 * 在初始化阶段<clinit>()中赋值的情况:
 * 排除上述准备环节赋值的情况之外的情况
 *
 * 最终结论:使用static + final修饰,且显式赋值中不涉及到方法或构造器调用的基本数据类型或String类型的显式赋值
 * 是在链接阶段的准备环节进行的
 */

public class InitializationTest1 {

    public static int a = 1;// 在初始化阶段<clinit>()中赋值
    public static final int INT_CONSTANT = 10;// 在链接阶段的准备环节赋值

    public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100);// 在初始化阶段<clinit>()中赋值
    public static Integer INTEGER_CONSTANT2 = Integer.valueOf(1000);// 在初始化阶段<clinit>()中赋值

    public static final String s0 = "helloworld0";
    public static final String s1 = new String("helloworld1");

    public static String s2 = "helloworld2";

    public static final int NUM1 = new Random().nextInt(10);
}

3、类加载器

下面的这些都是属于类加载子系统的加载阶段!!!

启动类加载器(引导类加载器,Bootstrap ClassLoader)

这个类加载使用C/C++语言实现的,嵌套在JVM内部。
它用来加载Java的核心类库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类;
并不继承自java.lang.ClassLoader,没有父加载器;
加载扩展类和应用程序类加载器,并制定他们的父类加载器;
出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun开头的类;

扩展类加载器(Extension ClassLoader)

Java语言编写,由sun.misc.Launcher$ExtClassLoader实现; 派生于ClassLoader类;
父类加载器为启动类加载器;
从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录,例如D:\java\java8\jdk1.8.0_91\jre\lib\ext)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。

应用程序类加载器(系统类加载器,AppClassLoader)

Java语言编写,由sun.misc.Launcher$AppClassLoader实现; 派生于ClassLoader类;
父类加载器为扩展类加载器; 他负责加载环境变量claspath或系统属性java.class.path指定路径下的类库;
该类加载时程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载;
通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器;

下面是代码案例:

package com.example.jvm;

/**
 * @Author George
 * @Date 2022/12/1 19:24
 * @VDesc
 */
public class ClassLoaderTest {

    public static void main(String[] args) {
        // 获取系统类加载器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader);// sun.misc.Launcher$AppClassLoader@18b4aac2

        // 获取扩展类加载器
        ClassLoader extClassLoader = systemClassLoader.getParent();
        System.out.println(extClassLoader);// sun.misc.Launcher$ExtClassLoader@7ea987ac

        // 试图获取引导类加载器
        ClassLoader bootstrapClassLoader = extClassLoader.getParent();
        System.out.println(bootstrapClassLoader);// null

        // 获取不到,位null,这几个类加载器是包含关系

        // 用户自定义类是哪个类加载器加载的
        ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
        System.out.println(classLoader);// sun.misc.Launcher$AppClassLoader@18b4aac2

        // String类使用引导类加载器加载的——>Java的核心类库都是使用引导类加载器加载的
        ClassLoader strClassLoader = String.class.getClassLoader();
        System.out.println(strClassLoader);// null

    }
}

在这里插入图片描述

用户自定义类加载器

在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器互相配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。

用户自定义类加载器实现步骤:

1、开发人员可以通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求;
2、在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loaderClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后,已不再建议用户去覆盖loadClass()方法,而是建议把自定义类的类加载逻辑卸载findClass()方法中;
3、在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法及其获取字节码的方式,是自定义类加载器编写更加简洁。

为什么要自定义类加载器?

隔离加载类:很多框架都实现了自己的类加载器,实现类的隔离加载 修改类加载的方式 扩展加载源:从上面的
【补充:加载.class文件的方式】几种情况加载类需要自己定义类加载器 防止源码泄漏:Java代码容易反编译后泄漏源码,做一些加密措施)

自定义类加载器源码展示:

package com.example.jvm;

import java.io.FileNotFoundException;

/**
 * @Author George
 * @Date 2022/12/2 5:51
 * @VDesc
 */

public class CustomClassLoader extends ClassLoader{

    @Override
    protected Class<?> findClass(String pathname) throws ClassNotFoundException {

        try {
            byte[] result = getClassFromCustomPath(pathname);
            if (result == null){
                throw new FileNotFoundException();
            }else {
                return defineClass(pathname, result, 0, result.length);
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }

        throw new ClassNotFoundException(pathname);
    }

    private byte[] getClassFromCustomPath(String name){
        // 从自定义路径中加载指定类:根据自己的需求实现
        // 如果指定路径的字节码文件进行了加密,则需要在此方法中进行解密操作

        return null;
    }

    public static void main(String[] args) {
        CustomClassLoader customClassLoader = new CustomClassLoader();
        try {
            Class<?> clazz = Class.forName("One", true, customClassLoader);
            Object o = clazz.newInstance();
        } catch (ClassNotFoundException | IllegalAccessException | InstantiationException e) {
            e.printStackTrace();
        }
    }
}

4、双亲委派机制

在这里插入图片描述

1、如果一个类加载器收到了类加载请求,他并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
2、如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
3、如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

优势:避免类的重复加载;保护程序安全,防止核心API被随意篡改;例如,你自己写一个java.lang.String类根本就不会被加载进虚拟机,启动类加载器会加载包名为java、javax、sun开头的类,没有轮到你写的这个类时,java.lang.String类就已经被加载进虚拟机了。另一个,你写的类用java.lang开头也是不被允许的,会抛出SecurityException异常。其实这个过程就是JDK保护自己安全的一种沙箱安全机制。

JVM必须知道一个类型是由启动加载器加载的还是有用户类加载器加载的。如果一个类形式由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。

在JVM中表示两个class对象是否使用同一个类存在两个必要条件:类的完整类名必须一致,包括包名;加载这个类的ClassLoader(指ClassLoader实例对象)必须相同。

换句话说,在JVM中,即使这两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载他们的ClassLoader实例对象不同,那么这两个对象也是不相等的。

类的主动使用和被动使用

Java程序对类的使用方式分为:主动使用和被动使用

主动使用,又分为七种情况: 创建类的实例 访问某个类或接口的静态变量,或者对该静态变量赋值 调用类的静态方法
反射(比如:Class.forName(“com.example.jvm.Test”) 初始化一个类的子类
Java虚拟机启动时被标明为启动类的类
JDK7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果,REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化
除以上7种情况,其他使用Java类的方式都被看做是对类的被动使用,都不会导致类的初始化。

天下事有难易乎?为之,则难者亦易矣;不为,则易者亦难矣!

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值