JVMの类加载器与双亲委派机制

        在第一篇中,我们介绍了字节码文件相关知识,从这一篇开始将逐一介绍JVM的组件。

        JVM架构图:

1、类的生命周期

        在介绍第一个组件类加载器之前,有必要先补充一下关于类生命周期的概念:

        通常来说,类的生命周期分为五个部分:

  • 加载阶段:当程序执行时,类的定义被加载到内存中(类加载器)。在Java等语言中,类通常是在首次被引用时加载的。
  • 连接阶段:包括验证(验证内容是否满足规范),准备(给静态变量赋初值),解析(将常量池中的符号引用替换成指向内存的直接引用)三个步骤。
  • 初始化阶段:当我们创建类的实例(对象)时,会为该类分配内存空间,并调用构造函数进行初始化。在这一阶段,对象会被赋予初始状态。
  • 使用阶段:在程序执行过程中,对象被使用、操作和传递给其他方法或对象。这是类生命周期中最主要的阶段,它涉及到类的方法调用、属性访问等操作。
  • 销毁阶段:当对象不再被引用或程序执行结束时,对象所占用的内存空间会被释放,这是类生命周期的结束阶段。在Java中,会有垃圾回收机制来自动管理对象的销毁过程。

        下面先展开说一下加载阶段、连接阶段和初始化阶段:

        1.1、加载阶段

        在加载阶段,使用到的一个重要组件是类加载器(Class Loader), 它会根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息。

        类加载器加载完成后,JVM会将读取到的字节码信息保存到内存的方法区中,生成一个InstanceKlass对象,保存类的基本信息。

方法区一般存放以下信息:

  • 类的结构信息:包括类的完整名称、父类的引用、接口的引用、字段、方法、方法的字节码等。

  • 运行时常量池:用于存放编译期生成的各种字面量和符号引用。

  • 静态变量:存放类的静态变量,即使用static关键字修饰的变量。

  • 即时编译器编译后的代码:在JIT编译时,方法区会将热点代码编译成本地机器码,并保存在方法区中,以提高程序的执行效率。

在JDK1.8中,InstanceKlass对象是方法区的一部分,用于表示Java类的内部数据结构。在方法区中,包括了Java类的结构信息、常量池、字段、方法等。

        同时还会在堆区中生成一个与InstanceKlass对象类似的java.lang.class对象,包含了类的信息和静态变量。这样设计的目的是为了控制开发者访问数据的权限,通常InstanceKlass对象包含的信息要多于java.lang.class对象。

        我们使用JHSDB工具查看一下:

        如果是Java8版本,需要把sawindbg.dll文件从jdk的jre目录下复制到bin目录下,然后进入lib目录,通过cmd输入以下命令:

java -cp .\sa-jdi.jar sun.jvm.hotspot.HSDB"

        然后通过jps命令查找当前运行的Java程序的id:

        连接JSHDB工具:

         选择tool中的对象查看器,搜索HSDB实例(是在代码中创建出的)

         查看详细信息:

        会发现在类加载时创建了两个对象,第一个是InstanceKlass,第二个是java.lang.class,java.lang.class对象存放了静态变量,并且InstanceKlass的范围大于java.lang.class

        1.2、连接阶段

        在验证这一步,重点是对以下四点进行校验:

        1、校验.class文件头是否包含"cafebabe"。关于"cafebabe"的含义上一讲说过,是用于进行类型判断,任何正常编译后的.class文件都应该包含"cafebabe"。

        如果手动修改编译后的文件头信息:

        在运行时会报错。

        以及主次版本号是否满足当前Java虚拟机的要求:

        即主版本号需要在JVM支持的最大版本号和最小版本号之间,并且当主版本号和JVM支持的最大版本号相等时,会校验副版本号,必须小于JVM支持的最大副版本号。

        2、校验源信息,是否有父类(所有的类都继承自Object)       

        3、验证字节码指令的语义

        4、修饰符验证,是否引用了其他类的私有方法。

        在准备阶段,只会给静态变量初值。即静态关键字修饰的变量是属于类而不是某个对象的。

        之所以要赋初值,是因为如果不给默认值,可能会读取内存中残留的值。

        而对于被final关键字修饰的静态变量,则会赋予真正的值。因为被final修饰的变量是不可变的。

        解析阶段,会将常量池中的符号引用替换成直接引用:

        查看HsdbDemo的字节码信息:

        它的父类信息首先指向常量池中的8索引:

        然后通过8索引指向40索引,即真正的引用

         下面我们使用JDK自带的HSDB工具查看运行时的引用情况(因为运行时已经经过了连接阶段中的解析阶段,会将常量池中的符号引用替换成直接引用。)

       选择tools中的Class Browser,输入HSDB进行搜索

        可以看到直接指向了父类的地址


        1.3、初始化阶段

        上面提到,在准备阶段,会给静态变量赋初值,例如static int i = 1,在准备阶段就会给i赋初值为0。        

        真正让i从初始值变成1,是在初始化阶段。初始化阶段会执行静态代码块中的代码并赋值:

