[标题] IBM JDK 1.4.2的垃圾回收行为(一)
内容提要:
目前,IBM JDK 1.4.2已经被广泛地使用在很多IBM的软件产品当中,例如:WebSphere Application Server V5.1.1, WebSphere Applistion Server 6.0等。但是,大多数用户对于Java虚拟机(Java Virtual Machine,以下简称JVM)的垃圾回收器(Garbage Collector,以下简称GC)行为并不很理解。本文将对IBM JDK 1.4.2当中的GC进行概述并着重讨论分配的问题。在后续的文章中还会讨论垃圾回收的细节。
正文:
一、垃圾回收概述
GC从堆空间(heap)中分配存储区域用于定义对象,数组和类。对象被分配后,如果在JVM的活动声明中存在一个对象的引用(指针)那么对象将会持续的存活(live),这个对象就是可获得(reachable)状态。当一个对象停止被活动声明所引用,它就变成了垃圾(garbage)可以被回收重新使用。当这种回收发生的时候,GC必须执行一个可能的finalizer并且也要确保任何与这个对象相关联的JVM内部资源被返回到这些资源的池中。
对象分配(Object allocation)
对象分配(Object allocation)是由JVM内部对于java对象,数组或类的存储分配需求驱动的。每一次分配通常需要一个堆锁(heap lock)为了防止并发线程的访问。为了优化这种分配,堆空间的一部分被委托给一个线程,并且这个线程能够从委托给它的本地堆区域分配对象而不需要申请堆锁屏蔽其它线程的访问。这种技术给小对象的分配提供了尽可能好的性能。对象被直接从一个线程本地分配缓存中分配出来,这个缓存已经被线程提前从堆空间中分配出来了。一个新对象被从这段缓存的末尾分配出来,而在这个过程中却不需要去获取一个堆锁。因此,这种分配是非常有效率的。只有那些足够小的对象(小于512字节)才能使用这种方法分配。这个缓存就是经常被提到的线程本地堆(thread local heap简称TLH)。
可获得对象(Reachable object)
Java虚拟机的活动声明是由一组栈组成的,这些栈里面描述了线程,Java类当中的静态声明,和本地以及全局的JNI引用集合。所有在Java虚拟机里被调用的方法在线程栈上面形成了一个框架。这些信息是用来找到根(roots)的。这些根接下来被用作找到对其他对象的描述。这一过程被重复一直到所有的可获得对象被找到。
垃圾回收(Garbage collection)
当Java虚拟机由于缺乏空间无法从当前的堆空间分配一个对象的时候,以个内存分配失败就发生了并且GC就被调用。GC的主要个任务是收集堆空间中的所有垃圾。这一过程发生在任何一个线程调用GC的时候,不管是由于分配失败早成的间接调用还是指定调用System.gc()方法。第一步是获取垃圾回收进程所需要的所有锁。这个步骤确保所有其他的线程没有因为持有紧急锁(critical locks)而处于挂起状态。接下来所有的其他线程被挂起,垃圾回收开始。它经历如下的3个阶段:
标记(Mark)
清扫(Sweep)
紧凑(可选的)(Compaction)
标记阶段(Mark phase)
在标记阶段,所有被线程栈,静态受监控的字符串,以及JNI引用描述的对象被确定出来。这个动作创建了Java虚拟机引用的对象根集合。每一个这类的对象可能接下来会引用其他的对象。因此,过程的第二步是扫描每一个对象引用的其他对象。这2个过程一起产生了一个向量(vector),这个向量定义了所有活动的对象。
清扫阶段(Sweep phase)
标记阶段后,标记向量(mark vector)为每一个堆空间中的可获得对象包含了一个比特位(bit)。标记向量必然是分配比特向量(allocbits vector)的子集。清扫阶段的任务是确定这些向量的交集——那些已经被分配但是不再被引用的对象。
清扫阶段原来的技术会从堆空间的底部扫描,并且按顺序访问每一个对象。每一个对象的长度被保存在堆空间中紧挨着对象前面的字(word)当中。对于每一个对象适合的分配比特和标记比特被探测并用于定位垃圾。
现在,比特清扫(bitsweep)技术不再需要扫描堆空间中的对象,因此避免了相关联的在页面调度上的高额消耗。在比特清扫技术中,标记向量被用于直接查询那些可能定义为空闲空间的长序列0(未被标记)。
当这种长序列被发现时,在序列开头处的对象长度被检验出来用于决定将被释放的空闲空间量。对象不再按照通常那样从堆空间自己那里分配出来,而是从TLH(线程本地堆thread local heap)那里分配。线程本地堆是从堆空间分配出来的,然后被一个单独的线程所使用,用以满足分配的需求。
紧凑阶段(Compaction phase)
当垃圾被从堆空间中删除后,GC可以开始紧凑对象的结果集以除去对象之间的空间。由于紧凑会占用比较长的时间,GC尽可能的去避免这种操作。紧凑操作是一种非常罕见的事件。
堆大小(Heap size)
最大堆大小是被-Xmx参数所控制。如果这个参数没有定义,那么Java虚拟机的最大堆大小默认按照如下的标准申请:
Windows:
真实存储量的一半,最小不小于16MB,最大不大于2GB-1
OS/390和AIX:
64MB
Linux:
真实存储量的一半,最小不小于16MB,最大不大于512MB-1
初始堆大小是被-Xms参数所控制。如果这个参数没有定义,那么Java虚拟机的初始堆大小默认按照如下的标准申请:
Windows, AIX, and Linux:
4MB
OS/390:
1MB
一些基本的堆大小问题(Some basic heap sizing problems)
对于大多数应用,堆大小的默认设置工作的很正常。堆空间将一直扩张直到它达到一个稳定的状态,然后将保持这一状态。在这种状态下堆空间的占用率(堆空间中任何一个时刻存活的数据)应该在70%左右。在这个级别上,垃圾回收的频度和暂停的时间应该是可以接受的。
对于某些应用,堆大小的默认设置可能不能得到最优的性能。下面将会列出可能发生的情况以及一些有针对性地建议。使用详细垃圾回收(verboseGC)设定可以帮助用户监控堆空间的情况。
垃圾回收的频度太高一直到堆空间达到一个稳定状态:
使用详细垃圾回收来确定稳定状态时的堆大小并且将-Xms设定为这个值。
堆空间已经完全扩展但是堆空间的占用率仍旧高于70%:
增加-Xmx的值使得堆空间的占用率低于70%,但是从最优性能考虑尽量确保堆空间不要被扇出到磁盘交换区。最大堆空间应该尽可能保证堆空间被保存在物理内存中。
堆空间占用率在70%的情况下,垃圾回收的频度过高:
更改-Xminf的设置。默认值为0.3,在扩展尽量保存30%空余空间。如果设定为0.4,代表增加空余空间到40%,这将减少垃圾回收的频度。
暂停时间太长:
尝试使用-Xgcpolicy:optavgpause参数。这将减少暂停时间并且让暂停时间长度在堆空间占用率上升的时候更稳定。然而,这个参数将减少系统吞吐量大约5%,当然这一数值将因程序不同而有一定差异。
综合上面的概述,可以总结出一些有用的技巧:
1,保障堆空间不被扇出到硬盘交换区,堆空间的最大大小必须保证被包含在物理内存中。
2,避免finalizers。用户无法保证finalizer什么时候会运行,并且通常finalizer会造成问题。如果用户确实需要使用finalizer,尽量避免在finalizer方法中避免分配对象。详细垃圾回收会显示finalizer是否被调用。
3,避免紧凑。详细垃圾回收会显示紧凑是否发生。紧凑通常是申请大对象造成的。分析大对象申请的需求,如果可能的话尽量避免。例如:如果大对象是大数组,尽量将其分割为小数组。
系统堆(The system heap)
系统堆仅仅包含那些生存预期值和Java虚拟机的生命周期相同的对象。这些对象在堆空间里包括系统类对象,共享中间件的类对象,应用类对象。系统堆从来不会被垃圾回收,因为系统堆当中的所有对象要么是在Java虚拟机的整个生命周期内都是可获得的,要么作为共享应用在Java虚拟机的整个生命周期内都可能被重用。系统堆是一条由不连续的存储区域组成的链。系统堆的初始大小在32位体系架构中是128KB,在64位体系架构中是8MB。如果初始存储区被填满了,系统堆获取另一个扩展区并把这些扩展区链接在一起。
二、分配
GC是Java虚拟机的内存管理者,因此GC除了负责回收垃圾外还负责分配内存。但是,由于内存分配的任务和垃圾回收比起来小的多,术语“垃圾回收”通常也以为着“内存管理”。
堆锁分配(Heap lock allocation)
堆锁分配发生在分配请求大于512字节或者分配不能被包含在已经存在的缓存区中的情况下。正如它的名字暗示的,堆锁分配需要一个锁并且这种操作应该是尽量避免通过使用缓存。
如果GC无法发现一个足够大整块的空闲存储区,分配将失败并且GC必须运行一次垃圾回收。完成一次垃圾回收周期后,如果GC创建了足够的空闲存储区,GC再次搜索空闲列表并且分配一块空闲块。如果GC找不到足够的空闲存储,GC返回Out Of Memory(以下简称OOM)。HEAP_LOCK只有在对象被分配出来的时候或者无法发现足够空间的情况下才会被释放。
缓存分配(Cache allocation)
缓存分配是特别为提高小对象分配性能而设计的。对象被直接从线程本地分配缓冲中分配出来,这个缓冲区是事先从堆空间中分配出来的。一个新的对象被从缓存的末尾分配出来,这一操作不许要或取堆锁,因此缓存分配是效率很高的。
如果分配对象的大小小于512字节或者对象可以被包含在已经存在的缓存内,GC将采用缓存分配。
缓存块有时候被叫做一个本地线程堆(TLH)。TLH的大小在2KB到164KB之间变化,具体的大小依赖于TLH的用途。
荒野(The wilderness)
荒野(The wilderness)现在被叫做大对象区域(Large Object Area,以下简称LOA)。这一方法专为提高大对象分配的性能而设计。在本文范围内“荒野”“大对象区域”和“LOA”这些术语是可以互换的。
初始化(Initialization):
LOA的边界在堆空间初始化的时候被计算出来,并且在每一次垃圾回收的时候重新计算。LOA的初始值为当前堆空间的5%。这一比例可以按照如下的算法重新调整:
1. 如果空闲空间的大小加上LOA的大小小于堆空间*-Xminf的值(默认为30%),LOA的大小为0的
2. 如果空闲空间的大小小于堆空间*-Xminf的值(默认为30%),LOA的大小将被减小到空闲空间的大小等于堆空间*-Xminf的值
当GC计算完LOA的大小时,它也设定了ca_progressFreeObjectCtr等于空闲空间大小减去当前堆空间*-Xminf的值。这个变量被用来决定什么时候分配超出了LOA。
扩展和收缩(Expansion and shrinkage)
GC使用如下的算法扩张或者收缩LOA:
如果分配失败发生在主堆空间:
-如果当前堆空间的大小超过了初始值并且如果LOA的空闲比例超过70%,那么将已经分配给LOA的空间减少1%
-如果当前堆空间的大小小于等于初始值并且如果LOA的空闲比例超过90%
-如果当前的堆空间大小超过1%则将已经分配给LOA的空间减少1%,
-如果当前的堆空间大小等于1%或者更小则将已经分配给LOA的空间减少0.1%最小到0.1%
如果分配失败发生在LOA空间:
-如果分配请求的空间大小超过当前LOA大小的5倍,则将LOA空间占堆空间的比例增加1%直到最大值20%
-如果当前的堆空间大小小于初始值并且如果LOA的空闲比例少于50%则将LOA空间占堆空间的比例增加1%
-如果当前的堆空间大小大于等于初始值并且如果LOA的空闲比例少于30%,则将LOA空间占堆空间的比例增加1%直到最大值20%
这一算法使得GC在LOA被高频调用的时候能够扩张LOA,而在LOA使用不多或者根本不被使用的情况下缩减LOA。如果用法发生变化,GC将尝试将LOA恢复到5%。如果2次扩张之间没有发生收缩,GC通过出发器COMPACT_LOA_EXPANDED触发一个增加的紧凑操作。
LOA中的分配(Allocation in the LOA)
在垃圾回收之前,从LOA中分配是在manageAllocFailure()执行的,该方法被调用的条件是GC无法从堆锁分配或者缓存分配的空闲列表中分配对象。在这时,存储空间仅仅从LOA的前半段中释放。GC会由于如下的2种原因释放存储空间;
-如果申请的空间大小大于等于64KB
-如果空闲的空间超过ca_progressFreeObjectCtr,GC仍旧没有完成足够的分配进程,那么GC尝试从LOA中找到空间。
在上面的2种情况下,如果GC在LOA中找到了空间,它将空闲的块放到空闲列表的开头并且返回而不需要进行垃圾回收。
在垃圾回收之后,LOA的后半段被用于分配对象。在handleFreeChunk方法中,如果能够满足分配需求的唯一块在LOA内,GC将把块分开并把足够的空间分配给请求。如果3次连续的存储分配都按照这种方法来自LOA,GC通过使用触发器COMPACT_LOA_PRESSURE触发一次增加的紧凑操作。
Pinned簇(Pinned cluster)
在Java堆空间中分配的内存对象通常是可以移动,如果垃圾回收程序(garbage collector)决定重新序列化堆空间的时候,可以四处移动这些对象。然而,有些对象永远或者临时无法移动。这些固定不动的对象就是常说的pin对象(pinned object)。
在IBM JDK 1.4.2中,垃圾回收程序首先会分配一个K簇作为堆空间底部的第一个对象。K簇是专门用来存储“类块”(class block)的区域。K簇可以容纳1280个类块条目。每个类块的大小是256个字节。紧接着垃圾回收程序会分配一个P簇作为堆空间中的第2个对象。P簇是用来存储pin对象的区域。第一个P簇的默认大小为16KB。
当K簇满了的情况下,垃圾回收程序在P簇中继续分配类块。当P簇满了的情况下,垃圾回收程序会分配一个大小为2KB的新P簇。由于这些新的P簇可以被分配到任何地方而且又不能被移动,这就造成了碎片的问题。
为了解决这些问题,IBM JDK中起用了pinnedFreeList来改变P簇的分配方法。方法的关键是在每一次GC(garbage collection)后,垃圾回收程序从未分配列表的底部分配一些存储区并把它们串到pinnedFreeList上。分配P簇的请求将从pinnedFreeList分配空间,而其他分配内存的请求将从堆的未分配列表上分配。无论堆的未分配列表或者pinnedFreeList被耗尽,垃圾回收程序都会造成一次分配失败并且引起GC。这种方法确保所有的P簇被分配在堆空间尽可能低的位置。
垃圾回收程序按照如下的算法确定给pinnedFreeList分配多少存储空间:
-初始分配的空间是50KB
-如果不是初始分配并且pinnedFreeList为空,那么垃圾回收程序会比较50KB和从上一次GC到现在总共分配P簇大小5倍的数值,按照较大的数值分配
-如果不是初始分配并且pinnedFreeList不为空,那么垃圾回收程序会比较P簇溢出设定值(默认为2K)和从上一次GC到现在总共分配P簇大小5倍的数值,按照较大的数值分配
这一算法在应用需要加载很多类的情况下会增大pinnedFreeList的大小。这样可以避免由于pinnedFreeList耗尽引起的分配失败。同时算法在分配很少P簇的情况下会减少pinnedFreeList的大小。这样可以避免pinnedFreeList占用过多的堆空间。
buildPinnedFreeList函数利用上面的算法构建pinnedFreeList。这个函数在如下地方会被调用:
-在初始化簇(initializeClusters)时
-在堆空间扩展(expandHeap)结束时
-在gc0_locked结束时
垃圾回收程序通过调用nextPinnedCluster函数在pinnedFreeList中分配P簇。这个函数的工作方式类似于nextTLH工作方式:总是从pinnedFreeList获取下一个空的块。如果pinnedFreeList空了,会产生manageAllocFailure。
在realObjCAlloc里,如果在P簇中没有空间了,垃圾回收程序就会调用nextPinnedCluster函数分配一个新的P簇。
在初始化簇(initializeClusters)时,垃圾回收程序调用nextPinnedCluster,nextPinnedCluster会分配一个50K大小的初始P簇,因为pinnedFreeList中唯一的空余块的大小是50K。空余块的大小等于50K是因为pinnedFreeList在初始状态下被设置为50K。