新手小白浅析JVM类加载机制

2023年 8月 18日 65.3k 0

一、背景

在现在工作中,一次让我熟悉下线上更新版本流程,各种因素原因,公司更新版本流程没有CI/CD,比较原始手工操作,需要自己手动本地打包,备份线上正在运行的jar,上传到对应的位置直接覆盖,然后重启。然而,当我提前做准备备份工作时,我心中就产生疑问:还没到更新时间,此时直接把要更新的jar包覆盖了对现在正在运行的应用有何影响?想起最开始工作微服务没有兴起时,使用SpringMVC,不像springboot内嵌tomcat,一个tomcat只能部署一个应用,都是需要自己部署一个tomcat容器,项目打成war包比较大丢到对应目录下,并且可以同时部署多个应用,当代码改动小的时候,不会全量更新,只需要覆盖对应的class文件就行啦,也不用重启,这又是怎么做到的呢?

二、JVM基础知识体系

image.png

jvm主要模块

一、类加载器ClassLoader

我们自己写的.java文件到最终运行需要经过编译类加载这两个阶段,才能够被加载到JVM里去运行

  • 编译:.java文件编译成.class二进制字节码文件
  • 类加载:.class文件经过类加载器ClassLoader加载到JVM内存中,装载完成以后会得到一个class对象,我们就可以使用new关键字来实列化这个对象