public class Demo1 {
    static {
        value = 2;
    }
    public static int value = 1;

    public static void main(String[] args) {

    }
}

        观察jclasslib的方法选项卡,会发现除了原有的构造方法和main方法外,又多了一个clinit:

        clinit中的字节码指令即是给静态变量赋最终值,执行静态代码块的代码:

0 iconst_2 --将2放入操作数栈中
1 putstatic #2 <init/Demo1.value : I> --将2从操作数栈推入堆区
4 iconst_1 ---将1放入操作数栈中
5 putstatic #2 <init/Demo1.value : I> --将1从操作数栈推入堆区
8 return

        代码是按照顺序执行的,所以最终i的结果是1。反之如果把上面案例中的静态代码块和静态成员变量调换位置,最终i的结果会是2。所以最后得到结论,并非静态代码块会优先执行。


        以下几种情况会触发类的初始化:

  • 访问一个类的静态变量或静态方法。注意:如果是final类型的,并且等号右边是常量就不会触发初始化

        Demo1的main方法直接访问Demo2的i变量:

public class Demo1 {
    public static void main(String[] args) {
        int i = Demo2.i;
        System.out.println(i);
    }
}


class Demo2{
    static {
        System.out.println("初始化了...");
    }
    public static int i = 0;
}

        将i定义为静态常量则不会触发初始化:

  • 调用Class.forName()方法:

        注意,Class类中的.forName()静态方法有两个重载:

        如果调用了一个参数的方法,会触发初始化:

        否则是否初始化由 boolean initialize 参数决定。

  •  创建目标类的对象,执行Main方法的当前类:
public class Demo5 {
    static {
        System.out.println("Demo5初始化了...");
    }
    public static void main(String[] args) throws ClassNotFoundException {
        new Demo6();
    }
}


class Demo6{
    static {
        System.out.println("Demo6初始化了...");
    }
}


        反之,以下几种情况不会触发类的初始化:

  • 无静态代码块并且无静态变量赋值语句(类的初始化与静态变量有很大关联)

  •  有静态变量,但是没有赋值

  • 静态变量使用了final关键字,会在准备阶段直接初始化

  • 直接访问父类的静态变量,不会触发子类的初始化
public class Demo01 {
    public static void main(String[] args) {
        System.out.println(A02.a);
    }
}

class A02{
    static int a = 0;
    static {
        a = 1;
    }
}

class B02 extends A02{
    static {
        a = 2;
    }
}

        最终的结果是1,因为没有触发子类的初始化

        在子类初始化前,会先初始化父类,所以最终的结果为2:

public class Demo01 {
    public static void main(String[] args) {
        new B02();
        System.out.println(A02.a);
    }
}

class A02{
    static int a = 0;
    static {
        a = 1;
    }
}

class B02 extends A02{
    static {
        a = 2;
    }
}

相关案例一

public class Test1 {
    public static void main(String[] args) {
        System.out.println("A");
        new Test1();
        new Test1();
    }

    public Test1(){
        System.out.println("B");
    }

    {
        System.out.println("C");
    }

