我们都知道,各种不同平台的虚拟机,都支持 “字节码 Byte Code” 这种程序存储格式,这构成了 Java 平台无关性的基石。甚至现在平台无关性也开始演变出 “语言无关性” ,就是其他语言也可以运行在 Java 虚拟机之上,比如现在的 Kotlin、Scala 等。
实现语言无关性的基础仍然是虚拟机和字节码存储格式,Java 虚拟机步包括 Java 语言在内的任何语言绑定,他只和 “Class 文件” 这种特定的二进制文件格式所关联,Class 文件中包含了 Java 虚拟机指令集、符号表以及其他若干辅助信息。
Java 的各种语法、关键字、常量变量和运算符号的语义最终都会由多条字节码指令组合来表达,这决定了字节码指令所能提供的语言描述能力必须比 Java 语言本身更强大才行。
jvm 提供的语言无关性如下图所示:
Java 技术能够一直保持着非常良好的向后兼容性,Class文件结构的稳定功不可没。JDK1.2 时代的 Java 虚拟机中就定义好的 Class 文件格式的各项细节,到今天几乎没有出现任何改变。Class文件格式进行了几次更新,但基本上只是在原有结构基础上新增内容、扩充功能。
Class 文件格式采用一种类似 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”。后面的解析都要以这两种数据类型为基础。
Class 文件格式的数据项如下所示:
这里面可以看到,一个 Class 文件的有些数据项是固定的 数量 × 长度,有些则不是。如果一个类型的数据数量不定,会采用多一个数据项来实现,一个前置的数据项作为容量计数器,后面连续的数据项,而数量就是前面的容量计数器的值,这时候这一系列连续的某一类型的数据称为某一类型的 “集合”。
比如上面的这个,从字面意思也看得出来,因为常量池本身就是很多常量复合组成的,数量就会先用一个 u2 类型的数据项来表示,也就是我们刚说过的容量计数器,然后接着这个常量池集合本身就有了数量。
这么严格要求的原因是,Class 文件没有任何分隔符,所以整个 Class 文件的格式,顺序、数量这样的细节,都是严格限定的,全都不允许改变。
接下来,我们来看各个数据项的含义。总共分为 7 项,按照上面的那张图的颜色框划分也很容易看出来,并且表示的信息也是见名知意的。
Class 文件的魔数是 0xCAFEBABE,咖啡宝贝。是因为 java 开发小组最初的关键成员觉得他象征著名咖啡品牌最受欢迎的咖啡,似乎对 java 的商标也有预示
通常常量池是占用 Class 文件空间最大的数据项之一。
分为两个部分,一个2字节的数据代表常量池容量计数值;下面是常量池的内容,可以看到这里使用容量的大小用的是 constan_pool_count-1 ,因为常量池的容量计数是 1 开始,而不是 0,比如这个 constan_pool_count 值翻译成十进制是 22,那么代表常量池有 21 项常量。
除了常量池,剩下的数据项表示都是从 0 开始计数的。
常量池中存放两大类常量:字面量和符号引用,具体含义和分类很复杂,这里不介绍了。
2 个字节,用于识别一些类或者接口层次的访问信息,包括 “这个 Class 是类还是接口” ,“是否定义为 public 类型”;“是否定义为 abstract 类型”,”如果是的话,是否被声明为final“。
2 个字节总共有 16 个标志位,目前只定义了 9 个,没有使用的标志位一律置为 0。
Class 文件中由这三项数据来确定该类的继承关系,显然因为 java 是单继承,却可以实现多个接口,所以有了 super_class 是一个 u2 的数据,而 interfaces 则需要一个 interfaces_count 。
类索引+父类索引这两项的值,就指向的是一个 类描述符常量,通过这个索引值就能找到对应的类。
描述类或接口中定义的变量,java 语言的 Field 包括:
但是不包括在方法内部声明的局部变量。
因为 field_info 本身也是一个表,具体的这里就不说明。
和字段表集合类似。
但是放发表的结构有一个特点,就是里面并没有方法体里的代码,方法体的代码在下一个属性表里。
Class 文件,字段表,方法表,三个集合内部都可以嵌套携带属性表集合。
具体属性表的格式之类的,也是很复杂,这里不赘述。
Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字以及跟随其后的零至多个代表此操作所需的参数构成。
由于Java虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。
在Java虚拟机的指令集中,大多数指令都包含其操作所对应的数据类型信息。
举个例子,iload指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据。这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在 Class文件中它们必须拥有各自独立的操作码。
对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:i 代表对 int 类型的数据操作, l 代表 long,s 代表 short,b 代表 byte,c 代表 char,f 代表 float,d 代表 double,a 代表 reference。
字节码指令可以分为:
加载和存储指令:讲数据在栈帧中的局部变量表和操作数栈之间来回传输;
运算指令:对两个操作数栈上的值进行运算,并把结果重新存入操作数栈顶;
类型转换指令:将不同数值类型相互转换;
对象创建与访问指令;
操作数栈管理指令:直接操作操作数栈的指令,出栈入栈等;
控制转移指令:让jvm从指定位置的下一条指令继续执行程序,可以认为是在修改PC寄存器的值;
方法调用和返回指令;
异常处理指令;
同步指令:Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程( Monitor,更常见的是直接将它称为“锁”) 来实现的。
定义:
Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称作虚拟机的类加载机制。
从定义里就可以看出来,java 和哪些编译时要进行连接的语言不同,java 的类型的加载、连接、初始化都是程序运行期间完成的,这给 java 应用提供了极高的扩展性,java 的可动态扩展的语言特性就是依赖于运行期动态加载和动态连接这个特点实现的。
例如,编写一个面向接口的程序,可以等到运行时再指定其实际的实现类,用户可以通过 java 预置或自定义类加载器,让某个本地应用程序在运行时从网络或者其他地方加载一个二进制流作为其程序代码的一部分。
(后面说的类加载的“类”,实际上可能是接口或者类)
如上图所示,一个类从被加载到虚拟机的内存中开始,到卸载出内存为止,生命周期分为 7 个阶段:
其中验证、准备、解析三个阶段可以合起来称为连接。
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。
请注意“开始”,而不是按部就班地“进行“,或按部就班地“完成”,强调这点是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。
关于什么时候需要开始类加载过程的第一个阶段“加载”,虚拟机规范没有强制约束,可以交给虚拟机的具体实现。但是初始化阶段,严格规定了有且只有 6 种情况必须立即对类进行初始化(这就意味着,加载验证准备都必须在此之前开始):
上面的六种场景中的行为,叫做对一个类型进行主动引用。除了这六种外的引用类型的方式都不会触发初始化,被称为被动引用。
接下来看详细过程。
注意啊,“加载”只是整个“类加载”中的一个阶段。
加载阶段,虚拟机主要做三件事:
其中,第一点的来源可以是各种各样,zip包,网络中,也可以利用动态代理技术在运行时计算生成。
第二点是要用到类加载器的,相对于五个阶段的其他阶段:
第三点就是如上面所说。
验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
为什么要验证?
结合上一个步骤,就是因为 Class 文件不一定就是 java 源码编译来的,可能是各种途径,甚至是自己手敲的 01 码,所以有必要验证字节码。
一般验证的内容分为四个:
验证阶段很重要,却不一定必须执行,因为通过了验证阶段,后面对程序执行就没有影响了,如果程序反复被验证和使用过就可以用参数关闭大部分的类验证措施:
-Xverify: none
准备阶段是正式为类中定义的变量(即静态变量,被 static 修饰的变量)分配内存、并设置类变量初始值的阶段。
从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但方法区本身是一个逻辑上的区域。
在上一篇,jvm 的内存结构里多次强调。在 JDK7及之前, HotSpot 使用永久代来实现方法区,所以还可以勉强把方法区这个概念保留;而在 JDK8 及之后,永久代也没有了,所以类变量随着 Class 对象一起存放在 Java 堆中,这时候 “类变量在方法区” 就有点牵强。
注意:
比如:
public static int value = 123;
经过这里的准备阶段,初始值 value 是 0,因为这个时候任何 java 方法都没有执行,初始化的指令是 putstatic ,这个指令是在类构造器的
所以 value 变成 123 是在类的初始化阶段才会执行的,就是 2.3.5 。
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程。
前面的 Class 文件格式部分提过一次,那解析阶段中所说的直接引用与符号引用又有什么关联呢?
解析动作主要针对 7 类符号引用进行转换:
类的初始化阶段是类加载过程的最后一个步骤。
之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java 虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。
2.3.3 的准备阶段,已经给变量赋过值了,是初始 0 值,而初始化阶段,会根据代码初始化类变量和其他资源,另一种更直接的形式来表达这个过程:
初始化阶段就是执行类构造器的
Java虚拟机设计团队有意把类加载阶段中的:
“通过一个类的全限定名来获取描述该类的二进制字节流”
这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。(就是上面讲的类加载过程的第一个步骤)
实现这个动作的代码被称为 “类加载器” ( Class loader)。
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。
对于任意一个类,都必须由加载它的类加载器、和这个类本身一起共同确立其在Java虚拟机中的唯一性,每个类加载器,都拥有一个独立的类名称空间。
这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里的相等,包括比较对象的 equals() 方法,isInstance() 方法、isAssignableFrom() 等的返回结果,以及 instanceof 关键字的判定结果。
站在Java虚拟机的角度来看,只存在两种不同的类加载器:
站在Java开发人员的角度来看,类加载器就应当划分得更细致一些。自 JDK12 以来,Java 一直保着三层类加载器、双亲委派的类加载架构。
注意:下面提及的源码目录在JDK9之后,因为模块化的改变,所以按照这些目录大概率自己的 jdk 文件里找不到的。
前面已经介绍过,这个类加载器负责加载存放在
启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用 null 代替即可。
这个类加载器是在类 sun.misc.Launcher$ExtClassLoader 中以 Java 代码的形式实现的。
它负责加载
根据 “扩展类加载器” 这个名称,就可以推断出这是一种 Java 系统类库的扩展机制,JDK 的开发团队允许用户将具有通用性的类库放置在 ext 目录里以扩展 JavaSe 的功能,在JDK9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由 Java 代码实现的,开发者可以直接在程序中使用扩展类加载器来加载 Class 文件。
这个类加载器由 sun.misc.LaunchersappClassloader 来实现。由于这个类加载器是 Classloader 类中的 getSystemClassloader() 方法的返回值,所以有些场合中也称它为“系统类加载器”。
它负责加载用户类路径 (ClassPath) 上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
除了这三种外,如果用户有必要,还可以自定义来进行扩展:
上面的图画出来的关系,就被称为类加载器的 “双亲委派模型( Parents DelegationModel)”。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以类继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。
双亲委派模型的工作过程:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。
因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求 (它的搜索范围中没有找到所需的类) 时,子加载器才会尝试自己去完成加载。
使用双亲委派模型来组织加载器之间的关系,一个显而易见的好处就是:java 类随着类加载器就具有了一种层级关系,比如 Object 类,不论哪个类加载器加载他,都会委派给模型最顶端的启动类加载器纪念性加载,因此 Object 类在各种类加载器环境里都能保证是同一个类,这样 java 整个体系的最基础行为就得到了保证。
双亲委派模型的实现,可以在 java.lang.ClassLoader 的 loadClass() 方法里看到: