JVM--Java类加载器笔记

Java类加载器

代码经过编译变成了字节码打包成 Jar 文件。让 JVM 去加载需要的字节码,变成持久代/元数据区上的 Class 对象,接着执行程序逻辑。

类声明周期和加载过程

步骤:加载->链接(校验->准备->解析)->初始化->使用->卸载

  • 加载:根据明确知道的 class 完全限定名, 来获取二进制 classfile 格式的字节流(找到文件系统中/jar 包中/或存在于任何地方的"class 文件"。 如果找不到二进制表示形式,则会抛出 NoClassDefFound 错误。)

  • 校验:确保 class 文件里的字节流信息符合当前虚拟机的要求,不会危害虚拟机的安全。

    • 在某个类的加载过程中,JVM 必须加载其所有的超类和接口。如果类层次结构有问题(例如,该类是自己的超类或接口,死循环了),则 JVM 将抛出 ClassCircularityError。 而如果实现的接口并不是一个 interface,或者声明的超类是一个 interface,也会抛出 IncompatibleClassChangeError
  • 准备:会创建静态字段, 并将其初始化为标准默认值(比如null或者0 值),并分配方法表,即在方法区中分配这些变量所使用的内存空间。

    • 准备阶段并未执行任何 Java 代码。
    • public static int i = 1;备阶段i的值会被初始化为 0,后面在类初始化阶段才会执行赋值为 1;
    • public static final int i = 1如果使用 final 作为静态常量,对应常量 i,在准备阶段就会被赋值 1;
  • 解析:进入可选的解析符号引用阶段。 也就是解析常量池,主要有以下四种:类或接口的解析、字段解析、类方法解析、接口方法解析。

    • 编写的代码中,当一个变量引用某个对象的时候,这个引用在 .class 文件中是以符号引用来存储的(相当于做了一个索引记录)。
    • 在解析阶段就需要将其解析并链接为直接引用(相当于指向实际对象)。如果有了直接引用,那引用的目标必定在堆中存在。
    • 加载一个 class 时, 需要加载所有的 super 类和 super 接口。
  • 初始化: 必须在类的首次"主动使用"时才能执行类初始化。

    • 初始化的过程包括执行:
      • 类构造器方法
      • static 静态变量赋值语句
      • static 静态代码块
    • 如果是一个子类进行初始化会先对其父类进行初始化,保证其父类在子类之前进行初始化。所以其实在 java 中初始化一个类,那么必然先初始化过 java.lang.Object 类,因为所有的 java 类都继承自 java.lang.Object。
类加载时机

触发类的初始化情况:

  • 当虚拟机启动时,初始化用户指定的主类,就是启动执行的 main 方法所在的类;
  • 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类,就是 new 一个类的时候要初始化;
  • 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
  • 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
  • 子类的初始化会触发父类的初始化;
  • 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
  • 使用反射 API 对某个类进行反射调用时,初始化这个类,其实跟前面一样,反射调用要么是已经有实例了,要么是静态方法,都需要初始化;
  • 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

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

  • 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
  • 定义对象数组,不会触发该类的初始化。
  • 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
  • 通过类名获取 Class 对象,不会触发类的初始化,Hello.class 不会让 Hello 类初始化。
  • 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。Class.forName("jvm.Hello")默认会加载 Hello 类。
  • 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作(加载了,但是不初始化)。

示例: 诸如 Class.forName(), classLoader.loadClass() 等 Java API, 反射API, 以及 JNI_FindClass 都可以启动类加载。 JVM 本身也会进行类加载。 比如在 JVM 启动时加载核心类,java.lang.Object, java.lang.Thread 等等。

类加载机制