    static {
        System.out.println("D");
    }
}

        在Test1类中,有一个main方法和无参构造构造,静态代码块和代码块。

        在执行main方法前,会对类进行加载并初始化,在init方法中,会获取到D并且打印:

        紧接着会执行main方法中的System.out.println("A");

        然后创建两次Test1对象,在构造方法中会先执行普通代码块中的System.out.println("C");语句,然后执行构造中的System.out.println("B");语句。(证明了:静态代码块只在类初始化的时候执行一次。


相关案例二

        创建引用数据类型的数组,不会导致引用对象执行初始化。

public class Test2 {
    public static void main(String[] args) {
        Test2_A[] arr = new Test2_A[10];

    }
}

class Test2_A {
    static {
        System.out.println("Test2 A的静态代码块运行");
    }
}

相关案例三

被final修饰的变量如果值需要执行运算才能确定,那么会执行初始化

public class Test4 {
    public static void main(String[] args) {
        System.out.println(Test4_A.a);
    }
}

class Test4_A {
    public static final int a = Integer.valueOf(1);

    static {
        System.out.println("Test3 A的静态代码块运行");
    }
}

2、类加载器

       常见的类加载器有:

  • 启动类加载器(BootStrap ClassLoader):是虚拟机利用C++语言的底层实现,会加载JDK文件夹下\jre\lib\中的jar包。
  • 扩展类加载器(Extension ClassLoader):允许扩展一些比较通用的类,会加载JDK文件夹下\jre\lib\ext中的jar包。
  • 应用程序类加载器(Application ClassLoader):会加载classpath目录下的自定义类以及Maven第三方依赖中包含的类。

        他们都共同继承ClassLoader抽象类。

        类加载器的详细信息也可以通过arthas工具的classloader命令查看:


       由于启动类加载器是JVM中较为偏底层的实现,如果我们通过代码去获取是获取不到的:

ClassLoader classLoader = String.class.getClassLoader();
System.out.println(classLoader);

        最终的结果是null。

        通过arthas工具查看到的结果也是空:


        针对启动类加载器和扩展类加载器,如果我们想自定义一个jar包,然后使用它们去加载,如何实现?

        有两种方式,第一种是直接把自定义的jar包放在它们所管理的,能识别到的目录下。

        第二种方式是通过添加JVM参数。

        很显然第二种方式比较合理,我们最好不要去修改jdk自有的资源。

演示自定义jar包被启动类加载器管理:

        在JVM参数中定义:

-Xbootclasspath/a:路径名/jar包名

         调用Class.forName()方法会触发类的初始化:

public class BootStrapClassLoaderDemo {
    public static void main(String[] args) throws ClassNotFoundException {
        //通过Class.forName获取A类,进行初始化
        Class<?> name = Class.forName("com.itheima.my.A");
        System.out.println(name);
        System.out.println(name.getClassLoader());
    }
}

        打印了A类中静态代码块中的语句,同时对它进行加载的类加载器是启动类加载器

演示自定义jar包被扩展类加载器管理: 

在JVM参数中定义:

前半段是操作系统中安装JDK的目录一直到ext文件夹,中间需要用;分隔(如果不用;分隔,会覆盖前半段的路径),后半段是自定义jar包的路径。

-Djava.ext.dirs="C:\Program Files\Java\jdk1.8.0_31\jre\lib\ext;D:\Idea_workspace\2024"

public class MyExtClassLoaderDemo {
    public static void main(String[] args) throws ClassNotFoundException {
        System.out.println(ScriptEnvironment.class.getClassLoader());

        //-Djava.ext.dirs="C:\Program Files\Java\jdk1.8.0_31\jre\lib\ext;D:\Idea_workspace\2024"
        Class<?> name = Class.forName("com.itheima.my.A");
        System.out.println(name);
        System.out.println(name.getClassLoader());
    }
}

         控制台输出结果表明,自定义的jar包已被扩展类加载器管理。


        应用程序类加载器则会加载一些自定义的类以及Maven中的第三方jar包

public class ApplicationClassLoaderDemo {
    public static void main(String[] args) {

        System.out.println(Student.class.getClassLoader());

        System.out.println(FileUtils.class.getClassLoader());
    }
}

3、双亲委派机制

        首先再介绍arthas工具的两个命令:

classloader -l

classloader -l -c

        作用是查看当前所有类加载器的hash值,并且根据hash值找到该类加载器管理的所有类

        通过命令我们查看一下最下层的应用程序类加载器

        会发现不仅包含自定义类和Maven中的jar包,还包含扩展类和启动类加载器的内容,这样是否会导致重复加载的问题?答案是否定的,双亲委派机制可以避免这样的问题:

        双亲委派机制是指,当一个类加载器需要加载某个类时,会从下向上查找,再从上向下加载:

        类加载器也存在上下级的关系,按照启动类->扩展类->应用程序类加载器的顺序:

        每个java实现的类加载器都保留一个父类加载器作为成员变量,应用程序类加载器的parent是扩展类加载器,扩展类加载器的parent是null(启动类加载器)。

        例如我现在有一个自定义的Student类,首先会从最下层开始向上查找是否加载过,第一次肯定都没有加载过,然后从最上层开始向下尝试加载,最后由应用程序类加载器进行加载。

经典问题:自定义java.lang.String

        如果我在java.lang包下自定义了一个String类,能否利用双亲委派机制进行加载?答案是不行。

        首先会从应用程序类加载器向上查找,根据java.lang.String查找到顶层时,会发现启动过程中,真正的java.lang.String已经被加载过了,所以自定义的java.lang.String类不会再被加载。


        由此可见,双亲委派机制设计的目的除了避免类重复被加载,还有安全性的考虑,避免恶意代码修改JDK的核心类库。

        但是说到这里很多人一定有一个疑问,那就是为什么自定义的Student类,需要从最下层查找到最上层,然后再从最上层尝试加载到最下层才能加载完成,而自定义的java.lang.String在查找到最上层的时候,就直接发现已经加载过同路径下的jar包了?

        原因在于:

自定义的Student对象在应用程序运行时,当代码中对该类的引用出现时才会被加载。加载操作是按需进行的,也就是说只有在代码中使用到了Student类时才会触发加载。

如果代码中没有直接或间接地使用到Student类,那么它不会被加载到内存中。

当代码执行到需要使用Student类的那一行时,应用程序类加载器会检查是否已经加载过该类。

如果没有加载过,则会触发加载操作,将Student类加载到内存中,并生成对应的Class对象。


而Java标准库中的核心类,是在JVM启动时就被加载到内存中的。启动类加载器作为所有类加载器的顶层父类加载器,它会优先加载核心类库中的类。

        加载的时机不同。

4、打破双亲委派机制

        想要打破双亲委派机制,首先需要了解一下其在源码中的体现:

ClassLoader loader = Student.class.getClassLoader();
      
loader.loadClass("classloader.demo4.Student");

        上面的代码,首先获取Student类的加载器(应用程序类加载器),然后通过类加载器的.loadClass()方法进行加载:

  public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
  }

          .loadClass() 方法又调用了重载的方法(第二个参数默认为false):

 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 thrown if class not found
                    // from the non-null parent class loader
                }
                 //调用findClass方法来实际查找并加载目标类。
                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    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;
        }
    }

        第二个参数默认为false,代表不会执行类生命周期中的连接阶段:

protected final void resolveClass(Class<?> c) {
        resolveClass0(c);
    }

    private native void resolveClass0(Class<?> c);

        findClass是一个模板方法:

        在它的实现中最终会调用defineClass方法,去做一些类名的校验,然后将字节码信息加载到虚拟内存中

        4.1、自定义类加载器

        这种方式在tomcat中有所体现。例如一个tomcat运行了多个web应用,每个web应用中都有一个同名的Servlet容器,如果不打破双亲委派机制,那么第一个Servlet容器加载后,后续同名的都不会去加载了(类加载通过全限定名进行

        在上面的源码分析中,可以得出看出,在类加载过程中,类加载器的.loadClass()方法是重点,其中defineClass() 方法又是必不可少的。

        我们可以自定义一个类加载器,继承ClassLoader,并且重写其中的loadClass方法。

@Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        //如果类全路径以java.开头,应该调用父类的类加载器去处理(启动类加载器)
        if(name.startsWith("java.")){
            return super.loadClass(name);
        }
        byte[] data = loadClassData(name);
        return defineClass(name, data, 0, data.length);
    }

        打印出的结果是false,证明了A加载了两个不同的实例,由两个不同的类加载器进行加载:

 public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException {
        BreakClassLoader1 classLoader1 = new BreakClassLoader1();
        classLoader1.setBasePath("D:\\Idea_workspace\\2024\\");

        Class<?> clazz1 = classLoader1.loadClass("com.itheima.my.A");

        BreakClassLoader1 classLoader2 = new BreakClassLoader1();
        classLoader2.setBasePath("D:\\Idea_workspace\\2024\\");

        Class<?> clazz2 = classLoader2.loadClass("com.itheima.my.A");

        System.out.println(clazz1 == clazz2);

        System.in.read();
     }

        使用arthas工具查看类加载器的hash值:

        4.2、线程上下文类加载器

        我们以JDBC为例,在JDBC中,通常通过

