Java基础知识专题4-Java类加载机制详解

Java基础知识专题4-Java类加载机制详解

前言

上一章我们大致讲解了Java代码从编译到运行的整体过程,在文中我们介绍了Java的整体架构,也知道了代码是从编译->类加载->内存分配->执行->垃圾回收来完成整个过程。

第一步编译,在上一章中我们已经大致讲解了。本章我们则对代码进入JVM的第一步类加载进行一个详细的讲解,以便我们对JVM后续的处理有个基础的了解和认识。

什么是类加载

将.class文件中的二进制数据读取到内存中,将其放到运行时数据区的方法区内,然后再堆区创建类的java.lang.Class对象,用来封装类的在方法区内的数据结构并作为方法区中这些数据的访问入口,为后续代码逻辑的运行做准备。

什么时候类加载器开始工作?

从JVM的执行流程上看,类加载器是在某个类被首次主动使用的时候采取加载它,但是JVM规范是允许类加载器在预料到某个类将要被使用是就去预先加载它的,换言之类加载器是根据情况不同随时随刻都可能运行的。
但是要注意的是:如果预先加载的过程中遇到.class文件缺失或者存在错误,那么类加载器是不会报出错误的,而是要等到这个类真正要首次使用的时候才会报出错误。这也很好理解,必定这个加载是预先判定的,并不是一定会使用,所以在使用的时候报问题才最合理。

开始类加载有三种方式:

  1. 命令行启动应用时候由JVM初始化加载;
  2. 通过Class.forName()方法动态加载;
  3. 通过ClassLoader.loadClass()方法动态加载。

.class文件的来源大致可以分为如下5类:

  1. 本地磁盘
  2. 网上加载(如:Applet)
  3. 从数据库中
  4. 压缩文件中(如:ZAR,jar等)
  5. 其他特殊类型文件(如:JSP)

类加载的过程

通过上面几个问题,我们了解了类加载的一些基本信息,接下来我们对类加载的整个过程进行详细讲解。首先类加载的整个过程如下图:
类加载流程
类加载的过程包含了五个阶段:加载、验证、准备、解析、初始化。这个五个阶段中,加载、验证、准备和初始化四个阶段一定是顺序发生的,而为了支持运行时绑定,解析阶段有时候是可以放在初始化之后再开始。

另外值得注意的是:几个阶段是按顺序开始,而不是按顺序执行或完成的,因为这些阶段通常是相互交叉的混合进行的,通常一个阶段执行的过程中调用或激活另一个阶段。

接下来我们对以上五个过程逐一进行解析。

第一步:加载

一句话描述:把.class文件读入内存

加载是类加载机制的第一步,该阶段主要完成以下三件事:

  1. 通过类的全限定名来获取其定义的二进制字节流;
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
  3. 在堆中生成一个代表这个类的java.lang.Class对象,作为方法区中这个类相关数据的访问入口。

相对于类加载机制的其他阶段,加载阶段是可控性最强的一个阶段。因为我们既可以使用系统的类加载器,也可以使用自己定义的类加载器。

第二步:验证

一句话描述:验证被读入的数据有没有问题

验证阶段主要的目的是保证被加载的类的正确性。也是连接阶段的第一步,说白了就是验证我们加载好的.class文件是否对虚拟机有没有危害,保证JVM的安全和规范。围绕着这个主题,它主要由四个阶段的验证:

  1. 文件格式的验证:是否已魔数0xCAFEBABE开头、主、次版本号是否在当前虚拟机处理范围之内、常量合理性验证等。此阶段保证输入的字节流能正确地解析并存储于方法区内,格式上符合描述一个Java类型信息的要求。
  2. 元数据的验证:是否存在父类,父类的继承链是否正确,抽象类是否实现了其父类或接口之中要求实现的所有方法,字段、方法是否与父类产生矛盾等。
  3. 字节码的验证:通过数据流和控制流分析,确定程序予以是合法的、符合逻辑的。例如保证跳转指令不会跳转到方法以外的字节码指令上。
  4. 符号引用验证:在解析阶段中发生,保证可将符号引用转化为直接引用。

对整个类加载机制而言,验证阶段是一个很重要但是非必需的阶段,如果我们的代码能够确保没有问题,那么我们就没有必要去验证,毕竟验证需要花费一定的的时间。当然我们可以使用-Xverfity:none来关闭大部分的验证。

第三步:准备

一句话描述:分配内存并设置初始值

