JVM-GC工作原理
分类: Java
上面这幅图是我从网络上摘到的,它展现了在一个的理想系统的模型下GC对系统的影响。图的最顶上红色线条显示出一个应用程序在单处理器环境下花费1%的时间做GC的情况。而其转换到32个处理器的设备上将导致损失超过20%的吞吐率。做10%的GC将损失超过70%的吞吐率。因此可以看出对GC稍稍的调整可以带了性能巨大的变化。
一、概述
Java 2 平台的一个最大好处在于将开发者从大量错综复杂的内存分配和回收中解放出来。但是,一次GC也可能成为一个主要的瓶颈问题,因此它变成有价值和必要去理解那些隐藏的实现部分。GC总是假设应用程序使用对象的策略,GC的这些行为可以通过可调的参数来进行调整以提高性能。
在运行的程序中不再被任何一个指针所引用,则它就成为垃圾。许多简单的垃圾回收算法仅仅是简单的迭代每一个可到达的对象,一些残留的不可到达的对象就成为垃圾。这种方式会依据存活对象的数量而成比例的影响时间的消耗,当一个庞大的应用有许多存活的对象时,这个代价是巨大的。
从JVM1.3版开始已经加入一些不同的垃圾回收算法用于GC。当简单的垃圾回收器检查每一个在堆中存活的对象时,垃圾回收器利用凭经验获取的许多应用程序的属性设置去回避额外的工作。
这些属性中最重要的是infant mortality。
上图中蓝色区域是一个典型的对象生存时间的分布。在图中左边描述的对象呈现山峰形状,它们可以在分配后立刻回收利用。迭代器对象,如单循环中是存活的。有些对象存活期很长,因此它一直延伸到图的右边。例如,一些典型的对象从初始化开始一直到程序退出都一直存活着。在两端中间存活的对象都存在于一些中间计算过程中,在图中表现在infant mortality峰顶的右边这么一团蓝色区域。一些应用程序的图看上去会非常的不同,但它们会和此图相似。有效收集可能被高度关注,大部分对象在Young中就死亡了。
为此,内存在代(Generations)中被管理:内存池控制着不同年龄的对象。当Generation被添满时,GC在就被开始工作,每个代都会独自发生这种情况。对象在Eden中被分配,因为初期很多对象都在这里死亡。当Eden被填满时就会发生minor collection,其中一些幸存的对象被移动到Old Generation中,当Old Generation需要被回收,这就是一个major collection ,它的运行常常非常慢,因为它要涉及所有存活着的类。
许多收集器都会去容忍长时间幸存的对象,并减少GC的发生。默认的GC参数设置是被用于许多小应用程序的。它们不是去优化许多服务器应用的,记住:
如果GC成为瓶颈,你应该去配置Generation的大小;检查GC的输出,并且尝试改变配置适合你独特情况的配置。
二、收集类型
每种不同的收集类型都对系统产生不同影响,从JDK 1.3版本之后系统提供了3类完全不同三种GC:
Copying:有时又被称为Scavenge,这个收集器在两个或多个代中移动对象非常高效,原代被清空,允许残留的死亡对象被快速回收,然而,Coping要求更多的轨迹,因此它将请求一个空闲内存去工作。在1.3.1版本后Copying收集被应用于所有的minor collections。
Mark-compact:这个收集器允许代在不增加额外内存的情况下在本地回收。但是很明显它要慢于Copying方式。在1.3.1版本后mark-compact 收集被应用于所有的major collections。
Incremental:有时又被称为train。此收集器只在当在命令行中指定-Xincgc 才生效。但经过仔细的簿记,Incremental GC在同一个时间仅仅回收old generation 中的一部分,并引起major collection 一个长时间暂停,甚至超过许多minor collections。但是考虑到全部性能,它通常比Mark-compact收集慢。
因此copying收集是最快的,使用copying尽可能收集许多对象是我们的目标,而不是使用Mark-compact和Incremental收集。
默认的代的排列如下图所示:
在最开始的时候,最大的地址空间实际上是被保留的,除非必要它才被分配。所有的地址空间被作为对象保留,内存可以被分配到一个Young和Old代中。
一个Young代包含一个Eden和二个survivor空间,对象最开始是被分配在Eden中的,一个Survivor空间在任何时间都是空的,服务于在Eden和另一个Survivor空间中下一个Copying收集后仍然存活的对象。对象以这种方式在Survivor空间之间进行拷贝,直到它们的年龄达到持久化(此时被拷贝到Old代中)。
(其他虚拟机,包括1.2版JVM,为Copying使用两个大小相等的空间,而不是一个大的Eden和二个小的空间。这意味者Young代的大小不是直接可以比较的。)
Old代在这里是采用mark-compact进行收集的。
Permanent代的调用是很特殊的部分,它掌握着JVM自己映射的数据如类和对象方法。
三、性能注意事项
GC性能有两个重要的方面:
吞吐率:在GC中在长时间运行情况下有效利用时间占总时间的百分比。吞吐率包括了在分配中浪费的时间(但不包括分配速度的调整是不包括的)。
暂停时间:当一个应用程序出现因为GC工作而停止工作的时间。
用户可以有不同的关于GC的需求。例如,一些优先考虑Web 服务的吞吐率,因此可以忍受暂停,或被网络因素所掩盖。单一个交互的图像程序,即时短暂的暂停都是让用户无法满意的。
一些用户对其它的东西敏感。
轨迹是一个进程的作业集,用于权衡在页和缓存中的行数。一个在物理内存或多处理器方面受限的系统,轨迹可以有很多伸缩性。
敏捷度是从一个对象死亡到这个内存变成可用之间的时间跨度。一个重要的考虑就是分布式系统的RMI。
一般的,代的大小选择就是基于这些考虑进行的。例如,一个非常巨大的Young代可以提供最大的吞吐率,但要付出轨迹和敏捷度的代价。使用一个小的Young带,采用incremental收集方式暂停时间可以最小化,但要付出吞吐率的代价。
这里不存在唯一正确的方法来配置代的大小,最好的方法是应用程序使用的内存和用户需求一致。因为这个理由JVM是没有选择优化的,用户可以通过命令行的方式对其进行修改。
四、测量方法
对于一个应用程序吞吐率和轨迹可以通过指标来测量,例如,Web服务的吞吐率可以通过在客户端装载发生器来进行测试,轨迹在Solaris操作系统中通过pmap命令来获取。暂停时间可以通过GC的输出预估出来。
通过在命令行中加入-verbose:gc 可以打印出每一个收集器的信息,例如,下面的例子给出了一个大型服务应用的输出:
[GC 325407K->83000K(776768K), 0.2300771 secs]
[GC 325816K->83372K(776768K), 0.2454258 secs]
[Full GC 267628K->83769K(776768K), 1.8479984 secs]
通过这个输出我们可以看到有二个minor collections工作和一个major collections工作。通过箭头前后数据我们可以看到GC前和后活着的对象占用内存大小。minor collections 后包括对象的数量不表示他们还活着,只是表示他们没有被回收。这是因为他们可能确实活着,也可能他们在Old代中。圆括号中间的数字表示可以内存大小,它是总堆大小减去一个survivor空间大小。
五、代的大小
下图中给出了一些可以改变代大小的参数。
六、全部堆
当代被填满后发生收集,吞吐率和可用内存数量成反比例,总的可用内存大小非常影响GC的性能。每次收集时不管是增减,JVM都会努力按照一定的比率为存活的对象保留自由空间。这个目标比例是一个用百分数表示的参数-XX:MinHeapFreeRatio=<minimum> 和 -XX:MaxHeapFreeRatio=<maximum> 的集合,并且总的堆的大小界定于-Xms 和-Xmx 指定的范围内,大小值大于-Xms 指定的值,但小于-Xmx 指定的值。
在Solaris操作系统中默认的值如下:
-XX:MinFreeHeapRatio= 40
-XX:MaxHeapFreeRatio= 70
-Xms = 3584k
-Xmx = 64m
大型的服务器应用系统应用这个默认配置有两个方面的问题,一是启动很慢,因为初始堆非常小必须使用多次major collections来调整堆的大小。一个更紧迫的问题是默认的最大堆的大小对于大多少服务应用来说过分的小,对于服务应用程序的经验法则是:
除非暂停时间对你是不容忽视的问题,否则分配可能的足够大的内存给JVM,默认的64M内存太小。设置-Xms 和-Xmx 到相同的值,以增加JVM做重要的大小调整时被进行内存清理的可能性。另一方面,如果你的配置不恰当,JVM是不能对此进行补偿和优化的。
一定要增加内存就如同你增加处理器一样,因为内存分配是可以并行的,但GC是不能并行工作的。
七、Young代
第二个有影响的突出问题是堆的一部分被划归Young代专用。 一个更大的Young代常很少发生minor collections。在一个有限的堆中,较大的Young代意味着有很小的Old代,这将增加major collections发生的频率。优化的选择来源于应用中生命周期的分布。
默认的Young代的大小由NewRatio参数控制,例如,设置-XX:NewRatio=3意味着Young代和Old代的比率是1:3。换句话说,Eden和Survivor空间总和是整个堆大小的四分之一。
NewSize和MaxNewSize两个参数指定了Young代的下限和上限值。设定他们相等的值就固定了Young代大小,就如同设置-Xms和-Xmx 两个值相等就固定整个堆大小一样。这种方式比通过NewRatio参数调整整体倍数要处理的更加精细。
因为Young代采用的是copying收集,在Old代中必须有充足的空闲内存被保留这样才能保障minor collection 可以正常完成。最坏的情况,这个被保留的内存大小会等于Eden空间大小加上Survivor中非空空间大小之和。在最糟糕的情况下,如果Old代中没有足够可用的空间,一个major collection 将被代替发生。这个策略对于小应用系统是非常好的,因为在Old代中保留的内存实际上是被提交但没有被使用。除非应用程序需要一个最大的堆,Eden空间超过堆中实际提交内存大小的一半是没有意义的,此时major collections将发生。
如果期望SurvivorRatio参数可以用来调整Survivor空间大小,但它常常不是最重要的。例如,-XX:SurvivorRatio=6设置Survivor和Eden空间的比例为1:6,换句话朔,每一个Survivor空间将是Young代大小的八分之一(为什么不是七分之一?因为Young带中有二个Survivor空间)。
如果Survivor空间太小,Copying收集将直接将其转移到Old代中。如果Survivor空间太大,他们将无有的空闲。每一个JVM的GC都会选择一个对象生存时间的阀值,低于这个阀值的将做Copying收集,高于这个阀值的将被转入Tenured。这个门槛值被设置为Survivor空间一半满。(在1.3.1版本后可以使用-XX:+PrintTenuringDistribution 来显示这个门槛值和对象在新代中的年龄,这对于观察应用程序生命周期的分布是有益的)
下面列出了在Solaris操作系统中的默认值:
NewRatio = 2 (client JVM: 8)
NewSize = 2172k
MaxNewSize = 32m
SurvivorRatio = 25
服务应用系统的一些遵循的准则如下:
首先确定可以给予JVM的总的内存值,描绘你自己的性能指标,然后依靠Young代的大小去发现最好的设置。
除非你发现有过多的major collection 或 暂停时间过长,你应该分配大量的内存到Young代,默认的MaxNewSize 为32MB,这太小了。
增加Young代大小到总堆大小的一半或少些将是无效果的。
增加Young代大小就如同你增加处理器数量一样,因为内存分配是并行的,但GC不能并行。
八、其它考虑
许多应用程序的permanent代大小设置对于GC来说是不合适的。一些应用程序动态的产生和装载很多类,例如,一些JSP的实现就是这么做的。如果必要的话,通过MaxPermSize参数可以设置最大的permanent代的大小。
一些使用finalization和weak/soft/phantom 引用的应用程序的GC是相互影响的。这些因素可以导致在JAVA程序语言级别的进行优化的问题,一个依赖finalization关闭文件描述的例子,这就产生一个外部资源依赖GC的敏捷度来完成工作,利用GC来管理资源这是一个十分糟糕的想法。
另一种应用程序通过明显的调用GC来实现和GC的互动,例如通过System.gc() 来调用。这些调用强制执行major collection,在大系统上抑制了其可伸缩性。显示调用GC会受到-XX:+DisableExplicitGC标志的影响。
一般显示使用GC的地方发生在RMI的分布式GC时,应用程序使用RMI涉及到对象在另一个JVM中,除偶然本地分配的对象外,这些分布式应用的垃圾是不能被回收的,若有RMI周期性的强制进行完全的收集。这些强制收集的频率是可以被一些属性控制的,例如:
java -Dsun.rmi.dgc.client.gcInterval=3600000
-Dsun.rmi.dgc.server.gcInterval=3600000 ...
这表明原来默认每分钟进行一次的频率被替换成每小时进行一次。它可能因为其它对象的原因花费更长的时间才被收集。这些属性可以被设置成非常高非常长,MAX_VALUE 可以设置时间介于无限大时间之间,如果没有特别说明这个值可以是DGC(分布式GC)时间线的上面。
在服务器上的JVM对软件引用的清除比在客户机上较积极。通过增加-XX:SoftRefLRUPolicyMSPerMB=10000这样一个参数可以将清除率减慢,默认值是1000,或每兆字节一秒。
对于巨大系统他们还有其它参数可以去改善性能。
九、结论
GC在高度并行的系统里会成为瓶颈。理解GC是如何工作的,再通过命令行参数去降低影响。