深入理解JVM之内存回收机制

 约 11 分钟

背景

C、C++等语言中,内存的分配和释放由程序代码来完成,容易出现由于程序员漏写内存释放代码引起的内存泄露,最终导致系统内存耗尽。
Java代码运行在JVM中,由JVM来管理 堆Heap 内存的分配和回收(Garbage Collection),把程序员从繁琐的内存管理工作中释放出来,更专注于业务开发。Java内存回收工作由标记(识别可回收对象)和回收(释放可回收对象)两个步骤组成。
和程序代码释放内存相比,内存自动管理会占用一部分CPU时间,Stop The World特点回暂停业务程序运行,非常影响执行效率。Java各版本中,一直致力于内存管理算法的优化,形成了一套针对各种内存分区(新生代、老年代)和运行场景(单核、多核、客户端、服务端)的特点而针对性设计的内存回收算法。

这里说的内存回收机制,主要是指针对堆Heap元空间Metaspace内存的回收,线程相关内存(栈、本地栈、程序计数器)内存随线程创建和回收,直接内存的释放由其在堆内存中引用释放时触发。

内存标记算法

在内存被回收前,系统必须标记哪些内存已经没有人使用可以释放,这个工作就由内存标记算法的来完成,在Java各版本中,使用过如下几种标记算法。

引用计数法(Reference Counting)

这是早期的内存标记算法,每个堆中分配的对象都有一个引用计数器,计数一个对象被引用的次数。当对象创建并赋值给变量时,计数为1,当有其他变量引用该对象时,引用计数+1;但引用此对象的变量超出存活范围或释放对对象引用(包括变量引用了其他对象或变量被设置为null等),引用计数-1。对象的引用计数为0时,表示此对象可被垃圾收集器回收。
引用计数法的有点是简单,执行速度快,只要变量一遍对象检测引用计数是否为0即可判断是否可回收;确定是无法检测出循环引用而导致内存无法回收。

可达性分析算法(Reachability Analysis)

又称跟踪算法(Tracing),算法引入了图论,把所有对象间的关系看成一张图,内存标记从一组根节点(GC Root Set)开始,通过递归搜索,建立对象的引用关系图,当搜索完毕后,图外的对象就是可回收对象。这是目前Java中使用的内存标记算法
GC Root Set.PNG

可作为GC Root的对象包括

  1. 栈中局部变量引用的对象
  2. 类静态变量引用的对象
  3. 常量引用的变量
  4. 本地方法栈引用的对象

内存回收方式

标记-清理算法(Mark and Sweep)

采用跟踪算法标记内存对象后,再扫描堆内存中未被标记的对象,进行回收。此算法不移动对象,仅对不存活对象进行回收,在存活对象占比高的情况下处理效率高,但不移动对象会引起内存碎片。

标记-整理算法(Compacting)

此方法和标记-清理算法使用相同标记算法,但在对不存活对象回收时,会把存活对象向内存前部空闲区域移动,同时更新对象的指针。此方法在清理的基础上,会对对象进行移动,执行成本较高,但可解决内存碎片问题。基于此算法的内存回收实现,一般会增加句柄和句柄表。

复制-清除算法(Copying)

该算法把内存分为空闲区和对象区,新建对象存储到对象区中。当对象区满时,先采用跟踪算法对对象进行标记,再把存活对象拷贝到空闲区,清空原对象区,空闲区和对象区互换角色。在拷贝过车中,程序需要暂停,此算法适用于存活对象叫少的情况,可以解决内存碎片问题。

分代回收策略

JDK8中,堆中移除了永生代区域,堆内存主要由新生代老年代两部分组成。其中新生代由一个伊甸园(Eden)和两个幸存者Survivor From和Survivor To 3部分组成,新创建对象首先保存在Eden中,当Eden中对象达到一定数量时,JVM触发Minor GC,GC时,先把Eden和From中的存活对象拷贝到Survivor To区,再清除Eden和From两个区域的数据,最后From和To互换身份,完成一次内存回收。新生代区域对象数量大,存活时间短,一般采用复制-清除算法,通过这种结构和回收方式来提高垃圾回收效率,减少内存碎片。
经过若干(默认15)次后还存活的对象,将进入老年代区,当老年代数据满时,会触发Major GC(又称Full GC),此时新生代、老年代、元区域、直接内存区域都会执行GC操作。
JVM堆信息.PNG
老年代:新生代的内存大小默认比例为2:1。Eden和两个Survivor的比例为8:1:1。

垃圾收集器

上述的内存标记算法、回收方式和分代策略是垃圾回收的方法,根据这些方法,针对不同的用户场景(Server、Client)和系统配置(单线程、多线程),JVM实现了适用于各场景的垃圾回收器。

  1. 年轻代收集器

    • Serial(复制-清除)
    • ParNew(复制-清除)
    • Parallel Scavenge(复制-清除)
  2. 老年代收集器

    • Serial Old(标记-整理)
    • Parallel Old(标记-整理)
    • CMS(Concurrent Mark Sweep)(标记-清理)
  3. 混合收集器

    • G1(标记-整理)应用于整个堆

年轻代收集器

Serial(复制-清除)

Serial是单线程收集器,Serial收集器只能使用单个线程进行收集工作,在收集的时候必须得停掉其它线程,等待收集工作完成其它线程才可以继续工作。
Serial收集器是JVM中最早的垃圾收集器,也是JDK1.3前的唯一收集器,不再适用于现代多核CPU和Server(服务端)场景,但是非常的适合单核CPU和Client场景。
Serial GC.PNG

ParNew(复制-清除)

