Java垃圾回收详解(2)

GC方式介绍

Posted by Jason Lee on 2018-07-04

为什么需要GC?

当程序创建对象、数组等引用类型实体时,系统都会在堆内存中为之分配一块内存区,对象就保存在这块内存区中,当这块内存不再被任何引用变量引用时,这块内存就变成垃圾,等待垃圾回收机制进行回收。在C和C++中,垃圾的回收是由程序员来手动执行,虽然实时性比较好,但由于内存分配和回收代码繁琐,较容易出错,内存泄露问题比较常见。于是一些语言如Java、python、Go等,通过程序实现了自动化内存管理来解决垃圾回收问题,从而降低了在这些语言中内存使用的门槛。另外,由于GC有一定的滞后性,在一些实时性较高或者内存吃紧的(单片机开发)软件中,还是有不少采用人工回收垃圾的案例。

GC的一些特点:
1. GC只负责回收内存中的对象,不回收物理资源(如文件流、socket等)。
2. 程序无法精确控制GC的运行,GC只会在合适的时候进行。

PS:1其实也是由于2的GC的不确定性导致的,比如一个socket占用了某个端口,当使用完后GC并不会立刻回收这个socket,当需要再用到这个端口的socket时,就会报错。

GC实现的两种常见方式

引用计数(Reference Counting)

引用计数的实现比较简单,核心就是给每一个对象增加一个引用计数器,当另一个对象引用当前对象时就给当前对象的引用计数+1,当有对象不再引用当前对象时,就将引用计数-1,当前对象的引用计数变成0时,递归地将该对象引用的子对象的引用计数-1,并把当前对象使用的内存区域释放到空闲链表中。
6efac91db597f7d968d53f68c99e32d4.gif

引用计数算法的优点:

  1. 引用归0时可立即回收垃圾,实时性好。

  2. 应用执行和垃圾回收“自然、依次”发生,GC开销分布在各次应用执行中,最大暂停时间消减(虽然GC次数增加)。

  3. 执行效率高,“事件触发”机制避免了整堆扫描标记的耗时。

引用计数算法的缺点:

  1. 循环引用问题无法解决,导致垃圾回收不彻底。

  2. 需要额外的存储空间来存放每一个对象的引用计数。

  3. 频繁的计数增减带来的高并发和本身需要的操作原子性的开销较大。

  4. 引用计数归0时,可能会级联删除大量对象,造成GC耗时不平稳。

以上可知,基于引用计数的GC在实时性和执行效率方面有较大优势,类似 python、perl、swift等语言都使用引用计数算法来实现GC。但由于引用计数GC存在高并发时的性能较低等问题,因此,对性能要求较高的系统在实现时一般不会采用引用计数算法,而是使用和应用隔离的“跟踪回收”方式来实现GC。

跟踪回收

跟踪回收是一种独立于应用程序外的GC方式,定期运行来检查垃圾。跟踪回收通过被称为“GC ROOTS”的对象开始,不断通过深度或者广度遍历的方式来跟踪标记所有”可达的对象“,然后其他“GC ROOTS不可达对象”的未标记对象就被视为垃圾,会被统一回收。跟踪回收是目前被广泛使用的技术,如:Java、Go、.net等都使用跟踪的方式来进行垃圾回收。

跟踪回收的GC方式在具体的算法实现上,主要包括以下几种垃圾回收算法:

标记-清除(mark-sweep)

标记清除算法主要分两个阶段:

  1. 第一阶段,从GC ROOTS开始进行遍历,标记所有可直达或间接到达的对象为“使用中”。

  2. 第二阶段,扫描整体内存,对上一阶段标记的“使用中”的对象进行回收。
    3ded3d0575a91f8dfde31dc446642d56.jpeg

原始的标记-清除算法有不少问题,比如:

  1. GC期间,整个业务线程都会被挂起,暂停时间较长。

  2. 内存容易出现碎片,多次GC后可用连续内存问题明显。

  3. GC的STW(Stop The World)的时间和heap大小正相关。

因此在标记-清除算法的基础上,又产生了很多基于标记-清除的衍生算法来优化这些问题。比如:

并发的标记-清除算法

标记-清除过程实际上大部分时间Collector(GC线程)是可以和Mutator(应用程序线程)并发执行,大部分的工作都在并发阶段完成,真正需要暂停应用线程的阶段只需要来解决并发过程中变化的那部分对象即可,从而并不需要整个回收阶段都block住应用线程。比如CMS垃圾回收器除了Init-Mark阶段和Final-Mark阶段外,其他阶段都是可以并发执行的。

标记–整理算法

针对内存容易出现碎片的问题,衍生的Mark-Compact算法通过将sweep过程替换成compact过程,每次GC都会对内存进行一次移动整理。

标记-整理算法一般分3部分:

a. 先在mark阶段标记存活对象。
b. 遍历heap计算出存活对象将要移动到的新位置。
c. 将存活对象真正移动到新位置并更新存活对象中被指向移动对象的指针。
a0da00148bc1a9638f5ea4256f3c0b1e.jpeg

由过程可知,标记-整理算法的compact阶段的耗时是和存活对象的多少成正比的。而且虽然解决了碎片的问题,但实际需要多次遍历heap、移动对象、更新指针,所以耗时上会比标记-清除算法要更长一些。所以默认情况下一些垃圾回收器CMS并不直接使用mark-compact算法,而是当由于存在碎片导致发生Full GC时才会使用基于mark-compact算法来进行内存整理。

PS:当然从另一个方面看,mark-compact阶段由于解决了内存碎片问题,在应用线程申请内存的时候就可以用“指针碰撞”(pointer bumping)方式来快速完成内存分配,如果碎片较多,“指针碰撞”就会较大概率导致多次指针挪动,就不适合了,这样就只能使用分配速度更慢的类似freelist的方式来进行内存的管理和分配。

复制-收集(Copying GC)

由于标记-清除算法存在碎片化的问题,标记-整理算法又由于需要多次遍历heap效率较低,因此“复制-收集”算法采用了一个空间换时间的方式来解决上面的问题。“复制-收集”算法的工作过程如下:

  1. 将可用内存分成大小相等的两块,from space和 to space。

  2. 同一时刻只有from space会被使用。

  3. GC时,将from space内存活的对象拷贝到to space

  4. 拷贝完成后,from space和to space交换身份
    1290c3eb4092bd52de293fbe1be152cb.jpeg

由“复制-收集”算法的实现可知,虽然这种算法比较简单高效,而且没有碎片问题。但一个比较明显的代价就是:应用程序实际可用的内存会被减半。另外,“复制-收集”算法很大一部分工作在于将存活对象移动到to space,因此它的时间开销和存活对象的数量成正比。

上面介绍了主流几种“跟踪回收”类GC的具体实现算法,每一种算法都有其擅长和不足的地方,因此在实际的GC算法选择中,会根据不同的场景和特性选择不同的算法实现,从而将每种算法“扬长避短”。后面我会再继续分析现代化的GC如何进一步优化这些算法过程。


标记-压缩 (Mark-Compact)

标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。在基于Compacting算法的收集器的实现中,一般增加句柄和句柄表。

垃圾回收器

GC算法是内存回收的方法论,垃圾收集器是内存回收的具体实现。新生代垃圾收集器有Serial、ParNew、Parallel Scavenge、G1,属于老年代的垃圾收集器有CMS、Serial Old、Parallel Old和G1。其中的G1是一种既可以对新生代对象也可以对老年代对象进行回收的垃圾收集器。大部分垃圾回收器之间能相互配合来发挥各自的优势。

85a94c88d8d3662165484268f0449723.png

Serial收集器

Serial收集器是最古老、最基本的一种垃圾回收器,采用单线程来进行垃圾回收。新生代回收采用复制算法,回收的整个过程会暂停业务线程(STW)。

Serial收集器的特性总结:
收集过程:暂停所有线程
算法:复制算法
优点:简单高效,拥有很高的单线程收集效率
应用:Client模式下的默认新生代收集器
参数控制:-XX:+UseSerialGC

56c88d3322821a6d6af5909213e888a5.png

ParNew收集器

ParNew收集器也是一个新生代垃圾回收器,可以看成是Serial收集器的多线程版本,也是采用复制算法来进行收集,回收整个过程也是暂停业务线程(STW)的,但由于是多线程并行执行的,所以在多CPU机器上一般会比Serial收集器快很多。

ParNew收集器的特性总结:
收集过程:暂停所有线程
算法:复制算法
优点:在CPU多的情况下,拥有比Serial更好的效果。单CPU环境下Serial效果更好
应用:许多运行在Server模式下的虚拟机中首选的新生代收集器
参数控制:
-XX:+UseParNewGC
-XX:ParallelGCThreads (限制线程数量)

