第六章 类文件结构
6.1 意义
代码编译的结果从本地机器码转变为字节码,冲破了平台界限。
6.2 无关性的基石
实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联。
6.3 Class类文件的结构
-
任何一个Class文件都对应着唯一的一个类或接口的定义信息,但是反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)
-
Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符
-
Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”(以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数)和“表”(多个无符号数或者其他表作为数据项构成的)
-
以上的顺序和数量是固定的
6.3.1 魔数与Class文件的版本
- 每个Class文件的头4个字节被称为魔数,值为0xCAFEBABE
- 紧接着魔数的4个字节存储的是Class文件的版本号,第5和第6个字节是次版本号,第7和第8个字节是主版本号
6.3.2 常量池
- 紧接着主、次版本号之后的是常量池入口,常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值,从1开始。
- 常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)
- 读取时查看即可
6.3.3 访问标志
在常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息
第七章 虚拟机类加载机制
7.1 意义
Class文件中的各类信息需要加载到虚拟机上才能被使用,Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程就是虚拟机的类加载机制。Java是先编译后执行的语言,类型的加载、连接和初始化在运行期间执行,虽然类加载时会有一些开销,但是也让Java应用具有极高扩展性和灵活性,这个特性就是依赖于动态连接和动态加载的特点实现的。
7.2 类加载的时机
一个类型的生命周期:
-
其中除了解析(某些情况会在初始化之后开始)和使用,其他五个阶段开始的顺序是确定的,但不代表完成一个阶段才会进入下一个阶段,通常都是交叉混合进行的。
-
类的初始化通常是在第一次主动使用该类的时候才会发生,必须对类初始化的时机,有且只有以下六种情况:
- 遇到
new
、getstatic
、putstatic
或invokestatic
这四条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化阶段。典型Java代码场景:- 使用new实例化对象时
- 读取或设置一个类的静态字段(被final修饰、编译期就已经放入常量池的除外)
- 调用一个类的静态方法时
- 反射调用时
- 初始化时发现父类没初始化,就要先初始化父类
- 虚拟机启动会初始化主类
- 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
- 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化
- 遇到
-
接口的初始化与类差不多,区别只是接口初始化时不需要初始化父接口,当使用父接口时才初始化。
7.3 类加载过程
类加载的过程是一个类生命周期的前五项,也就是,加载、验证、准备、解析、初始化。
7.3.1 加载
-
加载阶段时,Java虚拟机要干三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
这个阶段灵活性很大,使用者除了可以使用虚拟机内置的类加载器完成,还可以自定义这三件事的具体实现方式
-
数组类有些不同,是由Java虚拟机直接在内存中动态构造出来的。但是数组包含的元素还是要靠类加载器完成。显然,数组类的可访问性与组件类型的可访问性相同,具体实现过程:
- 如果数组的组件类型(去掉一个维度)是引用类型,就递归这个加载过程,这个数组将被标识在加载该组件类型的类加载器的类名称空间上
- 如果数组的组件类型不是引用类型,Java虚拟机将会把这个数组标记为与引导类加载器关联
7.3.2 验证
-
验证是连接的第一步,是为了确保Class文件的字节流中的信息是安全的。
-
验证会完成以下四个阶段,后三个阶段直接基于方法区上进行,不会再直接读取、操作字节流:
- 文件格式验证:要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,也就是保证输入的字节流能正确地解析并存储于方法区之内。
- 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求
- 字节码验证:通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。也就是不会做出危害虚拟机安全的行为。但是不能保证绝对的安全,因为存在“停机问题”。JDK6之后增加“StackMapTable”把尽可能多的校验辅助措施挪到Javac编译器里进行。
- 符号引用验证:在解析阶段发生,检查该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源,确保解析行为正常执行。
-
虽然重要,却不是必须执行,因为一些有使用经验的安全的类,可以省去验证,缩短加载时间。
7.3.3 准备
为静态变量分配内存并设置初始值的过程。注意区别与初始化的赋值是不同的
7.3.4 解析
-
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,首先要明白什么是符号引用和直接引用:
- 符号引用是指在代码中使用名称(变量名、类名、方法名等符号)来引用某个实体,而不是直接使用内存地址或其他直接引用方式。符号引用主要存在于源代码和编译过程的中间表示中
- 直接引用是指在代码中直接使用内存地址或其他底层实现方式来引用某个实体。直接引用通常是在运行时使用的,特别是在编译后的代码中
-
并未规定具体时间,可以根据需求自行决定,类被加载器加载时就对常量池中的符号引用解析,还是等到他被使用前才解析。
-
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,分别对应于常量池的
CONSTANT_Class_info
、CON-STANT_Fieldref_info
、CONSTANT_Methodref_info
、CONSTANT_InterfaceMethodref_info
、CONSTANT_MethodType_info
、CONSTANT_MethodHandle_info
、CONSTANT_Dyna-mic_info
和CONSTANT_InvokeDynamic_info
8种常量类型-
类或接口的解析:假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,这里主要是为了区分代码所处的类和引用的类,包含三个步骤:
- 首先判断C是不是数组类型,如果不是,虚拟机将会把代表N的全限定名传给D的类加载器去加载这个类C,这个过程可能会触发其他类的加载,比如父类或实现的接口
- 如果C是数组类型,且元素类型为对象(N的描述符会是类似“[Ljava/lang/Integer”)就会按照第一点的规则加载元素类。
- 解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限
-
字段解析:前提字段所属的类或接口的符号引用解析完成。四个步骤:
- 查找C本身是否包含匹配的字段
- 没查到的情况下,如果C实现了接口,就递归查找接口以及父接口中是否包含匹配的字段
- 还没有的话,递归查找父类。
- 否则,查找失败,抛出java.lang.NoSuchFieldError异常
查找成功的话,进行权限验证
-
类的方法解析:与字段解析类似,前提方法所属的类或接口的符号引用解析完成,五个步骤:
- 如果C是接口,直接抛出异常
- 如果是类,则在本类中查找
- 没有的话,递归在父类中查找
- 还没有的话,则在接口和父接口中递归查找,如果匹配到了,说明这是一个抽象类,抛出java.lang.AbstractMethodError异常
- 否则,查找失败,抛出异常
-
接口方法解析:前提接口所属的类或接口的符号引用解析完成,五个步骤:
- 如果发现C是类,直接抛出异常
- 如果是接口,在本接口中查找
- 没有的话,在父接口递归查找,直到java.lang.Object类(接口方法的查找范围也会包括Object类中的方法)为止。
- 由于接口存在多重继承,如果存在多个匹配的,返回其中一个
- 否则,查找失败,抛出异常
7.3.5 初始化
-
-
根据程序员的代码来初始化类变量,也就是执行类构造器()方法的过程
-
这个方法不由程序员编写,他会按照代码编写时的顺序自动收集类中给变量赋值的动作和静态语句块(静态语句块不可以访问定位在他之后的静态变量)。并且自动调用父类的()方法,但不会自动调用父接口的()方法
-
需要加锁同步,防止多个线程去初始化一个类。
7.4 类加载器
类加载器的作用:通过一个类的全限定名来获取描述该类的二进制字节流
7.4.1 类与类加载器
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。也就是判断两个类相等时,必须来自同一个类加载器才有可能相等。
7.4.2 双亲委派模型
Java一直保持着三层类加载器、双亲委派的类加载架构:
-
启动类加载器也叫引导类加载器:使用C++实现,负责加载存放在<JAVA_HOME>\lib目录的类库,无法被Java程序引用。
-
扩展类加载器:以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库
-
应用程序类加载器:由sun.misc.Launcher$AppClassLoader来实现,默认的系统类加载器。
-
双亲委派模型
-
工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,所以一定会经过顶层类加载器,只有当父类加载器无法加载时,子类才尝试自己加载。集中在java.lang.ClassLoader的loadClass()方法之中
7.4.4 破坏双亲委派模型
破坏的意义在于创新,主要出现过三次:
- 引入双亲委派模型时,为了兼容已有代码,添加一个新的protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,因为父类加载失败时,会自动调用自己的findClass()方法来完成加载,所以不会影响。
- 基础类型又要调用回用户的代码的情况,JDK提供了java.util.ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式完成父类加载器去请求子类加载器完成类加载的行为
- 热部署的需求:OSGi, 它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。由树状双亲委派模型变成了网状,出现了平级的类查找。
7.5 Java模块化系统
-
模块化的目标——可配置的封装隔离机制
-
解决JDK 9之前基于类路径(ClassPath)来查找依赖的可靠性问题。模块内可以声明对其他模块的显示依赖,这样启动时就可以避免一些类型依赖导致的运行时才会出现的问题
7.5.1 模块的兼容性
提出了模块路径的概念,只关注于存放的路径,简单来说,就是某个类库到底是模块还是传统的JAR包,只取决于它存放在哪种路径上。只要是放在类路径上的JAR文件,无论其中是否包含模块化信息(是否包含了module-info.class文件),它都会被当作传统的JAR包来对待;相应地,只要放在模块路径上的JAR文件,即使没有使用JMOD后缀,甚至说其中并不包含module-info.class文件,它也仍然会被当作一个模块来对待。
7.5.2 模块化下的类加载器
-
扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。
-
平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader,启动类加载器、平台类加载器、应用程序类加载器全都继承于jdk.internal.loader.BuiltinClassLoader
-
类加载的委派关系也发生了变动。当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载