JVM实战---类加载的过程

2022-10-17,,,

任何程序都需要加载到内存才能与cpu进行交流
同理, 字节码.class文件同样需要加载到内存中,才可以实例化类
classloader的使命就是提前加载.class 类文件到内存中
在加载类时,使用的是parents delegation model(溯源委派加载模型)

java的类加载器是一个运行时核心基础设施模块,主要是在启动之初进行类的加载、链接、初始化

第一步,load阶段

读取类文件产生二进制流,并转为特定数据结构,初步校验cafe babe魔法数、常量池、文件长度、是否有父类等,然后创建对应类的java.lang.class实例

第二步,link阶段

包括验证、准备、解析三个步骤

  • 验证是更详细的校验,比如final是否合规、类型是否正确、静态变量是否合理等
  • 准备阶段是为静态变量分配内存,并设定默认值,解析类和方法确保类与类之间的相互引用正确性,完成内存结构布局

    第三步,init 阶段

    执行类构造器 方法,如果赋值运算是通过其他类的静态方法来完成的,那么会马上解析另外一个类,在虚拟机栈中执行完毕后通过返回值进行赋值

类加载是一个将.class字节码文件实例化成class对象并进行相关初始化的过程
在这个过程中,jvm会初始化继承树上还没有被初始化过的所有父类,并且会执行这个链路上所有未执行过的静态代码块、静态变量赋值语句等。
某些类在使用时,也可以按需由类加载器进行加载。

全小写的class是关键字,用来定义类
而首字母大写的class,它是所有class的类
这句话理解起来有难度,类已经是现实世界中某种事物的抽象,为什么这个抽象还是另外一个类class的对象?
示例代码如下:

● 第1处说明:
class类下的newinstance()在jdk9中已经置为过时,使用getdeclaredconstructor().newinstance()的方式
着重说明一下new与newinstance的区别

  • new是强类型校验,可以调用任何构造方法,在使用new操作的时候,这个类可以没有被加载过
  • 而class类下的newinstance是弱类型,只能调用无参构造方法
    • 如果没有默认构造方法,就拋出instantiationexception异常;
    • 如果此构造方法没有权限访问,则拋 illegalaccessexception异常

java 通过类加载器把类的实现与类的定义进行解耦,所以是实现面向接口编程、依赖倒置的必然选择。

● 第2处说明:
可以使用类似的方式获取其他声明,如注解、方法等

● 第3处说明: private 成员在类外是否可以修改?
通过setccessible(true),即可使用class类的set方法修改其值
如果没有这一步,则抛出如下异常:

类加载器

类加载器是如何定位具体的类文件并读取的呢?

在类加载器家族中存在着类似人类社会的权力等级制度

  • 最高层的bootstrap
    在jvm启动时创建的,通常由与操作系统相关的本地代码实现,是最根基的类加载器,负责装载最核心的java类,比如object、system、 string ,java运行时的rt.jar等jar包
  • jdk9的platform classloader
    负责加载\lib\ext目录中的,或者java.ext.dirs系统变量指定的路径中的所以类库;
    加载一些扩展的系统类,比如xml、加密、压缩相关的功能类等;
    jdk9之前是extension classloader.
  • 第三层 application classloader
    应用类加载器,主要是加载用户定义的classpath路径下的类

第二、三层类加载器为java语言实现,用户也可以自定义类加载器
查看本地类加载器的方式如下:

在jdk8环境中,执行结果如下

appclassloader的parent为bootstrap,它是通过c/c++实现的,并不存在于jvm体系内,所以输出为 null

低层次的当前类加载器,不能覆盖更高层次类加载器已经加载的类
如果低层次的类加载器想加载一个未知类,要非常礼貌地向上逐级询问:“请问,这个类已经加载了吗?”
被询问的高层次类加载器会自问两个问题

  • 我是否已加载过此类
  • 如果没有,是否可以加载此类

只有当所有高层次类加载器在两个问题的答案均为“否”时,才可以让当前类加载器加载这个未知类
左侧绿色箭头向上逐级询问是否已加载此类,直至bootstrap classloader,然后向下逐级尝试是否能够加载此类,如果都加载不了,则通知发起加载请求的当前类加载器,准予加载
在右侧的三个小标签里,列举了此层类加载器主要加载的代表性类库,事实上不止于此

通过如下代码可以查看bootstrap 所有已加载类库

执行结果

bootstrap加载的路径可以追加,不建议修改或删除原有加载路径
在jvm中增加如下启动参数,则能通过class.forname正常读取到指定类,说明此参数可以增加bootstrap的类加载路径:

-xbootclasspath/a:/users/sss/book/ easycoding/byjdk11/src

如果想在启动时观察加载了哪个jar包中的哪个类,可以增加

-xx:+traceclassloading

