IBM JDK 垃圾收集及内存管理机制(转)
转:http://www.blogjava.net/yoda/archive/2008/04/22/194723.html
看到一篇介绍IBM jdk的文章,虽然是08年发的但感觉还是很有意义。
译者前言
本文介绍了IBM JDK 1.2.2到1.4.1 SR1版本垃圾收集原理,虚拟机内部内存分配及管理的机制。根据IBM的说明,本文档也适合JDK 1.4.2。总体感觉翻译这篇文档比Sun HotSpot虚拟机的内存管理机制的那一篇要更加吃力一些。文档中介绍了很多的细节,也有较多的陌生的词。在文档的最后附有中英文词汇对照表。如果有错误之处,希望大家踊跃指出。如果你的英语水平可以的话,建议去读原文。
了解虚拟机内部内存管理以及垃圾收集的机制,可能有助于你调优虚拟机。另外,翻译这篇文章主要的原因是IBM JVM 和HotSpot JVM的有很大的区别,基本上是两套差异较大的思路出来的产品。HotSpot是分代的,IBM JVM是不分的。但是根据IBM的文档,在1.5以及后续的版本中,提供了分代收集的策略。JRockit可以同时支持分代和不分代的两种策略。看来大家都在互相取长补短啊。
原文出自
http://www-128.ibm.com/developerworks/java/jdk/diagnosis/142.html
Garbage Collection and Storage Allocation techniques
转载请注明出处(http://www.blogjava.net/yoda/),谢谢!
本文档描述从1.2.2到1.4.1 SR1的存储组件(ST)功能。(译者注:根据IBM的说明,本文档也适合JDK 1.4.2)
存储组件(Storage Component)用来分配堆中的存储空间,这些存储空间定义了对象、数组以及类。每个对象在存储空间中占有一部分存储,如果从虚拟机活动状态中存在到这个对象的引用(指针),这个对象就是可到达的;当虚拟机活动状态不再存在到这个对象的引用,这个对象就被视为垃圾,他占用的存储空间可以被重新使用。当申请重用发生时,垃圾收集器必须进行一些清理工作,以确保该对象所关联的监视器能够被释放回对应的监视器池中。存储组件并不是等同对待所有的对象,比如类对象以及线程对象是在堆的一个特殊空间(Pinned Cluster,固化簇)内进行分配的;在追踪整个堆的时候,引用对象和其派生对象也是被特殊处理的。在4.4章"引用对象"中对于这种特殊情况有详细的阐述。
对象分配是由对于某个分配接口的调用来驱动的,例如stCacheAlloc, stAllocObject, stAllocArray, stAllocClass。调用这些接口会在堆上分配一块空间,但是调用时的参数有所不同。stCacheAlloc函数是为小对象的高效分配所设计的,每个线程会预先从堆中申请一块独享的空间,叫做线程本地堆(Thread Local Heap),简称TLH。小对象在这个空间内直接进行分配。一个新的对象从线程本地堆的底部开始分配,由于无需获取堆锁,所以这种分配动作是非常高效的。如果对象较小(当前上限是512字节),那么即使使用stAllocObject和stAllocArray接口,对象也会分配在线程本地堆。
线程执行栈、类内部的静态对象以及本地和全局的JNI引用共同构成了虚拟机的活动状态。虚拟机内部调用的函数会导致在C执行栈上生成一个帧,这些信息用来寻找根对象,从根对象出发来寻找被引用的对象。这个过程会一直重复直到找到全部可到达对象。
当堆内存空间不足,导致虚拟机内存分配失败时,就会发生垃圾收集。垃圾收集的第一步工作就是找到堆中全部的垃圾,这个工作可以由任何线程的内存分配失败激活,也可以由显式调用System.gc()函数激活。首先,要获取垃圾收集所需的全部锁,这可以保证对于当时拥有临界锁的线程不会被挂起。通过执行管理接口(execution manager, XM)来挂起其他线程,以确保对于调用线程,其他线程的挂起状态是可进入的,这个状态包括在挂起时刻的从顶到底的执行栈以及寄存器状态,以用来追踪对象引用。在此之后,垃圾收集才可以开始工作,包含3个阶段:
· 标识
· 清理
· 压缩(可选的)
在标识阶段,所有的被虚拟机活动状态引用,或者静态的,或者固化字符串以及被JNI引用的对象都被标识。这个动作创建了JVM引用的根对象,这些根对象可能会依次引用其他对象,因此,标识阶段的第二部分工作就是从根对象出发,扫描其他被引用对象。这两步工作产生一个活动对象集合。
分配集合(allocbits)中的每个比特位标识堆中的一个8字节段,一旦分配了一个对象,分配集合中的对应比特位会被标识。垃圾收集器开始追踪栈时,首先比较指向堆底和堆顶的指针,确保指针指向的是8字节边界的对象,然后对分配集合中对应的比特位进行标识,表示该指针指向一个活动对象。然后在标识集合(markbits)中对对应的比特位进行标识,表明该对象处于被引用状态。
最后,垃圾收集器扫描对象的域字段查找被这个对象引用的其他对象,这个扫描过程是准确完成的,因为方法指针存储在第一个字单元,垃圾收集器能够知道对象的类型。在对象链接时(对象第一个实例创建之前),类装载器会创建一个偏移集合,这个偏移集合中记录了对象中引用其他对象的字段偏移位置,垃圾收集器通过访问这个偏移集合找到对应的域及其引用的其他对象。
标识阶段之后,在标识集合中包含了堆中所有可到达对象的标志。标识集合必须是分配集合的一个子集,清理阶段的工作就是找到这两个集合之间的差集,也就是在那些已经分配但是不再被引用的对象。
最起初,清理阶段就是从堆底开始扫描,依次访问堆中的每个对象。对象的长度存储在一个字单元中,对于每个对象,垃圾收集器检测对应的分配标识位和标识标识位以定位垃圾。
现在,使用bitsweep技术无需扫描整个堆,这样就避免了页交换的额外消耗。bitsweep技术直接在标识集合中寻找长的连续的0(未被标识的对象),这段长的连续的0可能表示一段空闲空间。找到这样的序列之后,垃圾收集器检测序列开始前的对象的长度,以检测可以被释放的空闲空间的大小。
垃圾收集器将垃圾从堆中移除之后,将剩下的对象向一侧压缩排列,以便移除这些对象之间的空闲空间。由于压缩动作消耗很大,所以应该尽量避免。在4.3.1章对于如何避免压缩有详细的阐述。
压缩是一个非常复杂的过程,因为句柄已经不再存在于虚拟机中了。如果垃圾收集器移动了某个对象,那么需要修改所有指向这个对象的引用。如果有来自栈的引用,那么就无法确定是这个引用确实是对象引用(有可能是一个浮点数),所以这个对象就不能移动。这种对象临时固定在他原来的位置,并且在头信息中使用对应比特位标识。类似的,在JNI操作的过程中,从JNI引用的Java对象也是固化的,无法移动,直到JNI操作结束。利用mptr低三位设置为0,可移动的对象在两个阶段内被压缩到一起。这3个比特位中一个用来标识对象已经被清理,注意,清理标识位出现在两个地方:link域(即OLINK_IsSwapped)和mptr (GC_FirstSwapped)。这两种情况,都会设置最低的比特位(x01)。
在压缩阶段的最后,所有线程通过XM恢复运行。
图表 1 一个对象
图表1是堆中一个对象的布局
· size + flags (大小和标识)
size + flags在32位架构上占4字节,在64位架构上占8字节,主要目的是用来存储对象的大小。由于对象都是从8字节边界开始,并且对象大小可以被8整除,最低3个比特位不被使用,垃圾收集器用来作为标识位以标识对象的不同状态。另外,由于对象大小是有限的,所以最高2个比特位也用来作为标识位(mptr也是8字节边界的)。
size + flags 中的标识位如下:
§ 第1位有多个用途。在清理阶段作为清理标识,在压缩阶段也会用到它。第1位还是多次固化标识位,用来标识该对象被多次固化。在垃圾收集的一个周期,多次固化标识位被清除,用作其他用途,然后恢复
§ 第2位是dosed位。如果从栈或者寄存器有到某个对象的“引用”,就会在dosed位进行标识。这里“引用”是指在本次垃圾收集周期不能移动该对象,因为垃圾收集器无法确定该“引用”是一个真正指向对象的引用还是碰巧只是一个和对象句柄值相等的整数
§ 第3位是固化标识位。固化的对象就是从堆之外有指向这个对象的引用,例如线程和类对象。此类对象无法移动
§ 32位架构的第31位或者64位架构的第63位是锁(flat lock)竞争位,被锁管理模块(locking, LK)使用
§ 32位架构的第32位或者64位架构的第64位是哈希标识位,表示一个对象已经返回了哈希值。因为对象的哈希值就是对象的地址,如果垃圾收集器移动了这个对象,需要维护这个值
· mptr
mptr槽位在32位架构上占4字节,在64位架构上占8字节,mptr是8字节边界的(译者注:原文为The mptr slot is grained on an 8-byte boundary, not the size + flags. 不知道如何翻译才是准确的),功能为以下两种之一
§ 如果对象不是数组,mptr指向类方法表,垃圾收集器据此找到类信息。通过这种方式,垃圾收集器知道对象是从哪个类实例化的。类的方法表和类本身的信息由类加载器组件(class loader, CL)分配,但是不存储在堆空间
§ 如果对象是数组,mptr是数组内条目的计数
· locknflags
locknflags槽位在32位架构上占4字节,在64位架构上占8字节,但是只有低4字节被使用。主要作用是用来保存锁信息。另外还包含3个标识比特位
§ 第2比特位是数组标识。如果对象是数组,则该标识位设置为1,mptr槽位保存数组中对象的个数
§ 第3比特位是哈希和移动标识位,如果这个标识位被设置为1,表明该对象被移动过,可以在对象在移动之前的位置找到其哈希值
§ (译者注:在图表1中并未画出locknflags槽位,原文如此,笔误?)
· 对象数据
这里是开始记录对象数据的位置
size + flags, mptr以及locknflags一起称为对象头信息
图表 2 堆
图表2是堆的示意图。堆是虚拟机在初始化时从操作系统申请的一段连续的内存空间。堆底是堆的开始地址,堆顶是堆的结束地址,堆上限是堆中当前使用部分的结束地址。堆上限可以扩展和收缩。-Xmx参数限制了从堆底到堆顶的最大值,如果未设置,默认值如下:
· Xmx
§ Windows: 物理内存的一半,最小16MB,最大2GB-1
§ OS/390 和 AIX: 64MB
§ Linux: 物理内存的一半,最小16MB,最大512MB-1
· Xms
§ Windows, AIX, Linux: 4MB
§ OS/390: 1MB
对于大多数应用而言,默认的设置即可满足需求。在运行时,堆会自动扩展到一个稳定的状态,保证在任何时刻堆中活动对象占堆大小的70%,在这种状态下,垃圾收集的频率和暂停时间都是可以接受的。
对于某些应用而言,默认参数可能无法保证应用的良好运行,下面列出可能出现的问题以及应对策略。使用verbosegc可以监控堆的使用情况。
· 在堆到达稳定状态之前,垃圾收集发生的过于频繁
使用verbosegc检测堆到达稳定状态时的大小,然后设置-Xms参数等于该大小
· 堆已经扩展到最大限制,但是堆占用率仍然高于70%
增加-Xmx的设置,以使得堆能够扩展到一个能够保证占用率不高于70%的大小。但是需要注意的是,要保证堆的内存都是从物理内存占用,避免出现页交换
· 堆占用率在70%,但是垃圾收集发生的频率过高
修改-Xminf参数。默认是0.3,表示堆要通过扩展保证有30%的空闲空间。例如,设置该参数为0.4,会降低垃圾收集发生的频率
· 暂停时间过长
尝试使用-Xgcpolicy:optavgpause参数。在堆的占用比增高的情况下,该参数能够保证垃圾收集时间的稳定,但是会带来大约5%的吞吐量的下降,具体视应用而定
另外,还有一些小提示:
· 确保堆不会发生页交换(即堆内存全部从物理内存中获取)
· 避免使用finalizer。你不能确保finalizer执行的时机,这样会带来一些问题。在verbosegc的输出中,可以看到是否已经执行了finalizer。如果确实需要使用finalizer,需要注意以下三个关键点
§ 不要在finalizer方法中创建新的对象
§ 不要依赖finalizer来释放一些本地资源
§ 不要在finalizer方法中进行长时间执行的或者阻断式的动作
· 避免压缩。verbosegc的输出中可以显示是否进行了压缩动作。通常,压缩动作是由于大块内存的分配引发的,所以,要分析应用中对于大块内存的需求,比如,一个大的数组对象,可以将其拆分成多个小片段
图表 3 堆以及分配集合和标识集合
图表3是堆、分配集合和标识集合的示意图。这两个比特位集合标记了堆中对象的状态。因为堆中对象都是8字节边界的,所以每个比特位对应1个8字节段,这两个比特位集合的大小为堆大小的1/64。
一旦在堆中分配了一个对象,在分配集合中对应对象开始地址的比特位被设置为1。分配集合只是标识了对象被分配,但是无法得知对象是否是活动的。在垃圾收集的标识阶段,标识集合中对应的比特位会被设置,以表示对象是活动的。图表4表示堆中的2个对象,分配集合中对应比特位都被设置为1.
图表 4 堆中一些对象
在标识阶段,Object2是被引用的,Object1是未被引用的,所以在标识集合中对应
Object2的比特位被设置为1,在清理阶段,Object1会被垃圾收集。
图表 5 系统堆
系统堆中包含的对象是跨越整个虚拟机生命周期的对象,通常这些对象是系统级的类对象、可共享的中间对象以及应用级对象。垃圾收集器不会收集系统堆中的对象,因为这些对象在整个虚拟机生命周期内都是可达到的,或者是应用需要共享的一些对象。图表5是系统堆的示意图。系统堆不是连续的存储空间,而是由多个存储段构成的链。系统堆的初始大小在32位架构上是128KB,在64位架构上是8MB。如果系统堆被对象充满,虚拟机会重新分配一块空间,并且加入到系统堆的链表中来。
图表 6 空闲链表
图表6表示空闲链表,链表的头是一个全局的指针,指向空闲链表的第一个存储段。空闲链表的每个存储段都有一个大小字段和一个指向下一个空闲存储段的指针,链表的最后一个空闲存储段的Next为空指针。
当所需要的空间大于512字节或者在已有的缓存中无法分配所需的大小,就会发生锁堆分配,顾名思义,锁堆分配需要获取堆上的锁,所以应该尽量避免。
If size < 512 or enough space in cache try cacheAlloc return if OK HEAP_LOCK Do If there is a big enough chunk on freelist takeit goto Got it else manageAllocFailure if any error goto Get out End Do Cot it: Initialise object Get out: HEAP_UNLOCK |
图表 7 锁堆分配
图表7即是锁堆分配的伪代码,垃圾收集器首先检测要分配的空间的大小,如果小于512字节或者在当前缓存内有足够的空间进行分配,则尝试缓存分配;如果不能进行缓存分配,或者缓存分配失败,则会发生锁堆。垃圾收集器开始在空闲链表中查询可用的空间,如果找到满足条件的空间,则在此空间内进行分配,并且将剩余的空间返回到空闲链表中。需要注意的是,如果剩余的空闲空间不足512字节加上头信息大小(32位架构上12字节,64架构上24字节),那么就不会返回到空闲链表中,这些小的存储空间就被称为“暗物质”。如果垃圾收集器无法找到满足条件的空闲空间,就会发生分配失败,开始进行垃圾收集工作。垃圾收集结束之后,继续在空闲链表中查找满足需求的空间,如果依然无法找到,就会发生内存溢出的错误。无论对象是否分配成功,堆锁都会被释放。
在某些情况下,比如在很大的堆中,空闲链表是由大量的空闲存储空间段组成,或者应用频繁的申请较大的存储空间,锁堆分配策略可能会存在问题。因为每次都需要从空闲链表的头开始查找满足需求的空闲空间,效率相对较低,所以有了快速空闲链表查找算法来解决这个问题。
每次锁堆分配尝试查找链表时,都会收集以下数据:
· 在找到满足需求的空间之前已经在空闲链表中查找过的存储段数量
· 在找到满足需求的空间之前所查找的空闲段中的最大段的大小。也就是不能满足需求的段的最大大小
当找到满足需求的空间之后,如果查找计数大于20,表示需要创建一个active hint指向空闲链表。然后,根据实际的需求来决定是从空闲链表头开始查找,还是从active hint指向的位置开始查找,一旦在一个段中进行了分配,active hint会被及时更新。
图表 8 堆中的缓存段
缓存分配是针对小对象设计的高性能分配策略,线程预先从堆中分配一段空间,对象直接在这一段空间内进行分配,无需获取堆的锁,所以缓存分配的效率是极高的。如果满足以下条件,就会进行缓存分配:
· 对象小于512字节,或者
· 线程本地堆中有足够的空间容纳对象
图表8是堆中缓存段的示意图。缓存段又叫做线程本地堆(thread local heap,TLH)。当垃圾收集器为一个线程分配线程本地堆时,使用锁堆分配一段空间给该线程独享使用。分配集合中对应线程本地堆的比特位不会被设置,直到线程本地堆满了,或者一轮垃圾收集工作开始的时候,才会被设置。为了提高分配线程本地堆的性能,垃圾收集器总是使用在空闲链表中找到的第一个空闲段,并且不大于40KB。
图表 9 在缓存段中分配的对象
图表9显示的是在缓存段中分配的一些对象。在缓存段中,对象总是从段顶开始分配,这样能够比从段底分配更加高效。图表9中还显示了在分配集合中,并没有设置缓存段的标识位。直到缓存段满了,或者一轮垃圾收集工作开始的时候,这些标识位才会被设置。
4 垃圾收集
在锁堆分配中如果发生了分配失败,或者有对System.gc()的显式调用,则会发生垃圾收集。调用System.gc()的线程或者发生分配失败的线程负责进行垃圾收集。首先,他获取垃圾收集所需的锁,然后挂起其他线程,再开始垃圾收集的三个阶段的工作:标识、清理以及压缩阶段(非必须的)。IBM JDK的垃圾收集是stop-the-world类型的,因为在垃圾收集过程中,所有的应用线程都被挂起。
4.1 标识阶段
在标识阶段,所有的活动对象都被标识。因为不可到达对象不太容易定位,所以要明确所有的可到达对象,那么余下的就是垃圾了。这个标识所有可达到对象的过程也被称作追踪(tracing)。
被保存的寄存器、线程的执行栈、类中的静态域、本地或者全局的JNI引用,共同构成了虚拟机的活动状态。虚拟机自身调用的函数都会生成一个C执行栈上的一个帧。这个帧可能包含一些对象实例,可能是要赋值给本地变量的对象,也可能是来自调用者的调用参数。在追踪阶段,所有这些引用都是被同等对待的。垃圾收集器自顶到底扫描每个线程的栈,4字节为一组(在64位架构上是8字节一组),垃圾收集器假设栈是4字节对齐的(在64位架构上是8字节对齐的),然后检查栈上每个4字节组是否是指向堆上的一个对象,有可能所指向的不是一个真正的对象,因为可能只是碰巧和一个整数或者浮点数的存储表示相同。垃圾收集器扫描线程栈,然后保守的处理他所找到的这些指针,只要这个指针指向一个对象地址,那么就假设他真的是一个对象引用,并且在垃圾收集的时候,不能移动这个对象。如果满足以下3个条件,这个槽位就被认为是指向一个对象的指针:
1. 8字节对齐的
2. 位于堆的地址范围内
3. 对应的分配比特位已经设置为1
以这种方式被引用的对象就是根对象,根对象的dosed标识位被设置,表示这个对象不能被移动,只有在压缩阶段时,垃圾收集器才会设置dosed标识位。从根对象开始,可以精确的追踪其他被引用对象,因为垃圾收集器知道这些引用确实是真正指向对象的引用,由于可以修改引用,所以这些被引用的对象在压缩阶段可以被移动。追踪阶段使用一个可以容纳4KB条目的栈,所有的引用都被压栈,同时,设置对应的标识比特位。首先,全部根对象被压栈,然后再依次出栈,在出栈的过程中继续追踪。普通对象(非数组对象)通过mptr访问类信息块来追踪被其引用的其他对象。一旦找到引用,并且被引用对象尚未被标识,那么该对象就被标识并且压栈。
对于数组对象,垃圾收集器检查每个条目,如果该对象尚未被标识,则对其进行标识并且压栈。为了避免标识栈溢出,每次只处理数组的一部分内容。
垃圾收集器重复以上过程,直到标识栈为空。
4.1.1 标识栈溢出
因为标识栈的大小是有限制的,所有有可能发生标识栈溢出的问题。虽然这种问题发生的几率非常小,但是当发生标识栈溢出时,对于垃圾收集的暂停时间有非常大的影响。
4.1.1.1 溢出集合
垃圾收集器需要一个能够映射整个堆的比特位数组来记录堆中未被追踪的对象,就是FR_bits数组,该数组是为进行增量压缩(Incremental Compaction, IC)设置的,对于每个可能的引用槽位(在32位架构上是4字节,在64位架构上是8字节),有一个对应的比特位。由于JVMObject头信息不会包含任何引用信息,所以每个对象对应的FR_bits数组中的前2个比特位是不被IC使用的,因此垃圾收集器使用FR_bits数组中的第1个冗余比特位实现溢出集合。
4.1.1.2 处理非系统堆对象的标识栈溢出
当线程尝试将一个引用压栈到标识栈时,如果此时发现标识栈已经满了,他会向自己的本地标识队列发布一个任务。如果发布动作失败了,线程会设置这个引用对象对应的FR_bits数组,以表示发生了标识栈溢出。
然后追踪工作继续处理已经设置了FR_bits数组的、无法被压栈的引用。
一旦线程处理完了标识栈,他就会尝试接管溢出集合,并且,为了确保溢出集合只被一个线程处理,该线程设置一个是否发生标识栈溢出的全局标识为False。一旦确立了溢出集合的所属权,这个线程就开始扫描FR_bits数组,查找所有的非零比特位。一旦找到非零的比特位,则将其清零,并且对应的引用被压栈。到一定量的引用被压栈之后,他们被发布到本地标识队列,以便于其他线程辅助处理溢出集合。
在处理溢出集合的同时,有可能发生标识栈溢出。如果发生这种情况,那么一个全局标识会被设置以标识发生了溢出,上面描述的处理过程会重复执行。
4.1.1.3 系统堆溢出机制
在收集根对象时,垃圾收集器会将所有系统堆和ACS堆中的对象引用也压栈,因此同样会发生标识栈溢出的问题。但是FR_bits数组只映射了非系统堆,所以无法用来记录系统堆和ACS堆中未被追踪的对象。
在垃圾收集的标识阶段,已经加载的类的地址是不会被修改的,因此,垃圾收集器需要记录在发生标识栈溢出前的那一刻,他所到达的追踪链条的位置。所以,引入两个全局变量“overflowSystemClasses”和“overflowACSClasses”来表示对应在系统堆和ACS堆中的进行位置。当处理溢出集合时,这两个变量告诉垃圾收集器应该在什么位置停止。
4.1.1.4 处理系统堆对象的标识栈溢出
在并行标识(parallelMark)阶段,如果线程已经处理完了标识栈,接下来需要检查overflowSystemClasses和overflowACSClasses两个变量是否被设置。如果其中某个变量被设置,那么这个线程就会试图获取对应对象列表的控制权,并且将这个变量设置为NULL。一旦线程获取了控制权,就会将引用压栈到标识栈,一定量的引用被压栈之后,向本地标识队列发布任务,以允许其他线程辅助进行后续工作。
如果在处理的过程中,再次发生了标识栈溢出,线程会记录在发生溢出之前处理到的位置,然后重复上面的工作。
4.1.2 并行标识
由于优化的按位清理算法和压缩避免机制的存在,使得一个垃圾收集周期的主要时间消耗在对象标识阶段。所以,开发了并行标识技术,该技术使得在单CPU的主机上不降低标识的性能,并且能够在8路主机上将性能提高4倍左右。
在标识阶段的时间主要消耗在一些辅助线程以及协调这些辅助线程共同工作上。一个线程作为主要线程,也就是我们所说的垃圾收集主线程。这个线程负责扫描C堆栈,找到活动的根对象。在一个具有N个CPU的主机上,会创建N-1个辅助线程来辅助完成后续的标识阶段工作。辅助线程的数量可以由虚拟机启动参数
-Xgcthreadsn来重新设定。设置为1表示没有辅助线程,该参数取值范围为1到N。
更高层面讲,每个标识线程拥有自己的本地栈以及共享的队列,这两个变量都包含了那些已经被标识但是尚未被扫描的对象的引用。辅助线程大部分的标识工作都是依赖本地的栈变量来完成的,只有在需要负载均衡的时候才会在共享队列上进行同步的操作。由于对于标识比特位的操作是原子操作,所以无需获取锁。
由于每个线程的栈都可以容纳4KB个条目,标识队列可以容纳2KB个条目,所以大大降低了标识栈溢出的发生几率。
4.1.3 并发标识
并发标识机制保证在堆内存增大的时候,能够降低垃圾收集的暂停时间。在堆满之前,开始进行并发标识:垃圾收集器通知每个线程扫描自己的执行栈以查找根对象,然后基于这些根对象开始进行并发的追踪,追踪是在进行锁堆分配的时候,由一个较低优先级的后台线程以及全部的应用线程一起完成。
由于垃圾收集器在应用运行的同时标识活动对象,所以必须记录已经追踪的对象的任何变化。为了达到这个目的,他采用了一种写隔离(write barrier)的技术,在对象发生变化时被激活。首先将堆分隔成512字节的段,每个段对应卡片表(card table)中的一个字节。当指向一个对象的引用发生变化时,对应这个对象所在段的卡片表中的字节被设置为0x01。使用字节而不是比特位有两个原因:字节的写入速度比比特位更快,另外,字节中其他的比特位还可以用作其他用途。
如果发生以下情况,就会开始STW(Stop The World)收集:
· 分配失败
· System.gc()
· 并发标识阶段完成
垃圾收集器开始进行并发标识阶段的工作,以试图在堆耗尽之前完成垃圾收集工作。虚拟机启动参数可以管理并发标识的时间。
在STW阶段,垃圾收集器扫描所有的根对象,并且通过标识卡片来查看需要进一步追踪的对象,然后按照普通的模式进行清理。这样能够保证在并发标识阶段开始时的不可到达对象都可以被清理,但是无法保证在并发标识阶段进行过程中变成不可到达的对象也被清理。
并发标识策略可以减少垃圾收集的暂停时间,但是会带来额外消耗,因为应用线程需要在获取堆锁的时候进行一些跟踪工作。具体的性能降低取决于有多少空闲的CPU时间可以给后台线程使用。另外,写隔离机制也会有额外的消耗。
开启并发标识的参数为:
-Xgcpolicy:<optthruput|optavgpause>
设置-Xgcpolicy参数为optthruput禁用并发标识。optthruput是默认设置。如果你的应用中不存在暂停时间带来的问题,可以使用这个默认选项获得最好的吞吐能力。
设置-Xgcpolicy参数为optavgpause启用并发标识。如果应用因为垃圾收集的暂停时间导致响应能力下降,可以使用这个参数来改善情况,但是会降低应用吞吐量。
4.2 清理阶段
标识阶段结束之后,标识集合中对于堆中每个可到达对象都进行了标识,而且是分配集合的子集。清理阶段就是计算分配集合和标识集合的差集,也就是说,找到那些已经被分配但是已经不被引用的对象。
最初,使用按位清理(bitsweep)技术,这种技术检查标识集合中较长的连续0序列,可能对应的就是空闲空间。一旦找到连续0序列,垃圾收集器检查这个序列开始位置对应的对象的大小,来计算可以释放多少空间。如果这个大小大于512字节加上对象头大小,那么这一段空间就会重新加入到空闲链表中去。
未返回到空闲链表中的小的空闲空间,就被称作是“暗物质”,当紧邻“暗物质”空间的对象被释放之后,或者压缩动作执行之后,他就可能再次返回到空闲链表中去。垃圾收集器不需要清理空闲段中的每个对象,因为这一段都是可以被清理的对象。在这个过程中,标识集合会整个复制到分配集合中去,这样分配集合就表示了堆中所有的已分配对象。
4.2.1 并发按位清理技术
并发按位清理技术能够尽量使用可用的处理器以提高清理阶段的执行效率。在并发清理阶段,垃圾收集器使用和并发标识阶段同样的辅助线程,所以,默认的参与并发处理的线程数也可以由虚拟机参数-Xgcghreadsn来设置。堆被分割成多个段,段的数量要远远大于并发清理线程的数量,计算公式如下:
· 32 * 辅助线程数量 或者
· 堆的最大大小/16MB
中选择较大的一个。每次一个辅助线程选择一个段来扫描,然后按位清理,并且保存每个段的清理结果,完成之后,再重新建立空闲链表。
4.3 压缩阶段
在清理阶段结束之后,垃圾收集器可以对堆中剩余的活动对象进行压缩,以移除他们之间的空闲空间。这个压缩过程是非常复杂的,因为一旦移动了一个对象,那么所有指向这个对象的引用都需要修改。如果是来自栈的引用,那么垃圾收集器还不能确定这就是一个对象引用(有可能碰巧只是一个浮点数),就不能移动这个对象。这样的对象还会继续保持在原来的位置,并且dosed标识位会被设置。类似的,JNI操作中引用的对象也是要固化在原来的位置不能被移动的,直到JNI操作结束,不再引用这个对象的时候,才可能被移动。利用mptr的低三位设置为0,垃圾收集器可以在两个阶段内压缩那些可以移动的对象。其中一位用来标识对象被清理,这个清理标识位出现在两个地方:size+flags(即OLINK_IsSwapped)以及mptr(即GC_FirstSwapped),这两种情况中,最低的位(x01)都会被设置。
下图可以帮助你理解压缩过程:
图表 10 压缩阶段工作
图表10展示了压缩的效果。假设从A到B是一个走廊,走廊里面有一些家具(蓝色的块),代表对象。空白的区域代表空闲空间或者“暗物质”,有两个家具被固定在地板上(交叉纹理块),代表固化的或者不能被移动的对象。压缩的过程就类似你要把家具从B推到A,尽可能的靠近A端。但是,不幸的是,你不能把家具举起来越过固定在地板上的家具,所以,他们右侧的家具最远也就推到紧邻他们的位置。
4.3.1 避免压缩
图表 11 荒地
避免压缩主要是致力于为对象找到合适的放置位置,以减少或者避免移动对象。避免紧技术中最主要的一个概念叫做荒地预留。荒地预留技术试图在堆中预留一段空间,然后尽量在其他地方进行对象分配。在堆顶和荒地部分之间,定义一个边界。如果存在大对象的分配,或者上一次垃圾收集之后尚未满足分配需求的情况下,就会使用荒地空间。
图表11是堆中荒地的示意图。荒地在活动的堆空间的最后进行分配,初始大小是活动堆空间的5%,根据实际需求进行收缩或者扩展。在锁堆分配失败的情况下,如果对象大小小于64KB或者上一次垃圾收集的结果取得足够的进展,那么就再开始一轮垃圾收集工作。足够的进展的意思是,自从上一次垃圾收集以来,至少有30%的堆空间被占用。30%是默认值,可以通过-Xminf参数设置。如果没有取得足够进展,或者对象大于等于64KB,那么会尝试在荒地中进行分配。这样就能够避免垃圾收集和堆压缩动作。
如果未设置虚拟机参数-Xnocompactgc并且以下几个条件任何一个为true,那么就会发生堆压缩动作:
· 设置了虚拟机参数-Xcompactgc
· 清理阶段结束之后,还是无法满足分配需求
· 调用System.gc()并且在最后一次分配失败发生或者并发标识收集之前发生了压缩动作
· TLH消耗了至少一半的存储,并且TLH的平均大小低于1000字节
· 堆的空闲空间小于5%
· 堆的空闲空间小于128KB
4.3.2 增量压缩
4.3.2.1 介绍
垃圾收集释放了对象空间之后,在堆中就会产生碎片。那么会引发一种现象,堆中还有足够的空闲空间,但是由于他们是不连续的,所以无法进行后续的分配。
压缩动作就是用来整理堆中的碎片,他将堆中分散的已分配的存储段移动到堆的一端,那么在堆的另一端就会生成一个较大的连续的空闲空间。但是压缩会增加垃圾收集的暂停时间,对于1GB的堆,如果进行了压缩动作,垃圾收集的暂停时间可能增加到40秒。对于应用而言,这么长的暂停时间通常是不可接受的。增量压缩技术就是将压缩动作分散到多次垃圾收集周期中,以减少暂停时间。
增量压缩的另一个重要作用是清理暗物质。暗物质就是堆中的很小的(小于512字节)存储片,这些存储片不在空闲链表中,因此不能被重新利用。暗物质的存在程度直接影响了应用的吞吐能力,因为越多的暗物质就会导致堆中可用的存储空间越少,可用存储越少,垃圾收集发生的频率就会越高,对于应用的性能会产生非常明显的影响。这些暗物质分散在整个堆中,会占用堆的大部分空间。
4.3.2.2 增量压缩概览
图表 12 增量压缩
在图表12中,涂色的块表示已经分配的空间,未涂色的块表示空闲空间。暗物质会分布在空闲空间之间。下半部分表示在增量压缩之后,对象被移动到段的一侧,另一侧就整理出了较大的连续空闲空间。
只有在堆大小大于某个值(当前是128MB)时,才会发生增量压缩。如果堆小于128MB,增量压缩并不会带来比完整压缩更短的暂停时间。
增量压缩有如下两步:
1) 记录和标识指向压缩区域的所有引用,这个动作在标识阶段完成
2) 计算对象的新位置,在区域内进行压缩,并修改指向被移动对象的引用
增量压缩在一个周期内进行,一个增量压缩周期就是指在垃圾收集周期内,一次一个区域的完成整个堆的压缩动作。压缩动作会跨越多个垃圾收集周期,因此将压缩动作的耗时分散到多个垃圾收集周期,减少暂停随时间。
4.3.2.3 增量压缩相关的主要参数
默认是启用增量压缩的,是否运行增量压缩取决于一些触发条件。但是有两个参数可以由用户决定是使用增量压缩还是传统压缩:
· -Xpartialcompactgc,表示每次垃圾收集都使用增量压缩,除非必须进行完整的压缩动作
· -Xnopartialcompactgc,表示禁用增量压缩机制
但是,需要提醒的是,-X参数属于非标准的虚拟机参数,可能在未通知的情况下进行修改。
4.4 引用对象
引用对象能够使得所有的对象引用都以同样的方式被操作和处理,因此垃圾收集器在堆上创建两个独立的对象:一个是对象本身,一个是引用对象。当对象处于不可到达状态时,该引用对象可以方便的加入到一个队列中去。SoftReference,WeakReference以及PhantomReference由用户创建,不能修改,即,他们只能指向创建时的那个对象,不能指向其他对象。带有finalizer方法的对象在创建时注册Finalizer类,因此FinalReference对象指向一个需要finalize的对象,并且关联到Finalizer队列。
在垃圾收集阶段,引用对象被特殊处理:在标识阶段,不会处理引用对象的引用字段,标识阶段结束之后,按照如下顺序处理引用字段:
1) Soft
2) Weak
3) Final
4) Phantom
对于SoftReference对象的处理稍有特殊,如果他所指向的对象未被标识,ST组件会清理这个SoftReference。如果存储不足,那么垃圾收集器根据最近最常用的规则来进行清理。使用率根据最后一次被调用get方法来测量。一旦一个引用对象被处理,他所指向的真实对象会被标识,这样能够确保如果一个FinalReference也指向同一个对象,他能够看到这个标识。FinalReferece不会被放到处理队列中去。因此,在垃圾收集周期可以成功处理引用对象。
指向未标识对象的引用对象最初被放到ReferenceHandler线程的队列中,ReferenceHandler线程把对象从他的队列中移除,同时查看这个对象自己的队列是否存在,如果存在,则这个对象被重新放入ReferenceHandler线程的队列中去,以进行后续的处理。因此,FinalReference会重新入队,确保最后finalize方法被finalizer线程执行。
4.4.1 JNI Weak引用
JNI Weak引用对象提供类似WeakReference对象的功能,但是处理机制是不同的。一段JNI代码可以创建或者删除JNI Weak引用对象,这个对象指向一个真正的对象。当引用对象指向的对象未被标识,垃圾收集器会清理这个引用对象,但是不同于前一章提到的队列机制。需要注意的是,如果清理JNI Weak引用对象失败,可能会引发内存泄漏以及性能方面的问题。对于全局的JNI引用,也是同样的处理方式。JNI Weak引用对象最后会被引用对象处理线程处理,因此,对于一个已经被finalize的对象,如果存在指向他的phantom引用,他的JNI Weak引用对象也会持续存在。
4.5 堆扩展
堆扩展发生在垃圾收集完毕并且所有线程重新启动之后,但是HEAP_LOCK尚被持有的情况下。如果满足以下条件之一,堆的活动空间会进行扩展,直到堆的最大限制:
· 垃圾收集无法释放足够的空间来满足分配需求
· 空闲空间低于最小空闲空闲设定,-Xminf参数,默认30%
· 垃圾收集占用了超过13%的时间,并且按照最小扩展量(-Xmine)扩展之后,还是无法满足堆内最大空闲空间(-Xmaxf)要求的。
每次扩展的量计算规则如下:
· 如果由于无法满足堆的空闲空间为-Xminf(30%),垃圾收集器计算能够满足-Xminf堆空闲的扩展量。如果这个计算结果大于最大扩展量-Xmaxe(默认为0,即没有最大扩展量限制),那么会采用-Xmaxe设定的数量。如果计算结果小于-Xmine(默认为1MB),那么会按照-Xmine设定进行扩展
· 如果由于垃圾收集之后仍然不能满足分配需求,并且垃圾收集的时间占用总运行时间的比例不超过13%,则按照实际的分配需求来扩展
· 如果由于其他原因触发扩展,则垃圾收集器计算能够满足17.5%堆空闲的扩展量。类似上面,根据-Xmaxe和-Xmine的设定进行调整
· 最终,如果垃圾收集未能释放出满足分配需求的空间,则堆的扩展要保证至少满足分配需求
所有计算出的扩展量,在32位架构上是64KB的整倍,在64位架构上是4MB的整倍。
4.6 堆收缩
堆收缩发生在垃圾收集完毕并且所有线程都处于挂起状态时。如果满足以下任何一个条件,就不会发生堆收缩:
· 垃圾收集未能释放满足分配需求的空闲空间
· -Xmaxf参数设定为100%(默认是60%)
· 在最近的3次垃圾收集之中发生过堆扩展
· 由于调用System.gc()发生的垃圾收集,并且在收集周期开始之前,堆的空闲空间小于-Xminf(30%)
如果以上条件都不满足,并且存在大于-Xmaxf的空闲空间,垃圾收集器会计算收缩量,以保证能够拥有-Xmaxf的空闲空间,并且不小于初始大小(-Xms)。计算出的收缩量在32位架构上是64KB的整倍,在64位架构上是4MB的整倍。
如果满足以下任何一个条件,会在收缩之前发生压缩:
· 在本次垃圾收集周期未进行压缩
· 堆尾没有空闲存储片,或者堆尾的空闲存储片小于需要收缩量的10%
· 在上一次垃圾收集周期中未发生压缩和收缩
4.7 Resettable JVM
从1.3.0以后,引入了Resettable JVM,该虚拟机只能运行在z/OS平台。在http://www.s390.ibm.com/Java有详细的文档