深入理解Java虚拟机(3)-聊一聊Class文件结构和类加载机制

670 阅读18分钟

秉持着没图没真相、开篇必放图的原则,(Class文件结构都是定义的内容比较枯燥)我们先聊一聊类加载,再聊Class文件结构。我们按照生命周期图分部介绍。

一、类加载

1.加载

加载阶段是整个类加载过程的一个阶段,主要完成以下三件事:

  • 通过一个类的权限定名获取定义此类的二进制字节流。
  • 将整个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表整个类的java.lang.Class对象,作为方法区整个类的各种数据访问入口。 《Java虚拟机规范》中对于这三点的要求并不是特别决堤,留着给虚拟机实现与java应用的灵活度特别大。例如"通过一个类的权限定名获取定义此类的二进制字节流" 可以从Zip压缩包获取,网络获取,运行时计算生成,数据库中读取等。

对于数组类而言,本身不通过类加载器创建,它是由java虚拟机直接在内存中动态构造出来的,但数组类中元素类型(Element Type,指的是去掉所有维度的类型)还是要靠加载器来完成加载

2.验证

这一阶段目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,包含以下四个方面:

  • 文件格式的验证 : 第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。

    • 是否以魔术0xCAFEBABE开头
    • 主次版本号是否在当前java虚拟机的接受范围内。
    • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
    • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
    • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据。
    • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息
    • ....
  • 元数据验证 : 第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《java语言规范》的要求。

    • 这个类是否有父类(除了java.lang.Object之外, 所有的类都应当有父类)。
    • 这个类的父类是否继承了不允许被继承的类(被final修饰的类) 。
    • 如果这个类不是抽象类, 是否实现了其父类或接口之中要求实现的所有方法。
    • 类中的字段、 方法是否与父类产生矛盾(例如覆盖了父类的final字段, 或者出现不符合规则的方法重载, 例如方法参数都一致, 但返回值类型却不同等) 。
    • ....
  • 字节码验证 : 第三阶段是整个验证过程中最复杂的一个阶段, 主要目的是通过数据流分析和控制流分析, 确定程序语义是合法的、 符合逻辑的。 在第二阶段对元数据信息中的数据类型校验完毕以后, 这阶段就要对类的方法体(Class文件中的Code属性) 进行校验分析, 保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。

    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作, 例如不会出现类似于“在操作栈放置了一个int类型的数据, 使用时却按long类型来加载入本地变量表中”这样的情况。
    • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
    • 保证方法体中的类型转换总是有效的, 例如可以把一个子类对象赋值给父类数据类型, 这是安全的, 但是把父类对象赋值给子类数据类型, 甚至把对象赋值给与它毫无继承关系、 完全不相干的一个数据类型, 则是危险和不合法的。
    • ....
  • 符号引用验证 : 第四阶段校验该类是否缺少或者被禁止访问它依赖的某些外部类、 方法、 字段等资源。

    • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
    • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
    • 符号引用中的类、 字段、 方法的可访问性(private、 protected、 public、 ) 是否可被当前类访问。

3.准备

准备阶段是正式为类中定义的变量(即静态变量, 被static修饰的变量) 分配内存并设置类变量初 始值的阶段, 从概念上讲, 这些变量所使用的内存都应当在方法区中进行分配, 但必须注意到方法区 本身是一个逻辑上的区域, 在JDK 7及之前, HotSpot使用永久代来实现方法区时, 实现是完全符合这 种逻辑概念的; 而在JDK 8及之后, 类变量则会随着Class对象一起存放在Java堆中, 这时候“类变量在 方法区”就完全是一种对逻辑概念的表述了。

关于准备阶段, 还有两个容易产生混淆的概念笔者需要着重强调, 首先是这时候进行内存分配的 仅包括类变量, 而不包括实例变量, 实例变量将会在对象实例化时随着对象一起分配在Java堆中。 其 次是这里所说的初始值“通常情况”下是数据类型的零值。

假设一个类变量的定义为:public static int value = 123; 那变量value在准备阶段过后的初始值为0而不是123, 因为这时尚未开始执行任何Java方法, 而把 value赋值为123的putstatic指令是程序被编译后, 存放于类构造器()方法之中, 所以把value赋值 为123的动作要到类的初始化阶段才会被执行。

