Java内存区域与内存溢出异常
概述
因为Java程序员把控制内存的权利交给了Java虚拟机,一旦出现内存泄露和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题将成为一项异常艰难的工作。
运行时数据区域
这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域是依赖用户线程的启动和结束而建立和销毁。
程序计数器
程序计数器(Progam Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虛拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虛拟机字节码指令的地址;如果正在执行的是本地(Native) 方法,这个计数器值则应为空(Undefined) 。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。
Java虚拟机栈
线程私有,生命周期与线程相同。栈描述的是Java方法执行的线程内存模型,由于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法的调用和执行对应栈帧的入栈和出栈。
局部变量表存放了编译期可知的各种Java虚拟机基本数据类型、对象引用和returnAddress类型(指向一条字节码指令地址)
这些数据类型以局部变量槽(Slot)来表示,64位的long和double占用两个槽,其他类型占用一个槽。局部变量表所需的空间在编译期完成分配,进入方法时分配的空间大小(槽的数量)确定运行时不会改变。
《Java虚拟机规范》中规定了内存区域两种异常情况:如果线程请求的栈深度大于虚拟机允许深度,抛出StackOverflowError;对于非HotSpot虚拟机,如果栈容量扩展时没有足够空间抛出OutOfMemoryError。HotSpot无法扩容,所以在栈空间申请时可能会抛出OOM异常。
本地方法栈
执行本地方法(Native)的栈空间。同样存在栈溢出和OOM异常。
Java堆
线程共享区域,启动时创建。用于存放对象实例。由GC管理。从GC的角度看,在G1之前大部分GC都是基于分代收集理论设计,因此堆会被垃圾回收器分为新生代(Eden、Survivor)、老年代、永久代(方法区)等区域。但是G1开始不再对堆进行分代回收,也就不再划分分代区域。
堆也会划出多个线程私有的分配缓冲区(Thread Local Allocation Buffer TLAB)来提升对象分配时的效率。《规范》中没有规定堆的物理空间连续性,只规定了逻辑空间上的连续性。而虚拟机的实现可能要求大对象请求连续的内存空间。堆空间不够时会抛出OOM异常。《规范》中没有规定该区域是固定大小还是可伸缩。
方法区
线程共享,存储已经被虚拟机加载的类型信息、常量、静态变量、JIT代码缓存等数据。《规范》中描述方法区是堆的一部分,但方法区有个别名叫“非堆”。仅HotSpot使用永久代来实现方法区,以便垃圾回收器可以管理这部分内存省去专门编写方法区内存管理的工作。其他虚拟机不存在方法区概念。HotSpot在JDK8后完全废除永久代的概念,改用本地内存中实现的元空间来替代。《规范》中没有规定方法区物理内存连续性和空间是否固定,也没有规定必须实现垃圾回收。方法区内存不足时会抛OOM异常。
运行时常量池
是方法区的一部分。Class文件中的常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。该区域在《规范》中没有细节要求。并且常量可以动态产生放入常量池。常量池内存不足时会OOM异常。
直接内存
直接内存不是虚拟机运行时数据区的内容,也不是《规范》中定义的区域,存在OOM异常。
在JDK 1.4中新加入了NIO (New Input/Output)类,引入了一种基于通道(Channel) 与缓冲区( Buffr)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
HotSpot虚拟机对象
对象的创建
当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定。为对象分配空间的方法由GC是否进行碎片整理决定:对于Serial、ParNew等进行碎片整理的GC使用指针碰撞的方式(移动空闲空间指针);对于CMS等使用空间列表来分配(存储剩余空闲空间列表)。但是CMS的实现中,为了能在多数情况下快速分配,设计了Linear Allocation Buffer分配缓冲区,通过空闲列表获取一大块分配缓冲区后使用指针碰撞方式分配对象内存。
有两种方式解决线程分配时的线程安全性,一种采用CAS失败重试方式保证空间指针移动与对象内存分配的原子性;一种是使用本地线程分配缓冲TLAB,优先在本地缓冲区分配内存,当本地缓冲区用完后同步锁定。
内存分配完成之后,虚拟机将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这也就是为什么int的默认值是0,boolean的默认值是false。
接下来,Java虚拟机对象头进行初始化。
至此对于虚拟机来说一个对象已经创建完成了。接下来是Java程序的对象创建。调用构造函数进行对象初始化。即Class文件中的
对象内存格式
在HotSpot虚拟机中,对象在堆内存中的分为三个部分:对象头、实例数据、和对齐数据。
对象头存储两类信息,一类是运行时数据(Mark Word),一类是类型指针。其中运行时数据区域在对象的不同状态会存储不同内容:
普通对象
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|
数组对象
|---------------------------------------------------------------------------------|
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|
为了让一个字大小存储更多的信息,JVM将字的最低两个位设置为标记位,不同标记位下的Mark Word示意如下:
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | lock:2 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | lock:2 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|-------------------------------------------------------|--------------------|
64位的JVM的标记位为:
|------------------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | Normal |
|------------------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | Biased |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | lock:2 | Lightweight Locked |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 | Heavyweight Locked |
|------------------------------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|------------------------------------------------------------------------------|--------------------|
在64位虚拟机下对象头的class pointer部分和array length会浪费更多的空间,可以使用 +UseCompressedOops
开启指针压缩。
接下来存储的是继承下来的对象字段和对象自己的字段内容。在内存中的存储顺序受虚拟机分配策略参数和字段定义顺序的影响。如果不开启 +XX: CompactFields
则父类字段会在子类字段前面。
第三部分是填充数据,由于HotSpot虚拟机GC要求对象起始地址必须是8字节的整数倍。
对象访问定位
栈的本地变量表会保存对象的reference数据。《规范》中没有规定具体reference访问方法。主流访问方式有两种:通过句柄地址、通过对象指针,HotSpot使用的是对象指针访问:
使用对象指针访问可以节省一次指针定位开销(Shenandoah收集器除外会有一次额外转发)。
制造OOM
堆溢出
分为内存泄漏和内存溢出,内存泄漏表示你认为应该被回收的对象没有被回收导致OOM,内存溢出表示你认为所有对象都应该保留,导致OOM。
改小堆大小,创建很多的有效对象直到OOM。
栈溢出
虚拟机栈和本地方法栈有两种异常,一种是线程请求深度大于JVM允许深度,抛出StackOverFlowError。一种是栈允许动态扩展,但空间大小无法扩展时OutOfMemoryError。
使用 -Xss
参数减少栈内存容量,可以导致StackOverflowError。
定义大量本地变量,增大方法帧中本地变量表长度,可以导致StackOverflowError。
受直接物理内存影响,通过不断创建线程方式可能导致OOM。
方法区和运行时常量池溢出
运行时常量池是方法区一部分。JDK 6之前常量池分配在永久代。JDK7起字符串常量和静态变量放在了堆中,常量池只存引用。JDK8使用元空间替代永久代。
JDK 6之前通过改小 -XX: MaxPermSize
可以导致OOM:PermGen space异常。
JDK 7之前可以通过不断创建新类,例如动态代理可以导致方法区OOM: PermGen space异常。JDK 8使用元空间替代永久代,而元空间归本地内存不在虚拟机内存。所以只受限于本地内存大小。
本机直接内存溢出
直接内存容量通过 -XX: MaxDirectMemorySize
调整,可以通过Unsafe类申请内存。
垃圾收集器与内存分配策略
GC概念早于Java在Lisp语言开始存在。JVM内存中,程序计数器、虚拟机栈、本地方法栈内存伴随线程的生命周期。而Java堆和方法区则更需要垃圾收集。
判断可回收对象
引用计数法
需要占用额外的内存来存储计数,由于存在循环引用问题,需要额外的处理才能正确工作。
可达性分析算法
通过一系列称为”GC Roots”的根对象作为起始点,根据引用向下搜索。搜索过的路径称为“引用链”,到GC Roots不可达的对象可以被回收。Java中的GC Roots包括:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,比如参数、局部变量、临时变量
- 在方法区中类静态属性引用的对象
- 方法区中常量引用的对象,比如字符串常量池里的引用
- 本地方法栈中JNI(Native方法)引用的对象
- Java虚拟机内部的引用,比如Class对象、常驻异常对象空指针OOM异常、系统类加载器
- 被同步锁synchronized持有的对象
- 根据GC及当前回收的内存区域不同,GC会临时引入对象到GC Roots。比如某GC对一块区域进行回收,但是此区域对象可能被其他区域引用,就要把相关区域对象加入GC Roots
- 反映JVM内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等
引用
JDK 1.2之后,引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)。
- 强引用指引用赋值,类似new对象,只要引用还存在永远不会被回收
- 软引用,内存溢出前会进行回收,如果对软引用回收还没有足够内存才抛OOM
- 弱引用,每次GC都会被回收
- 虚引用,不对对象生命周期构成影响,也不能通过虚引用获取对象实例。虚引用可以用来代替 finalize 方法,保证对象在finalize时不会复活;或者在对象被回收时收到一个系统通知
回收过程
第一次可达性分析后对没有生存链的对象标记。然后筛选出需要执行finalize()方法的对象(覆盖了finalize方法,或者已经被虚拟机调用过),放入到JVM内名为F-Queue的对象,由一个低优先级Finalizer线程执行它们finalize()方法。为了避免finalize()方法实现问题导致对回收系统的影响,只会触发,不会等待方法执行完。然后对F-Queue队列中对象进行第二次标记,如果上一次调用finalize()方法后对象重新建立起了引用,就会被从F-Queue对象移除。之后剩下的对象就会被回收。
回收方法区
《规范》中没有规范方法区的回收行为。如ZGC不支持类卸载。因为方法区GC性价比较低。方法区回收部分主要包括:废弃常量和不再使用的类型。
如果常量池中的方法、字段符号引用已经没有被其他地方引用,可以被回收。
而类型的回收,需要判断这个类的所有实力都已经被回收,加载该类的加载器已经被回收,该类的Class对象没有被引用即可被回收。
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一-样,没有引用了就必然会回收。在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虛拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
垃圾收集算法
分代收集理论
分代收集建立在几个分代理论上,弱分代:绝大多数对象只短暂存在;强分代:经过多次GC后都没被回收的对象存在时间会更长;跨代引用:跨代引用只占极少数。
对于弱分代区,只需要关注如和保留少量存活对象,而不用去标记大量要被回收的对象。
对于强分代区,可以用较低的频率来回收。
对于跨代引用,在新生代建立一个全局数据结构把老年代划分成不同的块,只标记出哪一块会出现跨代引用。当收集弱分代区时把该块内老年代对象加入GC Roots。该方法会在改变对象引用关系时记录数据正确性,增加运行时开销。
- 部分收集(Partial GC) :指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
- 新生代收集(MinorGC/YoungGC):指目标只是新生代的垃圾收集。
- 老年代收集(Major GC/OldGC) :指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意M ajor GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
- 混合收集(MixedGC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
- 整堆收集(FullGC) :收集整个Java堆和方法区的垃圾收集。
标记-清除算法
最基础的垃圾收集算法,后续收集算法大多以此为基础,对其缺点进行改进。该算法分为两个阶段,第一个阶段是标记出需要回收的对象,或者标记出存活对象。第二个阶段是对需要回收的对象进行清理。
该算法的缺点主要有两个,一个是如果堆中对象数量很多且大部分需要被回收,导致标记清理执行效率随着对象数量的增长而降低;一个是清理后会有内存碎片产生,在以后分配大对象且无连续空间时需要触发另一次垃圾收集动作。
标记-复制算法
简称复制算法,又称“半区复制”。将可用空间分为大小相等的两块,每次只使用其中一块,GC时将存活的对象复制到另一块保留空间,然后把原空间一次清理。
但是如果内存中大多数对的都是存活的,会产生大量的内存复制开销。且会将可用内存空间缩小一半,浪费空间很大。但是根据其新生代对象生命周期特点,并不需要1:1分配。于是产生了现有的分配策略:把新生代分为一块较大的Eden空间和两块较小的Survivor空间,在Eden和s0分配内存,GC时把Eden和s0中存活对象复制到s1。HotSpot默认Eden和2个Survivor空间占比8:2。如果只有一个Survivor区,那么第一次GC后s区会有存活对象,再进行第二次GC在s区会产生空间碎片。而两个s区交替复制存活对象可以避免空间碎片。
但是如果存活对象大于一个s区空间,需要依赖其他内存区域进行“分配担保”,HotSpot上使用老年代。如果s区空间不够存活对象大小,则直接进入老年代。
标记-整理算法
鉴于标记复制算法适合存活率低的新生代,不实用与老年代。针对老年代提出了标记整理算法。分为两个阶段,第一个阶段标记出要回收对象或存活对象,第二个阶段把所有存活对象向内存空间一端移动。然后直接清理掉边界以外的空间。
由于老年代对象存活率较高,每次移动对象都需要暂停用户应用程序(STW)。但是如果不进行整理,就需要引入更复杂的内存分配策略和内存访问策略来解决碎片化问题。而内存访问又是用户程序频率最高的操作,不便在这个阶段增加负担。因此是否有整理过程有会存在弊端。反映到垃圾收集器上看,关注吞吐量的Parallel Scavenge收集器基于标记整理,关注延迟的CMS收集器则基于标记清理。而CMS的策略是,大多数时间使用标记清理算法,只有在碎片影响到内存分配时才采用标记整理算法清理一次碎片空间。
HotSpot算法细节
根节点枚举
可达性分析算法中需要从GC Roots开始寻找对象引用链。而可以作为GC Roots的对象除了在全局性引用(例如常量或静态类属性),还有执行的动态上下文中(例如栈帧中的本地变量表)。虽然目标区域范围明确,但还是需要每次GC时都需要从这些内存区域中扫描出引用类型对象,因为只有引用类型才能作为GC Roots,并且这个过程需要STW。由于HotSpot虚拟机使用的是准确式垃圾收集(内存位置上的对象类型是确定的),所以可以通过一组叫OopMap的数据结构来保存引用类型对象位置,而不需要逐一的扫描过滤出引用类型对象。OopMap的结构存放一个对象内什么偏移量上是什么类型的数据。
安全点
虽然OopMap可以在GC时快速在目标区域范围定位出引用类型对象。但是如果每一条指令都生成对应的OopMap,会占用很大的额外空间。因此HotSpot只有在安全点才会生成OopMap。而安全点也就决定了不能在用户程序执行的任一指令流位置执行GC,必须要到达安全点后才能暂停。安全点位置:
- 方法临返回前/调用方法的call指令后
- 循环的末尾
- 抛异常的位置
选取这些位置的目的是避免程序长时间无法进入安全点。GC之前要等所有的应用线程进入安全点,如果有一个线程一直没有进入安全点,就会导致GC时JVM停顿时间延长。比如超大的循环导致执行GC等待时间过长。
目有两种方法可以让GC发生时所有的线程都在最近的安全点停顿下来:抢占式中断和主动式中断。
抢先式中断在GC发生时,首先中断所有线程,如果发现线程未执行到安全点,就恢复线程让其运行到安全点上。
主动式中断在GC发生时,不直接操作线程中断,而是简单地设置一个标志,让各个线程执行时主动轮询这个标志,发现中断标志为真时就自己中断挂起。由于轮询标志的频率很高,需要轮询高效,所以HotSpot使用内存保护陷阱的方式,把轮询操作精简为一条test汇编指令。当需要暂停用户线程时,虚拟机把某一内存页设置为不可读,在执行test时就会产生异常信号,然后在预先设置的异常处理中挂起线程实现等待。这样一条汇编指令即可实现安全点的轮询和触发线程挂起。主动式中断也是目前HotSpot采用的中断方式。
安全区域
安全点机制只适用于执行中的线程,对于处于Sleep或Blocked状态的线程,引入了安全局域。安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
记忆集与卡表
记忆集是用来存储,老年代到新生代的引用,或者分区域收集时的非收集区域指向收集区域的引用。对于垃圾收集器来说,只需要通过记忆集判断出某一块非收集区域是否有指向收集区域的指针即可,并不需要知道所有对象引用关系。所以记忆集的粒度可以更大一些。可选的粒度有:
- 字长精度:每个记录精确到一个机器字长(32位和64位的寻址位数),该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,对象的字段包含跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
HotSpot所使用的是卡精度,称为“卡表(Card Table)”。卡表是记忆集的一种实现。卡表可以用于老年代到新生代的引用,也可以用于新生代跨Region之间的引用。将老年代内存空间,或者某一块Region按照512字节分成不同的“卡页(Card Page)”,一个Card Page内存中通常有多个对象,只有有一个对象存在跨代、区域的引用,那么这个卡页就是Dirty的。卡表中维护着一个字节数组,每一位字节按顺序对应一个Card Page。如果对应的Card Page是Dirty的,那么这一位就是1,否则是0。
写屏障
前面已经解决了如何使用Remembered Set来缩减GC Roots扫描范围,接下来解决如何维护卡表。卡表的维护难度在编译执行场景中,因为经过即时编译后的代码是机器指令流,没有虚拟机的介入空间了。
HotSpot虚拟机通过Write Barrier维护卡表状态。这个写屏障和低延迟收集器与内存屏障没有任何关系,清做好区分。写屏障可以看做是虚拟机层面对“引用类型变量的复制”这个动作的AOP切面,在引用赋值时以提供程序执行额外动作。赋值前叫Pre-Write Barrier,赋值后叫Post-Write Barrier。应用写屏障后会被织入赋值操作指令流,虽然会产生一些开销,但是相对于扫描整个老年代空间性价比会高。
假设处理器缓存行大小为64字节,一个Card Table元素占1字节,那一个缓存行对应32KB的Card Page大小。但是在多线程赋值操作在这32KB内是会存在并发问题,又叫“伪共享”,会影响性能。因此引入了预先判断的写屏障,先检查Card Table的标记,只有当未标记过,才去更新标记。JDK 7之后可以使用参数 -XX: +UseCondCardMark
决定是否开启预判断。
并发的可达性分析
经过上面的过程虽然找出了GC Root,但是遍历对象引用链的停顿时间也会和堆内对象数量和图结构复杂程度成正比。
为了不停顿,就需要一种能够和用户线程并行的遍历算法。三色标记,在JVM和GO等很多语言的垃圾收集中都有使用。三色标记指三种颜色:白色表示对象未被扫描过;黑色表示该对象所有引用都被扫描过;灰色表示该对象上至少存在一个引用没被扫描过。在使用三色标记的遍历算法下,当用户线程产生了从黑色对象到白色对象的引用并且删除了所有从灰色对象到该白色对象的引用时,会影响扫描结果而对用户程序造成影响。因此同时引入了两个解决方案,一个是增量更新,一个是原始快照。
增量更新避免了用户线程新建从黑色对象到白色对象的引用。当发生这种操作时,就将新插入的引用记录下来,当扫描结束之后再以记录的黑色节点为根重新扫描一次。
原始快照避免了所有灰色对象到该白色对象的引用都被删除。当发生这种操作时,就将要删除的引用记录下来,当扫描结束之后再以记录的灰色节点为根重新扫描一次。
以上两种解决方案都是通过写屏障实现的。在HotSpot中,CMS基于增量更新做并发标记,G1、Shenandoah用原始快照实现并发标记。
HotSpot中常见垃圾收集器
GC算法是方法论,收集器是具体实现。而且《规范》中也没有规定收集器该如何实现。
JDK 9中取消了Serial+CMS和ParNew+Serial Old组合。
Serial
单线程工作收集器,Serial线程在进行收集工作时,必须暂停所有用户线程。特点是简单且高效,相比其他收集器,由于GC所占用的额外内存最小,且没有线程交互开销。至今依然是HotSpot在Client模式下默认新生代收集器。可选参数-XX: SurvivorRatio、 -XX: PretenureSizeThreshold、-XX: HandlePromotionFailure等。
ParNew
只是Serial的多线程版本,但是在单核心处理器下由于存在线程交互开销可能还不如Serial收集器。目前只有Serial和ParNew可以与CMS配合,而Parallel Scavenge收集器和CMS除了一个面向低延迟一个面向高吞吐量的目标不一致外,技术上的原因是Parallel Scavenge收集器及后面提到的G1收集器等都没有使用HotSpot中原本设计的垃圾收集器的分代框架,而选择另外独立实现。Serial、 ParNew收集器则共用了这部分的框架代码。ParNew是CMS的默认新生代收集器。因此是CMS巩固了ParNew的地位。JDK 9后被G1取代。可选参数-XX: SurvivorRatio、 -XX: PretenureSizeThreshold、-XX: HandlePromotionFailure等。
Parallel Scavenge
基于标记复制的新生代收集器。CMS等目标是缩短GC用户线程停顿时间,而Parallel Scavenge目标是吞吐量可控。吞吐量=用户代码时间/(用户代码时间+GC时间)。提供了参数用于控制吞吐量:
- -XX: MaxGCPauseMillis 控制最大停顿时间,大于0的毫秒数。但是停顿时间变小可能会牺牲吞吐量和新生代空间。
- -XX: GCTimeRatio 设置吞吐量大小,大于0小于100的整数,1/(1+N)。
- -XX: +UseAdaptiveSizePolicy,根据系统运行情况动态调整新生代大小、Eden与Survivor比例、晋升老年代对象大小等参数。
Serial Old
标记整理算法的老年代单线程收集器,供Client模式使用。需要说明一下,Parallel Scavenge收集器架构中本身有PS MarkSweep收集器来进行老年代收集,并非直接调用Serial Old收集器,但是这个PS MarkSweep收集器与Serial Old的实现几乎是一样的,所以在官方的许多资料中都是直接以Serial Old代替PS MarkSweep进行讲解。
Parallel Old
Parallel Scavenge老年代版本,基于标记整理算法。可以和Parallel Scanvenge组合。
CMS 收集器
基于标记清理算法的关注定顿时间的收集器。整个过程分为四步:initial mark/concurrent mark/remark/concurrent sweep。
其中初始标记、重新标记需要STW。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
在并发阶段虽然不会导致用户线程停顿,但会占用处理器资源,处理器核心越少影响越大。
无法处理并发标记和清理过程中用户线程新产生的浮动垃圾。因为是与用户线程并发运行,还需要预留出空间,所以当老年代空间使用占比达到一定阈值后才会激活(-XX: CMSInitiatingOccupancyFraction)。而因为预留内存不足而导致并发失败会启用Serial Old收集器来工作。
因为是基于标记清理算法,所以会产生空间碎片。当不能分配大对象时会触发Full GC。-XX: +UseCMS-CompactAtFullCollection
参数可以配置在FullGC时开启碎片整理,由于碎片整理需要停顿时间变长,所以提供 -XX: CMSFullGCsBeforeCompaction
(JDK9中已废弃)配置多少次FullGC后,下一次FullGC进行碎片整理。
Garbage First
G1收集器是里程碑式成果。基于Region的内存布局形式的局部收集设计,在延迟可控的情况下获得尽可能高的吞吐量。JDK 9开始G1替换Parallel Scavenge+Parallel Old。G1不再应用于分代,而是面向堆内存中的任何部分来组成Collection Set(CSet)。进行回收的衡量标准是哪块内存中存放的垃圾数量最多,回收收益最大。这就是Mixwd GC模式。
G1中的Ragion划分,每个Region都可以扮演新生代Eden、Survivor、或者老年代。收集器能够对扮演不同角色的Region采用不同策略。Region中有一类叫Humongous区域专门用来存放大对象,大对象表示超过了一个Region容量一半的对象。每个Region大小可以通过 -XX: G1HeapRegionSize
设定,1M-32M。如果一个对象超过了一个Region大小会被存放在N个连续的Humongous Region中。
虽然G1仍然保留新生代和老年代概念,但是这两个空间不再固定,而是一系列Region的动态集合。根据每个Region回收获得的空间大小及所需时间,维护一个回收优先级列表,优先处理回收性价比最高的Region。这也是“Garbage First”名字的由来。这种使用Region划分内存空间,和按优先级的区域回收方式,保证了G1在优先的时间内获取更高的效率。
G1处理跨Region的对象引用的方法是,每个Region维护自己的RSet哈希表,Key是其他Region的起始地址,value是Card Page索引号的集合,这些RSet记录其他Region指向自己的指针,并标记这些指针位于哪些Card Page。这是一种双向的Card Page结构,同时记录了向外指针和向内指针。由于Region的数量很多,所以G1要使用大约占堆容量的10%~20%的额外内存。
G1处理与用户线程同步进行GC的方法是,采用原始快照SATB的方式。并且G1为每个Region设计了两个TAMS(Top at Mark Start)的指针,把Region中的一部分指针以上的空间用来并发回收过程中的新对象分配,G1会认为在这个地址以上的对象被隐式标记过默认存活。但是如果内存回收速度赶不上内存分配速度,G1会STW。
G1为了满足 -XX: MaxGCPauseMillis
用户期望停顿时间,是以衰减均值的方式。在GC过程中记录每个Region的回收耗时、Dirty Card数量等成本并得出统计信息。然后通过这些信息预测从哪些Region开始回收才能满足期望值。衰减平均值比普通的平均值更能代表最近时间的平均状态。
G1的GC过程大致可以分为以下四步:
- Initial Marking,标记GC Roots能直接关联到的对象,并修改TAMS指针让下一阶段用户线程分配对象时能正确在可用Region中分配。需要STW。
- Concurrent Marking,从GC Roots开始对堆中对象进行可达性分析,耗时较长但与用户线程并发进行。扫描完成后会再次重新处理SATB记录的引用变动对象。
- Final Marking,处理并发阶段结束后遗留的SATB记录。需要STW。
- Live Data Counting and Evacuation,筛选回收阶段更新Region的统计数据并排序,指定回收计划。把计划回收的Region中存活对象复制到空Region,再清理整个Region。需要SWT。
G1之所以不把回收阶段设计为并行,是为了保证简单的实现高吞吐量,而把这个设计延迟到ZGC实现。
-XX: MaxGCPauseMillis
默认两百毫秒,如果设置的非常低会导致每次只回收一小部分Region,从而收集速度跟不上分配速度最终引发Full GC。从G1开始垃圾收集器的设计导向由清理干净转变为收集速度能跟得上分配速度。
G1从整体上看是标记整理,局部上看是标记复制。都意味着G1不会产生空间碎片,不容易因为大对象无法找到连续空间而触发GC。
而G1的缺点也是存在的,每个Region都要保存一份RSet而带来的额外内存占用;多了写前屏障来跟踪并发时动态指针变化SATB带来的额外的运行负载。由于G1的对写屏障的复杂操作,不得不把写屏障要做的事放到队列里异步处理。
经验上看小内存CMS优于G1,大内存G1优于CMS。
低延迟垃圾收集器
衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)、延迟(Latency)。随着计算机硬件的发展内存的增长,吞吐量会更高,但是对延迟会造成负面影响,因此更低的延迟成为了下一代GC的目标。下图是前文中所有GC的停顿情况,浅色标识必须挂起用户线程,深色标识并发工作。
在CMS和G1之前的全部收集器,其工作的所有步骤都会STW;CMS和G1分别使用增量更新和原始快照技术,实现了标记阶段的并发,不会因管理的堆内存变大,要标记的对象变多而导致停顿时间随之增长。但是对于标记阶段之后的处理,仍未得到妥善解决。CMS使用标记清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优化改进,在设计原理上避免不了空间碎片的产生,随着空间碎片不断淤积最终依然逃不过STW。G1虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的。
而Shenandoah和ZGC, 几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本.上是固定的,与堆的容量、堆中对象的数量没有正比例关系。实际上,它们都可以在任意可管理的(譬如现在ZGC只能管理4TB以内的堆)堆容量下,实现垃圾收集的停顿都不超过十毫秒。这两款目前仍处于实验状态的收集器,被官方命名为“低延迟垃圾收集器”(Low-Latency Garbage Collector或者Low-Pause Time Garbage Collector)。
Shenandoah
由于和Oracle官方实现的下一代功能重合,遭受了Ocacle将其完全排除在了OracleJDK之外,因此只能在OpenJDK中使用。其和G1之间有很多的相似和代码复用,因此G1还从Shenandoah代码合并了多线程Full GC功能。G1回收阶段是多线程并行的,但却不能与用户线程并发,而Shenandoah支持并发的整理算法;Shenandoah没有新生代Region或者老年代Region之分;G1中消耗大量内存和计算资源维护的RSet在Shenandoah中改为使用Connection Matrix连接矩阵的全局数据结构来记录跨Region引用关系且降低了伪共享发生的概率。连接矩阵大致结构是一张二维表:
Shenandoah工作过程大致分为九个阶段,2.0版本中在初始标记之前还有Initial Partial/Concurrent Partial/Final Partial阶段可以理解为分代收集中的Minor GC的工作。
- Initial Marking初始标记:标记与GC Roots直接关联的对象,需要STW,与堆大小无关,至于GC Roots数量有关。
- Concurrent Marking并发标记:遍历对象引用图,标记处所有可达对象。时间取决于堆中存活对象数量和图的复杂程度。
- Final Marking最终标记:处理剩余SATB扫描,并按回收价值统计出Collection Set回收集,需要小段时间的STW。
- Concurrent Cleanup并发清理:清理Immediate Grabage Region(内部无存活对象的Region)。
- Concurrent Evacuation:并发回收:Collection Set中存活对象复制到空Region中,通过读屏障和Brooks Pointers转发指针解决旧对象引用问题。时间取决于Collection Set大小
- Initial Update Reference初始引用更新:此阶段确保所有对象复制操作已经完成。需要短暂STW。
- Concurrent Update Reference并发引用更新:按照内存物理地址顺序线性找出引用类型,把旧引用改为新引用。时间取决于引用数量多少。
- Final Update Reference最终引用更新:修正GC Roots中的引用,需要STW,与GC Roots数量有关。
- Concurrent Cleanup并发清理:回收集中所有Region变为Immediate Garbage Regions,统一回收。
GC(3) Pause Init Mark 0.771ms
GC(3) Concurrent marking 76480M->77212M(102400M) 633.213ms
GC(3) Pause Final Mark 1.821ms
GC(3) Concurrent cleanup 77224M->66592M(102400M) 3.112ms
GC(3) Concurrent evacuation 66592M->75640M(102400M) 405.312ms
GC(3) Pause Init Update Refs 0.084ms
GC(3) Concurrent update references 75700M->76424M(102400M) 354.341ms
GC(3) Pause Final Update Refs 0.409ms
GC(3) Concurrent cleanup 76244M->56620M(102400M) 12.242ms
Brooks Pointers转发指针用来实现对象移动与用户程序并发。Brooks是一个人名。再次之前的实现需要在被移动对象上设置内存保护陷阱,在异常处理器中把访问转发到新对象。这样会导致频繁的用户态核心态切换。转发指针在原对象结构头添加新引用字段,在正常情况下改引用指向自己。这样间接对象访问可以优化到只有一行汇编即可。
如果收集器和用户线程并发对同一个对象并发写,要保证写操作发生在新对象上。Shenandoah通过CAS保证GC更新转发指针和用户线程更新对象数据只有一个可以成功。Shenandoah为了覆盖全部对象访问操作,同时设置了读写屏障。因为读的操作量级很大,所以在JDK 13中Shenandoah内存屏障模型改为基于引用访问屏障,只拦截引用类型数据的读写。
ZGC
Oracle亲儿子。目标都是在不影响吞吐量的情况下实现堆大小对停顿时间无影响。PGC->C4->ZGC一脉相承。ZGC基于Region布局,不分代,使用了读屏障、染色指针和内存多重映射等技术实现可并发的标记整理算法。
与G1不同的是,ZGC的Region(Page或ZPage)动态的创建和销毁,动态的容量大小。ZGC的Region分为大中小三类:
- Small Region:固定为2MB,用于存放小于256KB的对象。
- Medium Region:容量固定为32MB,用于存放大于等于256KB小于4MB的对象。
- Large Region:大小为2MB的整数倍,用于存放4MB或以上的对象。每个Region只存放一个对象,且内存不会被重新分配。
Shenandoah使用转发指针和读屏障来实现并发整理,ZGC使用染色指针技术实现了读屏障。在此之前要在对象上存储额外数据都是在对象头中添加字段,例如传统垃圾收集会在对象头中打存活标记,但是这本质上只和引用有关。G1和Shenandoah使用了堆内存1/64大小的BitMap来记录标记,而ZGC的染色指针把标记信息记录类引用对象的指针上,这样可达性分析中只需要遍历指针即可。
在64位的操作系统中,限于硬件的发展和操作系统的使用方式,64位的指针中只会使用低几位用于寻址,高几位并没有使用到。例如在64位linux下只有低46位能用来寻址。而剩下的46位64TB的寻址范围当今还使用不到,所以ZGC在剩下的46位指针中取出高4位用于标记存储信息。因为在46位又拿出了4位用于存储信息,所以ZGC只能使用42位来分配地址,导致堆容量最大4TB。
而由于虽然46位中的高4位被ZGC用来标记,但是对于操作系统,还是要使用整个46位用来寻址。所以ZGC不得不使用标志位来分割虚拟内存空间,通过多重映射把(标志位+实际ZGC堆内地址)带来的空间偏移的多段虚拟内存空间通过mmap映射到同一块物理内存。这样才能正常的对堆中的一个对象进行寻址。
染色指针带来的好处:
- 某个Region的存活对象被移走后这个Region立即就能被释放和重用,不用像Shenandoah一样整个堆中所有指向这个Region的引用被修正后才能清理。
- 可以减少GC过程中内存屏障的使用数量。写屏障通常为了记录对象引用变动的情况,把这些信息维护在指针中可以省去一些专门的记录操作。
- 染色指针并不局限于使用46位中的高4位。实际上可以利用更高位。以便日后扩展。
- Concurrent Mark:遍历对象引用图的可达性分析阶段,前后也要经过初始标记、最终标记的短暂停顿。但是此阶段是在指针上标记。
- Concurrent Prepare for Relocate:并发预备重分配统计出要清理的Region组成重分配集Relocation Set。重分配集决定里面的存活对象会被重新复制到其他Region,集合里面的Region会被释放。
- Concurrent Relocate:并发重分配把存活对象复制到新Region,并未分配集中每个Region维护一个转发表Forward Table记录旧对象到新对象的转向关系。使用染色指针,ZGC可以从引用上判断对象是否在重分配集中,如果用户线程此时并发访问对象,会被内存屏障截获,并根据Region上的转发表将访问转发到新对象上,同时修正更新引用的值使其直接指向新对象,这个动作称为SelfHealing自愈能力。这样做只有在第一次访问转发对象的时候需要判断。而Shenandoah的Brooks指针每次都要付出开销。
- Concurrent Remap:并发重映射用来修正整个堆中的引用转发关系,虽然转发对象第一次访问时可以自愈,但是这个阶段可以帮助修正一些引用减少自愈开销。ZGC把这个阶段合并到下一次垃圾收集生命周期中的并发标记阶段。因为它们都需要遍历所有对象可以节省一次遍历开销。所有指针被修正后转发表才被释放。
ZGC的缺陷是,如果并发收集的生命周期过长,比如十几分钟。新分配的对象会当存活对象,会产生浮动垃圾。很有可能跟不上分配的速度。要从根本上解决这个问题还需要引入分代收集。
在多核处理器上的非同一内存访问架构中,ZGC会优先在当前处理器的本地内存上分配对象。
Epsilon
只负责对象分配管理,不进行垃圾回收的收集器。这个收集器可以用性能测试和压力测试中的差异性分析和内存压力测试,以避免gc的性能损耗对测试结果的影响。
在微服务和无服务架构的演进下,对于短周期应用可能不需要垃圾清理,可以使用Epsilon。
选择合适的垃圾收集器
收集器的权衡
- 应用是关注吞吐量还是关注及时性
- 硬件规格
- JDK发行版本
JVM及GC日志
JDK 9之前JVM各功能模块的开关分布在不同的参数上,且格式不统一。直到JDK 9才统一日志框架,所有的日志功能配置都统一到了 -Xlog
参数上。
实战内存分配与回收
对象优先在Eden分配
通过设置各分代大小,打印GC日志,查看对象先在Eden中分配,当Eden中内存不足时触发Minor GC,如果Survivor空间不足通过分配担保机制老对象提前转移到老年代。新对象再在Eden中分配。
大对象直接进入老年代
对于长字符串和数组,通过配置 -XX: PretenureSizeThreshold
,超过阈值的直接进入老年代。
长期存活的对象进入老年代
通过配置 -XX: MaxTenuringThreshold
存活周期超过阈值的进入老年代。
动态对象年龄判定
如果再Survivor空间中相同年龄所有对象的大小总和大于一半,年龄大于等于该年龄的对象将直接进入老年代
空间分配担保
在发生Minor GC之前,虚机要检查老年代最大可用连续空间是否大于新生代所有对象总和,如果大于认为这次Minor GC是安全的。如果小于,会查看 -XX: HandlePromotionFailure
是否允许担保失败,如果允许,会继续检查老年代连续空间是否大于历次晋升到老年代对象平均大小,如果大于将进行Minor GC。如果小于将进行一次Full GC。
性能监控、故障处理
概述
给一个系统定位问题的时候,知识、经验是基础,数据是依据,工具是运用知识处理数据的手段。数据包括但不限于异常堆栈、虚拟机运行日志、垃圾收集器日志、线程快照(threaddump/javacore)文件、堆转储文件(heapdump/hprof)等。
基础故障处理工具
jps:JVM进程状况工具
JVM Process Status Tool,可以列出正在运行的虚拟机进程。
jstat:JVM统计信息监控工具
JVM Statistics Monitoring Tool,可以显示本地或远程进程中的类加载、内存、垃圾收集、即时编译等运行时数据。
jinfo:Java配置信息工具
Configuration Info for Java,实时查看和调整虚拟机参数。
jmap:Java内存映像工具
Memory Map for Java,用于生成堆转储快照heapdum/dump文件。查询finalize执行队列、堆和方法区的空间利用率等信息。
jhat:JVM堆转储快照分析工具
JVM Heap Analysis Tool与jmap搭配使用。很少使用。
jstack:Java堆栈跟踪工具
Stack Trace for Java,生成JVM当前时刻线程快照。当前每一个线程的方法堆栈集合。
可视化工具
JConsole、VisualVM、Java Mission Control(需要和JFR一同使用)。
虚拟机执行子系统
为何需要虚拟机
由于各CPU的指令集和操作系统的架构不同,以往的语言需要针对不同的平台编译甚至针对不同的平台编写代码。 而JVM抽象了运行这一层,只需要针对不同的平台编写对应的JVM,及可实现一次编写到处运行。运行在JVM上的文件格式称为字节码,任何语言只要可以输出成JVM识别的字节码Class文件都可以在JVM上运行进而在所有平台上运行。Java只是可以通过编译生成JVM运行的字节码的语言中的一种。java语言有《Java语言规范》,虚拟机有《Java虚拟机规范》。JVM只与Class文件的字节码绑定。所以Java、Kotlin、Groovy、Scala都可以通过对应的编译器生成相同的Class文件字节码在JVM上运行。
Class 类文件结构
由于Java支持反射和类动态生成,所以任何一个Class文件都对应一个类或接口,但一个类或接口不一定都有对应的Class文件。(package-info.class、module-info.class用于描述)
Class文件是一组以8个字节为基础单位的二进制流,各个数据项紧凑排序。由于不是XML这种描述语言,所以内容格式是被严格限定的。当数据项需要8字节以上空间时,会按照高位在前的方式分割成若干个8字节。
Class文件格式中只有“无符号数”和“表”两种数据类型。无符号数属于基本数据类型,u1/u4/u8分别代表1字节、4字节、8字节的无符号数,无符号数可以用来描述数字、索引引用、数量值、或者UTF-8字符串。
表是又多个无符号数或其他表作为数据项构成的复合数据结构,为了便于区分所有表的命名都以_info结尾。
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
magic
独立于扩展名之外用于标识文件类型的任意编码,Java使用 0xCAFEBABE
来标识出Class文件。
minor_version 和 major_version
minor_version
是次版本号 major_version
是主版本号。高版本向下兼容,但是《规范》规定JVM不能执行高于版本号的Class文件。
constant_pool
常量池在Class文件中非常重要,cp_info
前面的 u2
存储常量池的容量。索引位 0
用来表达“不引用任何一个常量池项目”。所以实际常量池中数量等于 容量-1
。注意Class文件中只有常量池的索引是从1开始的。
常量池中主要存放两大类常量:字面量和符号引用。字面量如Java中的文本字符串、被声明为final的常量等。符号引用如包、类或接口的全限定名、字段或方法的名称和描述符、方法句柄和方法类型。
基于JVM的语言的编译并不像C++编译有连接过程,而是在JVM加载Class文件时动态连接。所以C++编译完每一个方法,字段在内存中的位置就已知了。而Class文件中不会保存方法、字段最终在内存中的布局,这些符号引用不经过JVM运行时转换的话是没有内存地址的,也就无法被使用。当JVM在类加载时获得符号引用,在类创建时或运行时解析到具体内存地址中。
常量池的数据类型有十几种,各自都有自己的数据结构,但是他们都有一个共有属性 tag
。tag
是标志位,标记是哪一种数据结构,现有的数据结构见附录。由于Class文件中方法字段都需要引用 CONSTANT_Utf8_info
常量来描述名称,所以该常量的最大长度也就是65535即方法和字段名的最大长度。
我们可以使用 javap
来输出解析好的Class文件字节码。
access_flags
访问标识,表示是类还是接口,是否public,是否abstract,如果是类的话是否是final等。一共有16个标识位可以使用,目前只使用到了9个。
现有标识见附录。
this_class && super_class && interfaces_count && interfaces[]
this_class
代表类全限定名在常量池中的索引。 super_class
标识父类全限定名在常量池中的索引。注意 Object
类的父类索引为0。 interface_count
表示该类实现接口的数量,后面紧跟的是接口集合。上面所有索引指向的都是常量池中的 CONTANT_Class_info
类型。而 CONTANT_Class_info
的数据类型见附录。
fields_count && field_info
该类中声明的成员变量集合,不包括方法中局部变量。字段包含的修饰符有作用域(public)、实例变量还是类变量(static)、可变性(final)、并发可见性(volatile)、能否被序列化(transient)、字段数据类型(基本类型、对象、数组)、字段名称。 field_info
中每一项的字段表数据格式如下
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes;
字段的访问标识符在标识位的含义见附录。 name_index
和 descriptor_index
都是对常量池的引用,分别代表字段的简单名称和方法描述符。其中简单名称就为字段名或方法名;而描述符描述了字段的数据类型、方法的参数列表和返回值。描述符中基本数据类型和void都用一个大写字母标识,对象类型用字母 L
加对象的全限定名来表示。详见附录。对于数组类型每一个维度使用一个 前置的 [
来描述,如二维数组 java.lang.String[][]
记录成 [[Ljava/lang/String;
, int[]
记录成 [I
。
用描述符来描述方法时,按照先参数列表、后返回值的严格顺序描述。比如 void inc()
描述符为 ()V
,int foo(char[] a,int b)
的表述符为 ([CI)I
。字段表集合中不会出现父类或父接口中继承来的字段。 随后的 attributes
并不是所有的字段都会有数据项,可能 attributes_count
为0。
methods_count && method_info
储存方法描述信息的方法表结构如下:
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes;
方法的访问标识符定义见附录。而方法中的代码,通过编译生成字节码之后会存放在 attribute_info
属性表中的名为”Code”的属性里面。如果父类方法在子类中没有被重写,方发表集合中也不会出现父类的方法信息。编译器可能会自动添加类构造器方法”
attributes_count && attribute_info
任何编译器都可以向属性表中写入自己的属性信息,但是《规范》中只规定了29项所有虚拟机都应该识别的属性,见附录。方法的内容字节码就存在这里面的 Code
属性中。每一个类型详解见附录。
字节码指令简介
由于Java语言设计之初是面向网络,需要再网络中传输Class文件。所以放弃了操作数长度对齐,可以省略大量填充符号和Class文件大小。但是在处理超过一个字节的数据时重建数据结构,导致在执行字节码时损失一些性能。比如将一个16位无符号整数使用两个无符号字节存起来,使用时 (byte1 << 8) | byte2
。
Java虛拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand) 构成。由于Java虛拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。
字节码与数据类型
见附录。
虚拟机类加载机制
Java虛拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。与C++这种在编译时进行连接的语言不同,JVM语言的类型加载、连接和初始化都是在程序运行期间完成的,这种会带来性能开销但也会提高灵活性比如动态代理。
类加载时机
上述周期中除了解析和使用,其他流程必须依次开始,允许交叉进行。《规范》中没有规定整个周期什么时候开始,但是明确规定了其中初始化的阶段必须在以下6种情况如果类没有被初始化则触发。因此加载、验证、准备阶段可以交给虚拟机灵活实现。
- 使用new实例化对象、读取或设置一个类的静态字段(final或编译器就存在常量池的除外)、调用类的静态方法。
- 使用reflect对类型进行反射
- 初始化一个类时其父类未初始化,需要初始化父类(如过一个接口被初始化,不需要初始化父接口)
- 虚拟机启动时,入口类(main())
- 动态语言支持中使用MethodHandler来触发
1.
中的情况时 - 实现了带有默认方法的类被初识化,则其接口要被初始化
加载
- 通过类的全限定名获取类的二进制字节流
- 将字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的数据访问入口
在 1 中,虚拟机可以从任何地方来获取字节流。开发者可以通过自定义类加载器来控制字节流的获取方式。对于数组的加载,如果数组是引用类型,则递归调用类加载流程,可访问性与组件类型的可访问性一直。如果不是引用类型,数组会与引导类加载器关联,可访问性默认为public
验证
由于Class文件不一定是由源码编译得到的甚至可能都没有Class文件,也就是说可以跳过Java编译生成数组越界、跳转不存在代码行的字节码。所以要对类的字节流进行验证。
文件格式校验
验证字节流是否符合Class文件格式规范,包括验证魔数、版本号、常量池中的常量类型等
元数据校验
对字节码进行 语义分析,包括验证这个类是否有父类、是否继承了final修饰的类、如果不是抽象类是否实现了接口中的所有方法等
字节码校验
分析数据流和控制流,确定程序语义是否合法,并对方法体(Class文件中的Code属性)进行校验,包括操作数栈的数据类型与指令码序列能够配合工作(上一条指令存放int类型,下一条指令取long类型)、跳转指令不会跳转到方法体以外的字节码指令上等
因为此阶段复杂耗时,所以把尽可能多的校验放在编译器里进行。因此只需要检查 Code
属性的属性表中的 StackMapTable
而不用进行类型推导。但是也存在 StackMapTable
和 Code
同时被篡改的可能。
符号引用校验
这一阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化过程发生在解析阶段。主要校验符号引用中的全限定名能否定位到类、指定类中是否有符合方法或字段描述对应的方法或字段,符号引用中的可访问性校验等。
这个阶段可以被关闭来缩短虚拟机类加载时间。
准备
为定义在类中的变量分配内存并设置默认值0,初始化值在初始化阶段赋值,而如果是 static final
修饰会赋初始化值。
解析
将符号引用替换为直接引用。符号引用是《规范》中定义的Class文件格式用来标识目标。直接引用是虚拟机实现用来定位目标。解析的触发点可以由虚拟机决定是在类加载时进行还是第一个符号引用被使用时进行。可以对同一个符号引用进行多次解析。解析动作主要针对以下7类符号引用,其中后三种与java的动态语言支持息息相关:
- 类或接口
- 字段
- 类方法(静态方法)
- 接口方法
- 方法类型
- 方法句柄
- 调用点限定符
初始化
初始化阶段就是执行类构造器 <clinit>()
方法的过程,这个方法并不是我们写的构造方法,而是由编译器自动生成。编译器会收集类中的所有类变量的赋值动作和静态语句块(static{}
)中的语句合并生成<clinit>()
方法,且收集顺序由在源文件中的出现顺序决定,因此静态语句块只能访问定义在静态语句块之前的变量,定义在它之后的变量静态语句块可以赋值,但不能访问。而<clinit>()
方法的调用,JVM会确保在子类的<clinit>()
方法执行之前执行父类的<clinit>()
方法(接口除外)。如果一个类中没有静态语句块或类变量的赋值操作,那么编译器可以不生成<clinit>()
方法。
对于接口,父接口中的类变量被使用时父类中的<clinit>()
方法才会被调用。接口的实现类也是只有其实现的接口的类变量被使用,接口的<clinit>()
方法才会被调用。
JVM保证一个类的<clinit>()
方法在多线程环境中只会被一个线程调用,其他线程阻塞。
类加载器
类加载器可以应用于类层次划分、程序热部署、代码加密、框架于程序隔离。
类与类加载器
每一个类加载器都有一个独立的类名称空间,所以一个类和加载这个类的加载器共同确定其在JVM中的唯一性。两个不同加载器加载的同一个类并不相等。
双亲委派模型
JDK9之后由于模块化,类加载模型有所变化,这里针对JDK9之前版本进行介绍。
Bootstrap Class Loader,在HotSpot虚拟机中这个加载器是由C++实现是虚拟机的一部分,用来加载 <JAVA_HOME>\lib
目录,或者被 -Xbootclasspath
参数指定的路径。这个加载器是按照预设的jar包名称查找。启动类加载器无法被Java程序直接引用,
Extension Class Loader,除了启动类加载器,其他都是由Java实现,扩展类加载器加载 <JAVA_HOME>\lib\ext
目录,用来加载系统的扩展类。
Application Class Loader,用来加载 ClassPath
路径的类库。
双亲委派模型中要求除了启动类加载器,其余类加载器都应有父类加载器。如果一个类收到了类加载的请求,首先把请求委派给父类加载器完成,只有父类加载器反馈自己无法完成加载请求时(它的搜索范围没有找到所需的类),子加载器才会完成加载。
双亲委派模型来组织类加载器之间的关系,可是是Java中的类随着加载器拥有一种带有优先级的层次关系。可以避免系统类被重复加载。来保证Java程序的稳定性。
双亲委派模型的特例
双亲委派模型并不是具有强制约束的模型:
在JDK 1.2之前,只有 java.lang.ClassLoader
而没有双亲委派模型。因此用户会重写 loadClass()
方法来实现自己的需求。JDK 1.2中双亲委派模型出现时,为了兼容已有代码,无法避免 loadClass()
方法被覆盖,因此提供了一个protected的方法 findClass()
,并引导用于来重写这个方法,而不是在 loadClass()
中编写代码。因为双亲委派模型的实现逻辑就在 loadClass()
中。
比如数据库连接 java.sql.Driver
JDK只提供接口,具体实现由厂商提供,在JDK1.6之前我们使用 Class clz = Class.forName("com.mysql.jdbc.Driver");
是没问题的,由应用类加载器加载驱动实现,但是这样我们就不是面向接口编程,要针对驱动实现硬编码。于是JDK1.6提供了SPI(Service Provider Interface)功能,然后实现提供商把实现类放到 META-INF/services/
目录中,用户在使用时直接 Connection connection =
DriverManager.getConnection("jdbc:mysql://xxxxxx/xxx", "xxxx", "xxxxx");
SPI就会自己去通过 java.util.ServiceLoader
去找实现类,而不用硬编码驱动程序。但是 java.sql.Driver
是由启动加载器加载的,它在实例化驱动实现时是无法通过加载自己的加载器来加载驱动实现。而且基于双亲委派模型,它也无法向上让应用加载器去加载驱动实现。因此不得不引入了线程上下文加载器 ThreadContextClassLoader
。通过 Thread.currentThread().getContextClassLoader()
来过去当前线程上下问中的应用加载器,然后加载驱动实现类。Tomcat也使用了线程上下文加载器来加载 WebApp
下面的类。
随着代码热替换、模块热部署的需求强烈,模块化规范中出现了OSGi和JDK9官方的Jigsaw。其中OSGi模块化热部署就是自定义类加载机制的实现。其不再使用双亲委派模型推荐的树状结构,而是发展为更加复杂的网状接口,尽量在平级的类加载器中进行。
JDK9中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了变动。当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某-一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器 完成加载,也许这可以算是对双亲委派的第四次破坏。
虚拟机字节码执行引擎
在不同的虚拟机实现中,执行引擎在执行字节码的时候通常会有解释执行和编译执行两种选择。
运行时栈帧结构
Java虛拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虛拟机运行时数据区中的虛拟机栈(VrtualMachineStack) 的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。一个栈帧需要分配多少内存并不受程序运行期变量数据影响,值取决于源码和具体虚拟机实现的栈内存布局。
对于执行引擎来讲,在活动线程中,只有位于栈项的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(Current Stack Frame),与这个栈帧所关联的方法被称为“当前方法”(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。
局部变量表
用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_ locals数据项中确定了该方法所需分配的局部变量表的最大容量。局部变量表的容量以变量槽为最小单位。一个变量槽可以存放一个32位以内的数据类型,对于32位以上需要两个变量槽。
Java虛拟机通过索引定位的方式使用局部变量表。为了节省栈帧内存空间,局部变量表中的变量在离开作用域后其变量槽可以被复用。注意这里如果被复用的是引用类型,请联想前文中的GC Roots。
所流传的把变量赋值为null可以方便其被回收。在解释执行阶段或许有意义,但是在经过即时编译优化后赋值操作一定会被当做无效操作消除掉的。而即时编译才是虚拟机执行代码的主要方式。所以赋值为null没有实际意义。
由于局部变量表没有类加载时的准备阶段
,所以其中的变量没有默认值,如果一个局部变量被定义了没有赋初始值是不能被使用的。(测试过确实会有错误提示)
操作数栈
操作数栈它是一一个后入先出栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的 max_stacks
数据项之中。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。
另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调 用时就可以直接共用一部分数据,无须进行额外的参数复制传递了。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynanic Linking)。字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。
方法返回地址
分为正常调用完成返回和异常返回。方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。.
方法调用
Java不同于C++编译连接之后方法调用就固定了,Java某些调用需要在类加载期间或者运行期间才能确定目标方法的直接引用。
解析
调用不同类型的方法,字节码指令集里设计了不同的指令,Java虚拟机支持以下方法调用字节码指令:
invokestatic
用于调用静态方法。invokespecial
用于调用实例构造器()方法、私有方法和父类中的方法。 invokevirtual
用于调用所有的虚方法。invokeinterface
用于调用接口方法,会在运行时再确定一个实现该接口的对象。invokedynamic
JDK7加入用于支持动态语言,先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑都固化在Java虛拟机内部,而invokedynamic指 令的分派逻辑是由用户设定的引导方法来决定的。
在Java中静态方法、私有方法、实例构造器、父类方法和被final修饰的方法都可以在类加载阶段进行解析,因为他们在编译那一刻就可以确定直接引用。它们使用invokestatic、invokespicial,被final修饰的方法由于历史原因是使用invokevirtual。以上方法类型解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,称为“非虚方法”。与之相反其他方法称为“虚方法”,主要的方法调用形式:分派(Dispatch)调用,它可能是静态的也可能是动态的,按照分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况。
分派
分派发生在编译期和运行期,编译期的分派为静态分派,运行期的为动态分派。编译期是根据对象声明的类型来选择方法,运行期是根据对象实际类型来选择方法。
方法调用者和方法参数被称为宗量。静态类型: 一个对象在声明时的类型称为静态类型,静态类型再编译器编译时可知. 如 Father a = new Son(), 静态类型为Father, 实际类型为Son。
单分派和多分派取决于宗量, 方法调用者和方法参数都是宗量。
Java中静态分派的方法调用,首先确定调用者的静态类型是什么,然后根据要调用的方法参数的静态类型(声明类型)确定所有重载方法中要调用哪一个, 需要根据这两个宗量来编译, 所以是静态多分派(多个宗量确定)。
Java中动态分派的方法调用,在运行期间,虚拟机会根据调用者的实际类型调用对应的方法, 秩序根据这一个宗量就可以确定要调用的方法,所以是动态单分派(一个宗量)。
对于方法的重载和重写,需要通过分派来确定最终调用的是哪个方法。Java中重载通过变量的声明类型也就是静态类型来确定,属于静态分派。而重写通过运行时对象的时机类型来确定属于动态分派。在确定重载方法时,会同时根据方法调用者的静态类型和方法的参数来确定最终方法,所以是静态多分派。在确定重写方法时只根据方法调用者的运行时实际类型来确定最终方法,所以是动态单分派。
虚拟机动态分派的实现
动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,因此,Java虚拟机实现基于执行性能的考虑,真正运行时一般不会如此频繁地去反复搜索类型元数据。面对这种情况,一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable, 与此对应的,在invokeinterface
执行时也会用到接口方法表 Interface Method Table,简称itable) ,使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。
虚方法表一般在类加载的连接阶段进行初始化。虚拟机除了使用虚方法表之外,为了进一步提高性能,还会使用类型继承关系分析(Class Hierarchy Analysis,CHA)、 守护内联(Guarded Inlining)、内联缓存( Inline Cache)等多种非稳定的激进优化来争取更大的性能空间。
动态语言支持
前面提到过得invokedynamic字节码指令。动态语言指它的类型检查是在运行期而不是编译期。动态语言变量本身没有类型,变量的值才有类型。所以动态语言编译期最多只能确定方法名称、参数、返回值信息,不会确定方法接受者静态类型。静态语言的好处是代码灵活,实现简洁,开发效率提升。因为方法内联是编译期做的工作,所以动态方法调用不能使用方法内联。
Java与动态类型
JDK7的invokedynamic
指令以及java.lang.invoke
包。
java.lang.invoke
这个包提供一种动态确定目标方法的机制,称为方法句柄。使用invoke包中的 MethodHandle
和 MethodType
。它和反射的区别是:
-
Reflection和MethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。
-
Reflection是重量级,而MethodHandle是轻量级。Reflection中的Method对象远比MethodHandle机制中的MethodHandle对象所包含的信息来得多。前者是方法在Java端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而后者仅包含执行该方法的相关信息。
-
由于MethodHandle是对字节码的方法指令调用的模拟,那理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还在继续完善中),而通过反射去调用方法则几乎不可能直接去实施各类调用点优化措施。
Reflection API的设计目标是只为Java语言服务的,而MethodHandle则设计为可服务于所有Java虚拟机之上的语言,其中也包括了Java语言而已.
invokedynamic
字节码指令层面的 java.lang.invode
,每一处含有invokedynamic
指令的位置都被称作“动态调用点”,这条指令的第一个参数不再是代表方法符号引用的CONSTANT_ Methodref_info常量,而是变为JDK 7时新加入的CONSTANT_InvokeDynamic_info常量。直到JDK 8出现Lambda才真正使用上了此指令。
基于栈的字节码解释执行引擎
Java虚拟机的执行引擎在执行Java代码的时候有解释执行和编译执行两种。
解释执行
编译原理中的编译过程
在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虛拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。
基于栈的指令集与基于寄存器的指令集
Javac编译器输出的字节码指令流是一种基于栈的指令集架构,区别于物理硬件直接支持的寄存器指令集架构。寄存器指令集架构依赖硬件,基于栈的指令结构可移植但是会有性能损耗。
类加载及子系统的案例与实战
案例分析
Tomcat
作为Web服务器需要满足部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离;部署在同一个服务器上的两个Web应用程序所使用的Java类库可以互相共享;服务器需要尽可能地保证自身的安全不受部署的Web应用程序影响。支持JSP的HotSwap功能。
- 放置在/common目录中。类库可被Tomcat和所有的Web应用程序共同使用。
- 放置在/server目录中。类库可被Tomcat使用,对所有的Web应用程序都不可见。
- 放置在/shared目录中。类库可被所有的Web应用程序共同使用,但对Tomcat 自己不可见。
- 放置在/WebApp/WEB-INF目录中。类库仅仅可以被该Web应用程序使用,对Tomcat 和其他Web应用不可见
OSGi
动态模块化系统。
- BundleA:声明发布了packageA,依赖了java.* 的包;
- Bundle B:声明依赖了packageA和packageC,同时也依赖了java.* 的包;
- BundleC:声明发布了packageC,依赖了packagcA.
- 以java.* 开头的类,委派给父类加载器加载。
- 否则,委派列表名单内的类,委派给父类加载器加载。
- 否则,Import 列表中的类,委派给Export这 个类的Bundle的类加载器加载。
- 否则,查找当前Bundle的Classpath,使用自己的类加载器加载。
- 否则,查找是否在自己的Fragment Bundle中, 如果是则委派给Fragment Bundle的类加载器加载。
- 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
- 否则,类查找失败。
字节码生成技术与动态代理的实现
Javassist、CGLib、ASM、InvocationHandler
前端编译与优化
前端编译是指把代码文件编译为字节码文件过程;即时编译是运行期间把字节码编译成机器码的过程;提前编译是把代码编译成机器码的过程。
Javac编译器
前端编译Javac的编译过程大致可分为1个准备过程和3个处理过程:
- 准备过程:初始化插入式注解处理器
- 解析与填充符号表过程,词法、语法分析,将源代码构造成抽象语法树;填充符号表,产生符号地址和符号信息。
- 插入式注解处理器的注解处理
- 分析与字节码生成过程,对语法进行检查;数据流及控制流分析,对程序动态运行进行检查;解语法糖;字节码生成。
词法分析是将源码字符流解析为token集合的过程,关键字、变量名、字面量、运算符都可以是token。语法分析是根据token序列构造抽象语法树的过程。
符号表是由一组符号地址和符号信息构成的数据结构,其中的信息在编译的不同阶段都会使用到,比如语义检查、地址分配
注解除了可以和代码一样在程序运行期发挥作用,也可以提前至编译期对代码进行处理,可以对抽象语法树进行操作,但是插入式注解对抽象语法树进行操作后编译器将回到解析及符号填充阶段进行重新处理,直到所有的插入式注解处理器都处理完。Lombok就是就是插入式注解处理器的实现。
语义检查是对程序的上下文进行检查,比如类型检查、控制流检查等。可以分为标注检查和数据及控制流分析两种。标注检查包含变量使用是否声明、变量使用类型是否正确等;数据流分析和控制流分析用来检查局部变量使用前是否赋值、方法是否每条路径都有返回值、是否所有异常都处理等。比如被final修饰的局部变量,因为其没有字段那样在常量池中的符号引用,也就不能存储访问标识等信息,而其生成的字节码和普通变量是一样的,所以对其进行检查是在编译期进行的。
把语法糖中的泛型、变长参数、box等还原成基础语法结构。
把语法树和符号表转化成字节码指令,和少量的代码修改:添加实例构造器和类构造器、代码替换把字符串的相加替换为SB的append操作等。最终生成Class文件。
Java中的语法糖
泛型
泛型本质是把类型作为参数的一种形式,针对泛化的数据类型的代码复用。Java泛型的实现方式叫“类型擦除式泛型”,而其他叫“具现化泛型”。具现化泛型在程序各个阶段都是真实存在的类型,而擦除式泛型会被替换成裸类型,只是运行时的类型转换。这种选择是对兼容的一种屈服。而由于Java中基础数据类型不支持Object类型的类型转换,也就导致了在Java中基础类型不能用于泛型。且运行期无法取得泛型类型信息。
由于类型擦除,也就导致在Java中泛型类型无法进行重载。但是神奇的是我们知道返回值类型不参与方法签名,如果泛型重载返回不同的类型却能够正常编译运行。这是因为在字节码层面两个方法描述符不同可以共存。
box与循环
==
运算及equals()
方法不会触发box
条件编译
在Java中if条件常量为false
的代码块不会被编译
后端编译优化
把Java字节码编译成机器码的过程。
即时编译器
热点代码的即时编译。
解释器与编译器
解释器程序启动快、节约内存。编译器获得执行效率。同时如果编译器的激进优化不成立时,可以回退到解释执行阶段。JDK中的分层编译有三个即时编译器分别是“客户端编译器C1”、“服务端编译器C2”和JDK10中出现用来代替C2的Grall编译器。解释器与编译器搭配使用的方式在虚拟机中称为混合模式,同时还可以通过启动参数强制使用解释模式、编译模式。分层编译根据编译器编译、优化的规模与耗时划分出如下层次,切层次并非固定:
- 0层: 纯解释执行不监控
- 1层:使用C1,不监控
- 2层:使用C1,开启方法及回边次数统计有限监控
- 3层:使用C1,开启分支跳转等全量监控
- 4层:使用C2,会进行激进优化
编译对象与触发条件
回边:遇到控制流向后跳转,比如循环。
不管是对方法编译还是对循环编译,编译的对象都是整个方法体,对循环编译编译完整个方法体后的执行入口不同,由于是编译发生在方法执行过程中,所以叫OSR(栈上替换)。对热点的探测有采样的热点探测和计数器的热点探测。区别是采样简单但不精确;计数器精确但不简单。HotSpot使用计数器探测热点,方法计数器还有热度衰减过程,每过一段时间就把方法计数器减少一半。当达到阈值后提交编译请求,继续以解释方式运行。下次执行时如果已经编译完则以编译方式运行。
编译过程
客户端编译的三段式编译过程,主要在于局部性优化,而没有耗时的全局优化手段:
第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(HIR, 即与目标机器指令集无关的中间表示)。HIR使用静态单分配(SSA)的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器已经会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成。
在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(LIR,即与目标机器指令集相关的中间表示),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。
最后的阶段是在平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码。
而服务端编译器是能容忍很高优化复杂度的高级编译器,它会执行大部分经典的优化动作,如:无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除、空值检查消除(不过并非所有的空值检查消除都是依赖编译器优化的,有一些是代码运行过程中自动优化了)等。另外,还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联、分支频率预测等。
提前编译器
分两种,一种是在程序运行之前把程序编译成机器代码,一种是缓存即时编译结果。第一种由于提前编译很多激进优化不能使用,而第二种可以退回解释执行做兜底。第二种得益于有运行时统计信息,可以做有效优化;并且第二种可以做连接时优化。
编译器优化技术
方法内联
方法内联是大多数其他优化方法的基石。静态非虚方法很容易实现内联,而对于Java的多态方法,方法内联的实现也不那么容易。为了解决虚方法内联问题,虚拟机引入了类型继承关系分析技术。如果当前方法只有一个版本,进行内联,叫守护内联,同时如果出现异常可以退回解释执行阶段;如果多个版本,使用内联缓存,记录每次调用,如果每次调用都是同一个方法,则进行内联;如果调用不同版本方法,则退化成超多态内联缓存,开销相当与查找虚方法表来进行分派。
逃逸分析
同样逃逸分析不是直接优化手段,而是为其他优化措施提供依据的分析技术。分析对象的作用域,如果方法内对象被方法外访问,称为方法逃逸;如果被外部线程访问,称为线程逃逸;还有就是从不逃逸。可以针对不同逃逸程度做优化。比如,如果确定一个线程不会逃逸到线程之外,可以进行栈上分配来优化垃圾收集;对一个不逃逸对象进行标量替换(如果一个对象可以被拆散,将其成员变量恢复为原始类型来访问,而不用创建整个对象);如果一个变量不会逃逸出线程,可以不对变量实施同步措施。
公共子表达式消除
如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,直接复用表达式结果。
数组边界检查消除
当编译器可以确定对数组的访问不会越界时,会省去上下边界检查。
Java内存模型与线程
Java内存模型
主内存与工作内存
Java内存模型的主要目的是定义程序中各种变量对内存的访问规则,此处的变量包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的(引用类型对象在堆中可以共享,但是引用本身在栈的局部变量表中线程私有)
Java内存模型规定了所有的变量都存储在主内存中(此处的主内存与介绍物理硬件时提到的主内存名字一样, 两者也可以类比,但物理上它仅是虚拟机内存的一部分) 。每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本(对于对象,对象的引用和对象中的某个字段可能被复制,而不是整个对象被复制),线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据(包括volatile)。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
这里所讲的主内存、工作内存与Java内存区域中的Java堆、栈、方法区等没有任何关系。如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,工作内存对应于寄存器和高速缓存。但是要看虚拟机的具体实现。
内存间的交互操作
见附录。
对于volatile变量的特殊规则
volatile关键字使得对变量的写操作会强制刷新到主内存并使其他线程工作内存中的变量副本无效,重新从主内存读取。
由于对volatile的赋值可能会使用前导代码的计算结果或代表前导计算完成,或者对volatile变量的读取可能会在后导运算中使用,所以指令重排时会通过内存屏障保证以volatile变量的写和读操作为分界线,阻止跨分界线的指令重排。只有一个处理器访问内存时,并不需要内存屏障;但如果有两个或更多处理器访问同一块内存, 且其中有一个在观测另一个,就需要内存屏障来保证一致性了。
如果你的字段是volatile,Java内存模型将在写操作后插入一个写屏障指令,在读操作前插入一个读屏障指令。
内存屏障指令是一条汇编代码(注意是处理器指令非字节码指令) lock addl $0x0,(%esp)
,把esp寄存器的值加0。之所以使用加0是因为规定lock
前缀不可以配合nop
指令使用,而屏障的关键在lock
指令。lock
指令的作用是将本处理器的缓存写入内存,同时让其他线程缓存无效。其将修改同步到内存时,
内存屏障是一个CPU指令。基本上,它是这样一条指令: a) 确保一些特定操作执行的顺序; b) 影响一些数据的可见性(可能是某些指令执行后的结果)。编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。因为指令重排,必须保证正确的执行结果,lock addl $0x0,(%esp)
指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这之前的指令, 就不能跑到这之后。
处理器的两种缓存回写到主内存的策略:写回和写通,写回是指如果缓存被修改过,才会写回到主内存;写通是指每次写命中都会同时写到主内存
针对long和double变量的特殊规则
Java内存模型对于64位的数据类型,允许将没有被volaitle修饰的64位数据读写操作划分为两次32位操作。这种情况对于32位虚拟机出现风险比较高。
原子性、可见性与有序性
除了long和double的基本数据类型的访问读写都具备原子性;synchronized、final和volatile关键字保证了多线程操作的可见性;如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
先行发生原则
Java与线程
线程的实现
实现线程主要有三种方式:使用内核线程实现(1: 1实现),使用用户线程实现(1: N实现) ,使用用户线程加轻量级进程混合实现(N: M实现)。
内核线程是直接由操作系统内核支持的线程,这种线程有内核来负责管控。程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口叫轻量级进程(Light WeightProcess,LWP),轻量级进程就是我们通常意义上所讲的线程,每个轻量级进程都由一个内核线程支持。因为是基于内核线程实现,需要再用户态和内核态之间切换,所以线程开销大。
而用户线程实现指的是完全建立在用户空间的线程库上,不需要在用户态和内核态之间切换,但是线程的所有操作、调度都需要用户程序自己处理。
混合实现,把操作系统支持的轻量级进程作为用户线程与内核线程之间的桥梁。
Java中的线程依赖于虚拟机中的具体实现。HotSpot采用的是内核线程实现方式。
Java线程调度
线程调度分为协同式和抢占式,协同式有线程自己获取cpu资源并自己释放;抢占式由操作系统分配处理器资源。Java使用的是抢占式线程调度。
状态切换
- New:创建后尚未启动
- Runnable:此状态线程有可能在执行,也有可能在等待分配时间片
- Waiting:只能等待被其他线程显式唤醒
- 没有设置timeout的Object::wait()方法
- 没有设置timeout的Thread::join()方法
- LockSupport::park()方法
- TimedWaiting:一段时间后自动唤醒
- Thread::sleep()
- 设置了timeout的Object::wait()
- 设置了timeout的Thread::join()
- LockSupport::parkNanos()
- LockSupport::parkUntil()
- Blocked:等待获得一个排它锁
- Terminated:已经终止结束运行。
Java与协程
Java以Web服务器后端兴起,伴随着Web服务的调用量越来越大,内核模式的线程调度浪费的资源越来越多。协程或者纤程成为未来主角。
线程安全与锁优化
线程安全
Java中的线程安全
不可变
Immutable类型不存在修改,自然是线程安全的
绝对线程安全
即使对一个变量的原子操作是线程安全的,但是如果方法上下文中的一系列前后关联的操作不是原子的,同样存在线程安全问题。如果不依赖外部调用做保障措施,而是变量内部维护不同线程的快照,就是绝对线程安全。
相对线程安全
如果依赖外部调用做保障措施,就是相对的线程安全。
线程兼容
变量完全不提供原子操作,需要线程协调保证。
线程对立
及时调用方做了保障也完全无法做到线程安全。
线程安全的实现方法
互斥同步
通过线程互斥来实现同步,允许线程获得共享数据的排它锁。synchronized关键字是一种块结构的同步语法。经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。
被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。且在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制己获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。
阻塞或唤醒一条线程需要耗费性能在用户态和核心态的转换,因此是重量级锁。JUC包提供了非块结构的互斥同步,可以在类库层面实现同步。
ReentrantLock与synchronized相比增加了一些高级功能,主要有以下三项:等待可中断、可实现公平锁及锁可以绑定多个条件。
非阻塞同步
不阻塞,而是通过不断地尝试执行操作直到符合预期。乐观锁的实现是硬件级别提供的,处理器提供了CAS指令。但是乐观锁有ABA问题。
无同步
不依赖共享资源和其他线程的方法。
锁优化
自旋锁与自适应自旋
阻塞会导致线程频繁的从用户态和内核态之间转换,因此线程会短时间内忙循环(自旋)来保留时间片。如果还获取不到资源就进入阻塞。自适应自旋会根据上次自旋的情况来预测本次自旋能等待到资源的概率,如果认为能够等待到资源,会稍微延长自旋时间。如果预测无法等待到资源,会直接放弃自旋而进行阻塞。
锁消除
通过逃逸分析如果发现一段代码不存在线程安全问题,会对锁进行消除。注意并不是用户加了锁才会有锁,虚拟机内部可能也会对一些代码加锁。
锁粗化
虽说通过把加锁的作用范围控制的尽量小可以使需要同步的操作数量变小,但是一系列零碎的加锁操作比如循环内部反而为造成不必要的性能损耗。这时虚拟机就会把锁范围扩大到整个操作序列外部。
轻量级锁
轻量级锁能在绝大部分的锁在整个同步周期内都不存在竞争的情况下显著提高性能。当对象锁被第一次获取时,会尝试使用CAS在对象头做一个标记,此时不阻塞线程。当多个线程尝试获取对象锁时,升级为重量级锁,阻塞没有获得锁的线程。
偏向锁
在轻量级锁的基础上,如果同一个线程多次锁的同步块时,只有第一次使用CAS设置轻量级锁,以后进入都不再做同步操作。如果中间有另一个线程尝试进入同步块,偏向锁会升级为重量级锁。由于偏向锁记录线程id的占用了存储对象哈希码的位置,而哈希码被计算后就不能再改变,所以当一个对象计算过一致性哈希码之后,就再也不能进入偏向锁状态;当一个对象处于偏向锁状态时,收到计算一致性哈希请求该对象就会升级成重量级锁。
Java技术体系
Java程序设计语言、Java虚拟机、Java类库三部分统称为JDK。JCP官方所定义的Java技术体系包括了:
- Java程序设计语言
- 各种硬件平台上的Java虚拟机实现
- Class文件格式
- Java类库API
- 第三方Java类库
Java虚拟机家族
- 虚拟机始祖:Sun Classic/Exact VM,只能解释执行Java,或通过外挂即时编译器。并且两者冲突。
- HotSpot VM,由Sun收购而来。拥有热点代码探测技术,通过执行计数器找到热点代码进行即时编译。和栈上替换编译(OSR)。
- Mobile/Embedded VMs,Java ME产品线上的虚拟机。
- BEA JRockit和IBM j9 VM。
- Google Android Dalvik VM。
- Microsoft JVM。
- 专用硬件平台上的虚拟机及其他。
2018年Oracle Labs公开了Graal VM。号称Run Programs Faster Anywhere。除了可以运行JVM系语言,还可以运行C、C++等基于LLVM语言和JavaScript、Ruby、Python、R。Graal VM可以无额外开销的混合使用这些语言,支持不同语言混用接口。
新一代即时编译器
HotSpot,虚拟机中含有两个即时编译器,分别是编译耗时短但输出代码优化程度较低的客户端编译器(简称为C1)以及编译耗时长但输出代码优化质量也更高的服务端编译器(简称为C2),通常它们会在分层编译机制下与解释器互相配合来共同构成HotSpot虚拟机的执行子系统。自JDK 10 起,HotSpot引入和Graal编译器。
向Native迈进
在未来微服务甚至无服务的趋势下,Java越来越显得臃肿和不适应。因为Java需要几百兆的JRE,以及预热才能达到快速运行。所以Java需要向提前编译迈进,但是提前编译就不能一次编写到处运行。直到Substrate VM的出现。
Subtrate VM是在Graal VM 0.20版本里新出现的一个极小型的运行时环境,包括了独立的异常处理、同步调度、线程管理、内存管理(垃圾收集)和JNI访问等组件,目标是代替HotSpot用来支持提前编译后的程序执行。它还包含了一个本地镜像的构造器(Native Image Generator),用于为用户程序建立基于Substrate VM的本地运行时镜像。这个构造器采用指针分析(Points-To Analysis)技术,从用户提供的程序入口出发,搜索所有可达的代码。在搜索的同时,它还将执行初始化代码,并在最终生成可执行文件时,将已初始化的堆保存至一个堆快照之中。这样一来,Substrate VM就可以直接从目标程序开始运行,而无须重复进行Java虛拟机的初始化过程。但相应地,原理上也决定了Substrate VM必须要求目标程序是完全封闭的,即不能动态加载其他编译器不可知的代码和类库。基于这个假设,Substrate VM才能探索整个编译空间,并通过静态分析推算出所有虛方法调用的目标方法。Substrate VM带来的好处是能显著降低内存占用及启动时间。
HotSpot
现在,HotSpot 虚拟机能够在编译时指定一系列特性开关,让编译输出的HotSpot虚拟机可以裁剪成不同的功能,譬如支持哪些编译器,支持哪些收集器,是否支持JFR、 AOT、CDS、NMT等都可以选择。能够实现这些功能特性的组合拆分,反映到源代码不仅仅是条件编译,更关键的是接口与实现的分离。
早期的HotSpot虛拟机为了提供监控、调试等不会在《Java虛拟机规范》中约定的内部功能和数据,就曾开放过Java虛拟机信息监控接口(Java Vrtual Machine ProflerInterface,JVMPI) 与Java虛 拟机调试接口(Java Vrtual Machine Debug Interface, JVMDI)供运维和性能监控、IDE等外部工具使用。到了JDK 5时期,又抽象出了层次更高的Java虚拟机工具接口(Java Virtual Machine Tool Interface, JVMTI) 来为所有Java虛拟机相关的工具提供本地编程接口集合,到JDK 6时JVMTI就完全整合代替了JVMPI和JVMDI的作用。
在JDK 9时期,HotSpot虛拟机开放了Java语言级别的编译器接口 (Java Vrtual Machine Compiler Interface, JVMCI) ,使得在Java虛拟机外部增加、替换即时编译器成为可能,这个改进实现起来并不费劲,但比起之前JVMPI、JVMDI和JVMTI却是更深层次的开放,它为不侵入HotSpot代码而增加或修改HotSpot虛拟机的固有功能逻辑提供了可行性。Graal编译器就是通过这个接口植入到HotSpot之中。
到了JDK 10,HotSpot又重构了Java虛拟机的垃圾收集器接口 (Java Vrtual Machine Compiler Interface),统一了其内部各款垃圾收集器的公共行为。有了这个接口,才可能存在日后(今天尚未)某个版本中的CM S收集器退役,和JDK 12中Shenandoah这样由Oracle以外其他厂商领导开发的垃圾收集器进入HotSpot中的事情。如果未来这个接口完全开放的话,甚至有可能会出现其他独立于HotSpot的垃圾收集器实现。
附录
常量池数据类型
类 型 | 标 志 | 描 述 |
---|---|---|
CONSTANT_Utf8_info | 1 | UTF-8 编码的字符串 |
CONSTANT_Integer_info | 3 | 整型字面量 |
CONSTANT_Float_info | 4 | 浮点型字面量 |
CONSTANT_Long_info | 5 | 长整型字面量 |
CONSTANT_Double_info | 6 | 双精度浮点型字面量 |
CONSTANT_Class_info | 7 | 类或接口的符号引用 |
CONSTANT_String_info | 8 | 字符串类型字面量 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 |
CONSTANT_Methodref_info | 10 | 类中方法的符号引用 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法的部分符号引用 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
CONSTANT_MethodType_info | 16 | 标识方法类型 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
CONSTANT_Module_info | 19 | 表示一个模块 |
CONSTANT_Package_info | 20 | 表示一个模块中开放或者导出的包 |
访问标识
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBIC | 0x0001 | 是否为 public 类型 |
ACC_FINAL | 0x0010 | 是否声明为 final |
ACC_SUPER | 0x0020 | JDK1.0.2 之后编译出来的类这个标志都必须为真 |
ACC_INTERFACE | 0x0200 | 是否为接口 |
ACC_ABSTRACT | 0x0400 | 是否为 abstract 类型 |
ACC_SYNTHETIC | 0x1000 | 标记这个类并非由用户代码产生 |
ACC_ANNOTATION | 0x2000 | 是否为注解 |
ACC_ENUM | 0x4000 | 是否为枚举类型 |
ACC_MODULE | 0x8000 | 标识这是一个模块 |
字段访问标识
标志名称 | 标 志 值 | 含 义 |
---|---|---|
ACC_PUBIC | 0x0001 | 是否为 public |
ACC_PRIVATE | 0x0002 | 是否为 private |
ACC_PROTECTED | 0x0004 | 是否为 protected |
ACC_STATIC | 0x0008 | 是否为 static |
ACC_FINAL | 0x0010 | 是否为 final |
ACC_VOLATILE | 0x0040 | 是否为 volatile |
ACC_TRANSIENT | 0x0080 | 是否为 transient |
ACC_SYNTHETIC | 0x1000 | 是否由编译器自动生成 |
ACC_ENUM | 0x4000 | 是否为 enum |
字段类型描述符
标识字符 | 含义 |
---|---|
B | byte |
C | char |
D | double |
F | float |
I | int |
J | long |
S | short |
Z | boolean |
V | void |
L | 对象类型 |
方法访问标识
标志名称 | 标 志 值 | 含 义 |
---|---|---|
ACC_PUBIC | 0x0001 | 是否为 public |
ACC_PRIVATE | 0x0002 | 是否为 private |
ACC_PROTECTED | 0x0004 | 是否为 protected |
ACC_STATIC | 0x0008 | 是否为 static |
ACC_FINAL | 0x0010 | 是否为 final |
ACC_SYNCHRONIZED | 0x0020 | 是否为 sychronized |
ACC_BRIDGE | 0x0040 | 是否由编译器产生的桥接方法 |
ACC_VARARGS | 0x0080 | 是否接受不定参数 |
ACC_NATIVE | 0x0100 | 是否为 native |
ACC_ABSTRACT | 0x0400 | 是否为 abstract |
ACC_STRICTFP | 0x0800 | 是否为 strictfp |
ACC_SYNTHETIC | 0x1000 | 是否由编译器自动产生 |
虚拟机规范预定义属性
属性名称 | 使用位置 | 含义 |
---|---|---|
Code | 方法表中 | Java代码编译成的字节码指令(即:具体的方法逻辑字节码指令) |
ConstantValue | 字段表中 | final关键字定义的常量值 |
Deprecated | 类中、方法表中、字段表中 | 被声明为deprecated的方法和字段 |
Exceptions | 方法表中 | 方法声明的异常 |
LocalVariableTable | Code属性中 | 方法的局部变量描述 |
LocalVariableTypeTable | 类中 | JDK1.5中新增的属性,它使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加 |
InnerClasses | 类中 | 内部类列表 |
EnclosingMethod | 类中 | 仅当一个类为局部类或者匿名类时,才能拥有这个属性,这个属性用于表示这个类所在的外围方法 |
LineNumberTable | Code属性中 | Java源码的行号与字节码指令的对应关系 |
StackMapTable | Code属性中 | JDK1.6中新增的属性,供新的类型检查验证器(Type Checker)检查和处理目标方法的局部变量和操作数栈所需要的类型是否匹配 |
Signature | 类中、方法表中、字段表中 | JDK1.5新增的属性,这个属性用于支持泛型情况下的方法签名,在Java语言中,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数类型(Parameterized Types),则Signature属性会为它记录泛型签名信息。由于Java的泛型采用擦除法实现,在为了避免类型信息被擦除后导致签名混乱,需要这个属性记录泛型中的相关信息 |
SourceFile | 类中 | 记录源文件名称 |
SourceDebugExtension | 类中 | JDK1.6中新增的属性,SourceDebugExtension用于存储额外的调试信息。如在进行JSP文件调试时,无法通过Java堆栈来定位到JSP文件的行号,JSR-45规范为这些非Java语言编写,却需要编译成字节码运行在Java虚拟机汇中的程序提供了一个进行调试的标准机制,使用SourceDebugExtension就可以存储这些调试信息。 |
Synthetic | 类中、方法表中、字段表中 | 标识方法或字段为编译器自动产生的 |
RuntimeVisibleAnnotations | 类中、方法表中、字段表中 | JDK1.5中新增的属性,为动态注解提供支持。RuntimeVisibleAnnotations属性,用于指明哪些注解是运行时(实际上运行时就是进行反射调用)可见的。 |
RuntimeInvisibleAnnotations | 类中、方法表中、字段表中 | JDK1.5中新增的属性,作用与RuntimeVisibleAnnotations相反用于指明哪些注解是运行时不可见的。 |
RuntimeVisibleParameterAnnotations | 方法表中 | JDK1.5中新增的属性,作用与RuntimeVisibleAnnotations类似,只不过作用对象为方法的参数。 |
RuntimeInvisibleParameterAnnotations | 方法表中 | JDK1.5中新增的属性,作用与RuntimeInvisibleAnnotations类似,只不过作用对象为方法的参数。 |
AnnotationDefault | 方法表中 | JDK1.5中新增的属性,用于记录注解类元素的默认值 |
BootstrapMethods | 类中 | JDK1.7新增的属性,用于保存invokedynamic指令引用的引导方法限定符 |
内存间的交互操作
- lock (锁定) :作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock (解锁) :作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read (读取) :作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的1oad动作使用。
- load (载入) :作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use (使用) :作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign (赋值) :作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store (存储) :作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
- write (写入) :作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
访问规则:
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
- 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无原因地(没有发生过任何assig操作)把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之 前,必须先执行assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
- 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、 write操作) 。