目 录CONTENT

文章目录

虚拟机类加载机制

FatFish1
2025-04-14 / 0 评论 / 0 点赞 / 41 阅读 / 0 字 / 正在检测是否收录...

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期包括:加载Loading、验证Verification、准备Preparation、解析Resolution、初始化Initialization、使用Using、卸载Unloading七个阶段。验证、准备、解析统称为连接Linking。

其中加载、验证、准备、初始化、卸载的顺序是固定的解析不一定,有时可能在初始化之前,有时也可能在初始化之后

类加载的时机

了解类加载的时机有助于理解JVM内存情况,知道类什么时候被加载或被回收

那么类加载的第一个阶段(Loading)是什么时候触发的?可以分成主动引用和被动触发两种场景:

主动引用

实际上虚拟机严格规定了有且只有六种情况(主动引用)会触发类的加载流程:

  • 遇到new、getstatic、putstatic、invokestatic四条字节码指令时,如果类没有进行过初始化,则需要触发初始化阶段。能生成这四条字节码的场景包括:

    • 使用new实例化对象

    • 读取或设置一个类的静态字段(被final修饰的会在编译器进入常量池,这种除外)

    • 调用一个类的静态方法

  • 使用java.lang.reflect包的方法对类型进行反射调用,如果没有初始化,则需要首先进行初始化

  • 初始化类的时候,如果发现父类还没初始化,则需要初始化父类

  • 当虚拟机启动时,有一个主类(包含main方法),虚拟机会先初始化这个主类

  • JDK7加入的java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且对应的类没有初始化,则触发其初始化。

  • 当一个接口定义了JDK8新加入的default接口方法,如果这个接口的实现类发生了初始化,则接口要在其之前初始化

总结下是三个方面:

  • 任何方法调用静态成员字段/静态方法

  • 创建实例或反射访问类

  • 父类或带default的接口先于子类初始化

被动引用的特殊场景

  • 子类调用父类的静态变量,父类初始化,子类不需要初始化

  • 创建对象数组,例如TestClass [] sca = new TestClass[10];这时数组初始化,但TestClass不需要初始化

  • 调用类中的final静态常量,不会初始化。因为这个已经在常量池了

特别注意一点就是final静态变量优先被加载进常量池,即不初始化类也会占内存空间

类加载的过程

加载Loading

加载阶段,虚拟机完成以下事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流

  • 将字节流所代表的静态存储结构转化为方法区的运行时数据结构

  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问的外部接口

通过一个类的全限定名来获取定义此类的二进制流

没说过必须从某个Class文件中获取,因此存在以下场景:从zip读取(例如jar、war等)、,从网络获取(Web Applet)、运行时计算生成(例如反射、代理)、有其他文件生成(JSP)、从数据库中读取(SAP Netweaver)、从加密文件获取(安全性,保护类的反编译)

类加载器

关于类加载器的更详细介绍可以参考类与类加载器部分

非数组类型的加载阶段使用java虚拟机中内置的引导类加载器完成,也可以自定义类加载器,即重写一个类加载器的findClass()loadClass()方法,从而赋予代码动态性。

数组类本身不是通过类加载器创建的,是虚拟机在内存中动态构造的。但数组中元素的类型(去掉所有维度,代称Element Type或Component Type)最终还是要靠类加载器加载

除了元素类型,还有组件类型,即去掉1个维度

举个例子:

public static void main(String[] args) throws ExecutionException, SQLException, ClassNotFoundException, IOException {
    // 一维数组,元素类型即组件类型
    int[] arry = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 };
    Class arryClass = arry.getClass();
    System.out.println(arryClass.getName());   // [I
    System.out.println(arryClass.getComponentType().getName());  // int
    
    // 二维数组,组件类型就是数组类型了,不再是int
    int[][] arry2 = new int[][] {{1,2},{2,3},{3,1}};
    Class arryClass2 = arry2.getClass();
    System.out.println(arryClass2.getName());  //  [[I
    System.out.println(arryClass2.getComponentType().getName());  //  [I
}