4.解析

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

  • 符号引用(Symbolic References) : 符号引用以一组符号来描述所引用的目标, 符号可以是任何形式的字面量, 只要使用时能无歧义地定位到目标即可。 符号引用与虚拟机实现的内存布局无关, 引用的目标并不一定是已经加载到虚拟机内存当中的内容。 各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的, 因为符号引用的字面量形式明确定义在《Java虚拟机规范》 的Class文件格式中。
  • 直接引用(Direct References) : 直接引用是可以直接指向目标的指针、 相对偏移量或者是一个能间接定位到目标的句柄。 直接引用是和虚拟机实现的内存布局直接相关的, 同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。 如果有了直接引用, 那引用的目标必定已经在虚拟机的内存中存在。 包含以下内容解析:
  • 1.类或间接接口的解析
  • 2.字段解析
  • 3.方法解析
  • 4.接口方法解析

5.初始化

《Java虚拟机规范》严格定义了有且仅有六种情况必须立即对类进行初始化:

1.遇到new、 getstatic、 putstatic或invokestatic这四条字节码指令时, 如果类型没有进行过初始化, 则需要先触发其初始化阶段。 能够生成这四条指令的典型Java代码场景有:

  • 使用new关键字实例化对象的时候。
  • 读取或设置一个类型的静态字段(被final修饰、 已在编译期把结果放入常量池的静态字段除外)的时候。
  • 调用一个类型的静态方法的时候。

2.使用java.lang.reflect包的方法对类型进行反射调用的时候, 如果类型没有进行过初始化, 则需要先触发其初始化。

3.当初始化类的时候, 如果发现其父类还没有进行过初始化, 则需要先触发其父类的初始化。

4.当虚拟机启动时, 用户需要指定一个要执行的主类( 包含main()方法的那个类) , 虚拟机会先 初始化这个主类。

5.当使用JDK 7新加入的动态语言支持时, 如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、 REF_putStatic、 REF_invokeStatic、 REF_newInvokeSpecial四种类型的方法句柄, 并且这个方法句柄对应的类没有进行过初始化, 则需要先触发其初始化。

6.当一个接口中定义了JDK 8新加入的默认方法( 被default关键字修饰的接口方法) 时, 如果有这个接口的实现类发生了初始化, 那该接口要在其之前被初始化。

直到初始化阶段,java虚拟机才真正开始执行类中编写java程序代码,将主导权移交给应用程序。进行准备阶段时, 变量已经赋过一次系统要求的初始零值, 而在初始化阶段, 则会根据程序员通过程序制定的主观计划去初始化类变量和其他资源。

6.类与类加载器

类加载器虽然只用于实现类的加载动作, 但它在Java程序中起到的作用却远超类加载阶段。 对于 任意一个类, 都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性, 每 一个类加载器, 都拥有一个独立的类名称空间。 这句话可以表达得更通俗一些: 比较两个类是否“相 等”, 只有在这两个类是由同一个类加载器加载的前提下才有意义, 否则, 即使这两个类来源于同一个 Class文件, 被同一个Java虚拟机加载, 只要加载它们的类加载器不同, 那这两个类就必定不相等。 这里所指的“相等”, 包括代表类的Class对象的equals()方法、 isAssignableFrom()方法、 isInstance() 方法的返回结果, 也包括了使用instanceof关键字做对象所属关系判定等各种情况。

7.双亲委派模型、三层类加载架构

(1)三层类加载器: 包含启动类加载器、扩展类加载器、应用程序类加载器三层。

  • 启动类加载器 启动类加载器(Bootstrap Class Loader) : 这个类加载器负责加载存放在 <JAVA_HOME>\lib目录, 或者被-Xbootclasspath参数所指定的路径中存放的, 而且是Java虚拟机能够 识别的(按照文件名识别, 如rt.jar、 tools.jar, 名字不符合的类库即使放在lib目录中也不会被加载) 类 库加载到虚拟机的内存中。 启动类加载器无法被Java程序直接引用, 用户在编写自定义类加载器时, 如果需要把加载请求委派给引导类加载器去处理, 那直接使用null代替即可。

  • 扩展类加载器 扩展类加载器(Extension Class Loader) : 这个类加载器是在类sun.misc.Launcher$ExtClassLoader 中以Java代码的形式实现的。 它负责加载<JAVA_HOME>\lib\ext目录中, 或者被java.ext.dirs系统变量所 指定的路径中所有的类库。 根据“扩展类加载器”这个名称, 就可以推断出这是一种Java系统类库的扩 展机制, JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能, 在JDK 9之后, 这种扩展机制被模块化带来的天然的扩展能力所取代。 由于扩展类加载器是由Java代码实现 的, 开发者可以直接在程序中使用扩展类加载器来加载Class文件.

  • 应用程序类加载器 应用程序类加载器(Application Class Loader) : 这个类加载器由 sun.misc.Launcher$AppClassLoader来实现。 由于应用程序类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值, 所以有些场合中也称它为“系统类加载器”。 它负责加载用户类路径 (ClassPath) 上所有的类库, 开发者同样可以直接在代码中使用这个类加载器。 如果应用程序中没有 自定义过自己的类加载器, 一般情况下这个就是程序中默认的类加载器。