97ff89bcbbc18cd50ba5287850435f43.png

Parallel Scavenge收集器

和ParNew收集器类似,Parallel Scavenge收集器也是一个新生代垃圾回收器,但和ParNew收集器不同的是Parallel Scavenge收集器更多关注的是可控制的吞吐量,支持自适应调节策略。

吞吐量 = 运行用户代码的时间/(运行用户代码的时间+垃圾收集时间)

Parallel Scavenge收集器提供几个参数控制垃圾回收的执行:

-XX:MaxGCPauseMillis
最大垃圾回收停顿时间。这个参数的原理是空间换时间,收集器会控制新生代的区域大小,从而尽可能保证回收少于这个最大停顿时间。简单的说就是回收的区域越小,那么耗费的时间也越小。所以这个参数并不是设置得越小越好。设太小的话,新生代空间会太小,从而更频繁的触发GC。

-XX:GCTimeRatio
垃圾回收时间与总时间占比(默认是99%)。这个是吞吐量的倒数,原理和MaxGCPauseMillis相同。

-XX:UseAdaptiveSizePolicy
开启动态自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整上面两个参数以提供最合适的停顿时间或者最大吞吐量。

Parallel Scavenge收集器的特性总结:
收集过程:暂停所有线程
算法:复制算法
优点:在CPU多的情况下,拥有比Serial更好的效果;对暂停时间和吞吐量能精细化控制。
应用:指定使用
参数控制:-XX:+UseParallelGC

Serial Old收集器

老年代的收集器,与上面的Serial一样是单线程回收,不同的是算法用的是标记-整理(Mark-Compact),整个过程和Serial收集器一样都会暂停业务线程。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。
0207d0a7741701cf070520c4f97f065e.png

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,JDK 1.6开始推出的,它使用多线程和“标记-整理”算法进行垃圾回收。通常与Parallel Scavenge收集器配合使用,“吞吐量优先”收集器是这个组合的特点,在注重吞吐量和CPU资源敏感的场合,都可以使用这个组合。
754b629778258dd09ede56a2a9f304b2.png

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的多线程并发收集器,基于“标记-清除”算法实现。CMS垃圾回收器将垃圾回收分成多个阶段,某些阶段可以和业务线程并发执行,这些并发阶段主要来做一些准备工作,这些准备工作能缩小最终的暂停阶段(Init-Mark和Remark)的时间,而且在暂停业务线程的两个阶段也由于多线程并行暂停时间控制也较好,是目前对用户响应时间敏感的且堆大小适中(太大、太小都不好)的应用广泛采用的年老代垃圾回收器。默认配合ParNew回收器作为新生代垃圾回收器。

CMS收集器的7个阶段:
初始标记(CMS initial mark)
并发标记(CMS concurrent mark)
并发预清理(CMS concurrent-preclean)
并发可取消的预清理 (CMS concurrent-abortable-preclean)
重新标记(CMS remark)
并发清除(CMS concurrent sweep)
并发重置(CMS concurrent-reset)

PS:这个盗的图稍微有点问题,从jdk 1.8开始,initial mark阶段也是多线程并行执行的,可以通过-XX:+CMSParallelInitialMarkEnabled参数控制。

CMS收集器的特性总结:

  • 收集过程:部分阶段和业务线程并发执行,部分阶段暂停所有业务线程

  • 算法:标记-清除算法

  • 优点:并发收集、低停顿

  • 缺点:清除算法会产生空间碎片、并发阶段会降低吞吐量、无法即时处理并发阶段产生的“浮动垃圾”

  • 应用:指定使用

  • 参数控制:

    -XX:+UseConcMarkSweepGC(是否采用CMS回收器)
    -XX:+UseCMSInitiatingOccupancyOnly(JVM是否基于运行时收集的数据来启动CMS垃圾收集周期还是只根据初始设置来启动)
    -XX:CMSInitiatingOccupancyFraction=80 (old区在使用了n%的后开始启动CMS backgroud GC)
    -XX:ConcGCThreads=4 (并发CMS阶段采用多线程时使用的线程数)
    -XX:+CMSConcurrentMTEnabled (并发阶段是否采用多线程)
    -XX:+CMSParallelInitialMarkEnabled (初始标记阶段是否采用多线程并行执行)
    -XX:+CMSParallelRemarkEnabled (重新标记阶段是否采用多线程并行执行)
    -XX:+UseCMSCompactAtFullCollection (是否允许Full GC时采用整理算法)
    -XX:CMSFullGCsBeforeCompaction=0 (采用整理算法前需要Full GC次数达到一定阈值)
    -XX:+CMSClassUnloadingEnabled (GC过程也对永久代进行垃圾回收)
    -XX:ExplicitGCInvokesConcurrent (允许显示的系统GC调用)
    -XX:+CMSScavengeBeforeRemark (CMS重新标记阶段之前是否尝试对年轻代进行一次回收,能有效降低remark的时间)
    -XX:+CMSIncrementalMode (增量模式,基本不开启,cpu资源少时可尝试)
    -XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses (当有系统GC调用时,永久代也被包括进CMS垃圾回收的范围内)

G1收集器

G1(Garbage First)收集器在JDK 1.7版本正式启用,在JDK 9中,G1被提议设置为默认垃圾收集器。应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。G1收集器的设计目标是取代CMS收集器,它同CMS相比,在以下方面表现的更出色:

  • G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。

  • G1的Stop The World(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。

与前面介绍回收器只能工作在年轻代或者年老代不同的是,G1收集器将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region的集合。G1的新生代收集跟ParNew类似,当新生代占用达到一定比例的时候,开始出发收集。和CMS类似,G1收集器收集老年代对象会有短暂停顿。

ba1807ea0d2b5304a7ad7ba2eb73f1f1.png

每个Region被标记了E、S、O和H,说明每个Region在运行时都充当了一种角色,其中H是以往算法中没有的,它代表Humongous,这表示这些Region存储的是巨型对象(humongous object,H-obj),当新建对象大小超过Region大小一半时,直接在新的一个或多个连续Region中分配,并标记为H。

G1收集器有三种GC模式:

  • Young GC:

    发生在年轻代的Region,所有eden类型的Region被分配完时,触发Young GC,将存活对象从eden的Region拷贝到survior类型的Region或者晋升到old Region,然后根据历史统计信息和用户定义的暂停时间来动态调整eden Region和survivor Region的个数,从而尽量让下一次GC的回收时间满足用户期望。和ParNew一样YGC采用的“复制-收集”算法。

  • Mixed GC:

    STW阶段,类似CMS一样当old类型的Region占用达到一定阈值的时候,G1会触发一次全局“标记”动作,统计得出收集收益高的老年代Region。“标记”工作完成后,在每次YGC之后或者再次发生Mixed GC前,JVM会检查整个堆的垃圾占比是否达到G1HeapWastePercent的阈值,如果达到了,下次就会触发一次Mixed GC(一次标记后可能会分多次进行Mixed GC)。

    Mixed GC并不仅仅是一次老年代GC,不仅进行正常的YGC回收整个young区,同时也会选择部分old Region也进行回收,和YGC一样,Mixed GC也是采用的“复制-收集”算法作为清除策略,因此整个G1垃圾回收不会存在和CMS一样的内存碎片问题。Mixed GC步骤包括 “标记”和“回收”两部分。

  • Full gc:

    如果对象内存分配速度过快,mixed gc来不及回收,导致老年代被填满,就会触发一次full gc,G1的full gc算法就是单线程执行的serial old gc,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免full gc。

其中YGC和ParNew等回收器的过程类似,比较简单,采用的是»复制-收集»算法,Mixed GC部分过程和CMS也比较相似,略有不同,Mixed GC分为»标记»和»回收»两大阶段。

Mixed GC的“标记”过程如下:

  1. initial mark(初始标记):会暂停所有线程,标记出所有可以直接从GC roots可以到达的对象,这是在Young GC的暂停收集阶段顺带进行的。因为 Young GC 是需要 stop-the-world 的,所以并发周期直接重用这个阶段,虽然会增加 CPU 开销,但是停顿时间只是增加了一小部分。

  2. concurrent-root-region-scan(扫描根引用区):找出所有的GC Roots的Region, 然后从这些Region开始标记可到达的对象,是一个并发阶段。

  3. concurrent marking(并发标记):和CMS的并发标记阶段类似,这个阶段G1递归寻找整个堆的存活对象,是和业务线程并发执行的。

  4. remark(最终标记):STW的阶段,完成最后的存活对象标记。使用了比 CMS 收集器更加高效的 snapshot-at-the-beginning (SATB) 算法。

  5. clean up(清理阶段):marking的最后一个阶段,G1统计各个Region的活跃性,完全没有存活对象的Region直接放入空闲可用Region列表中,然后会找出mixed GC的Region候选列表。

Mixed GC的“回收”过程如下:

  1. 选择所有young regions进行年轻代回收年轻代回收。

  2. 选取标记阶段之前标记出来的老年代的垃圾最多的部分区块,结合用户“期望”的GC Pause耗时相关,选取收益最大的部分Region进行回收,整个老年代标记完后可能会分多次Mixed GC执行,直到标记后的垃圾分区占比低于“堆废物占比”,之后再恢复到常规的年轻代垃圾收集,然后当堆的使用满足“触发标记周期阈值”会最终再次启动并发周期,进行下一轮循环。

G1常用参数

  • -XX:G1HeapRegionSize=n

    设置的 G1 区域的大小。值是 2 的幂,范围是 1 MB 到 32 MB 之间。目标是根据最小的 Java 堆大小划分出约 2048 个区域。

  • -XX:MaxGCPauseMillis=200

    为所需的最长暂停时间设置目标值。默认值是 200 毫秒。指定的值不适用于您的堆大小。

  • -XX:G1NewSizePercent=5

    设置要用作年轻代大小最小值的堆百分比。默认值是 Java 堆的 5%。这是一个实验性的标志。有关示例,请参见“如何解锁实验性虚拟机标志”。此设置取代了

  • -XX:DefaultMinNewGenPercent 设置。Java HotSpot VM build 23 中没有此设置。

  • -XX:G1MaxNewSizePercent=60

    设置要用作年轻代大小最大值的堆大小百分比。默认值是 Java 堆的 60%。这是一个实验性的标志。有关示例,请参见“如何解锁实验性虚拟机标志”。此设置取代了 -XX:DefaultMaxNewGenPercent 设置。Java HotSpot VM build 23 中没有此设置。

  • -XX:ParallelGCThreads=n

    设置 STW 工作线程数的值。将 n 的值设置为逻辑处理器的数量。n 的值与逻辑处理器的数量相同,最多为 8。

    如果逻辑处理器不止八个,则将 n 的值设置为逻辑处理器数的 5/8 左右。这适用于大多数情况,除非是较大的 SPARC 系统,其中 n 的值可以是逻辑处理器数的 5/16 左右。

  • -XX:ConcGCThreads=n

    设置并行标记的线程数。将 n 设置为并行垃圾回收线程数 (ParallelGCThreads) 的 1/4 左右。

  • -XX:InitiatingHeapOccupancyPercent=45

    设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%。

  • -XX:G1MixedGCLiveThresholdPercent=65

    为混合垃圾回收周期中要包括的旧区域设置占用率阈值。默认占用率为 65%。这是一个实验性的标志。有关示例,请参见“如何解锁实验性虚拟机标志”。此设置取代了

  • -XX:G1OldCSetRegionLiveThresholdPercent 设置。Java HotSpot VM build 23 中没有此设置。

  • -XX:G1HeapWastePercent=10

    设置您愿意浪费的堆百分比。如果可回收百分比小于堆废物百分比,Java HotSpot VM 不会启动混合垃圾回收周期。默认值是 10%。Java HotSpot VM build 23 中没有此设置。

  • -XX:G1MixedGCCountTarget=8

    设置标记周期完成后,对存活数据上限为 G1MixedGCLIveThresholdPercent 的旧区域执行混合垃圾回收的目标次数。默认值是 8 次混合垃圾回收。混合回收的目标是要控制在此目标次数以内。Java HotSpot VM build 23 中没有此设置。

  • -XX:G1OldCSetRegionThresholdPercent=10

    设置混合垃圾回收期间要回收的最大旧区域数。默认值是 Java 堆的 10%。Java HotSpot VM build 23 中没有此设置。

  • -XX:G1ReservePercent=10

    设置作为空闲空间的预留内存百分比,以降低目标空间溢出的风险。默认值是 10%。增加或减少百分比时,请确保对总的 Java 堆调整相同的量。Java HotSpot VM build 23 中没有此设置。


参考:
垃圾回收统一理论
Introduction to Garbage Collection
R大:并发垃圾收集器(CMS)为什么没有采用标记-整理算法来实现?
R大:请教Weak Reference及其在HotSpot GC中的行为
垃圾优先型垃圾回收器调优
G1: One Garbage Collector To Rule Them All
JDK源码阅读-Reference
Java垃圾回收浅析(2)-GC方式介绍



支付宝打赏 微信打赏

赞赏一下