准备阶段主要为类变量分配内存并设置初始值。这些内存都在方法区分配。这个过程的关键点在于:类变量和初始值:

  1. 类变量(static)会分配内存,但是实例变量不会,实例变量主要随着对象的实例化一块分配到Java堆中;
  2. 这里的初始值指的是数据类型对应的默认值,而不是代码中被显示赋予的值。
    比如:
public static int value =1;
//在准备阶段过后value的值为0,而不是1。因为int类型的默认值是0;
//而1是代码赋予value的值,本阶段不会赋值。

基本类型默认值:
在这里插入图片描述
注意:上面的value是被static所修饰的值在准备阶段之后才是0,但是如果同时被final和static修饰,那么准备阶段过后就是1了。我们可以理解为static final在编译器就将结果放入调用它的类的常量池中了。

第四步:解析

一句话描述:解析符号,确定引用

解析阶段则主要是虚拟机将常量池中的符号引用转化为直接引用的过程。什么是符号引用和直接引用呢?

  1. 符号引用:以一组符号来描述所引用的目标,可以是任何形式的字面量,只要是无歧义的定位到目标就好,就好比在班级中,老师可以用张三来代表你,也可以用你的学号来代表你,但无论任何方式这些都只是代号(符号),这个代号指向你(符号引用);
  2. 直接引用:直接引用是可以指向目标的指针、相对偏移量或者是一个能直接或间接定位到目标的句柄。和虚拟机实现的内存有关,不同的虚拟机直接引用一般不同。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。详细

第五步:初始化

一句话描述:执行类clinit()方法的过程。(class init就是执行静态方法,初始化静态变量)

在类初始化阶段,主要为类的静态变量赋予正确的初始值(代码显示赋值的,与准备阶段更具数据类型的默认值区分),JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值的赋予有两种方式:

  1. 声明类变量时赋值;
  2. 使用静态代码块赋值。

对于JVM而言,初始化是一个非常重要的操作,所以JVM严格规定了“有且只有”5中情况出现,必须对类进行初始化(加载、验证、准备自然是前提):

  1. 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这四条指令的Java场景有:
    • 使用关键字new实例化对象的时候;
    • 读取或设置一个类的静态字段(被final修饰,已在编译器吧结果放入常量池的静态字段除外)的时候;
    • 调用一个类的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发初始化;
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类方法;
  4. 当JVM启动的时候,用户需要指定一个要执行的主类(包含main方法的类),JVM会先初始化这个类;
  5. 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

通俗的说就是以下几种情况:

  1. 创建类的实例,也就是new的方式
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射(如 Class.forName(“com.shengsiyuan.Test”))
  5. 初始化某个类的子类,则其父类也会被初始化
  6. Java虚拟机启动时被标明为启动类的类( JavaTest),直接使用 java.exe命令来运行某个主类

以上5中情况称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。如:

  1. 通过子类引用父类的静态字段,不会导致子类初始化;
  2. 通过数组定义来引用类,不会触发此类的初始化;
  3. 常量(static final修饰的)在编译阶段会存入调用类的常量池中,因此调用其常量本质上并没有直接引用到定义常量的类,因此不会触发常量的类的初始化。

至此类加载的整个过程就完成了。

类加载器

下面将介绍一下类加载过程中使用的类加载器。

顾名思义:把实现类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作的代码模块称为“类加载器”。

JVM将加载动作放到JVM外部实现,以便用户可以自定义类的获取方式。同时类加载器在类层次划分、OSGI、热部署、代码加密等领域非常重要,我们运行任何一个Java程序都会涉及到类加载器。

Java自带的三个类加载器

在这里插入图片描述
通过上图可以看出,系统自带着三个类加载器存在紧密的继承关系,分别是:

  1. Bootstrap ClassLoader:最顶层的加载类,主要加载核心类库,也就是我们环境变量下:%JRE_HOME%\lib下的rt.jar、resource.jar、charsets.jar和class等。另外需要注意的是可以通过启动JVM是指定-Xbootstrapclasspath和路径来改变Bootstrap ClassLoader的加载目录。比如java -Xbootclasspath/a:path被指定的文件追加到默认的bootstrap路径中。我们可以打开我的电脑,在上面的目录查看,看看这些jar包是不是存在与这个系统;
  2. Extension ClassLoader:扩展的类的类加载器,加载目录%JRE_HOME%\lib\ext目录下的jar包和class文件。还可以加载-Djava.ext.dirs选项指定的目录;
  3. Appclass Loader:也称为SystemAppClass。加载当前应用的classpath的所有类。
  4. User ClassLoader:Java为我们提供了以上三个系统默认的类加载器,应用程序则由这三中加载器互相配合进行类加载,如果有必要,我们还可以加入自定义类加载器,来自己定义类加载的方式。(如网络传输加载。)