ParNew是Serial的升级版,其工作的流程和Serial基本一致,主要的改进是支持多线程同时执行垃圾回收工作,即上图中的GC Thread支持多线程,可以充分利用多核CPU的性能。它是HotSpot上第一个真正意义实现并发的收集器。GC默认开启线程数等于CPU数量,可通过 -XX:ParallelGCThreads 来控制垃圾收集线程的数量。

Parallel Scavenge(复制-清除)

Parallel Scavenge是吞吐量优先的收集器,其工作方式和ParNew基本一样,但是它以提高系统吞吐量(Throughput)为设计目标,吞吐量=业务运行时间/系统总运行(业务+GC)时间。
ParNew等收集器的关注点是尽量缩小垃圾回收的停顿时间,而缩短停顿时间必然需要提高垃圾回收的频率,导致业务线程和GC线程间频繁的切换,从而增加CPU在现场切换上的损耗。
而以吞吐量为设计目标的Parallel Scavenge收集器,可以通过扩大新生代内存容量,减少垃圾回收发生的次数,虽然提高了单次GC的时长,但减少了线程切换开销,从整体上可以提高系统的吞吐量。
Parallel Scavenge GC.PNG

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是:

参数 作用 说明
-XX:MaxGCPauseMillis 控制最大垃圾收集停顿时间 单次GC的最大毫秒数
-XX:GCTimeRatio 设置吞吐量大小 业务:GC时间比例,默认为99,即GC时间占比为 1/(1+99)=1%

单次GC时间参数并非设置的越小越好,而是一把双刃剑,如果减少单次GC时间,必然导致GC频率的上升;而设置的增大,则必然需要更大的内存来支撑。

由于Parallel Scavenge和其他收集器(Serial、ParNew、CMS等)使用了不用的设计框架,导致其无法和CMS协同工作。

老年代收集器

Serial Old(标记-整理)

工作模式基本和新生代的Serial一样为单线程,它采用标记-整理算法,这个模式主要是给Client模式下的JVM使用。如果是Server模式有两大用途:

  1. JDK5前和Parallel Scavenge搭配使用,JDK5前也只有这个老年代收集器可以和它搭配
  2. 作为CMS收集器的后备

Parallel Old(标记-整理)

Parallel Scavenge的老年版本,JDK6开始出现,采用标记-整理算法。Parallel Old的出现结合Parallel Scavenge,真正的形成“吞吐量优先”的收集器组合。JDK7和8中,作为老年代默认的收集器。

在JDK6以前,新生代的Parallel Scavenge只能和Serial Old配合使用,而Serial Old为单线程,Server模式下无法充分利用多核CPU,这种组合无法让应用的吞吐量最大化。

CMS(Concurrent Mark Sweep)(标记-清理)

CMS收集器是以最短回收停顿时间为目标的收集器。重视响应,以带来好的用户体验,是并发低停顿收集器,通过-XX:+UseConcMarkSweepGC参数启用CMS收集器。
CMS采用支撑多线程并发的标记-清除算法,它的运作分为4个阶段:

  1. 初始标记(Initial Mark)
    标记GC Root Set直接关联的对象
  2. 并发标记(Concurrent Mark)
    以初始标记对象为基础,并发标记其关联的对象,直到所有对象标记完成,标记进程和用户进程并发执行。
  3. 重新标记(Remark)
    为了修正因并发标记期间用户程序运行而产生变动的那一部分对象的标记记录,暂停用户进程对这部分对象重新标记。
  4. 并发清除(Sweep)
    将前面标记对象的内存回收,这个阶段GC线程与用户线程并发运行。

CMS GC.PNG
CMS在初始标记和重新标记阶段需要暂停业务线程,在执行时间上,初始标记 < 重新标记 < 并发标记,所以时间最长的并发标记,业务线程和GC线程并发运行,所以用户感受上,GC暂停的时间很短。但其也存在几个缺点,具体如下:

  1. CMS默认配置启动的时候垃圾线程数为 (CPU数+3)/4,性能很容易受CPU核数影响
  2. CMS无法处理浮动垃圾,可能导致Concurrent Mode Failure(并发模式故障)而触发Full GC
  3. CMS采用标记-清除算法,存在内存碎片的问题
为了解决CMS导致的内存碎片问题,CMS模式提供了 -XX:+UseCMSCompactAtFullCollection 选项,选项默认开启,用于CMS要进行Full GC时进行内存碎片整理,由于内存整理的过程无法并发,需要停止业务进程,所以启这个选项会影响性能。

G1收集器(Garbage First)

G1收集器再JDK7中首次出现,JDK7/8中默认关闭,可通过 -XX:+UseG1GC 参数打开,是JDK9中默认收集器。G1收集器是当前最先进的收集器之一,设计目标也是降低延迟,是用于替代CMS的功能更为强大的新型收集器,它解决了CMS内存空间碎片等缺陷。
G1收集器弱化了内存分代的概念,而是把内存分割为若干个大小相同的区域(Region),以区域为单位,增量式的执行内存回收,可以同时作用于新生代和老年代。G1采用标记-清除-复制方式回收内存,一个完整周期包括:初始标记、并发标记、重新标记、清除、转移回收。对象标记后,通过并行方式把一组或多组区域中存活的对象以增量的方式复制到不同区域进行压缩,从而减少内存碎片。
Garbage First GC.PNG

参数 作用 说明
-XX:G1HeapRegionSize 设置Region大小 默认系统根据最小堆内自动存划分为2048个区域
-XX:MaxGCPauseMillis 最大停顿时间 默认200ms
阅读 317

推荐阅读
目录