jvm高级特性

内存区域

Java虛拟机在执行Java程序的过程中会把它所管理的内存划分为若千个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一-直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java虛 拟机所管理的内存将会包括以下几个运行时数据区域:
在这里插入图片描述

虚拟机栈

Java虛拟机栈(Java Virtual M achine Stack)也是线程私有的,它的生命周期与线程相同。虛拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虛 拟机都会同步创建-一个栈帧( Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虛拟机栈中从入栈到出栈的过程。
经常有人把Java内存区域笼统地划分为堆内存(Heap) 和栈内存(Stack) ,这种划分方式直接继承自传统的C、C++程序的内存布局结构,在Java语 言里就显得有些粗糙了,实际的内存区域划分要比这更复杂。不过这种划分方式的流行也间接说明了程序员最关注的、与对象内存分配关系最密切的区域是“堆”和“栈”两块。其中,“堆”在稍后笔者会专门讲述,而“栈”通常就是指这里讲的虚拟机栈,或者更多的情况下只是指虛拟机栈中局部变量表部分。
局部变量表 存放了编译期可知的各种Java虛拟机基本数据类型(boolean、 byte、 char、 short、 int、float、long、 double) 、对象引用(reference类型), 它并不等同于对象本身,可能是-一个指向对象起始地址的引用指针,也可能是指向-一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一-条字节码指令的地址)。
这些数据类型在局部变量表中的存储空间以局部变量槽(Slot) 来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入-一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。请读者注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照1个变量槽占用32个比特、64个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。

本地方法栈