加载规则如下:

  • 数组的组件类型是引用类型,则递归采用本节讲的加载过程取加载,数组将被标识在加载该元素类型的类加载器的类名称空间上

  • 数组的组件类型是基本类型(int等),java虚拟机将会把数组标记为与引导类加载器关联。

  • 数组类的可访问性与它的组件类型的可访问性一致。如果组件类型不是引用类型,数组类的可访问性默认为public,可被所有类和接口访问

验证Verification

包括文件格式验证、元数据验证、字节码验证(正确的字节码流程、类型转换有效等)、符号引用验证

准备Preparation

在这个阶段正式为类中定义的变量(静态变量)分配内存并设置初始值

这里被分配且设置初始值的只有类变量,没有实例变量。实例变量是跟随对象实例化时随对象一起分配在java堆中的

初始值通常指的是零值,例如:

public static int value = 123;

准备阶段后value赋值为0,而不是123。因为这时候还没有执行任何java方法,而后续赋值为123是putstatic指令,是程序被编译后存放在类构造器<clinit>()方法中的指令。

各个类型的零值如下:

  • int , short , long , byte 的默认值是0。

  • char 的默认值是 \u0000 (空字符)。

  • float 的默认值是 0.0f 。

  • double 的默认值是 0.0d 。

  • boolean 的默认值是 false 。

  • 引用类型(类、接口、数组)的默认值是 null 。

但如果是final变量,会在运行时进行初始化,且后续不允许修改,例如:

public static final int value = 123;

static在属性表中会存在属性表中的ConstantValue属性中,final要求在运行时初始化,因此static final修饰时,在准备阶段就会初始化为ConstantValue指定的初始值123。即在编译期即把结果放入了常量池中。属性表和ConstantValue属性参考如下:

http://www.chymfatfish.cn/archives/class#constantvalue%E5%B1%9E%E6%80%A7

解析Resolution

符号引用和直接引用

解析是将java虚拟机内的常量池内的符号引用替换为直接引用的过程

  • 符号引用:描述引用目标的一组符号,可以是任意形式的字面量,引用的目标不一定是已经加载内存中的内容

  • 直接引用:指向目标的指针。

下面是两个例子:

比如org.simple.People类引用了org.simple.Language类,在编译成class文件后People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language。而加载class到内存中时,已经知道org.simple.Language地址了,就将org.simple.Language符号替换成实际常量

再举一个更直观的:

// 符号引用
String str = "abc";
System.out.println("str = " + str);

// 直接引用
System.out.println("str = " + "abc")

代码里面一般写上面的写法,因为通过定义变量代替实际值,可以方便代码阅读和重构,但class加载到内存后,则会被替换成下面这种形式,这是jvm所需要的

触发解析的时机

执行anewarry、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarry、new、putfield、putstatic操作符前会对符号引用进行解析。

如果执行之前发现这个符号引用已经被解析了,就可以不再执行了,除了invokedynamic指令以外

解析流程

类或接口解析

如果类C在类D中,不是数组,则通过D的类加载器去加载类C,可能顺便又触发加载类C的父类、实现接口等;如果类C是数组,则加载数组元素类型;加载完后验证类D对C的访问权限。

字段解析

如果字段C在类D中,本身包含简单名称,和字段描述符都与目标相匹配的字段,返回它的直接引用;否则如果C实现接口,将按照继承关系递归各个接口和父接口,找匹配的字段,返回它的直接引用;否则如果C不是object类,则递归搜索其父类,找到了则返回直接引用;都找不到则抛出NoSuchFieldError

方法解析

如果class方法表发现索引C是个接口,则抛出IncompatibleClassChangeError;通过检查,在类C中找相同方法,找到了返回直接引用;找不到递归其父类找;否则在其实现类找;最后验证访问权限。

接口方法解析

如果发现class方法表索引C不是接口,抛出异常;然后在接口C中找匹配方法,否则去父类中递归查找(接口允许多继承,找到一个即可)。

初始化Initialization

准备阶段已经赋过0值,初始化时会赋初始值

初始化就是执行类构造器<clinit>()方法,这个方法是javac自动生成的

