与C、C++等手动管理内存的编程语言不同,Java程序在运行时会自动对内存中不再使用的对象进行检测,并回收这部分对象所占用的内存空间。

在JDK中,JVM将堆(heap)内存分为了不同的区域块,每种区域会使用不同的垃圾收集器,每种垃圾收集器又会使用各自的垃圾收集算法。因此JVM的垃圾收集设计大致架构如下,从上至下越来越偏向于细节,本文使用自底向上的方式进行介绍

  1. 堆内存
  2. 堆内存分为多个区域
  3. 每个区域使用了不同的垃圾收集器
  4. 每种垃圾收集器使用了一个或多个垃圾收集算法

垃圾收集算法

检测垃圾的方式一般有引用计数法可达性分析两种,因为引用计数法无法检测到循环引用的垃圾对象,所以现在JVM中进行垃圾检测都是使用的可达性分析方法。

在检测到了垃圾对象之后,JVM就需要对这些垃圾对象进行垃圾回收(英语:Garbage Collection,缩写为GC),JVM中使用到的GC算法主要由如下三种

标记-清除算法(Mark-Sweep)

该算法分为标记和清除两个步骤,第一步标记是将所有活动的对象做上标记,第二部清除是将所有没有标记的对象进行回收。

标记-压缩算法(Mark-Compact)

标记压缩算法和标记清除算法基本上一致,只是标记清除算法过程只能单纯的把对象内存空间给释放出来,时间长了会造成内存碎片的问题。标记压缩算法在标记清除算法的基础上会对存活的对象进行移动,之后把这些对象向某一端进行移动来保证有完整的内存空闲空间。

标记压缩算法避免了内存碎片的问题,内存的利用率得到了提高,但是因为要移动对象所以它的GC效率要比标记清除算法要差。

复制算法(Copying)

复制算法将内存分为FROM和TO两块区域,对象在From区域内进行分配,之后对FROM区域进行GC,存活的对象被全部复制TO区域;之后对象在TO区域进行分配,GC发生在TO区域,GC后存活的对象被复制到FROM区域,如此反复。

垃圾收集器

在JVM中存在着多种垃圾收集器,这些垃圾收集器使用了一种或多种垃圾收集算法,我们可以认为垃圾收集算法是垃圾收集器的理论基础。

下面是JVM中几种常见的垃圾回收器

垃圾回收器 垃圾回收算法
Serial 复制算法
ParNew 复制算法
Serial Old 标记-整理
CMS 标记-清除
G1 标记-整理+复制算法

经典的堆内存布局(与G1做区分)

堆是区别于栈、方法区等等的一大块内存空间,用于存放对象。JVM将堆内存再进行分区,分为新生代(Young Generation)和老年代(Old Generation),在新生代进行的GC叫做Young GC或Minor GC,在老年代进行的GC叫做Old GC或Major GC,其中新生代的内存又可以分为伊甸(Eden)和存活区(Survivor),Survivor分为两块,一般称为From和To。典型的对象分配和GC流程如下

  1. JVM将对象分配在新生代的Eden区域
  2. 当Eden中的对象内存占用到一定的阈值时,触发一次Young GC
  3. JVM将Eden中存活的对象复制到Survivor的To区域,此时Eden从逻辑上已经被清空
  4. 将Survivor的From和To区域的名字进行对调,即To(此时包含上一次GC的存活对象)改为From,From改为To
  5. JVM继续将对象分配在Eden
  6. Eden中的对象到达一定的阈值之后触发一次Young GC,该次gc发生在Eden和Survivor的From(包含上一次GC的存活对象)这两个区中,JVM将Eden和From区中的存活对象复制到To区
  7. 继续进行From和To区的名字对调
  8. JVM继续向Eden分配对象,下次的GC会对Eden和From区中的对象进行,存活对象被复制到To区
  9. 如此往复,每次的GC存活对象都被保存到Survivor的To,随后To重新改名为From、From也改名为To,之后等待进行下一次的GC

上面介绍的是新生代的GC过程,当To区域存活的对象到达一定的年纪之后(每次GC的存活对象都会增加一岁),JVM就会将该对象移动到Old Generation。老年代的内存分配相较于新生代要简单的多,只有一块区域。

以JDK1.8中流行的 -XX:+UseConcMarkSweepGC 选项为例,该选项会在新生代使用ParNew垃圾收集器,在老年代使用CMS垃圾收集器。