本地方法栈(Native M ethod Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
《Java虛拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为–。与虚拟机栈–样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfM emoryError异常。

java堆

对于Java应用程序来说,Java堆 (Java Heap)是虚拟机所管理的内存中最大的一块。 Java堆是被所有线程共享的一-块内存区域,在虚拟机启动时创建。此内存区域的唯一目 的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。在《Java虛拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配”,而这里笔者写的“几乎”是指从实现角度来看,随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换[2)优化手段已经导致一- 些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。
Java堆是垃圾收集器管理的内存区域,因此- - 些资料中它也被称作“GC堆”(Garbage Collected Heap,幸好国内没翻译成“垃圾堆”)。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常 会出现“新生代”“老年代*永久代”“Eden空间"“From Survivor空间“To Survivor空间"等名词,这些概念在本书后续章节中还会反复登场亮相,在这里笔者想先说明的是这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚 拟机规范》里对Java堆的进一一步细致划分。不少资料上经常写着类似于“Java虚拟机的堆内存分为新生代、老年代、永久代、Eden、rurv…这样的内容。在十年之前(以G1收集器的出现为分界),作为业界绝对主流的HotSpot虚拟机,它内部的垃圾收集器全部都基于“经典分代》(3]来设计,需要新生代、老年代收集器搭配才能工作,在这种背景下,上述说法还算是不会产生太大歧义。但是到了今天,垃圾收集器技术与十年前已不可同日而语,HotSpot 里面也出现了不采用分代设计的新垃圾收集器,再按照上面的提法就有很多需要商榷的地方了。

根据《Java虛 拟机规范》的规定,Java堆 可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。
Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虛拟机都是按照可扩展来实现的(通过参数-Xmx和_Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再.扩展时,Java虛拟机将会抛出OutOfM emoryError异常。

方法区

方法区(Method Area)与Java堆一 样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一-个逻辑部分,但是它却有-一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。

《Java虛拟机规范》对方法区的约束是非常宽松的,除了和Java堆一一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,–般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。
根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfM emory Error异常。

运行时常量池

运行时常量池( Runtime Constant Pool)是方法区的一-部分。Class文 件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
Java虛拟机对于Class文件每一部分(自然也包括常量池)的格式都有严格规定,如每一 一个字 节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池,《Java虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域,不过一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于Class文件常量池的另外-一个重要特征是具备动态性,Java语言并不要求常量-定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfM emory Error异常。

直接内存

直接内存(Direct Memory)并不是虛拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfM emoryError异常出现,所以我们放到这里- -起讲解。
在JDK 1.4中新加入了NIO (New Input/Output)类,引入了- -种基于通道(Channel) 与缓冲区(Buffer)的IO方式,它可以使用Native函数库直接分配堆外内存,然后通过–个存储在Java堆里面的DirectBy teBuffer对象作为这块内存的引用进行操作。这样能在- -些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,- - 般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfM emory Error异常。

垃圾收集器与内存分配

判断对象是否已死

在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第- - 件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)了。

引用计数模型

判断对象是否存活的算法是这样的:在对象中添加一一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减- -;任何时刻计数器为零的对象就是不可能再被使用的。笔者面试过很多应届生和一些有多年工作经验的开发人员,他们对于这个问题给子的都是这个答案。
客观地说,引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。也有- -些比较著名的应用案例,例如微软COM (Component Object Model)技术、使用ActionScript 3的FlashPlayer、Python语言以及在游戏脚本领域得到许多应用的Squirel中都使用了引用计数算法进行内存管理。但是,在Java领域,至少主流的Java虛 拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题

可达性分析算法

当前主流的商用程序语言(Java、 C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析( Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GCRoots到这个对象不可达时,则证明此对象是不可能再被使用的。

在这里插入图片描述

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
.在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
.在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
.在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
.在本地方法栈中JNI (即通常所说的Native方法)引用的对象。
Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如
NullPointExcep iton、OutOfM emoryError)等,还有系统类加载器。
.所有被同步锁(sy nchronized关键字)持有的对象。
.反映Java虛拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

垃圾回收

分代收集理论

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collction) 的理 论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
1)弱分代假说(Weak Generational Hypothesis) :绝大多数对象都是朝生夕灭的。
2)强分代假说(Strong Generational Hypothesis) :熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虛拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某- -个或者某些部分的区域因而才有了“M inor GC" Major GC Full GC"这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法一因 而发展出了“标记复制算法”“标记-清除算法”标记-整理算法”等针对性的垃圾收集算法。
把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代( Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。如果读者有兴趣阅读HotSpot虚拟机源码的话,会发现里面存在着一-些 名为“*Generation"”的实现,如“DefNewGeneration"和“ParNewGeneration”等,这些就是HotSpot的“分代式垃圾收集器框架”。原本HotSpoti鼓励开发者尽量在这个框架内开发新的垃圾收集器,但除了最早期的两组四款收集器之外,后来的开发者并没有继续遵循。导致此事的原因有很多,最根本的是分代收集理论仍在不断发展之中,如何实现也有许多细节可以改进,被既定的代码框架约束反而不便。其实我们只要仔细思考- -下,也很容易发现分代收集并非只是简单划分一下内存区域那么容易,它至少存在-一个明显的困难:对象不是孤立的,对象之间会存在跨代引用

假如要现在进行一次只局限于新生代区域内的收集(MinorGC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是–样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
3)跨代引用假说(Intergenerat ional Reference Hypothesis) :跨代引用相对于同代引用来说仅占极少数。

这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立-一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存 会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加–些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

.部分收集(PartialGC) :指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
■新生代收集(Minor GC/YoungGC) :指目标只是新生代的垃圾收集。
■老年代收集(Major GC/OldGC) :指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“M ajor GC"这个说法现在有点混淆,在不同资料上常有不同所指,.读者需按上下文区分到底是指老年代的收集还是整堆收集。
■混合收集 (Mixed GC) :指目标是收集 整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
.整堆收集(FullGC) :收集整个Java堆和方法区的垃圾收集。

标记-清除算法

最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,在1960年由Lisp之父John McCarthy所提出。如它的名字-样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,这在前面讲述垃圾对象标记判定算法时其实已经介绍过了。
之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另–次垃圾收集动作。

标记-复制算法

标记复制算法常被简称为复制算法。为了解决标记清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出 了一种称为“半区复制”( Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了, 就将还存活着的对象复制到另外-块上面,然后再把已使用过的内存空间一-次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,IBM公司曾有- -项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释一新生 代中的对象有98%熬不过第一轮收集。 因此并不需要按照1 : 1的比例来划分新生代的内存空间。
在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了- .种更优化的半区复制分代策略,现在称为“Appel式回收”。HotSpot虛 拟机的Serial、ParNew 等新生代收集器均采用了这种策略来设计新生代的内存布局。Appel式回收的 具体做法是把新生代分为- -块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor.发生垃圾搜集时,将Eden和Survivor中仍然存活的对象- - 次性复制到另外- - 块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot 虛拟机默认Eden和Survivor的大小比例是8 : 1,也即每次新生代中可用内存空间为整个新生代容量的90% ( Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足 以容纳一次 Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保( Handle Promotion)。
内存的分配担保好比我们去银行借款,如果我们信誉很好,在98%的情况 下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证.如果我不能还款时,可以从他的账户扣钱,那银行就认为没有什么风险了。内存的分配担保也-样,如果另外- -块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一-般不能直接选用这种算法。
针对老年代对象的存亡特征,1974年 Edward Lueders提出了另外- -种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除"算法一一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一-端移动,然后直接清理掉边界以外的内存。
标记-清除算法与标记-整理算法的本质差异在于前者是一一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:

如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World。
但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表"来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。此语境中,吞吐量的实质是赋值器(Mutator, 可以理解为使用垃圾收集的用户程序,本文为便于理解,多数地方用“用户程序”或“用户线程”代替)与收集器的效率总和。即使不移动对象会使得收集器的效率提升- -些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。HotSpot虛 拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,这也从侧面印证这点。

另外,还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虛拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次, 以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

类文件结构

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一-小步,却是编程语言发展的一大步。
曾记得在第一-堂计算机程序课上老师就讲过:“计算机只认识0和1,所以我们写的程序需要被编译器翻译成由0和1构成的二进制格式才能被计算机执行。”十多年过去了,今天的计算机仍然只能识别0和1,但由于最近十年内虚拟机以及大量建立在虚拟机之上的程序语言如雨后春笋般出现并蓬勃发展,把我们编写的程序编译成二进制本地机器码(Native Code)已不再是唯一的选择, 越来越多的程序语言选择了与操作系统和机器指令集无关的、平台中立的格式作为程序编译后的存储格式。

Java技术能够- -直保持着非常良好的向后兼容性,Class 文件结构的稳定功不可没,任何一门程序语言能够获得商业上的成功,都不可能去做升级版本后,旧版本编译的产品就不再能够运行这种事情。本章所讲述的关于Class文件结构的内容,绝大部分都是在第- -版的《Java虚 拟机规范》( 1997年发布,对应于JDK 1.2时代的Java虚拟机)中就已经定义好的,内容虽然古老,但时至今日,Java发展经历了十余个大版本、无数小更新,那时定义的Class文件格式的各项细节几乎没有出现任何改变。尽.管不同版本的《Java虚拟机规范》对Class文件格式进行了几次更新,但基本上只是在原有结构基础上新增内容、扩充功能,并未对已定义的内容做出修改。
注意任何一个Class文件都对应着唯一 -的-一个类或接口的定义信息,但是反过来说,类或接口并不一-定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)。本文中,笔者只是通俗地将任意一一个有效的类或接口所应当满足的格式称为“Class文件格式”,实际上它完全不需要以磁盘文件的形式存在。

Class文件是–组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8个字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8个字节进行存储。
根据《Java虚拟机规范》的规定,Class文 件格式采用一一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”。后面的解析都要以这两种数据类型为基础,所以这里笔者必须先解释清楚这两个概念。
无符号数属于基本的数据类型,以u1、u2、 u4、 u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
.表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性地以“_ info”结尾。 表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视作是一-张表。

无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若千个连续的数据项的形式,这时候称这一系 列连续的某一类型的数据为某一类型的“集合”。

魔数与Class文件版本

每个Class文件的头4个字节被称为魔数(MagicNumber),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。不仅是Class文件,很多文件格式标准中都有使用魔数来进行身份识别的习惯,譬如图片格式,如GIF 或者JPEG等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全考虑,因为文件扩展名可以随意改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过而且不会引起混淆。Class文 件的魔数取得很有“浪漫气息”,值为0xCAFEBABE (咖啡宝贝? )。这个魔数值在Java还被称作“Oak语言的时候(大约是1991年前后)就已经确定下来了。它还有一段很有 趣的历史,据Java开发 小组最初的关键成员Patrick N aughton所说:“我们一直在寻找一些好玩的、容易记忆的东西,选择0xCAFEBA BE是因为它象征着著名咖啡品牌Peet’s Coffee深受欢迎的Baristas咖啡。”这 个魔数似乎也预示着日后“Java”这个商标名称的出现。
紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1 (JDK 1.0 ~ 1.1使用了45.0~45.3的版本号),高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,因为《Java虛 拟机规范》在Class文件校验部分明确要求了即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。

常量池

紧接着主、次版本号之后的是常量池入口,常量池可以比喻为Class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之- -,另外,它还是在Class文件中第–个出现的表类型数据项目。
由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_ pool_ count) 。与Java中 语言习惯不同,这个容量计数是从1而不是0开始的,常量池容量(偏移地址: 00000008) 为十六进制数0x0016,即十进制的22,这就代表常量池中有21项常量,索引值范围为1~21。在Class文 件格式规范制定之时,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于,如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为0来表示。Class 文件结构中只有常量池的容量计数是从1开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都与一-般习惯相同,是从0开始。

常量池中主要存放两大类常量:字面量(Literal) 和符号引用( Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,主要包括下面几类常量:
.被模块导出或者开放的包(Package)
.类和接口的全限定名( Fully Qualified Name)
字段的名称和描述符(Descriptor)
方法的名称和描述符
方法句柄和方法类型(M ethod Handle、M ethod Type、Invoke Dynamic)
动态调用点和动态常量(Dynamically -Computed Call Site、Dynamically-Computed Constant )

Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤, 而是在虚拟机加载Class文件的时候进行动态连接( 具体见第7章)。也就是说,在Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

访问标记

在常量池结束之后,紧接着的2个字节代表访问标志(access flags) ,这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final;等等。
在这里插入图片描述

类索引、父类索引与接口索引集合

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

类索引、父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引用两个u2类型的索引值表示,它们各自指向一个类型为CONSTANT_ Class_ info的类描述符常量,通过CONSTANT Class_ info类型 的常量中的索引值可以找到定义在CONSTANT Utf8 info类型的常量中的全限定名字符串。

字段表集合

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

方法表集合

Class文件存储格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样,依.次包括访问标志(access flags) 、名称索引(name_ index) 、描述符索引(descriptor _index) 、属性表集合(attributes) 几项。这些数据项目的含义也与字段表中的非常类似,仅在访问标志和属性表集合的可选项中有所区别。

也许有的读者会产生疑问,方法的定义可以通过访问标志、名称索引、描述符索引来表达清楚,但方法里面的代码去哪里了?方法里的Java代码,经过Javac编 译器编译成字节码指令之后,存放在方法属性表集合中一个名为“Code”的属性里面。

属性表集合

属性表(attribute_ _info) 在前面的讲解之中已经出现过数次,Class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息。

在这里插入图片描述
在这里插入图片描述

类加载机制

Java虛拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虛拟机的类加载机制。与那些在编译时需要进行连接的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为Java应用提供了极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。例如,编写- -个面向接口的应用程序,可以等到运行时再指定其实际的实现类,用户可以通过Java预置的或自定义类加载器,让某个本地的应用程序在运行时从网络或其他地方上加载一个二进制流作为其程序代码的一-部分。这种动态组装应用的方式目前已广泛应用于Java程序之中,从最基础的Applet、JSP 到相对复杂的OSGi技术,都依赖着Java语言运行期类加载才得以诞生。

类加载时机

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verifcation) 、准备( Preparation)、解析(Resolution) 、初始化(Initialization)、使用(Using) 和卸载(Unloading) 七个阶段,其中验证、准备、解析三个部分统称为连接(Linking) 。
类生命周期:
在这里插入图片描述

关于在什么情况下需要开始类加载过程的第–个阶段“加载”,《Java虛拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,《Java虛 拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始) :
1)遇到new、getstatic、 putstatic或invokestatic这 四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
●使用new关键字实例化对象的时候。
●读取或设置-一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
●.调用一个类型的静态方法的时候。
2)使用java lang reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main(方法的那个类),虚拟机会先初始化这个主类。
5)当使用JDK 7新加入的动态语言支持时,如果- -个java.lang invoke M ethodHandle实例最后的解析结果为REF_ getStatic、 REF_ putStatic、 REF_ invokeStatic、 REF _newInvokeSpecial四种类型的方法句
柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
6)当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

