此文介绍Java的基本垃圾回收机制。
内存数据的分代和分区
GC主要回收的是堆区,在堆中是有对象分代的,一个对象每“逃”过一次回收,对象代数便+1,新生对象被称作新生代(如果是占据内存较大的对象直接定义为老年代),当代数一定时对象将由新生代变为老年代。同时在Java1.7之前还有永久代,保存了一些静态变量。总之,内存回收只发生在新生代和老年代之间。除了分代,内存也有分区:
如图,是内存区域分配,其中Eden存储了新建的小对象,当回收时,将Eden中存活的对象转移到To Survivor区中,将From Survivor中的代数高(一般是15)的存活对象转移到老年代中,代数没达到阈值的存活对象转移到To Survivor中。然后清理掉Eden和From Survivor中的对象,对To Survivor与From Survivor互换身份。
Minor GC与Full GC
如上文第二段所述是Minor GC的过程,Minor GC只发生在新生代中,当Eden区内存不足会触发Minor GC,是件很频繁的事情。Full GC则是回收老年代,当老年代内存不足时或当触发。
垃圾收集算法
停止-复制
将存活对象直接转移到新的空间,然后删除旧空间的对象数据。这种算法一般用在新生代的GC中。
标记-清除
标记存活对象,然后清除掉没被标记的对象。这种算法使用的不多,因为内存不连续,产生很多碎片。
标记-整理
同样标记存活对象,然后将存活对象整理到内存连续的空间内,再清除剩下的空间边界外的对象。这种算法一般用在老年代的GC中。
GC Root与可达性分析
GC回收的基本方式采用GC Root的可达性分析法,即从GC Root对象为起点,通过对象的引用链条进行对象可达性分析,通过GC Root可达的对象就就不会被回收。
GC Root包括:
目前Java栈中引用的对象
方法中常量和静态变量引用的对象
JNI引用的对象
激活的线程
CMS与G1回收方法
stop-the-world
垃圾回收器是stop-the-world的,因为每当GC执行时,java所有线程都会停止工作,直到gc线程执行完毕,所以垃圾回收的核心其实是尽量改善GC的时间和次数,这也是GC调优的目的。jdk 8 中的GC主要采用两种方案:CMS和G1。
CMS
全称为ConcurrentMarkSweep,CMS只发生在老年代中,所以不能独立存在,基于标记-清除实现,执行步骤如下:
初始标记 (发生stop-the-world,CPU停顿, 很短),初始标记仅标记一下GC Roots能直接关联到的对象,速度很快;
并发标记 (收集垃圾跟用户线程一起执行) ,并发标记过程就是进行GC Roots Tracing的过程,即从GC Roots向下寻找可达的对象;
重新标记 (发生stop-the-world,CPU停顿,比初始标记稍微长,远比并发标记短),修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但比并发标记时间短得多;
执行并发清理。
整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
CMS能很大程度减小cpu的停顿时间,但是由于并发标记和清理过程依旧占用部分CPU,会导致程序在GC期间卡顿少,然而吞吐量小,同时因为没有进行内存整理,容易产生很多内存碎片,尤其是在老年代中,碎片化的数据不利于分配较大对象的内存,相应地CMS开放了一个可回收后对内存进行整理的开关,但是开启后会导致回收时间变长。
G1
G1可以回收新生代对象也可以回收老年代对象,被称作是面向服务端的垃圾收集器。
前文所指的分代回收多是复制算法和CMS的回收机制,G1收集器弱化了这个概念,将物理内存按逻辑分为了多个region,新生代与老年代物理上在一处空间,虽然依旧分代,但物理上不再隔离。执行步骤如下:
初始标记(STW):同CMS的第一步;
根区域扫描(root region scan):根分区扫描,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描(Root Region Scanning)。
并发标记:标记线程和用户线程并发执行,标记出根对象的可达路径。从初始标记开始找出所有存活对象(耗时长)。
重新标记(STW):同CMS的重新标记。
筛选回收(清除):筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。
G1在老年代中采用的是标记-整理算法,当发生GC时,会将处理后的对象几种移动到一片新region,然后删除剩下的空间。不会留下内存碎片,这就极大程度避免了因为没有内存连续空间分配大对象导致的gc。
为了避免在minor gc中扫描过多无用的老年代对象,G1还引入了卡表的概念,在堆中会有独立的存储空间记录分区(不一定是前文提到的region)的堆空间是否可能含有年轻代对象,如果可能含有,则进行扫描,反之则跳过相应的区域,这算是G1应对分代内存空间在物理上不连续的方案。
相比CMS,G1的优势主要是可预测的停顿时间,因为回收对象是region,所以他可以有计划地避免在整个堆范围进行内存回收,也可以在预料中的指定垃圾回收的市场时长。
一些古老的“垃圾”收集器-新生代
下面介绍一下古老的垃圾收集器,在Java 8以后可能很少见到他们的身影,这些收集器要么有某些缺陷,要么会有效率问题。
Serial
也称Copy或DefNew。新生代垃圾收集的最初版本,单线程单个cpu执行,专注于回收内存因而高效,但是全程STW。
ParNew
算是前期用的最多的垃圾收集器,在Java 7中有一个很经典的组合回收方式:CMS+ParNew,在G1出现以前,一般是ParNew担起了他的职责。ParNew其实就是并行版的Serial,但是在单个核心环境下表现会劣于Serial。
Parallel Scavenge
也称PS Scavenge或PSYoungGen。使用停止-复制算法,是ParNew的变种,相当于可以设定每次最多回收时间和最大吞吐量的ParNew,回收时间更短通常意味着需要回收更加频繁,但有时可能会有更好的用户体验,需要根据实际情况选择。
一些古老的“垃圾”收集器-老年代
Serial Old
也称MSC,Serial的老年代版本,采用标记-整理算法。
Parallel Old
Parallel Scavenge的老年代版本,在Java1.6之后出现,采用标记-整理算法。
gc常见组合
现有的垃圾回收如下图(原图来自《深入理解Java虚拟机:JVM高级特性与最佳实践》一书),可以任选一个年轻代和老年代垃圾回收构成gc组合
最初的组合是Serial+Serial Old,随后随着需求产生了ParNew+Serial Old组合,在Java 1.6产生Parallel Old之后,Parallel Scavenge回收参数可调的优越性才得以体现出来。
需要注意的是Parallel是吞吐量可控的垃圾回收,如果不结合Parallel Old使用将不能凸显它的优势。
查看当前、默认的垃圾回收器
Gc参数和使用情况见下表(来源:https://www.jianshu.com/p/254717efe1f5)
新生代(别名) | 老年代 | JVM 参数 |
---|---|---|
Serial (DefNew) | Serial Old(PSOldGen) | -XX:+UseSerialGC |
Parallel Scavenge (PSYoungGen) | Parallel Old (ParOldGen) | -XX:+UseParallelGC |
Parallel Scavenge (PSYoungGen) | Parallel Old (ParOldGen) | -XX:+UseParallelOldGC |
ParNew (ParNew) | Serial Old(PSOldGen) | -XX:-UseParNewGC |
ParNew (ParNew) | CMS+Serial Old(PSOldGen) | -XX:+UseConcMarkSweepGC |
G1 | G1 | -XX:+UseG1GC |
想要查看默认的垃圾回收器,只需要执行:java -XX:+PrintCommandLineFlags -version 查看默认命令
想要查看具体的程序垃圾回收期,需要执行 jinfo -flags {pid}查看当前、默认的垃圾回收器
尾注
在Jvm的参数优化中,选取合适的垃圾回收器一般不是很重要,换言之,gc调优不是Java应用优化的银弹,对于一般规模的web应用,STD的影响几乎可以忽略不计,若是盲目追求更新的垃圾回收器,可能因小失大。例如:对于堆内存小于8G的应用一般不必将垃圾回收由CMS调整为G1,这种调整带来的裨益可能不如G1分代产生的资源损耗,因此要参考具体的场景决定是否优化GC和怎么去优化GC。
参考资料:部分内容引自:G1 vs CMS详细对比 - tantexian的博客空间 - OSCHINA、《深入理解Java虚拟机:JVM高级特性与最佳实践》