JVM学习笔记 之 类加载子系统

本文详细介绍了Java虚拟机的类加载子系统,包括加载、链接(验证、准备、解析)和初始化阶段。类加载器子系统负责从文件系统或网络加载Class文件,并将其存储在方法区。类加载过程遵循双亲委派模型,保证了类加载的安全性和一致性。此外,文章还探讨了自定义类加载器的使用场景和沙箱安全机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

类加载子系统

本篇学习笔记基于bilibili尚硅谷的jvm课程整理而来。

概述

下面是之前在第一张看到的类加载子系统简图:

image-20200705080719531

完整图如下:

其中类加载过程分为三个阶段:

  • 加载阶段:使用引导加载器、扩展加载器、系统类加载器加载不同的类;
  • 链接阶段:分为验证、准备、解析三个环节;
  • 初始化阶段:静态变量的显式初始化等。

接下来就是进入内存层面:

  • 方法区
  • 虚拟机栈:就是平时所说的”栈“,每个线程栈中的小结构称为栈帧,栈帧中大致分为LV(局部变量表)、OS(操作数栈)、DL(动态链接)、RA(方法返回地址)等结构;
  • PC寄存器
  • 本地方法栈:存放本地方法接口的栈

前两者所有进程共有,后三者每个进程分别独有

后面就是执行引擎阶段了,这些内容后面再细学。

image-20200705080911284

类加载器子系统作用

下面是类加载器子系统的图解:

image-20200705081813409

它的作用可以分为如下两项:

  • 类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识

  • 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量数字常量(这部分常量信息是Class文件中常量池部分的内存映射)

需要注意的是:ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。

下面来看看常量池包含什么,比如现在有一个StackStruTest类:

public class StackStruTest {
    public static void main(String[] args) {
        int i = 2 + 3;
    }
}

运行该类的main方法,在终端输入javap -v StackStruTest.class命令,找到Constant pool部分,就是常量池的信息:

image-20220318201213051

常量池加载到内存后,就称为运行时常量池

Class Loader的角色可以这么理解:

  • class file 存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来根据这个文件实例化出n个一模一样的实例。

  • class file 加载到 JVM 中,被称为DNA元数据模板,放在方法区。通过类的class调用getClassLoader方法,就可以获得加载此类的类加载器。

  • 在.class文件 -> JVM -> 最终成为元数据模板的过程,就要一个运输工具,这个工具就是类装载器Class Loader(快递员)。

下面是图示:

image-20200705081913538

类的加载过程

例如下面的一段简单的代码

public class HelloLoader {
    public static void main(String[] args) {
        System.out.println("我已经被加载啦");
    }
}

它的加载过程:

image-20200705082255746

具体的流程图:

image-20200705082601441

加载阶段

这里的加载阶段是类加载阶段中的第一步,指狭义的加载

加载阶段主要做以下三个工作:

  • 通过一个类的全限定名获取定义此类的二进制字节流(字节码文件);

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

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

关于上面第一步加载字节码文件的方式:

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

链接阶段

链接阶段分为三个子阶段:验证、准备、解析。

验证 Verify

目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。

主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证。

在学习类加载器子系统的作用时,我们了解到.class文件开头会有一个文件标识。现在用 Binary Viewer 查看之前StackStruTest.class文件的内容:

image-20220318205929046

开头部分的CA FE BA BE(咖啡宝贝)就是所有能被jvm识别的字节码的有效起址

如果出现不合法的字节码文件,那么将会验证不通过。

准备 Prepare

类变量分配内存并且设置该类变量的默认初始值,即零值。

类变量就是静态变量

例如下面这个程序:

public class HelloApp {
    private static int a = 1;  // 准备阶段为0,在下个阶段,也就是初始化的时候才是1
    public static void main(String[] args) {
        System.out.println(a);
    }
}

上面的变量a在准备阶段会赋初始值0,而不是1。

需要注意的是:

  • 这里不包含用final修饰的static的常量,该常量会在编译的时候被分配值,在准备阶段会显式初始化

  • 这里不会为实例变量分配初始化(因为此时还没有创建对象),类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。

解析 Resolve

将常量池内的符号引用转换为直接引用的过程。

事实上,解析操作往往会伴随着 JVM 在执行完初始化之后再执行。

符号引用就是一组符号描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的 class 文件格式中。

