JVM拾遗(3): 类装载机制

上一篇我们了解了class文件结构, 那么JVM如何使用编译好的class二进制文件?

简言之: JVM会读取.class文件并加载和初始化到方法区, 之后才能被后续程序使用.
同时该过程还需要满足一些要求:

  • 加载的class文件不能影响虚拟机的稳定性, 也就是class文件要正确合法
    • java的rt.jar里定义的类,如java.lang.Object, 不能被替换
    • class文件里的各个符号引用要合法
  • 允许运行时添加功能, 如各种分析工具包(arthas)等

JVM是解释执行字节码的,不像c/c++那样编译的时候静态链接完了, 是在运行时期动态链接的。

1. 类装载器(ClassLoader)

1.1 加载(Load)

Loading is the process of finding the binary representation of a class or interface type with a particular name and creating a class or interface from that binary representation

这是一段jvm8规范, 表示装载的过程就是通过一个特定的名字(类全名)来找到并获取描述此类的二进制表示的过程, 装载器(ClassLoader)用来完成装载过程.

虚拟机规范并没有规定从哪里装载,所以可以从网络上, zip包里, class文件里等各个地方装载class文件.

1.2 双亲委派模型

JVM划分了3个层次的ClassLoader, 分别是:

  1. 启动类加载器BootstrapClassLoader: 加载JDK提供的各个类,也就是rt.jar里的各个类, 启动类加载器无法被Java程序直接使用
  2. 扩展类加载器ExtensionClassLoader: 加载${JavaHome}/lib/ext下的类库, 可以被开发者使用
  3. 应用类加载器ApplicationClassLoader: 这个是开发者用的最多的, 它复杂加载ClassPath上指定的类库, 如果应用程序没有指定类加载器,默认就是用这个类加载器加载的。

类加载器之间是有父子关系的,上面的类加载器的关系是:

这种层次关系称为双亲委派模型,如果一个ClassLoader收到了装载类的请求,它的工作过程是:

  1. 先交给父ClassLoader加载, 如果父在加载器加载完成,直接返回class
  2. 如果父ClassLoader不能加载,则自己负责加载

所以无论哪个ClassLoader加载java.lang.Object, 得到的都是BootstrapClassLoader加载出来的.

用户如果自定义ClassLoader, 继承的都是java.lang.ClassLoader,
据说最初ClassLoader设计出来是为了满足JavaApplet的需求, 由于笔者没有类似经验,就留给读者自己研究了.

违背双亲委派模型

  1. 双亲委派模型自JDK1.2以后才出现,而java.lang.ClassLoader在1.0就已经出现,所以JDK不得不兼容新加了一个findClass()方法, 1.2以后一定不推荐直接写loadClass方法, 而是推荐用findClass,这样可以保证新写出来的类加载器是符合双亲委派机制的
  2. 由于子类加载器会先将类交给父类加载器加载,解决了基础类的统一问题。如果基础类要调用用户类的代码,就无法作为。JDK为了满足这个需求(如在JNDI中),引入了Thread Context ClassLoader, 这个类加载器可以通过setContextClassLoader进行设置, JDBC用这个类加载器去加载SPI代码, 实际上违背了双亲委派机制.
  3. OSGi模块化,导致用户自定义的类加载器是网状结构

1.3 自定义ClassLoader

自定义ClassLoader只需要继承java.lang.ClassLoader并且重写findClass或者loadClass方法就行了, 后面还会讲.

一个类由类名和类加载器唯一标识, 每个类加载器都有自己的独立的命名空间.

及时两个类来自同一个class文件,被同一个JVM装载,只要加载它们的ClassLoader不一样,则这两个类不相等, 可以在同一个JVM里共存.

2. 类装载过程

根据jvm8规范,类被类装载器装载到虚拟机内存,到卸载出内存,中间要经历以下步骤:

  1. 创建和加载(Creation And Loading)
  2. 链接(Linking)
  3. 初始化(Initialization)
  4. 绑定本地方法实现(Binding Native Method Implementations)

2.1 创建和加载