(2)双亲委派模型

  • 工作流程: 如果一个类加载器收到了类加载的请求, 它首先不会自己去尝试加 载这个类, 而是把这个请求委派给父类加载器去完成, 每一个层次的类加载器都是如此, 因此所有的 加载请求最终都应该传送到最顶层的启动类加载器中, 只有当父加载器反馈自己无法完成这个加载请 求(它的搜索范围中没有找到所需的类) 时, 子加载器才会尝试自己去完成加载。

  • 模型优势 Java中的类随着它的类 加载器一起具备了一种带有优先级的层次关系。 例如类java.lang.Object, 它存放在rt.jar之中, 无论哪一 个类加载器要加载这个类, 最终都是委派给处于模型最顶端的启动类加载器进行加载, 因此Object类 在程序的各种类加载器环境中都能够保证是同一个类。 反之, 如果没有使用双亲委派模型, 都由各个 类加载器自行去加载的话, 如果用户自己也编写了一个名为java.lang.Object的类, 并放在程序的 ClassPath中, 那系统中就会出现多个不同的Object类, Java类型体系中最基础的行为也就无从保证, 应 用程序将会变得一片混乱。 如果读者有兴趣的话, 可以尝试去写一个与rt.jar类库中已有类重名的Java 类, 将会发现它可以正常编译, 但永远无法被加载运行。

模型实现: 实现方法loadClass

先检查请求加载的类型是否已经被加载过, 若没有则调用父加载器的 loadClass()方法, 若父加载器为空则默认使用启动类加载器作为父加载器。 假如父类加载器加载失败, 抛出ClassNotFoundException异常的话, 才调用自己的findClass()方法尝试进行加载。

8.破坏双亲委派模型

双亲委派模型:自定义类加载器->应用程序类加载器->扩展类加载器->启动类加载器 而破坏双亲委派模型即打破这个顺序或变更环节。

  • 为什么要破坏双亲委派模型呢? 举个破坏的例子:JDBC破坏双亲委派模型,为什么要这么做呢? 在JDBC 4.0之后实际上我们不需要再调用Class.forName来加载驱动程序了,我们只需要把驱动的jar包放到工程的类加载路径里,那么驱动就会被自动加载。 这个自动加载采用的技术叫做SPI,数据库驱动厂商也都做了更新。可以看一下jar包里面的META-INF/services目录,里面有一个java.sql.Driver的文件,文件里面包含了驱动的全路径名。 使用上,我们只需要通过下面一句就可以创建数据库的连接:使用上,我们只需要通过下面一句就可以创建数据库的连接:
Connection con =  DriverManager.getConnection(url , username , password ) ;   

因为类加载器受到加载范围的限制,在某些情况下父类加载器无法加载到需要的文件,这时候就需要委托子类加载器去加载class文件。

JDBC的Driver接口定义在JDK中,其实现由各个数据库的服务商来提供,比如MySQL驱动包。DriverManager 类中要加载各个实现了Driver接口的类,然后进行管理,但是DriverManager位于 JAVA_HOME中jre/lib/rt.jar 包,由BootStrap类加载器加载,而其Driver接口的实现类是位于服务商提供的 Jar 包,根据类加载机制,当被装载的类引用了另外一个类的时候,虚拟机就会使用装载第一个类的类装载器装载被引用的类。也就是说BootStrap类加载器还要去加载jar包中的Driver接口的实现类。我们知道,BootStrap类加载器默认只负责加载 JAVA_HOME中jre/lib/rt.jar 里所有的class,所以需要由子类加载器去加载Driver实现,这就破坏了双亲委派模型。