直接引用就是直接指向目标的指针相对偏移量一个间接定位到目标的句柄

解析动作主要针对类、接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info等

初始化阶段

概述

初始化阶段就是执行类构造器法()的过程.

类构造器方法不是类的构造器

此方法不需定义,是javac编译器自动收集类中的所有类变量(静态变量)的赋值动作静态代码块中的语句合并而来。也就是说,当我们代码中包含static变量的时候,就会有clinit方法。

案例
先行案例

这里写一个案例来理解上面那句话。

首先写一个Test类,代码如下:

public class Test {
    private static int num = 1;

    public static void main(String[] args) {
        System.out.println(num);
    }
}

这里我们采用的工具是ByteCode Viewer

将编译好的.class文件拖到ByteCode Viewer中打开,即可看到如下图所示界面:

image-20220319113537663

可以看到右侧显示了三个方法:、main、。第三个就是我们要找的方法,里面定义了变量1并引入静态变量的过程。

假如我们再假如静态代码块呢?

static {
    num = 2;
}

重新编译Test类并打开.class文件:

image-20220319114133472

可以看到的是,静态变量一开始赋值为1,后面赋值为2,最后return。并且构造器方法中的指令是按语句在源文件中出现的顺序执行的。

整体理解

现在我们再对上面的案例进行修改,结合之前所学知识进行一个梳理。

代码如下:

public class Test {
    private static int num = 1;

    static {
        num = 2;
        nums = 20;
    }

    private static int nums = 10;

    public static void main(String[] args) {
        System.out.println(num);
        System.out.println(nums);
    }
}

现在的情况是变量 nums 在 static 语句的下方进行定义,当然结果我们都已经知晓:nums变量的值是10。此时的方法是怎样定义的呢?

打开.class文件:

image-20220319114855457

链接阶段准备环节我们了解到,类加载子系统会在该阶段对nums变量(类变量)初始化为零值,也就是0;在初始化阶段,类加载子系统会对上面的变量采用bipush指令进行入栈,再由putstatic指令设置类中静态变量的值,此时nums的值被设置为20(静态代码),再接着,nums的变量被设置为10(定义语句)。

可见的是:java允许在static代码后面初始化static变量,因为这些static变量会在链接阶段准备环节被统一赋零值,所以不会出现语法错误

当然,如果在static代码块中引用后定义的类变量,则会报错:Illegal forward reference(非法的前向引用)。

还需要注意的地方

()方法对应的其实就是类的构造器。在Test类中我们没有声明构造器,所以生成的是默认的空参构造器。

image-20220319120807587

假如我们显示定义了构造器,比如下面的代码:

public class Test {
    private int a = 1;
    private static int c = 3;
    public static void main(String[] args) {
        int b = 2;
    }
    public Test() {
        a = 10;
        int e = 20;
    }
}

那么方法的内容会是下面这样的:

idea有bytecode viewer的插件,安装后在上方工具栏view选项即可找到对应选项

image-20220319121818842

关于继承

若该类具有父类,JVM会保证子类的()执行前,父类的()已经执行完毕。

这里我们修改一下Test类的代码:

public class Test {
    static class Father {
        public static int A = 1;
        static {
            A = 2;
        }
    }

    static class Son extends Father {
        public static int b = A;
    }

    public static void main(String[] args) {
        System.out.println(Son.b);
    }
}

输出结果为 2。

加载Test的main方法时,会加载Son类,执行Son类的初始化,但是Son继承了Father,因此需要先执行Father的初始化,在Father类中,A首先被定义初始化为1,然后在static代码块中被赋值为2,随后才在Son类的定义中,B被赋值为Father.A的值,最后回到Test类,调用Son.B变量的引用并输出。

关于锁

虚拟机会保证一个类的()方法在多线程下被同步加锁。通俗点理解就是:假如有两个线程同时访问某一个类的static代码,那么它们的访问将会被限制为同步访问,假如线程一访问static代码块但此时并没有退出,这个时候线程二也来访问,那么static代码块此时只会被线程一加载一次,而不会被线程二再次加载(因为是同步地访问)。

下面编写一个Test类,创造两个线程并访问staticClass类的static代码:

public class Test {
    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t 线程t1开始");
            new staticClass();
        }, "t1").start();

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t 线程t2开始");
            new staticClass();
        }, "t2").start();
    }
}
class staticClass {
    static {
        if (true) {
            System.out.println(Thread.currentThread().getName() + "\t 初始化当前类");
            while(true) {}
        }
    }
}

上面的代码,输出结果为:

image-20220319124117763

可以看到:t2抢到了static代码的访问,那么t1此时就无法访问static代码。

类加载器的分类

概述

JVM支持两种类型的类加载器,分别为:引导类加载器(Bootstrap ClassLoader)自定义类加载器(User-Defined Class Loader)

从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类Class Loader的类加载器都划分为自定义类加载器

无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个:引导类加载器、扩展类加载器、系统类加载器(应用程序类加载器)

如下图所示:

image-20200705094149223

这里的四者之间是包含关系,不是上层和下层,也不是子系统的继承关系。

我们通过一个ClassLoaderTest类,尝试获取不同的加载器:

public class ClassLoaderTest {
    public static void main(String[] args) {
        // 获取系统类加载器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader);

        // 获取其上层的:扩展类加载器
        ClassLoader extClassLoader = systemClassLoader.getParent();
        System.out.println(extClassLoader);

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

        // 获取自定义加载器
        ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
        System.out.println(classLoader);
        
        // 获取String类型的加载器
        ClassLoader classLoader1 = String.class.getClassLoader();
        System.out.println(classLoader1);
    }
}

得到的结果:

image-20220319171203943从结果可以看出根加载器无法直接通过代码获取,而目前用户代码所使用的加载器为系统类加载器。同时我们尝试获取String类型的加载器,发现是null,那么说明String类型是通过根加载器进行加载的,也就是说Java的核心类库都是使用根加载器进行加载的

下面对其中类加载器进行更加详细的描述。

虚拟机自带的加载器(具体分类)

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

其实就是加载核心包

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

其实就是加载java平台的扩展包

应用程序类加载器(系统类加载器,App/System Class Loader)
  • javI语言编写,由sun.misc.LaunchersAppClassLoader实现
  • 派生于Class Loader类
  • 父类加载器为扩展类加载器
  • 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
  • 该类加载器是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
  • 通过classLoader.getSystemclassLoader()方法可以获取到该类加载器

其实就是加载classpath中指定的jar包还有目录中的类

用户自定义类加载器

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

  • 隔离加载类
  • 修改类加载的方式
  • 扩展加载源
  • 防止源码泄漏

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

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

为了更好地理解各类加载器,我们创建一个ClassLoaderTest类,看看各类类加载器都能加载什么路径下的类。

引导类加载器

首先查看引导类加载器所能加载的api的路径:

public class ClassLoaderTest {
    public static void main(String[] args) {
        System.out.println("*********启动类加载器************");
        //获取BootstrapClassLoader能够加载的api的路径
        URL[] urLs = sun.misc.Launcher.getBootstrapClassPath().getURLs();
        for (URL element : urLs) {
            System. out . println(element . toExternalForm());
        }
    }
}

环境要求jdk8或以下,更高版本的Launcher类将不会对用户进行开放,防止用户查看

得到的结果

image-20220319174318411

我们在上面的路径中选择任意一个,这里选择jsse.jar,找到该jar包,解压到任意一个地方,在文件夹中可以找到一个Provider.class:

image-20220319190152933

运行下面的代码:

public class ClassLoaderTest {
    public static void main(String[] args) {
        ClassLoader classLoader = Provider.class.getClassLoader();
        System.out.println(classLoader);
    }
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JGx1cMLA-1648973777888)(https://cdn.jsdelivr.net/gh/senluoye/BadGallery@main/image/202203201714457.png)]

可以看到,显示结果为null,可以证明加载Provider类的加载器是引导类加载器。

扩展类加载器

下面看看扩展类加载器能加载的路径有哪些。

还是创建一个ClassLoaderTest类,代码如下:

public class ClassLoaderTest {
    public static void main(String[] args) {
        //从上面的路径中随意选择一个类,来看看他的类加载器是什么:
        System.out.println("**********扩展类加载器**********");
        String extDirs = System.getProperty("java.ext.dirs");
        for (String path : extDirs.split(";")) {
            System.out.println(path);
        }
    }
}

运行结果如下:

image-20220321211958000

随便在这些目录下找一个jar包并解压,这里找的是第一个目录下的sunec.jar:

image-20220321212743929

解压后选择Point.class:

image-20220321212812426

接下来查看它的类加载器类别:

public class ClassLoaderTest {
    public static void main(String[] args) {
        ClassLoader classLoader = GradientUtils.Point.class.getClassLoader();
        System.out.println(classLoader);
    }
}

结果如下,可以看到类加载器正是Launcher$ExtClassLoader:

image-20220321212855403

关于ClassLoader

在前面的笔记中多次提到了ClassLoader这个类,其实ClassLoader是一个抽象类,其后所有的类加载器都继承自ClassLoader(除了启动类加载器)

换句话说,所有派生于Class Loader类的加载器,都是用java语言编写的,前面也说到这些类都算是自定义类加载器

下面是ClassLoader类的一些非抽象方法:

image-20200705103516138

下面这幅图清晰地展示了扩展类加载器和应用程序类(系统类)加载器对于CLassLoader的间接继承关系:

image-20200705103636003

获取ClassLoader的几种方式:

  • 获取当前ClassLoader:clazz.getClassLoader()
  • 获取当前线程上下文的ClassLoader:Thread.currentThread().getContextClassLoader()
  • 获取系统的ClassLoader:ClassLoader.getSystemClassLoader()
  • 获取调用者的ClassLoader:DriverManager.getCallerClassLoader()

双亲委派机制

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。

案例

思考一个场景:我们使用String类之前,在开发目录下建立java.lang包,包下建立一个String类,类中写上如下静态代码:

package java.lang;

public class String {
    static {
        System.out.println("自定义String");
    }
}

这个时候在一个测试类中调用String类:

public class ClassLoaderTest {
    public static void main(String[] args) {
        java.lang.String str = new java.lang.String();
        System.out.println(str);
    }
}

运行之后会出现什么结果呢?

image-20220403153753609

很显然,测试类中调用的String类并不是我们自己写的String类,而是它本身定义的java.lang.String,从而避免了因为自定义类而造成代码调用后出现问题的错误。而这一切,就是基于双亲委派模型而完成的。

工作原理

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

image-20200705105151258

回到上面的案例,由于JVM会将类加载委托到最顶层:引导类加载器,而引导类加载器的工作就是完成java、javax、sun开头的类的加载,所以最终引用的对象还是java自身源码的String类的对象。而自身编写的测试类就是由系统类加载器加载的。

案例二

当我们加载jdbc.jar 用于实现数据库连接的时候,首先我们需要知道的是jdbc.jar是基于SPI接口进行实现的,所以在加载的时候,会进行双亲委派,最终从根加载器中加载SPI核心类,然后再加载SPI接口类,接着在进行反向委派,通过线程上下文类加载器进行实现类jdbc.jar的加载。

image-20200705105810107

双亲委派机制的优势

通过上面的例子,我们可以知道,双亲机制可以

  • 避免类的重复加载
  • 保护程序安全,防止核心API被随意篡改

沙箱安全机制

沙箱机制就是讲Java代码限定在JVM特定的运行范围中,并且严格限制代码对本地资源的访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。

例如在自定义的lang包下新增一个Test类:

package java.lang;

public class Test {
    public static void main(String[] args) {
        System.out.println("asdasd");
    }
}

运行情况如下:

image-20220403155744328

由于自定义的Test类位于java.lang包下,即使这个包也是我们自定义的,JVM还是会根据包名找到其本身的java.lang包,由于本身源码中没有Test类,所以,即使自定义Test类没有语法错误,JVM也会报出安全警告,告诉我们应当禁止使用该包名(没有该权限),以防止运行程序后出现错误。

其它

如何判断两个class对象是否相同

在JVM中表示两个class对象是否为同一个类存在两个必要条件:

  • 类的完整类名必须一致,包括包名。
  • 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同。

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

JVM必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中

当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的

类的主动使用和被动使用

Java程序对类的使用方式分为:王动使用和被动使用。
主动使用,又分为七种情况:

  • 创建类的实例
  • 访问某个类或接口的静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射(比如:Class.forName(“com.atguigu.Test”))
  • 初始化一个类的子类(父类初始化)
  • Java虚拟机启动时被标明为启动类的类
  • JDK7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化

除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值