类文件的基础知识
这一块知识之前,是要了解一些关于类文件的基础知识。
-
无关性的基石——字节码
虚拟机实现了平台无关性,这已经很熟悉了,而实现无关性的基石就是不同平台虚拟机都是用统一的程序存储格式——字节码(ByteCode),而无关性还有一层概念是——语言无关性,因为虚拟机只认字节码,所以有可能通过编译器将源程序编译成字节码的语言,都有在JVM上运行的可能性。字节码文件也就是我们常说的Class文件,.java文件通过javac编译成.class文件,然后JVM执行它。而书上介绍的还有其他各种语言都有相应的编译器可以把它们编译成.class文件,都可以在JVM上面运行,虚拟机只关心存储字节码的class文件,而不关心它是从哪里来的。
-
class文件简介
class文件是一组以8位字节为基础单位的二进制流,它之中包含了Java虚拟机指令集和符号表以及若干其它辅助性喜,有相关的强制性语法和结构化约束,语言的各种变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成的。
类加载
类加载是指虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可被虚拟机直接使用的Java类型的过程。
Java语言中,类型的加载、链接和初始化过程都是在程序运行期间完成的。
-
类加载的过程概述
类从被加载到虚拟机内存中开始、到卸载出内存为止,整个生命周期包括7阶段:
- 加载
- 验证
- 准备
- 解析
- 初始化
- 使用
-
卸载
验证准备解析三部分又统称为连接
类的生命周期
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,按顺序开始。但是解析这个阶段在某些情况下,为了支持Java语言的运行时绑定,可以在初始化阶段之后再开始。
注意这里的按顺序开始的意义,他们并不是按顺序一个完成了下一个才开始,通常情况下是混合进行的,只是按顺序开始,后者并不用等待前者进行完再开始。
下面开始详细的过程简介。 -
加载
加载是类加载过程的第一个阶段.
- 通过类的全限定名来获取定义此类的二进制字节流。这个二进制字节流不一定是来自Class文件,也可以如从ZIP包读取、从网络中获取、通过运行时计算生成、由其他文件生成、从数据库中读取等等途径......
- 将该二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构,该数据存储数据结构由虚拟机实现自行定义。
- 在内存中生成一个代表这个类的java.lang.Class对象,它将作为程序访问方法区中的这些类型数据的外部接口。
对Java语言来说就是将class文件字节码内容加载到内存中,并将这些静态数据(这个类的静态变量、静态方法、常量池以及类的代码等等)转换成方法区中的运行时数据结构,接着同时生成一个代表这个类的java.lang.Class对象(就是反射的那个对象,我们的反射技术就是利用这个类才进入方法区的,这个对象也没有指定是在Java堆中,例如HotSpot虚拟机的Class对象是在方法区里面)作为方法区数据的访问入口。这个过程需要类加载器参与。
类加载器的内容后面会介绍。
加载阶段可能尚未完成,连接阶段就开始了!
-
验证
验证阶段为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
那么,什么情况会危害JVM的安全呢?Java语言比较安全,没有c++里面那些指针什么的,而且如果访问数组边界以外的数据、非法转型等等操作,编译器都会拒绝编译。但是我们一开始就说了Class文件来源是任何途径的,甚至可能直接编写。。那上面这些Java语言做不到的事情都是有可能在语义上表达出来,让它发生的。JVM如果不检查输入的字节流,那就有可能导致系统崩溃。
所以验证阶段十分重要!验证阶段包含四个阶段:
-
文件格式验证:
- 内容:验证字节流是否符合Class文件格式的规范,以及是否能被当前版本的虚拟机处理等等。
- 验证项:常量池的常量中是否有不被支持的常量类型、指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量、主次版本号是否在当前JVM处理范围之内、Class文件中各个部分及文件本身是否有被删除的或附加的其他信息等等。
- 目的:保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。
-
元数据验证:
- 内容:对字节码描述的信息进行语义分析,保证其描述信息符合Java语言规范的要求。
- 验证项:这个类是否有父类(除了Object其它都有父类)、这个类是否继承了不允许被继承的类、如果这个类是抽象类,是否实现了父类或者接口中要求实现的所有方法、类中的字段、方法是否与父类产生矛盾等等。
- 目的:对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。
-
字节码验证:
- 内容;对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
- 目的:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
-
符号引用验证
- 最后一个阶段的验证发生在JVM将符号引用转化为直接引用的时候,这个转化发生在解析阶段,
- 验证项:符号引用中通过字符串描述的全限定名能否找到对应的类、指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段、符号引用中的类、字段、方法的访问性是否可以被当前类访问等等。
- 目的:确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类。
-
准备
准备阶段正式为类变量分配内存并设置类变量初始值。这些变量所使用的内存都将在方法区进行分配。
- 为类变量分配内存:因为这里的变量是由方法区分配内存的,所以仅包括类变量而不包括实例变量,后者将会在对象实例化时随着对象一起分配在Java堆中。
- 设置类变量初始值:通常情况下零值。
注意!是为类变量分配内存和为类变量设置初始值,即static变量!不包括实例变量!实例变量不在方法区里,在Java堆里面。
例如:
public static int value = 123;
public int book;
在准备阶段过后value初始值是0而不是123,赋值123的动作在初始化阶段才完成!而这个book甚至不会被分配内存,因为准备阶段无关实例变量!book是一个实例变量。
不同类型的变量有不同的零值。除了常规意义上的表数字的零值都是“0”以外,boolean是false,reference是null,char是‘\u0000’。
-
解析
解析阶段是JVM将常量池内的符号引用替换为直接引用的过程。
符号引用和直接引用都是类文件中的内容,都是个啥。。初学挺难解释的,这里有一个很强的回答可以在姿势水平够之后进行参考
JVM里的符号引用如何存储? - RednaxelaFX的回答 - 知乎
https://www.zhihu.com/question/30300585/answer/51335493
下面引用这个问题的回答下面一个用比较通俗解释符号引用和直接引用的答案。
-
符号引用就是字符串,存在class文件中的常量池,包括类和接口的全限定名、字段的名称和描述符以及方法的名称和描述符。这个字符串包含足够的信息,以供实际使用时可以找到相应的位置。你比如说某个方法的符号引用,如:“java/io/PrintStream.println:(Ljava/lang/String;)V”。里面有类的信息,方法名,方法参数等信息。当第一次运行时,要根据字符串的内容,到该类的方法表中搜索这个方法。运行一次之后,符号引用会被替换为直接引用,下次就不用搜索了。符号引用的目标不一定已经加载到内存中。
-
直接引用就是指向目标的指针、相对偏移量或者一个能够间接定位到目标的句柄,通过直接引用虚拟机可以直接在该类的内存区域中找到方法字节码的起始位置。直接引用的目标一定在内存中存在
-
发生时间:
JVM会根据需要来判断,是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析。 -
解析动作:
解析动作主要针对7种符号引用(括号中为对应的class文件常量池中的常量类型):
1.类或接口(CONSTANT_Class_info)
2.字段(CONSTANT_Fieldref_info)
3.类方法(CONSTANT_Methodref_info)
4.接口方法(CONSTANT_InterfaceMethodref_info)
5.方法类型(CONSTANT_MethodType_info)
6.方法句柄(CONSTANT_MethodHandle_info)
7.调用点限定符(CONSTANT_InvokeDynamic_info)
介绍前四种。
1.类或接口的解析
设当前代码所处的为类D,把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,解析过程分三步: -
若C不是数组类型:JVM将会把代表N的全限定名传递给D类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作。一旦这个加载过程出现了任何异常,解析过程就宣告失败。
-
若C是数组类型且数组元素类型为对象:也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,JVM也会按照上述规则加载数组元素类型。需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表次数组维度和元素的数组对象。
-
若上述步骤无任何异常:此时C在JVM中已成为一个有效的类或接口,但在解析完成前还需进行符号引用验证,来确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。
2.字段解析
对字段进行解析时,会先在去解析字段所属的类或接口,解析成功后,在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束还没有查找到,就抛出异常。
如果成功返回引用,将会对字段进行权限验证,如果发现不具备对该字段的访问权限,就会抛出异常。
3.类方法解析
对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口(因此在父类和接口方法名冲突时候,父类方法优先级高!)。 最后同样的验证权限。
4.接口方法解析
与类方法解析步骤类似,只是接口不会有父类,因此,只递归向上搜索父接口就行了。最后依然要验证访问权限。
-
初始化
是类加载过程的最后一步,会开始真正执行类中定义的Java字节码。而之前的类加载过程中,除了在『加载』阶段用户应用程序可通过自定义类加载器参与之外,其余阶段均由虚拟机主导和控制。
我们在准备阶段就提到了初始化,准备阶段是给类变量分配空间赋初始零值。而初始化阶段则会根据Java程序的设定去初始化类变量和其他资源。
它是执行类构造器<clinit>()方法的过程。
<clinit>():由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}中的语句合并产生。编译器收集顺序是根据语句在源文件中的出现顺序来决定的。静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。
public class Test{
static{
i=0; //这一句赋值可以正常编译通过
System.out.println(i); //这一句访问会提示“非法向前引用”
}
static int i = 1;
注意以下几点:
-
父先子后:虚拟机保证子类的<clinit>()方法执行之前,父类的<clinit>()已经执行完毕。
-
非必须:如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为此类生成<clinit>()方法。
-接口和类不同点:接口中不能有static语句块,但是接口中定义的变量都是public static,所以也会有<clinit>()方法,不同点在于此处不需要先执行父接口的<clinit>(),只有父接口中定义的变量使用时候,才会初始化父接口。而接口的实现类在初始化的时候,也不一定会执行<clinit>()方法,只有接口中定义的变量用到时候,才会执行。 -
线程安全:虚拟机保证<clinit>()方法在多线程环境中被正确加锁、同步。
-
初始化的时机及需要注意的时机:
1.类的主动引用(一定会发生类的初始化)- new一个类的对象
- 调用类的静态成员(除了final常量,因为final常量在常量池中)和静态方法
- 使用java.lang.reflect包的方法对类进行反射调用
- 当虚拟机启动,java Hello,则一定会初始化Hello类。说白了就是先启动main方法所在的类
- 当初始化一个类,如果其父类没有被初始化,则先会初始化他的父类。
2.类的被动引用(不会发生类的初始化)
- 当访问一个静态域时,只有真正声明这个域的类才会被初始化
- 通过子类引用父类的静态变量,不会导致子类初始化
- 通过数组定义类引用,不会触发此类的初始化
- 引用常量(static final)不会触发此类的初始化(常量在编译阶段就存入调用类的常量池中了)
最后检验一下学会了没。。看这个分析!
一个有趣的题目
网友评论