3.2.3 内存回收(1)
收集器
JVM通过GC来回收堆和方法区中的内存,GC的基本原理首先会找到程序中不再被使用的对象,然后回收这些对象所占用的内存,通常采用收集器的方式实现GC,主要的收集器有引用计数收集器和跟踪收集器。
1. 引用计数收集器
引用计数收集器采用的为分散式的管理方式,通过计数器记录对象是否被引用。当计数器为零时,说明此对象已经不再被使用,于是可进行回收,如图3.9所示。
![]() |
(点击查看大图)图3.9 引用计数收集器 |
在图3.9中,当ObjectA释放了对ObjectB的引用后,ObjectB的引用计数器即为0,此时可回收ObjectB所占用的内存。
引用计数需要在每次对象赋值时进行引用计数器的增减,它有一定的消耗。另外,引用计数器对于循环引用的场景没有办法实现回收,例如上面的例子中,如 果ObjectB和ObjectC互相引用,那么即使ObjectA释放了对ObjectB、ObjectC的引用,也无法回收ObjectB、 ObjectC,因此对于Java这种面向对象的会形成复杂引用关系的语言而言,引用计数收集器不是非常适合,Sun JDK在实现GC时也未采用这种方式。
2. 跟踪收集器
跟踪收集器采用的为集中式的管理方式,全局记录数据的引用状态。基于一定条件的触发(例如定时、空间不足时),执行时需要从根集合来扫描对象的引用 关系,这可能会造成应用程序暂停,主要有复制(Copying)、标记-清除(Mark-Sweep)和标记-压缩(Mark-Compact)三种实现 算法。
复制(Copying)
复制采用的方式为从根集合扫描出存活的对象,并将找到的存活对象复制到一块新的完全未使用的空间中,如图3.10所示。
![]() |
(点击查看大图)图3.10 复制算法 |
复制收集器方式仅需从根集合扫描所有存活的对象,当要回收的空间中存活对象较少时,复制算法会比较高效,其带来的成本是要增加一块空的内存空间及进行对象的移动。
标记-清除(Mark-Sweep)
标记-清除采用的方式为从根集合开始扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未标记的对象,并进行回收,如图3.11所示。
![]() |
(点击查看大图)图3.11 标记-清除算法 |
标记-清除动作不需要进行对象的移动,且仅对其不存活的对象进行处理。在空间中存活对象较多的情况下较为高效,但由于标记-清除采用的为直接回收不存活对象所占用的内存,因此会造成内存碎片。
标记-压缩(Mark-Compact)
标记-压缩采用和标记-清除一样的方式对存活的对象进行标记,但在清除时则不同。在回收不存活对象所占用的内存空间后,会将其他所有存活对象都往左端空闲的空间进行移动,并更新引用其对象的指针,如图3.12所示。
![]() |
图3.12 标记-压缩算法 |
标记-压缩在标记-清除的基础上还须进行对象的移动,成本相对更高,好处则是不产生内存碎片。
Sun JDK中可用的GC
以上三种跟踪收集器各有优缺点,Sun JDK根据运行的Java程序进行分析,认为程序中大部分对象的存活时间都是较短的,少部分对象是长期存活的。基于这个分析,Sun JDK将JVM堆划分为了新生代和旧生代,并基于新生代和旧生代中对象存活时间的特征提供了不同的GC实现,如图3.13所示。
![]() |
图3.13 Sun JDK中可用的GC方式 |
新生代可用GC
Sun JDK认为新生代中的对象通常存活时间较短,因此选择了基于Copying算法来实现对新生代对象的回收,根据以上Copying算法的介绍,在执行复制 时,需要一块未使用的空间来存放存活的对象,这是新生代又被划分为Eden、S0和S1三块空间的原因。Eden Space存放新创建的对象,S0或S1的其中一块用于在Minor GC触发时作为复制的目标空间,当其中一块为复制的目标空间时,另一块中的内容则会被清空。因此通常又将S0、S1称为From Space和To Space,Sun JDK提供了串行GC、并行回收GC和并行GC三种方式来回收新生代对象所占用的内存,对新生代对象所占用的内存进行的GC又通常称为Minor GC。
1. 串行GC(Serial GC)
当采用串行GC时,SurvivorRatio的值对应eden space/survivor space,SurvivorRatio默认为8,例如当-Xmn设置为10MB时,采用串行GC,eden space即为8MB,两个survivor space各1MB。新生代分配内存采用的为空闲指针(bump-the-pointer)的方式,指针保持最后一个分配的对象在新生代内存区间的位置, 当有新的对象要分配内存时,只须检查剩余的空间是否够存放新的对象,够则更新指针,并创建对象,不够则触发Minor GC。
按照Copying算法,GC首先需要从根集合扫描出存活的对象,对于Minor GC而言,其目标为扫描出在新生代中存活的对象。 Sun JDK认为以下对象为根集合对象:当前运行线程的栈上引用的对象、常量及静态(static)变量、传到本地方法中,还没有被本地方法释放的对象引用。
如果Minor GC仅从以上这些根集合对象中扫描新生代中的存活对象,则当旧生代中的对象引用了新生代的对象时会出现问题,但旧生代通常比较大。为提高性能,不可能每次 Minor GC的时候去扫描整个旧生代,Sun JDK采用了remember set的方式来解决这个问题。
Sun JDK在进行对象赋值时,如果发现赋值的为一个对象引用,则产生write barrier,然后检查需要赋值的对象是否在旧生代及赋值的对象引用是否指向新生代;如果满足条件,则在 remember set做个标记,Sun JDK采用了Card Table来实现remember set。
因此,对于Minor GC而言,完整的根集合为Sun JDK认为的根集合对象加上remember set中标记的对象,在确认根集合对象后,即可进行扫描来寻找存活的对象。为了避免在扫描过程中引用关系变化,Sun JDK采用了暂停应用的方式,Sun JDK在编译代码时为每段方法注入了SafePoint,通常SafePoint位于方法中循环的结束点及方法执行完毕的点,在暂停应用时需要等待所有的 用户线程进入SafePoint,在用户线程进入SafePoint后,如果发现此时要执行Minor GC,则将其内存页设置为不可读的状态,从而实现暂停用户线程的执行。
在对象引用关系上,除了默认的强引用外,Sun JDK还提供了软引用(SoftReference)、弱引用(WeakReference)和虚引用(PhantomReference)三种引用 。
强引用
A a=new A();就是一个强引用,强引用的对象只有在主动释放了引用后才会被GC。
软引用
软引用采用SoftReference来实现,采用软引用来建立引用的对象,当JVM内存不足时会被回收,因此SoftReference很适合用 于实现缓存。另外,当GC认为扫描到的SoftReference不经常使用时,也会进行回收,存活时间可通过 -XX:SoftRefLRUPolicyMSPerMB来进行控制,其含义为每兆堆空闲空间中SoftReference的存活时间,默认为1秒。
SoftReference的使用方法如下:
Object object = new Object();
SoftReference < Object > softRef = new SoftReference < Object > (object);
object = null ;
当需要获取时,可通过softRef.get来获取,值得注意的是softRef.get有可能会返回null。
弱引用
弱引用采用WeakReference来实现,采用弱引用建立引用的对象没有强引用后,GC时即会被自动释放 。
WeakReference的使用方法如下:
Object object = new Object();
WeakReference < Object > weakRef = new WeakReference < Object > (object);
object = null ;
当需要获取时,可通过weakRef.get来获取,值得注意的是weakRef.get有可能会返回null。
可传入一个ReferenceQueue对象到WeakReference的构造器中,当object对象被标识为可回收时,执行weakRef.isEnqueued会返回true。
虚引用
虚引用采用PhantomReference来实现,采用虚引用可跟踪到对象是否已从内存中被删除。
PhantomReference的使用方法如下:
Object object = new Object();
ReferenceQueue < Object > refQueue = new ReferenceQueue < Object > ();
PhantomReference < Object > ref = new PhantomReference < Object > (object,refQueue);
object = null ;
值得注意的是ref.get永远返回null,当object从内存中删除时,调用ref.isEnqueued()会返回true。
当扫描引用关系时,GC会对这三种类型的引用进行不同的处理,简单来说,GC首先会判断所扫描到的引用是否为Reference类型。如果为 Reference类型,且其所引用的对象无强引用,则认为该对象为相应的Reference类型,之后GC在进行回收时这些对象则根据 Reference类型的不同进行相应的处理 。
当扫描存活的对象时,Minor GC所做的动作为将存活的对象复制到目前作为To Space的S0或S1中;当再次进行Minor GC时,之前作为To Space的S0或S1则转换为From Space,通常存活的对象在Minor GC后并不是直接进入旧生代,只有经历过几次Minor GC仍然存活的对象,才放入旧生代中,这个在Minor GC中存活的次数在串行和ParNew方式时可通过-XX:MaxTenuringThreshold来设置,在Parallel Scavenge时则由Hotspot根据运行状况来决定。当To Space空间满,剩下的存活对象则直接转入旧生代中。
Minor GC的过程如图3.14所示。
![]() |
(点击查看大图)图3.14 Minor GC过程 |
Serial GC在整个扫描和复制过程均采用单线程的方式来进行,更加适用于单CPU、新生代空间较小及对暂停时间要求不是非常高的应用上,也是client级别 (CPU核数小于2或物理内存小于2GB)或32位Windows机器上默认采用的GC方式,也可通过-XX:+UseSerialGC的方式来强制指 定。
2. 并行回收GC(Parallel Scavenge)
当采用并行回收GC时,默认情况下Eden、S0、S1的比例划分采用的为InitialSurvivorRatio,此值默认为8,对应的新生代 大小为/survivor space,可通过-XX:InitialSurvivorRatio来进行调整,在Sun JDK 1.6.0后也可通过-XX:SurvivorRatio来调整 ,但并行回收GC会将此值+2赋给InitialSurvivorRatio。当同时配置了InitialSurvivorRatio和 SurvivorRatio时,以InitialSurvivorRatio对应的值为准,因此在采用并行回收GC时,如果不配置 InitialSurvivorRatio或SurvivorRatio,那么当-Xmn设置为16MB时,eden space则为12MB,两个Survivor Space各2MB;如果配置SurvivorRatio为8,那么eden space则为12.8MB,两个Survivor Space各1.6MB,为保持和其他GC方式统一,建议配置SurvivorRatio。
对于并行回收GC而言,在启动时Eden、S0、S1的比例按照上述方式进行分配,但在运行一段时间后,并行回收GC会根据Minor GC的频率、消耗时间等来动态调整Eden、S0、S1的大小。可通过-XX:-UseAdaptiveSizePolicy来固定Eden、S0、S1 的大小。
PS GC不是根据-XX:PretenureSizeThreshold来决定对象是否在旧生代上直接分配,而是当需要给对象分配内存时,eden space空间不够的情况下,如果此对象的大小大于等于eden space一半的大小,就直接在旧生代上分配,代码示例如下:
public class PSGCDirectOldDemo{
public static void main(String[] args) throws Exception{
byte[] bytes = new byte[1024*1024*2];
byte[] bytes2 = new byte[1024*1024*2];
byte[] bytes3 = new byte[1024*1024*2];
System.out.println("ready to direct allocate to old");
Thread.sleep(3000);
byte[] bytes4 = new byte[1024*1024*4];
Thread.sleep(3000);
}
}
以-Xms20M -Xmn10M -Xmx20M -XX:SurvivorRatio=8 -XX:+UseParallelGC执行以上代码,在输出ready to direct allocate to old后,通过jstat可查看到,bytes4直接在旧生代上分配了,这里的原因就在于当给bytes4分配时,eden space空间不足,而bytes4的大小又超过了eden space大小/2,因此bytes4就直接在旧生代上分配了。
并行回收GC采用的也是Copying算法,但其在扫描和复制时均采用多线程方式来进行,并且并行回收GC为大的新生代回收做了很多的优化。例如上 面提到的动态调整eden、S0、S1的空间大小,在多CPU的机器上其回收时间耗费的会比串行方式短,适合于多CPU、对暂停时间要求较短的应用上。并 行回收GC是server级别(CPU核数超过2且物理内存超过2GB)的机器(32位Windows机器除外)上默认采用的GC方式,也可通过 -XX:+UseParallelGC来强制指定,并行方式时默认的线程数根据CPU核数计算。当CPU核数小于等于8时,并行的线程数即为CPU核数; 当CPU核数多于8时,则为3+(CPU核数*5)/8,也可采用-XX:ParallelGCThreads=4来强制指定线程数。
3. 并行GC(ParNew)
并行GC在基于SurvivorRatio值划分eden space和两块survivor space的方式上和串行GC一样。
并行GC和并行回收GC的区别在于并行GC须配合旧生代使用CMS GC,CMS GC在进行旧生代GC时,有些过程是并发进行的。如此时发生Minor GC,需要进行相应的处理 ,而并行回收GC是没有做这些处理的,也正因为这些特殊处理,ParNew GC不可与并行的旧生代GC同时使用。
在配置为使用CMS GC的情况下,新生代默认采用并行GC方式,也可通过-XX:+UseParNewGC来强制指定。
当在Eden Space上分配内存时Eden Space空间不足,JVM即触发Minor GC的执行,也可在程序中通过System.gc的方式(可通过在启动参数中增加-XX:+DisableExplicitGC来避免程序中调用 System.gc触发GC)来触发。
Minor GC示例
以下通过几个例子来演示minor GC的触发、Minor GC时Survivor空间不足的情况下对象直接进入旧生代、不同GC的日志。
1. Minor GC触发示例
以下为一段展示Eden Space空间不足时minor GC状况的代码:
public class MinorGCDemo {
public static void main(String[] args) throws Exception{
MemoryObject object = new MemoryObject(1024*1024);
for(int i = 0 ;i < 2 ;i++){
happenMinorGC(11);
Thread.sleep(2000);
}
}
private static void happenMinorGC(int happenMinorGCIndex) throws Exception{
for(int i = 0 ;i < happenMinorGCIndex ;i++){
if( i ==happenMinorGCIndex-1){
Thread.sleep(2000);
System.out.println("minor gc should happen");
}
new MemoryObject(1024*1024);
}
}
}
class MemoryObject{
private byte[] bytes;
public MemoryObject(int objectSize){
this.bytes = new byte[objectSize];
}
}
以-Xms40M -Xmx40M -Xmn16M -verbose:gc -XX:+PrintGCDetails参数执行(执行机器cpu为6核,物理内存4GB,os:linux 2.6.18 32 bit,jdk为sun 1.6.0 update 18)以上代码,此时的GC方式为默认的并行回收GC方式,按照这样的参数,Eden space大小为12MB,S0和S1各2MB,旧生代为24MB。通过jstat -gcutil [pid] 1000 10查看Eden、S0、S1、old在minor GC时的变化情况。
在输出minor gc should happen后,jstat输出如下信息:
S0 S1 E O P YGC YGCT FGC FGCT GCT
0.00 57.82 8.33 0.00 10.33 1 0.011 0 0.000 0.011
从代码分析可以看出,在输出minor gc should happen后,此时Eden space中已分配了11个1MB的对象,再分配1个1MB对象时,就导致Eden space空间不足,因此触发minor GC,minor GC执行完毕后可看到S0、旧生代仍然为0。新生代此时则由新创建的1MB对象占用了8%,S1则由于object对象而占用了57.82%,此次 minor GC耗时为11ms。
同时程序执行的控制台上在minor gc should happen后出现了以下信息:
[GC [PSYoungGen: 11509K- > 1184K(14336K)] 11509K- > 1184K(38912K), 0.0113360 secs] [Times: user = 0 .03 sys = 0 .01, real = 0 .01 secs]
上面信息中关键部分的含义为:
PSYoungGen表示GC的方式为PS,即Parallel Scavenge GC;
PSYoungGen后面的 11509K->1184K(14336K)表示在Minor GC前,新生代使用空间为11 509KB,回收后新生代占用空间为1 184KB。为什么和jstat不一样,是因为jstat输出的时候新分配的对象已占用了eden space空间,新生代总共可用空间为14 336KB;
11509K->1184K(38912K)表示在Minor GC前,堆使用空间为11 509KB,回收后使用空间为1 184KB,总共可使用空间为38 912KB;
0.0113360 secs表示此次Minor GC消耗的时间;
Times: user=0.03 sys=0.01、real=0.01 secs表示Minor GC占用cpu user和sys的百分比,以及消耗的总时间。
当再次输出minor gc should happen后,jstat输出如下信息:
57.81 0.00 8.33 0.00 10.33 2 0.020 0 0.000 0.020
同样也是由于Eden space空间不足触发的minor GC,这次minor GC后可看到S0空间使用了57.81%,而S1空间变成了0,表明每次minor GC时将进行S0和S1的交换,此次Minor GC耗时为9ms。
2. Minor GC时survivor空间不足,对象直接进入旧生代的示例
根据分析,按照示例一的启动参数,survivor空间大小为2MB,于是修改代码如下:
public static void main(String[] args) throws Exception{
MemoryObject object = new MemoryObject(1024*1024);
MemoryObject m2Object = new MemoryObject(1024*1024*2);
happenMinorGC(9);
Thread.sleep(2000);
}
按同样的启动参数执行此段代码,在输出minor gc should happen信息后,jstat输出的信息如下所示:
0.00 57.43 8.33 8.33 10.33 1 0.020 0 0.000 0.020
从以上代码可以看出,当minor GC触发时,此时需要放入survivor空间的有object、m2object。两个对象加起来占据了3MB多的空间,而survivor空间只有 2MB,按顺序先放入的为object,这样m2object就无法放入,于是m2object对象被直接放入了旧生代中,这就可以解释为什么在 minor GC执行后旧生代空间被占用了8.33%。
3. 不同GC的日志示例
将上面代码修改为仅触发一次Minor GC的情况:
MemoryObject object = new MemoryObject(1024*1024);
happenMinorGC(11);
Thread.sleep(2000);
由于Serial GC对SurvivorRatio值的使用和并行回收GC不同,因此在使用Serial GC运行上面代码时,需要调整下SurvivorRatio值,用以下参数启动:
-XX:+UseSerialGC -Xms40M -Xmx40M -Xmn16M - verbose:gc -XX:+PrintGCDetails -XX:SurvivorRatio = 6
在控制台输出minor gc should happen后,可看到如下gc日志信息:
[GC [DefNew: 11509K- > 1138K(14336K), 0.0110060 secs] 11509K- > 1138K(38912K), 0.0112610 secs] [Times: user = 0 .00 sys = 0 .01, real = 0 .01 secs]
和并行回收GC不同的地方在于标识GC方式的地方,这里为DefNew,表明使用的为串行GC方式。
改为以下参数来查看并行GC时的日志信息如下:
-XX:+UseParNewGC -Xms40M -Xmx40M -Xmn16M -verbose:gc -XX:+PrintGCDetails -XX:SurvivorRatio = 6
其输出的GC日志信息为:
[GC [ParNew: 11509K- > 1152K(14336K), 0.0129150 secs] 11509K- > 1152K(38912K), 0.0131890 secs] [Times: user = 0 .05 sys = 0 .02, real = 0 .02 secs]
不同点在于标识GC方式的地方,这里为ParNew,表明使用的为并行GC方式。
旧生代和持久代可用的GC
JDK提供了串行、并行及并发三种GC来对旧生代及持久代对象所占用的内存进行回收。
1. 串行
串行基于Mark-Sweep-Compact实现,Mark-Sweep-Compact结合Mark-Sweep、Mark-Compact做了一些改进。
采用串行方式时,旧生代的内存分配方式和新生代采用的串行方式相同。
串行GC分为三个阶段来执行:
1)从根集合对象开始扫描,按照三色着色 的方式对对象进行标识;
2)遍历整个旧生代空间或持久代空间,找出其中未标识的对象,并回收其内存;
3)执行滑动压缩(Sliding compaction),将存活的对象向旧生代空间的开始处进行滑动,最终留出一块连续的到结尾处的空间。
串行执行的整个过程需暂停应用,且采用的为单线程方式,通常要耗费较长的时间,可通过增加-XX:+PrintGCApplicationStoppedTime来查看GC造成的应用暂停的时间。
串行是client级别或32位的Windows机器上默认采用的GC方式,也可通过-XX:+UseSerialGC来强制指定。
2. 并行
并行采用Mark- Compact实现,在内存分配方式上则和串行方式相同。
并行GC分为三个阶段来执行,如图3.15所示。
1)首先将代空间划分为并行线程个数的区域(regions),然后根据根集合可直接访问到的对象及并行线程的个数进行划分,并行地对这些对象进行三色着色。当对象被着色时,同时更新其所在的region存活的大小及存活对象所在的位置;
2)经分析,大部分情况下经过多次GC后,通常旧生代空间左边存放的是一些活跃的对象。对这些对象进行压缩移动是不值得的,因此并行GC在这一步做 了优化,方式为从最左边开始往右扫描regions,直到找到第一个值得进行压缩移动的region,并将此region左边的region作为密度高区 (dense prefix),对这些区域则不进行回收;然后继续往右扫,对于右边的regions根据存活的空间来决定压缩移动的源region和目标region, 切换引用这些对象的指针,并在region上做标志,同时清除regions中其他不存活对象所占用的空间,目前此过程为单线程进行。
3)基于regions上分析的信息,找到需要操作的目标region及完全没有存活对象的region,并行地进行对象移动和region的回收。
![]() |
(点击查看大图)图3.15 并行GC执行过程 |
较 之串行方式而言,并行大部分时候是多线程同时进行操作,再加上其所做的dense prefix的优化,因此其对应用造成的暂停时间会缩短。但由于旧生代较大,在扫描和标识对象上需要花费较长的时间,因此仍然要耗费一定的应用暂停的时 间,并行是server级别机器(非32位Windows)上默认采用的GC方式,也可通过-XX:+UseParallelGC或 -XX:+UseParallelOldGC来强制指定。
3. 并发(CMS: Concurrent Mark-Sweep GC)
Mark-Sweep方式要对整个空间中的对象进行扫描并标记,这个过程会造成较长时间的应用暂停,有些应用对响应时间有很高的要求。因此Sun JDK提供了CMS GC,好处为GC的大部分动作均与应用并发进行,因此可大大缩短GC造成应用暂停的时间。
CMS采用的是Mark-Sweep方式,其在回收完毕后可能会形成多个空闲的空间,这就没办法再采用bump-the-pointer的方式来分 配内存了,于是CMS采用了free list的方式来记录旧生代空间中哪些部分是空闲的。当有对象要在旧生代分配内存时,就要先去free list中寻找哪个部分是可以放下这个对象的。多数情况下旧生代分配内存的请求都来源于Minor GC阶段,CMS这种分配旧生代内存的方式会导致Minor GC的速度下降。
另外,由于CMS执行过程中大部分时候是和应用并发进行的,分配内存的动作有可能和回收内存的动作同时进行,这时会造成free list竞争激烈,CMS为了避免这个现象,引入了Mutual exclusion locks,以JVM分配内存为优先。
CMS执行的扫描、着色和清除步骤如下。
1. 第一次标记(Initial Marking)
该步聚须暂停整个应用,扫描从根集合对象到旧生代中可直接访问的对象,并对这些对象进行着色。对于着色的对象CMS采用一个外部的bit数组来进行记录。
2. 并发标记(Concurrent Marking)
在初始化标记完毕后,CMS恢复所有应用的线程,同时开始并发对之前着色过的对象进行轮循,以标记这些对象可访问的对象。
CMS为确保能够扫描到所有的对象,避免在Initial Marking中还有遗漏的未着色的对象,采用的方法为找到着色的对象,并将这些对象放入Stack中。扫描时寻找所依赖的对象,如果依赖的对象地址在其 之前,则将此对象也进行着色,并同时放入Stack中,如果依赖的对象地址在其之后,则仅着色。
在进行Concurrent Marking时Minor GC可能会同时进行,这时很容易造成旧生代对象引用关系改变,CMS为应对这样的并发现象,提供了一个Mod Union Table来进行记录。在这个Mod Union Table中记录每次Minor GC后修改的Card的信息,这也是在采用CMS时新生代GC必须采用Serial GC或ParNew GC的原因。
在进行Concurrent Marking时还有可能会出现的一个并发现象是,应用修改了旧生代中对象的引用关系,CMS中采用Card Table的方式来进行记录,并在Card中将某对象标识为dirty状态。但即使这样,仍然可能出现一种导致不再被引用的对象是marked的状态,图 3.16所示为一个这种情况的例子。
![]() |
图3.16 CMS GC时浮动垃圾产生的示例 |
Concurrent Marking扫描到a引用了对象b,b引用了c和e。如果在此之后应用将b引用的对象由c改为了d,同时g不再引用d,此时会将b、g对象的状态在card中标识为dirty,但c的状态并不会因此而改变。
3. 重新标记(Final Marking(remark))
该步需要暂停整个应用,在Concurrent Marking时应用可能会修改对象的引用关系或创建新的对象,因此要对这些改变或新创建的对象也进行扫描,包括Mod Union Table及Card Table中dirty的对象,并重新进行着色。
4. 并发收集(Concurrent Sweeping)
在完成了Final Marking后,恢复所有应用的线程,就进入到这步了,这步要负责的是将没有标记的对象进行回收。
由于内存碎片的原因,可能会造成每次回收的内存比之前分配出去的小。为避免这种现象,在进行sweeping的时候,CMS会尽量将相邻的块重新组装为一个块,采用的方法为首先从free list中删除块,组装完毕后再重新放入free list中。
从以上整个步骤来看,CMS中只有Initial Marking和Final Marking需要暂停整个应用,其他动作均与应用并发进行,这也是它能够做到GC过程中影响应用时间很短的原因。但同时由于并发进行,也意味着CMS会 和应用线程争抢CPU资源,为降低和应用争抢CPU资源的现象发生,CMS还提供了一种增量的模式,称为i-CMS。在这种模式下,CMS仅启动一个处理 器线程来并发扫描标记和清除,并且该线程在执行一小段时间后会先将CPU使用权让出来,分多次多段的方式来完成整个扫描标记和清除的过程,这样降低了对 CPU资源的消耗,同时也降低了CMS的性能和回收的效率,因此仅适用于CPU少和内存分配不频繁的应用。
对比并行GC,CMS GC需要执行三次mark,因此其完整的一次GC执行的时间会比并行GC长。对于关注GC总耗时的应用而言,CMS GC并不是合适的选择。
另外,CMS回收内存的方式使得其很容易产生内存碎片,降低了内存空间的利用率。为了减少产生的内存碎片,提高空间的利用率,CMS提供了一个整理 碎片的功能,可通过在JVM中指定-XX:+UseCMSCompactAtFullCollection来启动此功能。在启动此功能后默认为每次执行 Full GC时都会进行整理,也可通过-XX:CMSFullGCsBeforeCompaction=来指定多少次Full GC后才执行整理。值得注意的是,整理这个步骤是需要暂停整个应用的。
除内存碎片外,CMS在回收时容易产生一些应该回收但要等到下次CMS才能被回收掉的对象,例如图3.16中的c对象,通常把这些对象称为"浮动垃 圾",再加上CMS回收过程中大部分时间是和应用并发进行的,因此该过程中应用可能会分配内存,这就要求采用CMS的情况下需要提供更多可用的旧生代空 间。
默认情况下CMS GC并不开启,可通过在启动参数上增加-XX:+UseConcMarkSweepGC来启用CMS进行旧生代对象的GC,其默认开启的回收线程数为(并行GC线程数+3)/4,可通过-XX:ParallelCMSThreads=10来强行指定。
CMS GC触发的条件为旧生代已使用的空间达到设定的CMSInitiatingOccupancyFraction百分比,例如默认 CMSInitiatingOccupancyFraction为68%,如旧生代空间为1 000MB,那么当旧生代已使用的空间达到680MB时,CMS GC即开始执行;另外一种触发方式是JVM自动触发,JVM基于之前GC的频率及旧生代的增长趋势来评估决定什么时候执行CMS GC,如果不希望JVM自行触发,可设置-XX:UseCMSInitiatingOccupancyOnly=true。
持久代的GC也可采用CMS方式,方式为设置以下参数:-XX:+CMSPermGenSweepingEnabled -XX:+CMSClassUnloadingEnabled。
由于CMS GC在执行时需要分为多个步骤,其输出的GC日志信息也会比较多,下面为一段CMS GC的日志及对其的解释 。
[GC [1 CMS-initial-mark: 13433K(20480K)] 14465K(29696K), 0.0001830 secs] [Times: user = 0 .00 sys = 0 .00, real = 0 .00 secs]
开始执行CMS GC,进行Initial Marking步骤,旧生代的空间为20 480KB,CMS GC在旧生代被占用了13 433KB后触发。
[CMS-concurrent-mark: 0.004/0.004 secs] [Times: user = 0 .01 sys = 0 .00, real = 0 .01 secs]
完成Concurrent mark步骤,耗时4ms。
[CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user = 0 .00 sys = 0 .00, real = 0 .00 secs]
该步用于重新扫描在concurrent mark阶段CMS Heap中被新创建的对象或从新生代晋升到旧生代对象的引用关系,以减少remark所需耗费的时间,这是Sun JDK 1.5后增加的一个优化步骤。
CMS: abort preclean due to time [CMS-concurrent- abortable-preclean: 0.007/5.042 secs] [Times: user = 0 .00 sys = 0 .00, real = 5 .04 secs]
当eden space占用超过2MB时,执行此步,并且将一直并发的执行到eden space的使用率超过50%,之后触发remark动作,这两个值可通过-XX: CMSScheduleRemarkEdenSizeThreshold和-XX: CMSScheduleRemarkEdenPenetration来设置。以上日志信息表示为preclean动作执行了5秒后,eden space使用仍然未超过50%,此时也停止执行preclean,触发remark动作。5秒这个值可通过-XX: CMSMaxAbortablePrecleanTime=5000(单位为毫秒)来进行设置。
[GC[YG occupancy: 3300 K (9216 K)][Rescan (parallel) , 0.0002740 secs][weak refs processing, 0.0000090 secs] [1 CMS-remark: 13433K(20480K)] 16734K(29696K), 0.0003710 secs] [Times: user = 0 .00 sys = 0 .00, real = 0 .00 secs]
执行并完成remark动作。
[CMS-concurrent-sweep: 0.000/0.000 secs] [Times: user = 0 .00 sys = 0 .00, real = 0 .00 secs]
执行并完成concurrent sweeping动作。
< P > [CMS-concurrent-reset: 0.000/0.000 secs] [Times: user = 0 .00 sys = 0 .00, real = 0 .00 secs] </ P >
重新初始化CMS的相关数据,为下次CMS GC执行做准备。
除以上GC实现外,在JDK 5以前的版本中还有一个GC实现是增量收集器,增量收集器可通过-Xincgc来启用,但在JDK 5及以上版本中废弃了此增量收集器。当在这些版本中设置-Xincgc时,会自动转为采用并行收集器去进行垃圾回收,原因是其性能低于并行收集器,因此本 书中就不介绍此收集器了。