<clinit>()方法

  • <clinit>()方法由编译器自动收集类中所有类变量的赋值动作和静态块合并产生收集顺序就是语句在源文件中的顺序。因此静态块只能访问到静态块之前的变量,但是可以对后面的变量赋值但不能访问

  • <clinit>()方法不显式调用父类的构造器,java虚拟机保证子类的<clinit>()方法执行之前,父类的<clinit>()就已经执行完成了。因此java虚拟机中第一个被执行的一定是Object类的。且父类在静态块中的赋值语句就会先于子类的变量赋值操作执行

static class Parent{
	public static int A =1;
	static {
	    A = 2;
    }
}

static class Sub extends Parent {
	public static int B = A;
}

Sub.B == 2;
  • <clinit>()方法对于类和接口非必须,没有静态块,也没有变量赋值操作,编译器就不自动生成这个类的<clinit>()方法

  • 接口可以有赋值操作,也可以有<clinit>()方法,但是子接口的<clinit>()方法执行无需先执行父接口的<clinit>()方法,因为父接口定义的变量被使用时才会被初始化接口实现类的<clinit>()也无需先执行接口的

  • <clinit>()在多线程环境中,java虚拟机保证加同步锁执行。如果多个线程同时初始化一个类,只能有一个线程执行<clinit>()方法,其他线程都要阻塞到<clinit>()执行完成。此处有阻塞风险,往往代码中不易发现

类与类加载器

类加载器的作用是通过一个类的全限定名来获取描述该类的二进制字节流

类加载器是在JVM外部的,意味着用户可以自行实现这个过程,从而获取所需要的类。不实现的话,也提供了默认的类加载器

类加载器与类的唯一性

每个类加载器都有自己独立的命名空间,加载相同类获取到的Class实例也不是同一个

比较两个类是否相等,只有在两个类由同一个类加载器加载的前提下才有意义,否则即使两个类来源于同一个Class文件被同一个JAVA虚拟机加载,只要类加载器不同,那么必定不同。

所谓的“相等”,包括:equals方法、isAssignableFrom方法、isInstance方法、instanceof关键字

举个例子:

public class MyTest {
    public static void main(String[] args) throws Exception {
        ClassLoader classLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream is = getClass().getClassLoader().getResourceAsStream(fileName);
                    if (is == null) {
                        return super.loadClass(name);
                    }
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    return defineClass(name, b, 0, b.length);
                } catch (Exception e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };
        Object instance = classLoader.loadClass("com.huawei.cbc.cbcbillingsortingservice.service.MyTest").newInstance();
        System.out.println(instance.getClass());
        System.out.println(instance instanceof com.huawei.cbc.cbcbillingsortingservice.service.MyTest);
    }
}

这里第二个判断结果应该为false(存疑,应该因为is 为null,走了super.loadClass流程导致使用了一套类加载器,疑似idea配置问题),因为instance是由自定义的类加载器加载的MyTest类实例化出来的,与原先的MyTest类不是一个类

类加载过程 - 双亲委派模型

类加载器的分类

在JVM的角度,类加载器有两部分:

  • 启动类加载器:Bootstrap ClassLoader,基于C++实现,是虚拟机一部分

  • 其他类加载器:Java实现,都继承了java.lang.ClassLoader,独立于虚拟机

但是站在开发者角度,分类就应该更细致一些,即堆其他类加载器更细化,加上启动类加载器,可以概括为:

  • 启动类加载器(引导类加载器):Bootstrap ClassLoader,负责加载存放在<JAVA_HOME>\lib目录下的的核心类库,比如rt.jarcharsets.jar。启动类加载器是用C++语言写的,它是在Java虚拟机启动后初始化的,无法被Java程序直接引用

  • 扩展类加载器:Extension ClassLoader,在sun.misc.Launcher$ExtClassLoader中由java实现,负责加载<JAVA_HOME>\lib\ext目录,或被java.ext.dirs系统遍历所指定的路径中的所有类库。它的作用是为java系统类库提供扩展机制。

    • 扩展类加载器继承自引导类加载器

  • 应用程序类加载器(系统类加载器):Application ClassLoader。负责加载ClassPath路径下的类包,主要就是加载自己写的那些未指定类加载器的类。应用程序类加载器在用户不自定义累加器的情况下,是默认类加载器。由于应用程序类加载器也是ClassLoader类中getSystemClassLoader方法的返回值,因此也叫系统类加载器。

    • 应用程序类加载器继承自扩展类加载器

