Loading

《深入理解java虚拟机》第七章读书笔记——虚拟机类加载机制

系列文章目录和关于我

一丶虚拟机类加载机制是什么

java虚拟机将描述类的数据从class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可用被虚拟机直接使用的java类型。

二丶类加载时机

1.什么时候会触发虚拟机的类类加载昵?

  • 遇到new(使用new关键字实例化对象)getstatic(读取一个类的静态非final字段)putstatic(设置一个类的非final静态字段),或invokestatic(调用一个类型的静态方法)这些字节码指令的时候,如果类没有经过初始化,那么需要先触发其初始化阶段。
  • 使用java反射手段,对类型进行反射调用的时候,如果类型没有进行初始化,那么将先触发其初始化。
  • 当都初始化类的时候,如果其父类没有进行初始化,那么则需要先触发其父类的初始化。
  • 当虚拟机启动的时候,虚拟机会先初始化用户指定的主类
  • 当使用MethodHandler实例,并解析为REF_getstatic,REF_putstatic,REF_invokestatic,REF_newinvokeSpecial四种类型方法句柄,并且这个方法句柄对应的类没有经过初始化,那么先进行类的初始化
  • 如果一个接口定义了default方法,其实现类发生了初始化,那该接口要在其之前进行初始化

2.不会触发类加载,但是有趣的几种案例

2.1子类引用父类静态属性

public class SuperClass {
    public static int staticValue =1;
    public  static final int finalValue = 2;
    static {
        System.out.println("super");
    }
}

public class SubClass extends SuperClass{
    static {
        System.out.println("sub");
    }
}


public class Main {

    public static void main(String[] args) {
        System.out.println(SubClass.staticValue);
    }
}

Main类的main方法执行,只会输出super,说明并没有触发子类的初始化。这是因为:对于静态字段,只有直接定义这个字段的类才会初始化

2.2 使用静态final字段,并不会触发类的初始化

public class Main {

    public static void main(String[] args) {
        System.out.println(SuperClass.finalValue);
    }
}

使用静态final字段并不会触发类的初始化。这是由于编译阶段通过常量传播优化,已经将此常量的值直接存储于SuperClass的常量池中,对常量的使用,都被转化为SuperClass对自身常量池的引用了,Main的Class文件并没有SuperClass类的符号引用入口,编译之后Main类和SuperClass并没有任何联系了。

image-20230219175633495

这是Idea target中Main类,反编译后的内容,可用看到并没有对SuperClass的引用。

2.3 使用类的数组类型,并不会触发类的初始化

public class Main {

    public static void main(String[] args) {
        SubClass[] subClasses = new SubClass[10];
    }
}

上面所示代码,并不会触发SubClass的初始化,因为SubClass[].class这种类型,是由虚拟机自动生成,并继承自Object类型,创建动作由newarry字节码指令触发。

三丶类加载过程

image-20230219181223429

1.加载

  1. 通过类的全限定类名,获取此类的二进制字节流、

    • 从zip压缩包中获取——jar,war 格式的基础
    • 从网络中获取 —— web applet 引用
    • 运行时计算生成——动态代理结束,使用java动态代理Proxy生成代理对象的时候,就是生成*$Proxy代理类的二进制字节流

    加载阶段可用使用java虚拟机里内置的引导类加载器完成,也可以自定义类加载,重写findClass 或者loadClass方法,可用自定义如何获取类的二进制字节流。

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

  3. 在堆中生成一个代表此类的Class对象,作为方法区这个类的各种数据数据的入口

image-20230219183145659

经过加载阶段,java虚拟机外部的二进制字节流就按照虚拟机设定的格式存储在方法区中了(图中的运行时数据结构),类型数据存储后,会在java堆中生成Class对象,作为方法区类型数据的访问接口

注意这时还不能生成该类的实例,还需要进行验证,准备,解析,初始化

2.验证

验证时为了保证class文件字节流中包含的信息符合虚拟机规范,保证这些信息被当作代码运行后,不会危害虚拟机的安全。

2.1 文件格式校验

验证字节流符合class文件格式规范,并且可用被当前版本的虚拟机处理。

2.2 元数据校验

对字节码描述信息进行语义校验,保证其描述的信息符合虚拟机规范

  • 当前类是否由父类,除了Object外所有类都需要由父类
  • 这个类是否继承了被final修饰的类
  • 如果类不是抽象类,那么是否实现了必须实现的方法
  • .....

2.3 字节码验证

通过数据流分析,和控制流分析,确定程序语义是合法的,符合逻辑的。

2.4 符号引用验证

符号引用验证,发生在解析阶段,就是验证对类滋生外的各类信息进行匹配型校验。检查该类是否缺少或者被禁止访问它依赖的某些外部类,方法,字段等。

3.准备

准备阶段是正式为类中i党员的变量(静态属性)分配内存和设置初始值的阶段。

如果是static final类型的字段在此阶段会进行值的设置(static final int a= 1,在这个阶段会设置a的值为1)

但是static属性只会设置初值(static int a =1,在这个阶段值为0)

4.解析