类加载过程可以描述为"通过一个类的全限定名 a.b.c.XXClass 来获取描述此类的 Class 对象",这个过程由"类加载器(ClassLoader)"来完成。这样的好处在于,子类加载器可以复用父加载器加载的类。

  • 系统自带的类加载器分为三种:
    • 启动类加载器(BootstrapClassLoader):由 JVM 内部实现的,在 Java 的 API 里无法拿到。
      • 用来加载 Java 的核心类,是用原生 C++ 代码来实现的,可以看做是 JVM 自带的,在代码层面无法直接获取到启动类加载器的引用,所以不允许直接操作它。
      • 例如:java.lang.String 是由启动类加载器加载的,所以 String.class.getClassLoader() 就会返回 null。但是后面可以看到可以通过命令行参数影响它加载什么。
    • 扩展类加载器(ExtClassLoader):
      • 负责加载 JRE 的扩展目录,lib/ext 或者由 java.ext.dirs 系统属性指定的目录中的 JAR 包的类,代码里直接获取它的父类加载器为 null(因为无法拿到启动类加载器)。
      • 类加载器在 Oracle Hotspot JVM 里,都是在中sun.misc.Launcher定义的。一般都继承自URLClassLoader类,这个类也默认实现了从各种不同来源加载 class 字节码转换成 Class 的方法。
    • 应用类加载器(AppClassLoader):
      • 负责在 JVM 启动时加载来自 Java 命令的 -classpath 或者 -cp 选项、java.class.path 系统属性指定的 jar 包和类路径。在应用程序代码里可以通过 ClassLoader 的静态方法 getSystemClassLoader() 来获取应用类加载器。如果没有特别指定,则在没有使用自定义类加载器情况下,用户自定义的类都由此加载器加载。
      • 类加载器在 Oracle Hotspot JVM 里,都是在中sun.misc.Launcher定义的,一般都继承自URLClassLoader类,这个类也默认实现了从各种不同来源加载 class 字节码转换成 Class 的方法。
    • 自定义类加载器。
      • 自定义类加载器都以应用类加载器作为父加载器。应用类加载器的父类加载器为扩展类加载器。这些类加载器是有层次关系的,启动加载器又叫根加载器,是扩展加载器的父加载器,但是直接从 ExClassLoader 里拿不到它的引用,同样会返回 null。
  • 类加载机制
    • 双亲委托:当一个自定义类加载器需要加载一个类,比如 java.lang.String,先委托自己的父加载器去加载,父加载器如果发现自己还有父加载器,会一直往前找,这样只要上级加载器,比如启动类加载器已经加载了某个类比如 java.lang.String,所有的子加载器都不需要自己加载了。如果几个类加载器都没有加载到指定名称的类,那么会抛出 ClassNotFountException 异常。
    • 负责依赖:如果一个加载器在加载某个类的时候,发现这个类依赖于另外几个类或接口,也会去尝试加载这些依赖项。
    • 缓存加载:为了提升加载效率,消除重复加载,一旦某个类被一个类加载器加载,那么它会缓存这个加载结果,不会重复加载。
自定义类加载器示例
public class Hello {
    static {
        System.out.println("Hello Class Initialized!");
    }
}

import java.util.Base64;

public class HelloClassLoader extends ClassLoader {

    public static void main(String[] args) {
        try {
            new HelloClassLoader().findClass("jvm.Hello").newInstance(); // 加载并初始化Hello类
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        }
    }

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

        String helloBase64 = "yv66vgAAADQAHwoABgARCQASABMIABQKABUAFgcAFwcAGAEABjxpbml0PgEAAygpVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBABJMb2N" +
           "hbFZhcmlhYmxlVGFibGUBAAR0aGlzAQALTGp2bS9IZWxsbzsBAAg8Y2xpbml0PgEAClNvdXJjZUZpbGUBAApIZWxsby5qYXZhDAAHAAgHABkMABoAGwEAGEhlb" +
                "GxvIENsYXNzIEluaXRpYWxpemVkIQcAHAwAHQAeAQAJanZtL0hlbGxvAQAQamF2YS9sYW5nL09iamVjdAEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdXQBABVMamF2" +
                "YS9pby9QcmludFN0cmVhbTsBABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgEAFShMamF2YS9sYW5nL1N0cmluZzspVgAhAAUABgAAAAAAAgABAAcACA" +
                "ABAAkAAAAvAAEAAQAAAAUqtwABsQAAAAIACgAAAAYAAQAAAAMACwAAAAwAAQAAAAUADAANAAAACAAOAAgAAQAJAAAAJQACAAAAAAAJsgACEgO2AASxAAAAAQAK" +
                "AAAACgACAAAABgAIAAcAAQAPAAAAAgAQ";

