Java_类文件及加载机制
类文件及类加载机制
标签(空格分隔): Java
本篇博客的重点是分析JVM是如何进行类的加载的,但同时我们会捎带着说一下Class类文件结构,以便对类加载机制有更深的理解。
类文件结构
平台无关性
众所周知,Java是平台无关的语言,那么是如何实现平台无关的呢?
Java程序要在Java虚拟机(JVM)上运行,而JVM并不与包括Java在内的任何语言绑定,它只与"Class文件"这种特定的二进制文件格式所关联。
也就是说,任何语言,只要可以编译成符合某种特定规则的Class文件,都可以在JVM上运行。
Class类文件结构
Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件中,中间没有任何分隔符。
任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(比如类或接口也可以通过类加载器直接生成)。
魔数与Class文件的版本
每个Class文件的头4个字节成为魔数(Magic Number),它的唯一作用是确定这个文件是否是一个能够被JVM接收的Class文件。
Java的Class文件的头4个字节为:CAFEBABE
后面第4,5字节表示的是次版本号;6,7字节表示的是主版本号。
常量池
版本号之后就是常量池入口,它是Class文件结构中与其他项目关联最多的数据类型,主要存放字面量(Literal)和符号引用(Symbolic Reference)。
关于符号引用,作如下解释。Java编译共分两步:
javac Hello.java //将.java文件编译成.class文件
java Hello //JVM对.class文件进行加载解析
其中,javac编译时,并不会进行“连接”,而是在java时JVM加载class文件时才会进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此如果不进行运行期转换的话无法得到真正的内存入口。当JVM运行时,需要从常量池中获得对应的符号引用,然后在类创建或运行时解析、翻译到具体的内存地址中。
访问标志
常量池结束之后是两个字节的访问标志(access_flags),这个标志用于识别一些类或者接口层次的信息:这个CLass是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话是否被final修饰等信息。
类索引、父类索引与接口索引集合
类索引(this_class)用于确定这个类的全限定名;
父类索引(super_class)用于确定这个类的父类的全限定名;
接口索引(interfaces)用于描述这个类实现了哪些接口。
字段表集合
字段表(field_info)用于描述接口或类中声明的字段。包括类级变量(static)以及实例级变量(类内的字段),但不包括局部方法内部的局部变量。
方法表集合
包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)等。而具体的Java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里面,可扩展。
-
重写(Override):指子类重写父类的方法,方法表中会出现父类的方法信息;
-
重载(Overload):指一个类中有多个名称相同,但参数或返回值不同的方法。所以除了要与原方法具有相同的简单名称之外,还要求有一个与原方法不同的特征签名。BUT:返回值不会包含在特征签名中,所以Java中无法仅仅依靠返回值的不同来对一个已有方法进行重载。
public int add(int a, int b) {
return a + b;
}public double add(int a, int b) {
return (double) (a + b);
}
这两个方法在Java中是不同作为重载函数的!
属性表集合
- Code属性:Java程序方法体中的代码经过Javac编译器处理后,最终变为字节码存储在Code属性中;
- Exception属性:列举出方法中可能抛出的受查异常(Checked Exceptions),也就是方法描述时在throws关键字后面列举的异常;
- LineNumberTable属性:用于描述Java源码行号与字节码行号之间的对应关系。选择该项可以在程序抛出异常时,堆栈中会显示出错的而行号,也可以按照源码行号来设置断点;
- LocalVariableTable属性:用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系;
- ConstantValue属性:通知JVM自动为静态变量赋值。对于非static类型的变量的赋值是在实例构造器
方法中进行的;而对于类变量,可以在类构造器 方法中国或者使用ConstantValue属性进行赋值都可以。 - InnerClass属性:用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,则编译器会为它以及它所包含的内部类生成InnerClass属性。
字节码指令
JVM的指令由操作码(Opcode)以及操作数(Operands)构成。
操作码包括:加载和存储指令,运算指令,类型转换指令,对象创建与访问指令,操作数栈管理指令,方法调用和返回指令,异常处理指令,同步指令。
JVM描绘了JVM应有的共同程序存储格式:Class文件以及字节码指令集。它们与硬件、操作系统以及具体的JVM之间是完全独立的。
类加载机制
- JVM如何加载Class文件?
- Class文件中的信息进入到虚拟机后会发生什么变化?
简而言之,JVM把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(loading), 验证(verification), 准备(preparation), 解析(resolution), 初始化(initialization), 使用(using), 卸载(unloading).
加载(Loading)
JVM在此阶段需要完成:
- 通过一个类的全限定名来获取定义此类的二进制字节流(可以从.class文件,从网络中-Applet,动态代理,从JSP文件生成);
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
验证(Verification)
该阶段的目的是确保class文件的字节流中包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。直接决定了JVM能否承受恶意代码的攻击。
准备(Preparation)
准备阶段正式为类变量分配内存并设置类变量(不包含实例变量)初始值,这些变量所使用的内存都将在方法区中进行分配。
public static int value = 123;
注意:类变量value的值在准备阶段后的初始值为0而不是123,把value赋值为123的动作在初始化阶段才会被执行。
解析(Resolution)
解析阶段是JVM将常量池内的符号引用替换为直接引用的过程。
需要进行类或接口的解析,字段解析,方法解析。
类或接口的解析
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,则JVM会完成3个步骤:
- 如果C不是一个数组类型,JVM会把代表N的全限定名传递给D的类加载器去加载这个类;
- 如果C是一个数组类型,则符号引用N会是形如"[Ljava/lang/Integer"的形式,则需要加载的元素类型就是"java.lang.Integer",然后JVm会生成一个代表数组维度和元素的数组对象;
- 如果上述步骤都成功,则C在JVM中已经成为一个有效的类或接口了。最后再检查D是否具备对C的访问权限,如果不具备,将会抛出"java.lang.IllegalAccessError"异常。
字段解析
要解析一个未被解析过得字段符号引用,则首先会解析字段所属的类或接口的符号引用,设为C,然后JVM会从这个类开始逐级往父类或接口找与这个字段相匹配的字段:
- 如果该类C本省就包含了名称与描述符都匹配的字段,则返回这个字段的直接引用,over;
- 否则,如果C中实现了接口,则按照继承关系从下往上搜索各个接口和它的父接口,寻找与之相匹配的字段,找到则over;
- 否则,如果C不是java.lang.Object的话,会按照继承关系从下往上搜索其父类,搜索到则over;
- 否则,查找失败,抛出"java.lang.NoSuchFieldError"异常。
根据它的搜索规则,我们会有这样的疑问:如果有一个呕吐那个名字打UN同时出现在C的接口和父类中,怎么办?
编译器会拒绝编译。
类方法解析
其中,方法解析分为类方法解析与接口方法解析,两者略有不同。
对于类方法解析,同字段解析一样,首先要解析出该类方法所属的类或接口的符号引用,假设解析为C:
- 如果在类方法表中发现索引C是个接口,则直接抛出java.lang.IncompatibleClassChangeError异常;
- 在类C中查找是否含有简单名称和描述符都与目标相匹配的方法,有则返回这个方法的直接引用,over;
- 否则,在类C的父类中递归查找是否有简单名称和描述符都匹配的方法,如果有则返回这个方法的直接引用,over;
- 否则,在类C实现的接口列表和它们的父接口中递归查找,如果存在,就说明类C是一个抽象类,查找结束,抛出"java.lang.AbsrtactMethodError"异常;
- 否则,查找失败,抛出"java.lang.IllegalAccessError"异常。
接口方法解析
接口方法也要首先解析出该方法所属的类或接口,假设为C:
- 如果在接口方法表发现索引C是一个类,则抛出"java.lang.IncompatibleClassChangeError"异常;
- 否则,在接口C中查找是否有简单名称和描述符相匹配的犯法,有则返回这个方法的直接引用,over;
- 否则,在接口C的父接口中递归查找,知道java.lang.Object类,看是否有匹配的方法,有则返回直接引用,over;
- 否则,查找失败,抛出"java.lang.NoSuchMethodError".
初始化(Initialization)
初始化时类加载过程的最后一步,在这个阶段,才真正开始执行类中定义的Java程序代码。
在Preparation阶段,已经给类变量有过一次初始的赋0或null的设置,是执行类构造器clinit()方法的过程。
这里稍微列一下clinit()方法的特点:
- clinit()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。收集器的收集顺序由语句在源文件中出现的顺序决定,静态语句块后面的语句可以进行赋值,但是访问不到(不能print,不能使用)。
- 它并不是必需的。如果一个类中红既没有赋值操作,有没有静态语句块,可以没有该方法;
- 虚拟机会保证父类的clinit()方法在子类的执行前执行。所以第一个被执行的一定是java.lang.Object;父类中定义的static语句块要由于子类的变量赋值操作;
- 虚拟机会保证一个类的clinit()方法子啊多线程环境下被正确的加锁、同步,如果多个线程同时初始化一个类,那么只有一个线程会执行这个类的clinit()方法。
类加载器
通过一个类的全限定名来获取此类的二进制字节流 ---- 类加载器。而这个动作在JVM之外实现。
类的唯一性:类加载器 和 类本身 一同决定。也就是说,如果两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,则这两个类就不相等(equal, isInstance()方法等);
双亲委派模型
从Java虚拟机的角度看,只有两种不同的类加载器:
- 启动类加载器(Bootstrap ClassLoader):用C++实现,是虚拟机自身的一部分;
- 所有其他的类加载器:用Java语言实现,独立于虚拟机外部,都继承自抽象类java.lang.ClassLoader;
从Java开发人员看,类加载器可分为3种:
- 启动类加载器(Bootstrap ClassLoader):负责加载<\JAVA——HOME>\lib目录中的并且可以被虚拟机识别的;
- 扩展类加载器(Extension ClassLoader):负责加载<\JAVA_HOME>\lib\ext目录中的所有类库,开发者可以直接使用扩展类加载器;
- 应用程序类加载器(Application ClassLoader):它是ClassLoader中的getSystemClassLoader()方法的返回值,所以也称它为系统类加载器。他负责加载用户类路径(ClassPath)上所指定的类库。
我们的应用程序都是由着3种类加载器互相配合进行加载的,他们的关系如图所示。而这种层次关系称为类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型中,类加载器之间的父子关系一般不会以继承(Inheritance)而是以组合(Composition)的关系来复用父类加载器。
双亲委派模型的工作过程:如果一个雷加载器收到了类加载的请求。它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每个层次的加载器都是如此。所以所有的加载请求都会传送到顶层的启动类加载器中加载,只有当父类加载器表示自己无法加载该类时,才会由子加载器尝试加载。
这样做的优势也很直观:因为我们前面有提到如果两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,则这两个类就不相等。为了避免同一个类被识别成不同类,所以应该使用双亲委派模型。
总结:本篇博客首先分析了class文件的基本组成结构,然后分析了一个类加载的全过程(loading, verification, preparation,resolution, initialization),最后分析了几种不同的类加载器以及重要的双亲委派模型。