DriverManager.getConnection(DB_URL, USER, PASS);

        去获取连接,而DriverManager类是在rt.jar中的,会被启动类加载器加载:

        而加载的驱动是在自定义的jar包中的,由应用程序类加载器加载。

        需要等待启动类加载器加载完成后去委托应用程序类加载器进行驱动加载,启动类加载器的优先级是高于应用程序类加载器的,按照正常的流程应该是由优先级低的向上委托加载。

        补充:DriverManager是如何找到驱动的?

        运用了SPI机制,是JDK内置的一种服务发现机制。

        SPI机制包含以下主要组件:

  1. 服务接口(Service Interface):定义一组抽象方法,用于描述服务的功能和行为。

  2. 服务提供者接口(Service Provider Interface):定义一组用于注册和访问服务提供者的方法。

  3. 服务提供者(Service Provider):实现了服务接口,并提供具体的服务实现。

  4. 服务配置文件(Service Configuration File):在META-INFO/services目录下的配置文件,以服务接口的全限定名命名,内容为服务提供者的类名。

        在这个案例中,服务提供者是驱动,实现了java.sql.Driver接口,java.sql.Driver是服务提供者接口。

         服务提供者在静态代码块中注册自己的实例

        通过线程安全的方式注册到DriverManager的CopyOnWriteArrayList<DriverInfo> registeredDrivers中(初始化阶段


        而在DriverManager类中,也有一个静态初始化方法loadInitialDrivers(),其主要的作用是根据类的全限定名获取加载器并创建对象

        可以看到使用的也是应用程序类加载器,而应用程序类加载器是何时获取到的?

        答案是利用线程上下文,线程上下文默认获取的就是应用程序类加载器


补充:利用arthas工具热更新项目

1、进行反编译

 jad -source-only 类全限定名 > 目录/文件名.java

例:

 jad -source-only com.itheima.springbootclassfile.controller.UserController > test/UserController.java

2、查看当前类加载器的hash值:

 sc -d 类全限定名

例:

 sc -d com.itheima.springbootclassfile.controller.UserController

3、编译修改后的代码

mc -c 类加载器的hash值 目录/文件名.java -d 输出目录

例:

mc -c 21b8d17c test/UserController.java -d test

4、加载新的字节码

retransform 目录/文件名.java

retransform test/UserController.java

注意:

程序重启后,字节码文件会恢复,并且不可修改正在使用的方法,也不可增加字段/方法。

  • 22
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
JVM中的双亲委派机制是一种类加载机制,它规定了在Java中一个类被加载时如何进行类加载器的选择。根据这个机制,当一个类需要被加载时,首先会由类加载器ClassLoader检查是否已经加载过该类,如果是,则直接返回已经加载过的类;如果不是,则将该请求委派给父类加载器去加载。这样的过程会一直向上委派,直到达到顶层的引导类加载器(Bootstrap ClassLoader)。引用 引用中提到,并不是所有的类加载器都采用双亲委派机制Java虚拟机规范并没有强制要求使用双亲委派机制,只是建议使用。实际上,一些类加载器可能会采用不同的加载顺序,例如Tomcat服务器类加载器就是采用代理模式,首先尝试自己去加载某个类,如果找不到再代理给父类加载器。 引用中提到,引导类加载器(Bootstrap ClassLoader)是最早开始工作的类加载器,负责加载JVM的核心类库,例如java.lang.*包中的类。这些类在JVM启动时就已经被加载到内存中。 综上所述,JVM双亲委派机制是一种类加载机制,它通过类加载器的委派方式来加载类,首先检查是否已经加载过该类,如果没有则委派给父类加载器去加载,直到达到顶层的引导类加载器。不过,并不是所有的类加载器都采用该机制,一些类加载器可能会采用不同的加载顺序。引用<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* [JVM-双亲委派机制](https://blog.csdn.net/m0_51608444/article/details/125835862)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *2* *3* [jvm-双亲委派机制](https://blog.csdn.net/y08144013/article/details/130724858)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值