类加载器调用顺序

在一个类加载时,类加载器调用顺序即双亲委派机制,如图所示:

其设计理念是:

【一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委派给父类加载器去完成】

因此所有的请求最终都会委派到引导类加载器。

使用双亲委派机制的好处在于沙箱安全性,即保证核心API类库不被随意篡改;以及避免重复加载,即保证一个类在各种类加载器环境中都是唯一的,同样的,例如java.lang.Object只会由引导类加载器加载。

可以研究ClassLoader#loadClass方法对双亲委派机制做进一步了解

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        // 首先判断这个类是否被加载过
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    // 判断此类加载器有没有父类,如果有直接调用父类加载器的loadClass方法
                    c = parent.loadClass(name, false);
                } else {
                    // 没有父类直接委派给引导类加载器
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 这里发现父类/引导类加载器都加载不了,会抛ClassNotFoundException
                // 捕获这个异常,并且不做任何处理
            }
            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                // 到这里还是没加载出来,调用此类加载器的findClass方法进行加载
                long t1 = System.nanoTime();
                c = findClass(name);
                // this is the defining class loader; record the stats
                PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

根据上面代码可以看出,执行loadClass方法首先先判断类是否已经存在了,不存在,先找父类或引导类加载器去加载,如果父类或引导类加载器加载不了,捕获异常,调用findClass方法由自己加载。

因此就知道了我们如果要自行实现类加载器,则重写ClassLoader#findClass方法即可

自定义类加载器

自定义类加载器的父类加载器默认是应用程序类加载器,因为不写特殊的构造方法时,初始化默认调用父类的无参构造,即

protected ClassLoader() {
    this(checkCreateClassLoader(), getSystemClassLoader());
}

第二参数传入的是父类加载器,ClassLoader的无参构造默认传入的是SystemClassLoader,即应用程序类加载器。

// java.lang.ClassLoader
public static ClassLoader getSystemClassLoader() {
        initSystemClassLoader();
        …………

private static synchronized void initSystemClassLoader() {
        if (!sclSet) {
            if (scl != null)
                throw new IllegalStateException("recursive invocation");
            sun.misc.Launcher l = sun.misc.Launcher.getLauncher();
            if (l != null) {
                Throwable oops = null;
                scl = l.getClassLoader();
                ………………

// sun.misc.Launcher
public ClassLoader getClassLoader() {
        return loader;
}

public Launcher() {
        ………………
        // Now create the class loader to use to launch the application
        try {
            loader = AppClassLoader.getAppClassLoader(extcl);
        } catch (IOException e) {
            throw new InternalError(
                "Could not create application class loader", e);
        }
        ………………

破坏双亲委派机制

还有一种写法是重写ClassLoader#loadClass方法,但是由于原始的ClassLoader#loadClass 中有双亲委派的逻辑,一旦修改这部分代码,就有可能打破这种机制,因此不推荐这样做

但是有的常见,也有一些打破双亲委派的案例,例如:

  • 同一个JVM中有需要支持同一第三方类库的不同版本同时运行。如Tomcat,对于不同的war包,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库以及Tomcat本身引用类库都是独立的,保证相互隔离。

  • JVM不重启情况下实现class文件的热加载。如Tomcat,对于jsp文件要在JVM不重启的情况下实现热加载。而如果按照传统双亲委派机制加载,jsp文件修改后,类加载器会直接取方法区中已经存在的,并不会重新加载。所以可以通过为每一个class文件单独创建一个ClassLoader,每次更新class文件后,卸载之前的ClassLoader,重新加载。

老版本的SPI也是打破双亲委派机制实现的。后来优化成了在META-INF/services下面存放实现类从而优化了这一点。

0

评论区