类的唯一性和类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在JVM中的唯一性。

即使两个类来源于统一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类也不相等。

这里所指的相等,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。

类加载的机制

Java类加载的机制主要有以下三种:

  1. 全盘负责模式:所谓全盘负责,就是当一个类加载器负责加载某个Class时,该Class所依赖和引用的其他Class也将由该类加载器全面负责载入,除非显示的使用另外的类加载器载入;
  2. 双亲委派(最广泛的实现方式):所谓双亲委派,则是先让父类加载器视图加载该Class,只有在父类加载器无法加载该类是才尝试从自己的类路径中加载该类。通俗而言,就是某个特定的类加载器在接到加载类的请请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就返回成功;只有父加载器无法完成此加载任务时,才自己去加载;
  3. 缓存机制:缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。这就是为什么修改Class后,必须重启JVM,程序所做的修改才会生效的原因。
双亲委派详解

双亲委派机制,其工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式,即每个儿子都很懒,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子自己才想办法去完成。

整个执行过程如下图:
在这里插入图片描述

它的优点有:

  1. Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次;
  2. 其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。

自定义类加载器

由于Java默认的ClassLoader,只加载制定目录下的class,如果需要动态加载类到内存,例如要从远程网络下载一个类的二进制,然后调用这个类中的方法实现我的业务逻辑,就需要自定义ClassLoader。

自定义加载器需要两部:
1、继承java.lang.ClassLoader;
2、重写父类的findClass()方法。

定义需要加载的类
// 存放于D盘根目录
public class Test {
    public static void main(String[] args) {
        System.out.println("Test类已成功加载运行!");
        ClassLoader classLoader = Test.class.getClassLoader();
        System.out.println("加载我的classLoader:" + classLoader);
        System.out.println("classLoader.parent:" + classLoader.getParent());
    }
}
编译要被加载的测试类
javac -encoding utf8 Test.java
自定义一个类加载器
import java.io.*;

public class MyClassLoader extends ClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 加载D盘根目录下指定类名的class
        String clzDir = "D:\\" + File.separatorChar
                + name.replace('.', File.separatorChar) + ".class";
        byte[] classData = getClassData(clzDir);

        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] getClassData(String path) {
        try (InputStream ins = new FileInputStream(path);
             ByteArrayOutputStream baos = new ByteArrayOutputStream()
        ) {

            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead = 0;
            while ((bytesNumRead = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}
使用自定义加载器调用测试类
public class MyClassLoaderTest {
    public static void main(String[] args) throws Exception {
        // 指定类加载器加载调用
        MyClassLoader classLoader = new MyClassLoader();
        classLoader.loadClass("Test").getMethod("test").invoke(null);
    }
}

结语

new一个对象过程中发生了什么?

  1. 确认类元信息是否存在:当 JVM 接收到 new 指令时,首先在 metaspace 内检查需要创建的类元信息是否存在。 若不存在,那么在双亲委派模式下,使用当前类加载器以 ClassLoader + 包名+类名为 Key 进行查找对应的 class 文件。 如果没有找到文件,则抛出 ClassNotFoundException 异常 , 如果找到,则进行类加载(加载 - 验证 - 准备 - 解析 - 初始化),并生成对应的 Class 类对象;
  2. 分配内存对象:首先计算对象占用空间大小,如果实例成员变量是引用变量,仅分配引用变量空间即可,即 4 个字节大小,接着在堆中划分—块内存给新对象。 在分配内存空间时,需要进行同步操作,比如采用 CAS (Compare And Swap) 失败重试、 区域加锁等方式保证分配操作的原子性;
  3. 设定默认值:成员变量值都需要设定为默认值, 即各种不同形式的零值;
  4. 设置对象头:设置新对象的哈希码、 GC 信息、锁信息、对象所属的类元信息等。这个过程的具体设置方式取决于 JVM 实现;
  5. 执行init方法:初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

至此,JVM的类加载机制就基本解释完了,有不对的地方还请指正。下一章我们将对JVM内存进行详解。

参考资料:

https://www.cnblogs.com/czwbig/p/11127222.html
https://www.cnblogs.com/xiaoQLu/p/10589066.html

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值