解析阶段负责将常量池中符号引用,替换为直接引用。

  • 符号引用:

    用一组符号描述所引用的目标,可用是任何形式的字面量,可以没有歧义的定位到目标。

  • 直接引用:

    指向目标的指针,相对偏移,或者能简介定位到目标的句柄。

    对于invokedynamic指令,并不会缓存第一次解析的结果,invokedynamic又称为动态调用限定符,动态意味着等程序运行到这条指令的时候才会去解析。其余触发解析的指令都是静态的,可用在完成加载阶段后进行解析。

5.初始化阶段

准备阶段已经进行赋零值的操作,初始化阶段就是执行类的构造器<cinit>(),此方法由java编译器自动生成,会自动收集类中所有类变量的赋值动作和静态代码块,并合并产生,收集的顺序由源文件中的顺序决定。

  • 虚拟机保证在调用子类的<cinit>()方法之前,父类的<cinit>()已经执行完毕。
  • 父类的<cinit>()先于子类执行
  • 如果一个类没有静态代码块,也没有对类静态变量的赋值操作,那么编译器可用不生成<cinit>()方法
  • 接口中不能使用静态代码块,但仍然可有由变量的初始化操作,执行接口的<cinit>()不需要先执行其父类的<cinit>(),只有当父类的静态变量被使用的时候才会调用父类的<cinit>()。接口实现类初始化的时候也不会调用接口的<cinit>()
  • 虚拟机保证一个类的<cinit>()方法在多线程环境下,会正确的加锁同步(为什么说静态代码块的单例是线程安全的)。

四丶类加载器

类加载器的任务是根据一个类的全限定名来读取此类的二进制字节流到JVM中,然后转换为一个与目标类对应的java.lang.Class对象实例,在虚拟机提供了3种类加载器,引导(Bootstrap)类加载器、扩展(Extension)类加载器、系统(System)类加载器(也称应用类加载器)

类加载器在java程序中的作用不仅仅是类的加载阶段,还会作用于Class对象的equals,isAssignableFrom,以及isInstance()和instanceof关键字,只有两个类是由同一个类加载器加载的前提下,才能“相等”(满足前面说的equals,isAssignableFrom等)。不同的类加载器加载相同的全限定类名,可能在java虚拟机中存在多个独立的类。

  • 启动类加载器

    启动类加载器主要加载的是JVM自身需要的类,这个类加载使用C++语言实现的,是虚拟机自身的一部分,它负责将 <JAVA_HOME>/lib路径下的核心类库或-Xbootclasspath参数指定的路径下的jar包加载到内存中,注意必由于虚拟机是按照文件名识别加载jar包的,如rt.jar,如果文件名不被虚拟机识别,即使把jar包丢到lib目录下也是没有作用的(出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类)。

  • 扩展类加载器

    扩展类加载器是ExtClassLoader类,由Java语言实现的,是Launcher的静态内部类,它负责加载<JAVA_HOME>/lib/ext目录下或者由系统变量-Djava.ext.dir指定位路径中的类库,开发者可以直接使用标准扩展类加载器。

  • 应用程序类加载器

    AppClassLoader。它负责加载系统类路径java -classpath-D java.class.path 指定路径下的类库,也就是我们经常用到的classpath路径,开发者可以直接使用系统类加载器,一般情况下该类加载是程序中默认的类加载器,通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器。

五丶双亲委派模型

image-20230219224746306

1.何为双亲委派

如果一个类加载器收到类加载请求,首先不会自己尝试加载,而是把此请求交给父类加载器进行加载,因此最终所有的类都将交给启动类加载器进行加载,只有当父加载器反馈自己无法完成这个加载请求(搜索范围内不存在目标类)子加载器才会去自己加载

image-20230219225254264

可用看到父类抛出ClassNotFoundException后,也会尝试自己去加载。

2.双亲委派的好处

双亲委派保证了,所有类的记载,首先交给父类加载器进行,保证相同的类只会存在一个(都交给了父,父如果加载不了,那么子,最终相同的类肯定由相同的类加载器进行加载)。如果打破双亲委派,那么我们可用自己实现java.lang.Object,系统中会出现多个Object类,java最基础的行为都无法得到保证。

3.打破双亲委派

  • JNDI现在已经是Java的标准服务,它的代码由启动类加载器去加载(在JDK 1.3时放进去的rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI,Service Provider Interface)的代码,但启动类加载器不可能去加载ClassPath下的类。

    但是有了线程上下文类加载器就好办了,JNDI服务使用线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。

  • Tomcat中可以部署多个web项目,为了保证每个web项目互相独立,所以不能都由AppClassLoader加载,所以自定义了类加载器WebappClassLoader

    1. 先在本地缓存中查找是否已经加载过该类(对于一些已经加载了的类,会被缓存在resourceEntries这个数据结构中),如果已经加载即返回,否则 继续下一步。
    2. 让系统类加载器(AppClassLoader)尝试加载该类,主要是为了防止一些基础类会被web中的类覆盖,如果加载到即返回,返回继续。
    3. 前两步均没加载到目标类,那么web应用的类加载器将自行加载,如果加载到则返回,否则继续下一步。
    4. 最后还是加载不到的话,则委托父类加载器(Common ClassLoader)去加载。
posted @ 2023-02-19 23:28  Cuzzz  阅读(431)  评论(2编辑  收藏  举报