类加载过程

加载

“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一一个阶段。在加载阶段,Java虛拟机需要完成以下三件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,即映射为 JVM 认可的数据结构
3)在内存中生成一个代表这个类的javalangClass对象,作为方法区这个类的各种数据的访问入口。

验证

验证是连接阶段的第一步, 这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虛拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虛拟机是否能承受恶意代码的攻击.

准备

准备阶段是正式为类中定义的变量( 即静态变量,被static修 饰的变量)分配内存并设置类变量初始值的阶段。

解析

将常量池中的符号引用(symbolic reference)替换为直接引用。

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

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。

初始化

这一步真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑。

类加载器

站在Java虛拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器( Bootstrap .ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分; 另外- -种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java. lang ClassI oader.

站在Java开发人员的角度来看,类加载器就应当划分得更细致一些。自JDK 1.2以来,Java- -直保持着三层类加载器、双亲委派的类加载架构,尽管这套架构在Java模块化系统出现后有了一些调整变动,但依然未改变其主体结构.

Bootstrap Class Loader

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

Extension Class Loader

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

Application Class Loader(System Class Loader)

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

字节码执行引擎

执行引擎是Java虛拟机核心的组成部分之一-。 “虚拟机”是-个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
在《Java虛拟机规范》中制定了Java虚拟机字节码执行引擎的概念模型,这个概念模型成为各大发行商的Java虛拟机执行引擎的统一-外观 (Facade) 。在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,还可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎。但从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值