此参数在解决类冲突时非常实用,毕竟不同的jvm环境对于加载类的顺序并非是一致的
有时想观察特定类的加载上下文,由于加载的类数量众多,调试时很难捕捉到指定类的加载过程,这时可以使用条件断点功能
比如,想查看hashmap的加载过程,在loadclass处打个断点,并且在condition框内输入如图

jvm如何确立每个类在jvm的唯一性

类的全限定名和加载这个类的类加载器的id

在学习了类加载器的实现机制后,知道双亲委派模型并非强制模型,用户可以自定义类加载器,在什么情况下需要自定义类加载器呢?

  • 隔离加载类
    在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境
    比如,阿里内某容器框架通过自定义类加载器确保应用中依赖的jar包不会影响到中间件运行时使用的jar包
  • 修改类加载方式
    类的加载模型并非强制,除bootstrap外,其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需进行动态加载
  • 扩展加载源
    比如从数据库、网络,甚至是电视机机顶盒进行加载
  • 防止源码泄露
    java代码容易被编译和篡改,可以进行编译加密。那么类加载器也需要自定义,还原加密的字节码。

实现自定义类加载器的步骤

  • 继承classloader
  • 重写findclass()方法
  • 调用defineclass()方法

一个简单的类加载器实现的示例代码如下

由于中间件一般都有自己的依赖jar包,在同一个工程内引用多个框架时,往往被迫进行类的仲裁
按某种规则jar包的版本被统一指定, 导致某些类存在包路径、类名相同的情况,就会引起类冲突,导致应用程序出现异常
主流的容器类框架都会自定义类加载器,实现不同中间件之间的类隔离,有效避免了类冲突。

1 加载的定位

“加载”是“类加载”(class loading)过程的第一步

1.1 加载的过程

在加载的过程中,jvm主要做3件事情

  • 通过一个类的全限定名来获取定义此类的二进制字节流(class文件)
    在程序运行过程中,当要访问一个类时,若发现这个类尚未被加载,并满足类初始化的条件时,就根据要被初始化的这个类的全限定名找到该类的二进制字节流,开始加载过程
  • 将这个字节流的静态存储结构转化为方法区的运行时数据结构
  • 在内存中创建一个该类的java.lang.class对象,作为方法区该类的各种数据的访问入口

程序在运行中所有对该类的访问都通过这个类对象,也就是这个class对象是提供给外界访问该类的接口

1.2 加载源

jvm规范对于加载过程给予了较大的宽松度.一般二进制字节流都从已经编译好的本地class文件中读取,此外还可以从以下地方读取

  • zip包
    jar、war、ear等
  • 其它文件生成
    由jsp文件中生成对应的class类.
  • 数据库中
    将二进制字节流存储至数据库中,然后在加载时从数据库中读取.有些中间件会这么做,用来实现代码在集群间分发
  • 网络
    从网络中获取二进制字节流.典型就是applet.
  • 运行时计算生成
    动态代理技术,用proxygenerator.generateproxyclass为特定接口生成形式为"*$proxy"的代理类的二进制字节流.

    1.3 类和数组加载过程的区别

    数组也有类型,称为“数组类型”.如:

string[] str = new string[10];

这个数组的数组类型是ljava.lang.string,而string只是这个数组的元素类型

当程序在运行过程中遇到new关键字创建一个数组时,由jvm直接创建数组类,再由类加载器创建数组中的元素类型.

而普通类的加载由类加载器创建.既可以使用系统提供的引导类加载器,也可以由用户自定义的类加载器完成(即重写一个类加载器的loadclass()方法)

1.4 加载过程的注意点

  • jvm规范并未给出类在方法区中存放的数据结构
    类完成加载后,二进制字节流就以特定的数据结构存储在方法区中,但存储的数据结构是由虚拟机自己定义的,虚拟机规范并没有指定
  • jvm规范并没有指定class对象存放的位置
    在二进制字节流以特定格式存储在方法区后,jvm会创建一个java.lang.class类的对象,作为本类的外部访问接口
    既然是对象就应该存放在java堆中,不过jvm规范并没有给出限制,不同的虚拟机根据自己的需求存放这个对象
    hotspot将class对象存放在方法区.
  • 加载阶段和链接阶段是交叉的
    类加载的过程中每个步骤的开始顺序都有严格限制,但每个步骤的结束顺序没有限制.也就是说,类加载过程中,必须按照如下顺序开始:

    加载 -> 链接 -> 初始化

但结束顺序无所谓,因此由于每个步骤处理时间的长短不一就会导致有些步骤会出现交叉

2 验证

验证阶段比较耗时,它非常重要但不一定必要(因为对程序运行期没有影响),如果所运行的代码已经被反复使用和验证过,那么可以使用-xverify:none参数关闭,以缩短类加载时间

2.1 验证的目的

保证二进制字节流中的信息符合虚拟机规范,并没有安全问题

2.2 验证的必要性