二、内存结构

  • 类放在方法区
  • 类创建的实例即对象放在堆中
  • 堆里面的对象在调用方法时会用到虚拟机栈,程序计数器,本地方法栈
  • 三、执行引擎

    方法执行时每行代码是由执行引擎中的解释器逐行进行执行
    方法里的热点代码(被频繁调用的代码)会被即使编译器进行优化后的执行
    GC对堆中一些不再被引用的对象进行垃圾回收

    四、本地方法接口

    java不方便实现的功能会调用底层操作系统的功能,通过本地方法接口(常见Object类带有native修饰的方法,没有方法体)调用操作系统提供的功能方法

    三、类加载过程

    类加载过程有三个阶段:加载链接初始化;其中链接阶段有可以细分为:验证、准备和解析。

    image.png

    1 加载

    加载过程会执行三个操作:

  • 通过一个类的全限定名来获取定义此类的字节码(二进制字节流)
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中创建类的Class对象,作为方法区这个类的各种数据的访问入口
  • 说明:方法区的字节码(C++的数据结构,instanceKlass描述Java类),我们java代码不能直接访问,java通过类对象相应的方法(反射)获取相应的成员属性和方法

    类的父类没有加载,先加载父类(接口同理,加载实现类时如果父接口没有加载,优先加载接口,在加载实现类),加载和连接两个阶段可能是交替运行的

    • 类的加载是懒惰的,首次用到才加载,并且类的加载只会执行一次

    触发类加载条件:
    1:使用类.class
    2: 用类加载器的loadClass方法加载类

    demo验证

    package com.mall.portal.study;
    
    import java.util.Scanner;
    
    public class LazyTest {
        public static void main(String[] args) {
            System.out.println("未加载Student类");
            System.out.println("第一次接收到控制台输入" + new Scanner(System.in).next());
            //使用类对象,触发类的加载,这里前后观察内存中shi
            System.out.println(Student.class);
            System.out.println("已加载Student类");
            System.out.println("第二次接收控制台输入" + new Scanner(System.in).next());
            //创建对象,会触发类的初始化,也会触发类加载,但是类加载只会执行一次,前面已加载过不会在重复加载
            Student student = new Student();
        }
    }
    
    class Student {
        static {
            System.out.println("已初始化Student类");
        }
    }
    

    在等待第一次接收到控制台输入的时候通过arthas去查看加载的类信息如下:可并没有加载Student类

    image.png

    键盘输入后的类加载信息如下:发现已经加载 Student类

    image.png

    顺便提前说明静态代码块的执行在初始化阶段,下面讲解

    image.png

    2 链接(验证-->准备-->解析)

    验证(Verify)
    验证类是否符合class规范,合法性,安全性检查

    准备(Prepare)
    为static修饰的类变量分配内存并设置初始值,这里初始值指数据类型的初始值(比如int类型初始值为0,Integer 类型初始值为null),不会执行赋值语句,对于数据的赋值会在初始化阶段进行;但对于final修饰的常量会在准备阶段就完成赋值

    static int a=1;
    static final int b=1;
    

    如上,经过准备阶段,a会被设置初始值0,初始化阶段才会赋值为1;而b由于是final修饰的常量,会在准备阶段直接赋值为1

    final修饰基本类型变量原理

    public class LazyTest {
        public static void main(String[] args) {
            //访问基本类型final static修饰的变量不会触发类的加载
            System.out.println(Student.a);
            System.out.println("第一次接收到控制台输入" + new Scanner(System.in).next());
            //访问基本类型final static修饰的变量不会触发类的加载
            System.out.println(Student.b);
            System.out.println("第二次接收控制台输入" + new Scanner(System.in).next());
            //访引用类型final static修饰的变量会触发类的加载和初始化
            System.out.println(Student.c);
            System.out.println("第三次接收控制台输入" + new Scanner(System.in).next());
        }
    }
    
    class Student {
        final static int a = 10;
        final static int b = Short.MAX_VALUE + 1;
        final static Integer c = 10;
    
        static {
            System.out.println("Student.class init");
        }
    }
    

    访问基本类型final static修饰的变量不会触发类的加载,引用类型会触发类的加载和初始化,每一步操作自行用arthas验证,这里久不演示拉

    下面看编译器生成的字节码

    image.png

    Constant pool:
       #1 = Methodref          #21.#35        // java/lang/Object."":()V
       #2 = Fieldref           #36.#37        // java/lang/System.out:Ljava/io/PrintStream;
       #3 = Class              #38            // com/mall/portal/study/Student
       #4 = Methodref          #39.#40        // java/io/PrintStream.println:(I)V
       #5 = Class              #41            // java/lang/StringBuilder
       #6 = Methodref          #5.#35         // java/lang/StringBuilder."":()V
       #7 = String             #42            // 第一次接收到控制台输入
       #8 = Methodref          #5.#43         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
       #9 = Class              #44            // java/util/Scanner
      #10 = Fieldref           #36.#45        // java/lang/System.in:Ljava/io/InputStream;
      #11 = Methodref          #9.#46         // java/util/Scanner."":(Ljava/io/InputStream;)V
      #12 = Methodref          #9.#47         // java/util/Scanner.next:()Ljava/lang/String;
      #13 = Methodref          #5.#48         // java/lang/StringBuilder.toString:()Ljava/lang/String;
      #14 = Methodref          #39.#49        // java/io/PrintStream.println:(Ljava/lang/String;)V
      //15号常量池,即b的值
      #15 = Integer            32768
      #16 = String             #50            // 第二次接收控制台输入
      #17 = Fieldref           #3.#51         // com/mall/portal/study/Student.c:Ljava/lang/Integer;
      //......省略部分
    //找到主方法,看a和b的获取
      public static void main(java.lang.String[]);
        descriptor: ([Ljava/lang/String;)V
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
          stack=5, locals=1, args_size=1
             0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
             //取a,没有用到Student, final static修饰的基本类型,谁用到a,用的类将a的值进行复制到自己的类中,看class文件是直接写死在自己的代码中
             3: bipush        10
             5: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
             8: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
            11: new           #5                  // class java/lang/StringBuilder
            14: dup
            15: invokespecial #6                  // Method java/lang/StringBuilder."":()V
            18: ldc           #7                  // String 第一次接收到控制台输入
            20: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
            23: new           #9                  // class java/util/Scanner
            26: dup
            27: getstatic     #10                 // Field java/lang/System.in:Ljava/io/InputStream;
            30: invokespecial #11                 // Method java/util/Scanner."":(Ljava/io/InputStream;)V
            33: invokevirtual #12                 // Method java/util/Scanner.next:()Ljava/lang/String;
            36: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
            39: invokevirtual #13                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
            42: invokevirtual #14                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
            45: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
            //取b 没有用到Student,ldc命令从常量池中找到相应的值,加载[#15]号常量
            48: ldc           #15                 // int 32768
           //.....省略
    

    基本类型并且final修饰的变量,其它类使用不需要原始类,触发原始类型的加载初始化,直接将值复制到自己的类型中,a和b的区别,值比较小是直接写死在代码中,值超过short的最大范围( final static int b = Short.MAX_VALUE 重新编译自行看字节码验证),值会写入常量池中

    解析(Resolve)
    将常量池的符号引用解析为直接引用

    3 初始化

    执行静态代码块和非final静态变量的赋值,且类初始化也是懒惰执行的

    触发条件

    • main方法所在类
    • 首次访问静态方法或者静态变量(非final)
    • 子类初始化,导致父类初始化
    • Class.forName(String className);Class.forName(String name, boolean true,ClassLoader loader)
    • new、clone、反序列化

    借助%JAVA_HOME/bin目录下的工具javap javap -c -v -p Student.class 将编译器生成的字节码翻译我们可读的,

    image.png
    摘取部分分析

    //编译器会将静态变量的赋值语句,静态代码块语句,按语句的出现顺序,由上至下,合到一起变成一个static方法,在类的初始化调用
      static {};
        descriptor: ()V
        flags: ACC_STATIC
        Code:
          stack=2, locals=0, args_size=0
             //准备常量10
             0: bipush        10
             //赋值静态变量a
             2: putstatic     #2                  // Field a:I
             //准备静态变量System.out
             5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
             //准备字符串 Student.class init
             8: ldc           #4                  // String Student.class init
             //调用成员方法println方法,把参数传进去
            10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
            13: bipush        20
            //赋值静态变量b,
            15: putstatic     #6                  // Field b:I
            //注意,后面没有final修饰的静态变量基本类型 c的操作,但有final修饰的静态变量引用类型o 的操作
            //创建new Object()对象
            18: new           #7                  // class java/lang/Object
            21: dup
            22: invokespecial #1                  // Method java/lang/Object."":()V
            //final修饰的引用类型o 的赋值操作
            25: putstatic     #8                  // Field o:Ljava/lang/Object;
            28: return
    }
    
    

    静态变量赋值,static final 引用类型赋值,静态代码块语句,最后会按照由上到下出现顺序合到一个类的初始化方法,在类的初始化中调用。

    四、类加载器

    类加载器是Java虚拟机用于加载类文件的一种机制。在Java中,每个类都由类加载器加载,并在运行时创建一个Class对象。类加载器负责从文件系统、网络或其他来源中加载类的字节码,并将其转换为可执行的Java对象。
    JVM在运行时会产生3个类加载器,这三个类加载器组成一个层级关系,每个类加载器负责加载自己作用范围内的jar包。

    image.png

        public static void main(String[] args) {
            //当前类的类加载器 sun.misc.Launcher$AppClassLoader@18b4aac2
            System.out.println(Student.class.getClassLoader());
            //sun.misc.Launcher$ExtClassLoader@42f30e0a
            System.out.println(Student.class.getClassLoader().getParent());
            //null Bootstrap Classloader C++写的,java代码不能直接访问
            System.out.println(Student.class.getClassLoader().getParent().getParent());
            //null Bootstrap Classloader C++写的,java代码不能直接访问
            System.out.println(String.class.getClassLoader());
        }
    

    五、双亲委派机制

    这里的双亲,翻译为上级更为合适,它们并没有继承关系,只是级别不一样

    父委托模型 向上查找,向下委派(加载)
    按照类加载器的层级关系逐层进行委派,比如需要加载一个class文件,根据类的全限定名,首先把class文件的查询和加载委派给父加载器执行,如果父加载器无法执行,在自己尝试加载

    image.png

    • 安全性:层级关系即优先级,所有类的加载优先给到Bootstrap Classloader加载,核心类库中类不被破坏,比如自己写java.lang.Object最终会给Bootstrap Classloader加载,在由于Bootstrap Classloader加载的作用范围,自己的java.lang.Object无法覆盖核心类库中的类
    • 重复加载导致程序混乱,父类加载器已经加载过,子加载器不会重复加载

    双亲委派机制核心在调用类加载器的loadClass方法时,查找类的规则

     public abstract class ClassLoader {
        //每个类加载器都有个父加载器
        private final ClassLoader parent;
        public Class loadClass(String name) {
            //1、查找一下这个类是不是已经加载过了
            Class c = findLoadedClass(name);
            //如果没有加载过
            if( c == null ){
              if (parent != null) {
               //2、递归有上级的话,委派给父加载器去加载
                  c = parent.loadClass(name);
              }else {
                  //3、 如果没有上级(ExtClassLoader),则委派给BootstrapClassLoader
                  c = findBootstrapClassOrNull(name);
              }
            }
            // 如果父加载器没找到
            if (c == null) {
             //4、每一层找不到,本类加载器,调用findClass方法(每个类加载器自己扩展)来加载
                c = findClass(name);
            }
            .....
            return c;
        }
        
        protected Class findClass(String name){
           //1. 根据传入的类名name,到在特定目录下去寻找类文件,把.class文件读入内存
              ...
           //2. 调用defineClass将字节数组转成Class对象
           return defineClass(buf, off, len);
        }
        
        // 将字节码数组解析成一个Class对象,用native方法实现
        protected final Class defineClass(byte[] b, int off, int len){
           ...
        }
    }
    

    demo验证
    将自己写的student类打成jar包(单个文件可命令eg: jar cvf classloader-test.jar commallportalstudyStudent.class),丢掉扩展类程序加载的目录下

    image.png

    在应用程序下写个同限定名的类,触发类加载,发现是扩展类程序加载我们自定义的Student类

    image.png

    注:jvm如何认定两个对象同属于一个类型,必须同时满足下面两个条件

    • 都是用同名的类完成实例化的
    • 两个实例各自对应的同名的类的加载器必须是同一个。比如两个相同名字的类,一个是用系统加载器加载的,一个扩展类加载器加载的,两个类生成的对象将被jvm认定为不同类型的对象

    六、双亲委派机制破坏

    双亲委派机制可以保护类不会被篡改,要打破双亲委派机制核心在要告诉类加载器如何获取自己提供的类信息,然后类的加载核心在loadClass方法中,因此,破坏双亲委派机制重点在于绕开loadClass方法

    1 SPI机制打破双亲委派

    在Java中,JNDI是一个标准服务,它是由启动类加载器来完成加载的,但它存在的目的是对资源进行查找和集中管理,所以它需要调用由其他厂商实现并部署在应用程序的下的JNDI服务提供者接口。如果使用双亲委派机制,那么启动类加载器根本无法调用这些实现接口方法,所以Java的设计团队设计了线程上下文类加载器,这个类加载器可以通过Thread类的setContextClassLoader方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

    SPI机制规范,jar包的META-INF/services包下,以接口全限定名为文件名,文件内容是实现类全限定名

    image.png

    有spi的规范,面向接口编程思想,可以使用得到实现类

    image.png

    不管是文件名还是文件内容都是全限定名,所以通过反射很容易创建相应的类

    image.png

    线程上下文类加载器Thread.currentThread().getContextClassLoader()当前线程使用的类加载器 ,默认应用程序类加载器
    JDK有时需要打破双亲委派机制,调用应用程序类加载机器完成非作用范围类的加载,否则有些类找不到

    我们在使用JDBC时,都需要加载Driver驱动Class.forName("com.mysql.jdbc.Driver");,但是我们项目中并没有写,也可以让驱动正确加载,如何做到的呢?源码分析

    public class DriverManager {
        // 注册驱动的集合 List of registered JDBC drivers
        private final static CopyOnWriteArrayList registeredDrivers = new CopyOnWriteArrayList();
        /* Prevent the DriverManager class from being instantiated. */
        private DriverManager(){}
        static {
           //初始化驱动
            loadInitialDrivers();
            println("JDBC DriverManager initialized");
        }
    

    查看DriverManager 的类加载器

     //输出null,代表是DriverManager的类加载器的Bootstrap Classloader
    System.out.println(DriverManager.class.getClassLoader());
    

    疑问:DriverManager初始化是启动类加载器,搜索的$JAVA_HOME/jre/lib下的jar包,但是lib下没有我们的mysql-connect-java 驱动包,那么DriverManager静态代码块如何实现com.mysql.cj.jdbc.Driver(8.0.28版本)类的加载呢
    静态代码块加载驱动分析

       private static void loadInitialDrivers() {
            String drivers;
            try {
                drivers = AccessController.doPrivileged(new PrivilegedAction() {
                    public String run() {
                        return System.getProperty("jdbc.drivers");
                    }
                });
            } catch (Exception ex) {
                drivers = null;
            }
            // If the driver is packaged as a Service Provider, load it.
            // Get all the drivers through the classloader
            // exposed as a java.sql.Driver.class service.
            // ServiceLoader.load() replaces the sun.misc.Providers()
            AccessController.doPrivileged(new PrivilegedAction() {
                public Void run() {
                  //使用ServiceLoader机制加载驱动,即SPI
                   //里面使用线程上下文类加载器,当当前线程上下文类加载(即默认我们的应用程序类加载器),当作参数传入,后续使用此加载器完成驱动的加载
                    ServiceLoader loadedDrivers = ServiceLoader.load(Driver.class);
                    Iterator driversIterator = loadedDrivers.iterator();
                    try{
                        while(driversIterator.hasNext()) {
                            driversIterator.next();
                        }
                    } catch(Throwable t) {
                    // Do nothing
                    }
                    return null;
                }
            });
            println("DriverManager.initialize: jdbc.drivers = " + drivers);
            //使用jdbc.drivers定义的驱动名加载驱动
            if (drivers == null || drivers.equals("")) {
                return;
            }
            String[] driversList = drivers.split(":");
            println("number of Drivers:" + driversList.length);
            for (String aDriver : driversList) {
                try {
                    println("DriverManager.Initialize: loading " + aDriver);
                    //Class.forName 完全驱动类的类加载
                    Class.forName(aDriver, true,
                       // ClassLoader.getSystemClassLoader() 就是应用程序类加载器,可自己查看main方法执行打印           sun.misc.Launcher$AppClassLoader@18b4aac2
                       //这里打破了双亲委派模式,按理本来DriverManager类初始化时理应使用启动类加载器加载相关类
                            ClassLoader.getSystemClassLoader());
                } catch (Exception ex) {
                    println("DriverManager.Initialize: load failed: " + ex);
                }
            }
        }
    

    2 自定义类加载器

    使用场景:

    • 加载任意路径下class文件,自己的class文件 不在启动类加载器,扩展类加载器、应用程序类加载加载的路径范围内
    • 框架设计,解耦,通过接口来使用实现
    • 同限定名类有多个版本,可以同时使用,需要隔离,不同应用同名类都可以加载,不冲突,tomcat容器

    定义:

  • 继承父类 ClassLoader
  • 遵循双亲委派机制,重写 findClass 方法(不是重写loadClass,loadClass会调用findClass实现双亲委派模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题)
  • 读取类的字节码文件
  • 调用父类的defineClass方法加载类
  • 类的使用者调用该类加载器的loadClass方法
  • image.png

    3 tomcat打破双亲委派机制

    开篇提到一个tomcat下可以部署多个应用,并且直接覆盖对应的class,无需重启应用就可以实现热部署。
    在tomcat中,有多个应用。每个应用里面使用不同版本的jar包,例如:每个应用中都是用了spring 框架,但是应用A中使用的是spring 3的技术,应用B中使用的是spring 4的技术。根据双亲委派机制,不能同时加载这两个版本的spring类包,每个应用的包要进行隔离,并且tomcat也有自己依赖的类库,不能与应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。因此,tomcat需要打破双亲委派机制

    image.png
    热部署打破双亲委派

    tomcat启动的时候会有启动一个线程每隔一段时间会去判断应用中加载的类是否发生变法(类总数的变化,类的修改),如果发生了变化就会把应用的启动的线程停止掉,清除引用,并且把加载该应用的WebappClassLoader设为null,然后创建一个新的WebappClassLoader来重新加载应用

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论