        byte[] bytes = decode(helloBase64);
        return defineClass(name,bytes,0,bytes.length);
    }

    public byte[] decode(String base64){
        return Base64.getDecoder().decode(base64);
    }

}
  • 两个没有关系的自定义类加载器之间加载的类是不共享的(只共享父类加载器,兄弟之间不共享),这样就可以实现不同的类型沙箱的隔离性
  • 可以用多个类加载器,各自加载同一个类的不同版本,在这个基础上可以实现类的动态加载卸载,热插拔的插件机制等。

实用技巧

  • 排查再找不到jar包的问题

    • 结果可以看到三种类加载器各自默认加载了哪些 jar 包和包含了哪些 classpath 的路径

      import java.lang.reflect.Field;
      import java.net.URL;
      import java.net.URLClassLoader;
      import java.util.ArrayList;
    
      public class JvmClassLoaderPrintPath {
    
          public static void main(String[] args) {
    
              // 启动类加载器
              URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
              System.out.println("启动类加载器");
              for(URL url : urls) {
                  System.out.println(" ==> " +url.toExternalForm());
              }
    
              // 扩展类加载器
              printClassLoader("扩展类加载器", JvmClassLoaderPrintPath.class.getClassLoader().getParent());
    
              // 应用类加载器
              printClassLoader("应用类加载器", JvmClassLoaderPrintPath.class.getClassLoader());
    
          }
    
          public static void printClassLoader(String name, ClassLoader CL){
              if(CL != null) {
                  System.out.println(name + " ClassLoader -> " + CL.toString());
                  printURLForClassLoader(CL);
              }else{
                  System.out.println(name + " ClassLoader -> null");
              }
          }
    
          public static void printURLForClassLoader(ClassLoader CL){
    
              Object ucp = insightField(CL,"ucp");
              Object path = insightField(ucp,"path");
              ArrayList ps = (ArrayList) path;
              for (Object p : ps){
                  System.out.println(" ==> " + p.toString());
              }
          }
    
          private static Object insightField(Object obj, String fName) {
              try {
                  Field f = null;
                  if(obj instanceof URLClassLoader){
                      f = URLClassLoader.class.getDeclaredField(fName);
                  }else{
                      f = obj.getClass().getDeclaredField(fName);
                  }
                  f.setAccessible(true);
                  return f.get(obj);
              } catch (Exception e) {
                  e.printStackTrace();
                  return null;
              }
          }
      }
    
  • 如何排查类的方法不一致的问题

    • 假如确定一个 jar 或者 class 已经在 classpath 里了,但是却总是提示java.lang.NoSuchMethodError
    • 很可能是加载了错误的或者重复加载了不同版本的 jar 包。
    • 用前面的方法就可以先排查一下,加载了具体什么 jar,然后是不是不同路径下有重复的 class 文件,但是版本不一样。
  • 怎么看到加载了哪些类,以及加载顺序?

    • 假如有两个地方有 Hello.class,一个是新版本,一个是旧的,怎么才能直观地看到他们的加载顺序呢?
    • 可以直接打印加载的类清单和加载顺序。
    • 只需要在类的启动命令行参数加上-XX:+TraceClassLoading 或者 -verbose 即可,注意需要加载 Java 命令之后,要执行的类名之前,不然不起作用。例如:java -XX:+TraceClassLoading jvm.HelloClassLoader
  • 怎么调整或修改 ext 和本地加载路径?

    • 从前面的例子我们可以看到,假如什么都不设置,直接执行 java 命令,默认也会加载非常多的 jar 包,怎么可以自定义加载哪些 jar 包呢?比如我的代码很简单,只加载 rt.jar 行不行?答案是肯定的。

      bash 复制代码
      $ java -Dsun.boot.class.path="D:\Program Files\Java\jre1.8.0_231\lib\rt.jar" -Djava.ext.dirs= jvm.JvmClassLoaderPrintPath
      
      启动类加载器
         ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/rt.jar
      扩展类加载器 ClassLoader -> sun.misc.Launcher$ExtClassLoader@15db9742
      应用类加载器 ClassLoader -> sun.misc.Launcher$AppClassLoader@73d16e93
         ==> file:/D:/git/studyjava/build/classes/java/main/
         ==> file:/D:/git/studyjava/build/resources/main
      • 我们看到启动类加载器只加载了 rt.jar,而扩展类加载器什么都没加载,这就达到了我们的目的。
      • 命令行参数-Dsun.boot.class.path表示我们要指定启动类加载器加载什么,最基础的东西都在 rt.jar 这个包了里,所以一般配置它就够了。需要注意的是因为在 windows 系统默认 JDK 安装路径有个空格,所以需要把整个路径用双引号括起来,如果路径没有空格,或是 Linux/Mac 系统,就不需要双引号了。
      • 参数-Djava.ext.dirs表示扩展类加载器要加载什么,一般情况下不需要的话可以直接配置为空即可。
  • 怎么运行期加载额外的 jar 包或者 class 呢?

    • 有时候在程序已经运行了以后,还想要再额外的去加载一些 jar 或类.简单说就是不使用命令行参数的情况下,怎么用代码来运行时改变加载类的路径和方式

    • 假如说,在d:/app/jvm路径下,有刚才使用过的 Hello.class 文件,怎么在代码里能加载这个 Hello 类呢?

      • 一个是自定义 ClassLoader 的方式
      • 还有一个是直接在当前的应用类加载器里,使用 URLClassLoader 类的方法 addURL,不过这个方法是 protected 的,需要反射处理一下,然后又因为程序在启动时并没有显示加载 Hello 类,所以在添加完了 classpath 以后,没法直接显式初始化,需要使用 Class.forName 的方式来拿到已经加载的Hello类(Class.forName("jvm.Hello")默认会初始化并执行静态代码块)。代码如下:
      java 复制代码
      package jvm;
      
      import java.lang.reflect.InvocationTargetException;
      import java.lang.reflect.Method;
      import java.net.MalformedURLException;
      import java.net.URL;
      import java.net.URLClassLoader;
      
      public class JvmAppClassLoaderAddURL {
      
          public static void main(String[] args) {
      
              String appPath = "file:/d:/app/";
              URLClassLoader urlClassLoader = (URLClassLoader) JvmAppClassLoaderAddURL.class.getClassLoader();
              try {
                  Method addURL = URLClassLoader.class.getDeclaredMethod("addURL", URL.class);
                  addURL.setAccessible(true);
                  URL url = new URL(appPath);
                  addURL.invoke(urlClassLoader, url);
                  Class.forName("jvm.Hello"); // 效果跟Class.forName("jvm.Hello").newInstance()一样
              } catch (Exception e) {
                  e.printStackTrace();
              }
          }
      }

      执行以下,结果如下:

      $ java JvmAppClassLoaderAddURL Hello Class Initialized!

      结果显示 Hello 类被加载,成功的初始化并执行了其中的代码逻辑。

相关推荐
m0_5719575835 分钟前
Java | Leetcode Java题解之第543题二叉树的直径
java·leetcode·题解
一点媛艺2 小时前
Kotlin函数由易到难
开发语言·python·kotlin
魔道不误砍柴功3 小时前
Java 中如何巧妙应用 Function 让方法复用性更强
java·开发语言·python
NiNg_1_2343 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
闲晨3 小时前
C++ 继承:代码传承的魔法棒,开启奇幻编程之旅
java·c语言·开发语言·c++·经验分享
_.Switch3 小时前
高级Python自动化运维:容器安全与网络策略的深度解析
运维·网络·python·安全·自动化·devops
测开小菜鸟4 小时前
使用python向钉钉群聊发送消息
java·python·钉钉
P.H. Infinity5 小时前
【RabbitMQ】04-发送者可靠性
java·rabbitmq·java-rabbitmq
生命几十年3万天5 小时前
java的threadlocal为何内存泄漏
java
caridle5 小时前
教程:使用 InterBase Express 访问数据库(五):TIBTransaction
java·数据库·express