虽然java语言是一门安全的语言,它能确保程序猿无法访问数组边界以外的内存、避免让一个对象转换成任意类型、避免跳转到不存在的代码行.也就是说,java语言的安全性是通过编译器来保证的.

但是我们知道,编译器和虚拟机是两个独立的东西,虚拟机只认二进制字节流,它不会管所获得的二进制字节流是哪来的,当然,如果是编译器给它的,那么就相对安全,但如果是从其它途径获得的,那么无法确保该二进制字节流是安全的。

通过上文可知,虚拟机规范中没有限制二进制字节流的来源,在字节码层面上,上述java代码无法做到的都是可以实现的,至少语义上是可以表达出来的,为了防止字节流中有安全问题,需要验证!

2.3 验证的过程

  • 文件格式验证
    验证字节流是否符合class文件格式的规范,并且能被当前的虚拟机处理.
    本验证阶段是基于二进制字节流进行的,只有通过本阶段验证,才被允许存到方法区
    后面的三个验证阶段都是基于方法区的存储结构进行,不会再直接操作字节流.

通过上文可知,加载开始前,二进制字节流还没进方法区,而加载完成后,二进制字节流已经存入方法区
而在文件格式验证前,二进制字节流尚未进入方法区,文件格式验证通过之后才进入方法区
也就是说,加载开始后,立即启动了文件格式验证,本阶段验证通过后,二进制字节流被转换成特定数据结构存储至方法区中,继而开始下阶段的验证和创建class对象等操作
这个过程印证了:加载和验证是交叉进行的

  • 元数据验证
    对字节码描述信息进行语义分析,确保符合java语法规范.
  • 字节码验证
    本阶段是验证过程的最复杂的一个阶段.
    本阶段对方法体进行语义分析,保证方法在运行时不会出现危害虚拟机的事件.
    字节码验证将对类的方法进行校验分析,保证被校验的方法在运行时不会做出危害虚拟机的事,一个类方法体的字节码没有通过字节码验证,那一定有问题,但若一个方法通过了验证,也不能说明它一定安全
  • 符号引用验证
    发生在jvm将符号引用转化为直接引用的时候,这个转化动作发生在解析阶段,对类自身以外的信息进行匹配校验,确保解析能正常执行.

    3 准备

    完成两件事情

  • 为已在方法区中的类的静态成员变量分配内存
  • 为静态成员变量设置初始值
    初始值为0、false、null等
public static final int value = 123;

准备阶段后 a 的值为 0,而不是 123,要在初始化之后才变为 123,但若被final修饰的常量如果有初始值,那么在编译阶段就会将初始值存入constantvalue属性中,在准备阶段就将constantvalue的值赋给该字段(此处将value赋为123).

4 解析

解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程.

5 初始化

真正开始执行类中定义的java程序代码(或者说是字节码)
初始化阶段就是执行类构造器clinit()的过程.

clinit()方法由编译器自动产生,收集类中static{}代码块中的类变量赋值语句和类中静态成员变量的赋值语句。在准备阶段,类中静态成员变量已经完成了默认初始化,而在初始化阶段,clinit()方法对静态成员变量进行显示初始化。

5.1 初始化过程的注意点

  • clinit()方法是ide自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,ide收集的顺序是由语句在源文件中出现的顺序所决定的.
  • 静态代码块只能访问到出现在静态代码块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问.
public class test {
    static {
        i=0;
        system.out.println(i);//编译失败:"非法向前引用"
    }
    static int i = 1;
}
  • 实例构造器init()需要显示调用父类构造函数,而类的clinit()不需要调用父类的类构造函数,虚拟机会确保子类的clinit()方法执行前已经执行完毕父类的clinit()方法.因此在jvm中第一个被执行的clinit()方法的类肯定是java.lang.object.
  • 如果一个类/接口中没有静态代码块,也没有静态成员变量的赋值操作,那么编译器就不会为此类生成clinit()方法.
  • 接口也需要通过clinit()方法为接口中定义的静态成员变量显示初始化。
  • 接口中不能使用静态代码块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成clinit()方法.不同的是,执行接口的clinit()方法不需要先执行父接口的clinit()方法.只有当父接口中的静态成员变量被使用到时才会执行父接口的clinit()方法.
  • 虚拟机会保证在多线程环境中一个类的clinit()方法别正确地加锁,同步.当多条线程同时去初始化一个类时,只会有一个线程去执行该类的clinit()方法,其它线程都被阻塞等待,直到活动线程执行clinit()方法完毕.

其他线程虽会被阻塞,只要有一个clinit()方法执行完,其它线程唤醒后不会再进入clinit()方法.同一个类加载器下,一个类型只会初始化一次.

参考

  • 《码到成功》

  • 《深入理解java虚拟机第二版》

《JVM实战---类加载的过程.doc》

下载本文的Word格式文档,以方便收藏与打印。