Java垃圾回收深入

什么是垃圾?

在内存中, 一块内存如果不再使用, 占用它的对象就被称为垃圾. JVM无法判断一块内存是否会被再次使用(这只有使用者才知道), 它通过判断内存对象是否可能被再次使用来判断其是否为垃圾.

最直接可以想到的定义垃圾对象的方法应该是引用计数法,一个对象在创建它的引用时,比如Object a = new Object();创建了一个指向new Object()这个对象的一个引用a,记下这个对象有一个引用。将引用赋值给另一个引用,比如Object b = a;会递增这个计数器。当你销毁一个引用时,比如通过a=null;实现,就会递减这个计数器。直到这个计数器降为0,认为没有地方引用这个对象,称之为垃圾,将其回收。

只是引用计数法有循环引用导致无法删除的问题,举例说:

Obj a = new Obj();
Obj b = new Obj();
a.next = b;
b.next = a;

在将a置为null的时候,由于它还被b.next引用,无法回收;同理将b置为null也由于它被b.next引用,也无法回收。当然这也不是不可解的,而且由于它更简单,netty这样自己维护堆外内存的框架就使用引用计数法来维护自己的对象池。只是JVM未选择它。

目前所有JVM判断垃圾对象使用的都是根搜索算法, 即从一个ROOT出发, 如果无法通过有限次引用找到的对象就视为垃圾, 回收其空间. 能够作为ROOT的包括但不限于: 类的静态变量; 类加载器; JNI对象; 存活的线程等.

什么是分代回收, 为什么使用它?

分代回收是现代垃圾回收器普遍采用的一种顶层设计. 它的设计依据是内存中的垃圾对象存在不同的生存周期(代系), 根据生存周期的差异分别采用不同的机制来做垃圾清理内存回收. 就是所谓的分代回收.

最简单的垃圾回收回收策略为两种: 复制->整理策略, 优点是速度快, 没有碎片; 缺点是浪费空间. 标记->清除策略, 优点是内存使用率高, 相应的缺点是容易产生内存碎片, 需要定期整理(即标记->整理策略).

复制整理法, 速度快, 内存减半, 空间换时间
标记清除法, 内存利用率高, 但容易产生内存碎片. 需要定期整理.

而这两种方法恰恰与经验上得出的内存垃圾的特点相符, 由于大多数对象是朝生夕死, 使用回收速度见长的复制整理法来回收此类对象(称之为年轻代, 对应Minior GC), 可以快速回收大量内存, 相应垃圾对象占用的内存也比较少, 则复制整理策略所牺牲的内存减半损耗也变得比较小. 而小部分生命周期比较长的对象(或者体积过大不足以塞进年轻代空间的大对象)则使用节省内存的标记清除法进行回收(老年代, 对应Major GC), 因为对象较少, 所以单个对象回收效率低的问题也可以接受. PS. 为什么使用标记清除而不是标记整理?

主流垃圾回收器有哪些, 他们的特点及使用场景是什么?

在Java进程运行的过程中, 垃圾对象的内存空间由专门的gc线程去释放回收, 到目前为止Hotspot虚拟机主要提供了以下几种回收器:

  • 按gc线程是否与用户线程并发分为:
    • 串行: Serial
    • 并行: ParNew, Parallel Scavenge(PS)
  • 按是否吞吐量/停顿时间优先分为:
    • 吞吐量优先: Parallel Scavenge, Parallel Old
    • 停顿时间优先: CMS, G1
  • 按适用的内存空间分:
    • 仅能年轻代使用:Serial, ParNew, Parallel Scavenge
    • 仅能老年代使用:CMS, Serial Old, Parallel Old
    • 年轻代+老年代通用: G1

CMS回收器的原理及调优?

CMS(Concurrent Mark Sweep)是目前工作中常用的回收器, 只能清理老年代空间(默认跟年轻代ParNew回收器搭配使用), 侧重于降低垃圾回收的平均单次STW暂停时间, 被许多对实时性/流畅性要求较高的应用采用. 他的核心思想在于Concurrent Mark(并发标记). 对于CMS之前的垃圾回收器, 在清理垃圾的时候需要对所有用户线程进行暂停(即STW, Stop The World), 这样做的原因是为了防止gc线程在运行的过程中用户线程再次修改对象的可达关系. CMS则选择部分地让gc线程和用户线程并发运行. 它将整个回收过程划分四个阶段:

  • 初始标记,标记GCRoots能直接关联到的对象,时间很短
  • 并发标记,进行GCRoots Tracing(可达性分析)过程,时间很长
  • 重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,时间较长
  • 并发清除,回收内存空间,时间很长。其中,并发标记与并发清除两个阶段耗时最长,但是可以与用户线程并发执行.

从CMS的名字其实也可以知道它使用的是标记(Mark)清除(Sweep)策略进行垃圾回收, 所以它带有此类算法固有的内存碎片问题. 对应地CMS提供CMSFullGCsBeforeCompactionUseCMSCompactAtFullCollection 控制空间整理压缩.

由于存在用户线程和gc线程并发的阶段, 所以在gc线程运行的过程中可能产生新的对象需要空间, 也可能有新增的垃圾对象错过了本次清理导致内存占用. 基于此, CMS不能总像传统gc那样等到空间满才开始回收(这样会导致一次Concurrent Mark Failed, 并发失败, 会导致长时间停顿), 于是CMS设计了基于时间和空间的多种条件预判来触发background回收动作:

  • 基于时间的触发. CMS会统计每次gc耗费的时间和周期, 预估下一次启动gc的最佳时刻(预测 CMS GC 完成所需要的时间大于预计的空间将要填满的时间,则进行 GC). 但是在刚启动时, 没有历史统计数据, 则按照堆空间的50%来触发gc.
  • 基于空间的触发. 当老年代高于一定阈值就开始gc, 默认为92%(此数值可以通过CMSInitiatingOccupancyFraction参数来调整百分比)

有的时候CMS设计的诸多(事实上还包含更多)自动gc触发机制反而会影响我们分析性能, 调试代码, 所以CMS还提供了UseCMSInitiatingOccupancyOnly参数来控制是否启动这些自动触发机制. 当次参数关闭时, 仅使用CMSInitiatingOccupancyFraction来启动每次gc.

G1回收器的原理及调优?

G1回收器是JDK9之后的默认垃圾回收器, 强调垃圾回收线程导致的应用停顿时间应该可控, 每次回收的时间尽可能短(事实上可以通过参数配置每次gc的最长STW时间, -XX:MaxGCPauseMillis=<>, 默认200ms).

在G1之前, 所有的垃圾回收器在内存空间的规划上, 大多按照代际划分几个大型的内存空间, 每次回收均回收一整块内存. 从Serial到Parallel, 再到CMS的优化主要主要关注如何加快垃圾回收的速度. 而这在大内存的场景中依然会产生长时间的STW, 通常是不能接受的.

事实上GC发生的时候我们需要的仅仅是当下创建对象需要的内存空间, 如果GC每次只回收一小块内存, 耗时当然小得多. G1依着这种思路, 将完整的一大块内存切成若干块小内存, GC时仅回收部分垃圾对象占比最大的内存块(这也正是G1, Garbage First的名字来源).

G1回收器的内存结构图

具体地, G1将内存区域等分为若干个不连续的称为Region的内存块, 每个Region拥有自己的代系, 同样分为Eden, Surivior, Old和一个特殊的Humongous. 新对象创建时, 优先进入Eden区, 当新生代的GC触发时, 会将所有的Eden区存活对象进行复制整理, 移到新的Eden区(相应对象的age递增)保存. 寿命超过阈值, 升级到老年代Old. 而部分对象如果超过单个Region大小的50%, 则视为大对象, 直接进入Humongous区域, Humongous会根据目标对象的大小, 合并多个Region来保证空间足够保存它.

显然, G1可以同时管理新生代和老年代的内存空间. 相应地, G1也设计了不同的垃圾回收机制:

年轻代回收

G1年轻代的回收跟Parallel Scavenge的类似, STW中断所有用户线程, 多线程并发地做回收工作. 为了使回收效率更高, 优化主要在于采用了RememberSet的优化, 这是一种空间换时间的实现. 每个Region保存一个叫做RememberSet(RSet in short)的结构, 保存拥有对本Region内对象引用的Region-对象信息. 在标记此Region时, 可以利用RSet来缩小扫描范围. 具体地, 年轻代的RSet中仅保存老年代对本Region的引用信息(因为年轻代回收是针对整个年轻代的, 不需要重复保留同代内部引用).

老年代也存在RememberSet的设计, 但和年轻代不同, 老年代中仅保存老年代和年轻代Surviver区的对象对本Region内对象的引用.

Region1和Region3拥有对Region2内对象的引用(蓝色), 所以在Region2的RSet中保存此关系(粉色)

老年代回收

老年代回收工作跟CMS非常类似, 主要在以下几个地方做了优化:

  1. 由于G1采用Region划分内存, 当遇到全为垃圾对象的Region时, 可以直接跳过内存回收, 直接将其标记为可写入即可重新使用.
  2. G1设计成每次gc仅回收部分Region, 那么如何从诸多Region中找到回收效率最高的是一个重要问题. G1为此引入了停顿预测模型(Pause Prediction Model). 在标记垃圾对象的过程中同时也统计了这些对象的一些统计信息, 最终代入模型即可计算出最优的回收Region.
  3. 类似于CMS, G1的标记过程也被分为三个阶段: 初始标记(STW, 时间短), 并发标记(并发, 时间长), 重新标记(STW, 时间介于两者之间). 不同地方在于G1在重新标记阶段使用SATB算法, 比CMS的 Increment Update效率更高. 前者可以做到只扫描并发标记阶段修改的SATB buffer, 而后者需要重新扫描mod-union table里的dirty card外加整个根集合,而此时整个young gen(不管对象死活)都会被当作根集合的一部分,因而CMS remark有可能会非常慢[1]。

具体地看, G1 老年代GC分为两个阶段:

  1. 并发标记阶段.
    1. 年轻代回收[串行]. 在开始并发标记之前, 先触发一次年轻代的gc以控制年轻代的对象数量. (在CMS中也有类似的配置XX:+CMSScavengeBeforeRemark, 启动时将在重新标记前执行一次年轻代gc)
    2. 初始标记[串行]. 扫描根集合可以直达的对象. 由于此阶段紧跟年轻代回收, 因此没有单独的暂停.
    3. 并发标记[并行]. 根据初始标记得到的根集合标记所有对象, 同时用户线程还在运行, 如果用户线程修改了对象引用, 会经过SATB的Write Barrier记录下来.
    4. 重新标记[串行]. 重新扫描并发标记过程中修改的对象确保不会漏标记导致错误清除有用对象. (但可能会有错标记的情况, 此时对象会成为漂浮垃圾, 问题不大, 下次gc会将其回收)
  2. 回收阶段
    1. 并发回收, 无需多言.

[1] [HotSpot VM] 请教G1算法的原理

发表评论

电子邮件地址不会被公开。

6 + 2 =