这个阶段,虚拟机要完成3个事情:

  1. 通过一个类的全限定名(Full Qualified Name)来获取定义此类的二进制流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

以上规则不对数组类适用, 因为数组类本身不通过类加载器创建,而是通过JVM直接创建.数组类满足以下规则:

  • 如果数组的元素类型(Component Type, 也就是数组去掉一个维度的类型)是引用类型,递归加载这个类型,并且数组将在加载这个元素类型的类名称空间上被标识
  • 如果数据的元素类型不是引用类型, 如int[], JVM会交给BootstrapClassLoader
  • 数组类的可见性和它的元素类型一致

2.2 链接

链接阶段又包括验证Verification,准备Preparation, 解析Resolution, 访问控制Access Control, 覆盖Overriding等步骤

2.2.1 验证

这一阶段的目的是确保class文件的字节流中包含的信息符合JVM的规范,并且不会危害JVM自身的安全.

一般通过javac编译出来的class文件,都不会有任何问题,但是class文件可以通过各种途径生成, 比如从网络里读取,裸写二进制到文件等

验证阶段可能导致额外的类和接口被装载,验证的过程需要满足

  1. class文件格式验证: 魔数的正确性(0xCAFEBABE), 主次版本号满足要求,常量约束等
  2. 元数据验证: 对字节码描述的信息进行语义分析,比如这个类是否有父类,父类是否存在,是否继承了不该被继承的类(final**, 是否实现了要求实现的抽象方法等
  3. 字节码验证: 保证指令集是JVM规范里的,保证指令集的语义合法, 类型转换是有效的等
  4. 符号引用验证: 符号引用描述的类全限定名能不能找到对应的类, 引用的字段能不能访问等等

上一节讲到class文件结构的属性表里有一个StackMapTable属性, 描述了方法体的所有基本块,只要检查这些状态的合法性就能判断字节码是否正确,能加速JVM的验证过程.
在1.6的HotSopt里能通过-XX:UseSplitVerifier来关闭这个优化

验证失败会抛出VerifyError, 终止类加载过程, 虚拟机退出

2.2.2 准备

准备阶段是正式为类变量分配内存,并且设置类变量的初始值.

分配的内存在方法区,也就是说类变量都在方法区里存着

2.2.3 解析

解析阶段JVM会将常量池内的符号引用替换为直接引用.直接引用一般就是一个直接指向目标的指针,相对偏移量或者间接定位到目标的句柄.

对同一个符号引用的解析,JVM会进行缓存,避免解析动作重复进行.除了invokedynamic指令,这个指令本身就是动态解析,只有程序运行到这的时候,解析动作才能进行.

解析动作主要针对类或接口,字段,类方法,接口方法,方法类型,句柄,调用点这7类符号引用。

2.2.4 初始化

类的初始化是最后一步,虚拟机规范里的访问控制和覆盖在前面几步已经交叉进行了.

到了初始化阶段才真正开始执行Java程序代码, 会执行类的初始化(不是调用类的构造函数)

<cinit>: 类初始化,会搜集所有的static代码块合并并执行, 执行本类的<cinit>之前会先执行父类的. 如果类没有static代码,编译器可以不生成<cinit>, cinit是线程安全的.一个类型只会初始化一次.

2.3 绑定本地方法实现

这个过程是用来绑定c这样的native实现的, 虚拟机也没规定说要到最后再绑定.

3. 小结

本次主要讲了JVM如何装载一个类, JVM是通过Classloader来装载类, ClassLoader通过双亲委派机制工作,并且每次装载都会经历加载,链接,解析这样的过程, 加载阶段会将二进制类表示在方法区, 链接阶段会验证class的合法性,并且初始化类变量, 但是不赋值(如int初始化为0).初始化阶段会触发一系列的类变量初始化工作和静态代码块执行工作, 这个阶段才真正开始执行java程序代码.

回过头看开头提出的需求,Java的动态性, 如热部署,动态编译JSP, 运行时增强类功能等, 通过类加载器动态加载类都可以实现:)