G1垃圾收集器堆内存布局

与经典的堆内存不一样,G1垃圾收集器会将堆内存分为一个个的region区域,使用多个region来替代以前连续的堆内存空间。内存分块这种思想与Linux中的内存分页十分类似,本质都是把内存打散以方便进行管理,用离散的内存来替代连续的内存。内存分块之后可以从根本上解决内存碎片的问题,并且内存管理起来更加的简单。

G1的GC可以分为Young Only Phase、Mixed gc Phase和Full gc Phase。

Young Only Phase

对象会不断地分配到Eden分区中,Eden分区的数量会不断地增长,这个操作会一直进行下去直到Eden分区数量达到上限。

当Eden分区的数量达到了阈值之后,就会对所有的Eden分区进行GC,之后存活的对象会移动到Survivor区中,如果没有Survivor区则会随意挑选一个空闲的region作为Survivor并保存存活对象。

同时如果Survivor分区中的某些对象达到了一定年纪之后,这些对象也会被复制到Old分区中。

Mixed gc Phase

Old分区越来越大,当老年分区的占比达到了一定比例之后,就会触发针对年轻代和老年代的GC,是为Mixed gc。

在进行Mixed gc之前,先需要进行**并发标记周期(Concurrent Marking Cycle)**,这个过程会分为5步

  1. 初始标记(Young Collection with Initial Mark),它伴随着一次普通的Young GC发生,然后对Survivor区进行标记,因为该区可能存在对老年代的引用,会触发Stop The World
  2. 根区间扫描,因为先进行了一次YGC,所以当前年轻代只有Survivor区有存活对象,它被称为根引用区(root region)。扫描Survivor到老年代的引用,该阶段必须在下一次Young GC发生前结束
  3. 并发标记(Concurrent Marking),寻找整个堆的存活对象,该阶段可以被Young GC中断
  4. 重新标记(Remark),完成最后的存活对象标记。使用了比CMS收集器更加高效的 snapshot-at-the-beginning (SATB) 算法,会触发Stop The World
  5. 清除(Clean),清理阶段真正回收的内存很少

到这里,G1的一个并发周期就算结束了,其实就是主要完成了垃圾定位的工作,定位出了哪些分区是垃圾最多的。因为整堆一般比较大,所以这个周期应该会比较长,中间可能会被多次STW的Young GC打断。

等到并发标记周期完成之后,就会进入Mixed gc Phase了,混合垃圾收集周期既会回收新生代的垃圾,也会回收老年代的垃圾。

Full gc Phase

当以下两个现象同时发生时就会触发Full GC

  1. 空闲分区不足或巨型对象无法在老年代找到连续的分区,此时JVM报错to-space exhausted。
  2. 在现象1发生时,G1会尝试增加堆的使用量,如果增加失败则会触发Full gc

Full gc时单个线程会对整个堆的所有代中所有分区做标记、清除以及压缩动作,非常昂贵。

可以通过对JVM设置如下的Xlog来打印GC相关的日志以方便对JVM的GC过程进行深入的了解:

-Xlog:gc*:file=logs/gc.log:t,tags,level:filecount=5,filesize=100m

G1在Elasticsearch中的使用

Elasticsearch对JDK 8u40之前的Java版本是不推荐使用G1垃圾收集器的,如果检测到错误的Java版本和GC配置,Elasticsearch会启动失败。但是在Elasticsearch 7.5.2自带的版本为13.0.1的bundled JDK中,G1已经成为了默认的垃圾收集器,G1已经通过了ES的测试验证并且ES团队也给出了一些关于Java堆(heap)内存管理的最佳实践

参考

咱们从头到尾说一次 Java 垃圾回收 - InfoQ
JVM中的垃圾回收策略
搞懂G1垃圾收集器 - 博客园
G1GC 概念与性能调优 - OPPO互联网技术
G1 垃圾收集器介绍 - Javadoop
Universal GC Log Analyzer
Java Hotspot G1 GC的一些关键技术 - 美团技术团队
新一代垃圾回收器ZGC的探索与实践 - 美团技术团队

Getting Started with the G1 Garbage Collector
Go 垃圾回收(三)——三色标记法是什么鬼?
V8 增量 GC 之三色标记
Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide
jdk11下g1收集器使用