(二)、Class文件结构

图2-1

1.魔数: 0~3 u4

字节码开始的四个字节0xCAFEBABE(咖啡宝贝): 每个class文件固定。

2.次版本号: 4~5 u2

3.主版本号: 6~7 u2

jdk1.1 java的版本号是45开始d的,大版本主版本号+1。如jdk8 45+8-1=52 高版本可以向下兼容以前的Class文件,不可逆。

4.常量池 u2+

8~9 放容量计数: 如图2-1 0x0016十进制22,索引值0空出来预留,所以可以存储21个常量。 主要存放两类常量: 字面量和符号引用。

  • 字面量存储:如文本字符串,被声明final的常量值。
  • 符号引用存储如下:
    • 被模块导出或者开放的包(Package)
    • 类和接口的全限定名(Fully Qualified Name)
    • 字段的名称和描述符(Descriptor)
    • 方法的名称和描述符
    • 方法句柄和方法类型(Method Handle、 Method Type、 Invoke Dynamic)
    • 动态调用点和动态常量(Dynamically-Computed Call Site、 Dynamically-Computed Constant)

图2-2

10开始就没有固定的索引了。我们按照图读一个常量值。常量类型u1,A为07,对应表的类型为CONSTANT_Class_info,而CONSTANT_Class_info 的结构如下: u2为name_index 我们在读两个字节BC : 0x0002,也就是指向了常量池中的第二项常量。

5.访问标志: u2

在常量池结束之后, 紧接着的2个字节代表访问标志(access_flags) , 这个标志用于识别一些类或 者接口层次的访问信息, 包括: 这个Class是类还是接口; 是否定义为public类型; 是否定义为abstract 类型; 如果是类的话, 是否被声明为final; 多个标志: 使用或运算 如0x0001|0x0020=0x0021。

6.类索引、 父类索引与接口索引集合 (u4)+(u2+)

类索引(this_class) 和父类索引(super_class) 都是一个u2类型的数据, 而接口索引集合 (interfaces) 是一组u2类型的数据的集合, Class文件中由这三项数据来确定该类型的继承关系。 类索 引用于确定这个类的全限定名, 父类索引用于确定这个类的父类的全限定名。 由于Java语言不允许多 重继承, 所以父类索引只有一个, 除了java.lang.Object之外, 所有的Java类都有父类, 因此除了 java.lang.Object外, 所有Java类的父类索引都不为0。 接口索引集合就用来描述这个类实现了哪些接 口, 这些被实现的接口将按implements关键字(如果这个Class文件表示的是一个接口, 则应当是 extends关键字) 后的接口顺序从左到右排列在接口索引集合中。

7.字段表集合

字段表(field_info) 用于描述接口或者类中声明的变量。 Java语言中的“字段”(Field) 包括类级变 量以及实例级变量, 但不包括在方法内部声明的局部变量。 读者可以回忆一下在Java语言中描述一个 字段可以包含哪些信息。 字段可以包括的修饰符有字段的作用域(public、 private、 protected修饰 符) 、 是实例变量还是类变量(static修饰符) 、 可变性(final) 、 并发可见性(volatile修饰符, 是否 强制从主内存读写) 、 可否被序列化(transient修饰符) 、 字段数据类型(基本类型、 对象、 数组) 、 字段名称。 上述这些信息中, 各个修饰符都是布尔值, 要么有某个修饰符, 要么没有, 很适合使用标 志位来表示。 而字段叫做什么名字、 字段被定义为什么数据类型, 这些都是无法固定的, 只能引用常 量池中的常量来描述。

8.方法表集合

结构方法表的结构如同字段表一样。 因为volatile关键字和transient关键字不能修饰方法, 所以方法表的访问标志中没有了 ACC_VOLATILE标志和ACC_TRANSIENT标志。 与之相对, synchronized、 native、 strictfp和abstract 关键字可以修饰方法, 方法表的访问标志中也相应地增加了ACC_SYNCHRONIZED、 ACC_NATIVE、 ACC_STRICTFP和ACC_ABSTRACT标志。

9.属性表集合

Class文件、 字段表、 方法表都可以携带自己的属性表集合, 以描述某些场景专有的信息。

熊猫笔记邮箱: panda_nodes@163.com