JVM类加载机制

JVM类加载机制

JVM类加载机制分为五个部分:加载,验证,准备,解析,初始化

类加载步骤

加载

加载阶段JVM会在内存中生成一个代表这个类的Class字节码对象放入堆中,同时ClassLoader会加载一个类并把类型信息保存到方法区中

连接

连接包括验证阶段、准备阶段、和解析二进制数据阶段

验证

确保Class文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身安全

准备

正式为类变量(静态变量)分配内存(方法区内存)并设置类变量的初始值

这里的初始值由变量类型决定

例如:public static int p = 8080

  • p在准备阶段后初始值为0而不是8080,编译后虚拟机才会将p属性赋值为8080

解析

虚拟机将常量池中的符号引用替换为直接引用的过程

符号引用

符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中,各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中

直接引用

直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄,如果有了直接引用,那引用的目标必定已经在内存中存在

初始化

初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由 JVM 主导,到了初始阶段,才开始真正执行类中定义的 Java 程序代码

类构造器<client>

初始化阶段是执行类构造器方法的过程

  • <client>方法是由编译器自动收集类中的静态变量的赋值操作静态语句块中的语句合并而成的

虚拟机会保证子<client>方法执行之前,父类的<client>方法已经执行完毕,如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成<client>()方法

以下几种情况不会执行类初始化

  1. 子类引用父类静态字段,只会触发父类初始化,不会触发子类初始化
  2. 定义对象数组,不会触发该类初始化
  3. 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不触发类初始化
  4. 通过类名获取Class对象,不会触发类初始化
  5. 通过Class.forName加载指定类,如果指定参数initialize为false,也不会触发类初始化
  6. 通过ClassLoader默认的loadClass方法,也不会触发初始化动作

类加载器

参考博客

类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class实例对象

一旦一个类被加载入JVM中,同一个类就不会被再次载入了,正如一个对象有一个唯一的标识一样,一个载入JVM的类也有一个唯一的标识

  • 在Java中,一个类用其全限定类名(包括包名和类名)作为标识
  • 但在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识

在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识。例如,如果在pg的包中有一个名为Person的类,被类加载器ClassLoader的实例kl负责加载,则该Person类对应的Class对象在JVM中表示为(Person.pg.kl)。这意味着两个类加载器加载的同名类:(Person.pg.kl)和(Person.pg.kl2)是不同的、它们所加载的类也是完全不同、互不兼容的

3种类加载器

虚拟机设计团队把加载动作放到 JVM 外部实现,以便让应用程序决定如何获取所需的类,JVM提供了3种类加载器

启动类加载器

Bootstrap ClassLoader

Bootstrap ClassLoader它本身是虚拟机的一部分,并不是一个JAVA类,也就是无法在java代码中获取它的引用,对Java不可见

主要加载JDK核心类库,用C++语言实现的,加载时的搜索路径是由 sun.boot.class.path 所指定的

负责加载JAVA_HOME\lib目录下的jar包和class类文件,或通过-Xbootclasspath参数指定路径中的,且被虚拟机认可(按文件名识别,如rt.jar)的类

扩展类加载器

Extension ClassLoader

负责加载JAVA_HOME\lib\ext目录下的jar包和class类文件,或通过 java.ext.dirs 系统变量指定路径中的类库

父类加载器为null(bootstrap ClassLoader)

应用程序类加载器

Application ClassLoader

负责加载用户路径(classpath)上的类库

AppClassLoader(应用程序加载器/系统类加载器)是自定义加载器的父类,负责加载classPath下的类文件,平时引用的jar包以及我们自己写的类都是这个加载器进行加载的,同时AppClassLoader还是线程上下文加载器,如果想实现一个自定义加载器的话就继承(extends)ClassLoader来实现

JVM 通过双亲委派模型进行类的加载,当然我们也可以通过继承 java.lang.ClassLoader 实现自定义的类加载器

类加载器的初始化——Launcher

ExtClassLoader、AppClassLoader都是java对象

而这些对象的生成都是由Launcher完成的

Launcher类是java程序的入口,在启动java应用的时候会首先创建Launcher类的对象,创建Launcher类的时候会创建ExtClassLoader、AppClassLoader

类加载器加载Class大致要经过如下8个步骤:

  1. 检测此Class是否载入过,即在缓冲区中是否有此Class,如果有直接进入第8步,否则进入第2步。
  2. 如果没有父类加载器,则要么Parent是根类加载器,要么本身就是根类加载器,则跳到第4步,如果父类加载器存在,则进入第3步。
  3. 请求使用父类加载器去载入目标类,如果载入成功则跳至第8步,否则接着执行第5步。
  4. 请求使用根类加载器去载入目标类,如果载入成功则跳至第8步,否则跳至第7步。
  5. 当前类加载器尝试寻找Class文件,如果找到则执行第6步,如果找不到则执行第7步。
  6. 从文件中载入Class,成功后跳至第8步。
  7. 抛出ClassNotFountException异常。
  8. 返回对应的java.lang.Class对象。

双亲委派模型

参考博客

当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载器中

只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的 Class),子类加载器才会尝试自己去加载

采用双亲委派的一个好处是比如加载位于 rt.jar 包中的类 java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个 Object 对象

向上委派

AppClassLoader是加载我们自己编写的class类的

  • 当它遇到一个新的class类的时候,不会直接进行加载,而是向上委派给ExtClassLoader去查询是否缓存了这个Class类,如果有则返回
  • 否则继续委派给BootstrapClassLoader,如果BootstrapClassLoader中缓存有则加载返回

向下查找

  • 如果当前class类向上委派到BootstrapClassLoader时还是没有该类的缓存,此时启动类会查找加载自己路径也就是%JAVA_HOME%/lib下的jar与class类文件,如果还没有则会向下查找
  • ExtClassLoader也是做同样的操作,查找加载ExtClassLoader对应路径的文件,如果有则加载返回,没有则继续向下到AppClassLoader查找加载
  • AppClassLoader是加载classPath也就是我们程序员自己编写的class类,如果AppClassLoader找不到则会抛出找不到class类异常

双亲委派这种机制能够防止类的重复加载

双亲委派模型的局限性

双亲委派模型的局限性在于父级加载器无法加载子级类加载器路径中的类,如果基础的类调用了用户类就会发生问题

问题:

这就引申出来我们对双亲委派机制的缺陷的讨论,接口:java.sql.Driver,定义在java.sql包中,包所在的位置是:jdk\jre\lib\rt.jar中,java.sql包中还提供了其它相应的类和接口比如管理驱动的类:DriverManager类;

很明显java.sql包是由BootstrapClassloader加载器加载的;而接口的实现类com.mysql.jdbc.Driver是由第三方实现的类库,由AppClassLoader加载器进行加载的;

我们的问题是DriverManager再获取链接的时候必然要加载到com.mysql.jdbc.Driver类,这就是由BootstrapClassloader加载的类使用了由AppClassLoader加载的类,很明显和双亲委托机制的原理相悖,那它是怎么解决这个问题的?

线程上下文类加载器

在启动类加载器中有方法获取应用程序类加载器,该方法就是使用线程上下文类加载器

可以通过Thread.setContextClassLoaser()方法设置,如果不特殊设置会从父类继承,一般默认使用的是应用程序类加载器

自定义类加载器

参考博客

除了启动类加载器是由C/C++实现的,其它的类加载器都是ClassLoader的子类,所以如果我们想实现自定义加载器,首先要继承ClassLoader

ClassLoader进行类加载的核心实现在loadClass()方法中,再根据loadClass()方法的源码,我们可以知道有两种方式来实现自定义类加载,分别如下:

  • 如果不想打破双亲委派机制,那么只需要重写findClass()方法
  • 如果想要打破双亲委派机制,那么就需要重写整个loadClass()方法

Java官方推荐重写findClass方法,而不是重写整个loadClass方法,这样既让我们能够按照自己的意愿加载类,也能保证自定义的类加载器符合双亲委派机制

重写findClass时有一个核心方法defineClass, 他可以将一个字节数组转为Class对象,这个字节数组就是.class文件读取后最终的字节数组,也就是说我们只需要通过文件流字节流FileInputStream把.class文件中的字节流读进来,然后通过defineClass反序列化成一个Class对象即可

代码实现

class MyClassLoader extends ClassLoader{

    //默认ApplicationClassLoader为父类加载器
    public MyClassLoader(){
        super();
    }

    //加载类的路径
    private String path = System.getProperty("user.dir")+"\\target\\classes\\";

    //重写findClass
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {

        byte[] dataByte = new byte[0];

        dataByte = ClassDataByByte(name);

        //defineClass主要的功能是:将一个字节数组转为Class对象,这个字节数组是class文件读取后最终的字节数组
        //如,假设class文件是加密过的,则需要解密后作为形参传入defineClass函数
        return this.defineClass(name,dataByte,0,dataByte.length);

    }

    /**
     * 读取class文件作为二进制流放入byte数组,findClass内部需要加载字节码文件的byte数组
     * @param name
     * @return
     */
    private byte[] ClassDataByByte(String name) {

        //输入流
        InputStream is = null;

        //用于存放class文件的二进制流
        byte[] data = null;

        ByteArrayOutputStream arrayOutputStream = new ByteArrayOutputStream();

        //为了定位class文件的位置,将包名替换为文件名
        name = name.replace(".","/");

        try {

            is = new FileInputStream(new File(path + name + ".class"));

            int read = 0;

            while ((read = is.read()) != -1){

                //读取class文件,写入byte数组输出流
                arrayOutputStream.write(read);

            }

            //将输出流转换为byte数组
            data = arrayOutputStream.toByteArray();


        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }finally {

            if (is != null){
                try {
                    is.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

            if (arrayOutputStream != null){
                try {
                    arrayOutputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

        }

        return data;

    }
}

测试

public static void main(String[] args) throws ClassNotFoundException {

    //测试自定义类加载器
    MyClassLoader myClassLoader = new MyClassLoader();

    //获取到字节码对象
    Class aClass = myClassLoader.findClass("com.os467.TestObject");

    //用Class.forName的方式
    Class<?> aClass1 = Class.forName("com.os467.TestObject");

    //输出false
    System.out.println(aClass == aClass1);


}

可以看出两种方式获取到的字节码对象是不同的,可以说明在JVM中类的字节码对象是由类的全类名和所对应的加载器作为唯一标识的

loadClass() 中调用了findClass()方法,findClass() 中使用了defineClass()将字节码文件生成的class类对象信息装载进jvm内存中

forName()loadClass(),都能获得类的字节码对象,但是两者的区别在于前者调用了底层C语言逻辑对类同时做了初始化处理,而后者并没有对类进行初始化

Class.forName()方法的本质还是使用classloader来进行类加载的


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以邮件至 1300452403@qq.com

文章标题:JVM类加载机制

字数:3.5k

本文作者:Os467

发布时间:2022-08-27, 18:10:23

最后更新:2022-09-05, 00:08:42

原始链接:https://os467.github.io/2022/08/27/JVM%E7%B1%BB%E5%8A%A0%E8%BD%BD%E6%9C%BA%E5%88%B6/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

×

喜欢就点赞,疼爱就打赏