JVM上篇内存与垃圾回收-垃圾回收相关概念及回收器

垃圾回收相关概念

System.gc()的理解

  • 在默认情况下,通过system.gc()或者Runtime.getRuntime().gc() 的调用,会显式触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。
  • 然而System.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用。(不能确保立即生效)
  • JVM实现者可以通过System.gc() 调用来决定JVM的GC行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。

内存溢出与内存泄露

内存溢出(OOM)

  • java 虚拟机的堆内存设置不够
  • 代码创建大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)

内存泄露

  • 只有对象不再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄露
  • 实际情况有一些疏忽导致对象的生命周期变的很长甚至OOM,宽泛意义上的内存泄露
  • 举例
    • 单例的生命周期和程序是一样长,如果单例程序中,持有对外部对象的引用的话,那么这个外部对象是不能被回收的,导致内存泄露
    • 一些提供close的资源未关闭导致内存泄露,如数据库链接,网络链接,和IO

Stop The World

简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应

可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。

  • 分析工作必须在一个能确保一致性的快照中进行
  • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
  • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证

STW是JVM在后台自动发起和自动完成的。

垃圾回收的并行与并发

并发(Concurrent)

在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。

并发不是真正意义上的“同时进行”,只是CPU把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于CPU处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。

并行(Parallel)

当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行(Parallel)。

其实决定并行的因素不是CPU的数量,而是CPU的核心数量,比如一个CPU多个核也可以并行。

并发 vs 并行

  • 并发,指的是多个事情,在同一时间段内同时发生了。
  • 并行,指的是多个事情,在同一时间点上同时发生了。
  • 并发的多个任务之间是互相抢占资源的。
  • 并行的多个任务之间是不互相抢占资源的。
  • 只有在多CPU或者一个CPU多核的情况中,才会发生并行。
  • 否则,看似同时发生的事情,其实都是并发执行的。

垃圾回收的并发与并行

并行(Parallel)

指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。如ParNew、Parallel Scavenge、Parallel Old;

串行(Serial)

相较于并行的概念,单线程执行。如果内存不够,则程序暂停,启动JM垃圾回收器进行垃圾回收。回收完,再启动程序的线程。

并发(Concurrent)

指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上;如:CMS、G1

安全点与安全区域

安全点

程序执行并非在所有地方都能停顿下来开始GC,只有特定的位置才能停顿下来开始GC,这些位置称为安全点

如果太少,导致GC等待时间长,如果太多导致运行时性能问题,,大部分指令执行都比较短,通常会根据是否具有让程序长时间执行的特征为标准选择一些执行时间较长的指令作为安全点,比如方法调用,循环跳转和异常跳转等

抢先式中断:(目前没有虚拟机采用了)

首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。

主动式中断

设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。(有轮询的机制)

安全区域(Safe Resion)

  • 如果线程处于sleep或者blocked状态,这时候线程无法响应jvm中断请求,走到安全点去中断挂起。对于这种情况,就需要安全区域来解决
  • 安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中任何位置开始GC都是安全的。
  • 当线程运行到安全区域代码时,首先标志已经进入了安全区域,如果GC,JVM会忽略标识为安全区域状态的线程
  • 当线程即将离开安全区域时,会检查JVM是否已经完成GC,如果完成了,则继续运行。否则线程必须等待直到收到可以安全离开安全区域的信号为止

引用

强引用—不回收

img

  • 最传统的引用定义,程序代码中普遍存在的引用赋值,类似new Object这种引用关系,无论任何情况下,强引用存在,垃圾收集器永远不会回收掉被引用的对象
  • 强引用是造成java内存泄露的主要原因之一
  • 强引用可以直接访问目标对象
1
StringBuffer str = new StringBuffer("hello mogublog");

局部变量str指向StringBuffer实例所在堆空间,通过str可以操作该实例,那么str就是StringBuffer实例的强引用

软引用—内存不足即回收

  • 系统将要发生内存溢出之前,会将这些对象列入回收范围之中进行第二次回收,如果这些回收后还没有足够内存,才会抛出内存溢出异常
  • 软引用通常用来实现内存敏感的缓存,高速缓存就有用到软引用
  • 垃圾回收器在某个时间决定回收软可达的对象的时候,会清理软引用,并可选的把引用存放到一个引用队列

在JDK1.2版之后提供了java.lang.ref.SoftReference类来实现软引用

1
2
3
Object obj = new Object(); // 声明强引用
SoftReference<Object> sf = new SoftReference<>(obj);
obj = null; //销毁强引用

弱引用—发现即回收

只被弱引用关联的对象只能够生生存到下一次垃圾收集器之前,当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象

在JDK1.2版之后提供了WeakReference类来实现弱引用

1
2
3
Object obj = new Object(); // 声明强引用
WeakReference<Object> sf = new WeakReference<>(obj);
obj = null; //销毁强引用

虚引用—对象回收跟踪

  • 一个对象是否有虚引用存在,完全不会对其生存时间构成影响。唯一目的就是在这个对象被收集器回收时收到一个系统通知
  • 他不能单独使用,也无法通过虚引用获取被引用的对象。

在JDK1.2版之后提供了PhantomReference类来实现虚引用。

1
2
3
4
Object obj = new Object(); // 声明强引用
ReferenceQueue phantomQueue = new ReferenceQueue();
PhantomReference<Object> sf = new PhantomReference<>(obj, phantomQueue);
obj = null;

终结器引用

它用于实现对象的finalize() 方法,也可以称为终结器引用。无需手动编码,其内部配合引用队列使用。

在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象调用它的finalize()方法,第二次GC时才回收被引用的对象

垃圾回收器

垃圾收集器分类

按垃圾回收线程数

串行垃圾回收器

p9ojNGT.jpg

  • 串行回收指同一个时间段内,只允许一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直到垃圾收集工作结束
  • 在单CPU处理器或者较小应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以串行回收默认被应用在客户端的client模式下的JVM中
  • 在并发能力比较强的CPU上,并行回收器产生的停顿时间要短于串行回收器

并行垃圾回收器

和串行相反,并行收集可以运用在多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了STW机制

按照工作模式分

并发式

垃圾回收器与应用程序交替工作,以尽可能减少应用程序的停顿时间

独占式

一旦运行,就停止应用程序中所有的用户线程,直到垃圾回收过程完全结束

p9ojazF.jpg

按照碎片处理方式

压缩式:在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。

非压缩式

按个工作内存区间分

年轻代和老年代

评估GC的性能指标

  • 吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收的时间)
  • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
  • 收集频率:相对于应用程序的执行,收集操作发生的频率。
  • 内存占用:Java堆区所占的内存大小。
  • 快速:一个对象从诞生到被回收所经历的时间。

吞吐量、暂停时间、内存占用 这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。

这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。

简单来说,主要抓住两点:吞吐量、暂停时间

高吞吐量与低暂停时间,是一对互相竞争的。因为如果高吞吐量优先,必然需要降低内存回收的执行频率,导致GC需要更长的暂停时间来执行内存回收。

如果选择低延迟优先为原则,也只能频繁的执行内存回收,引起程序吞吐量的下降

现在的标准,在最大吞吐量优先的情况下,降低停顿时间

不同的垃圾回收器概述

垃圾回收器发展史

  • 1999年随JDK1.3.1一起来的是串行方式的serialGc,它是第一款GC。ParNew垃圾收集器是Serial收集器的多线程版本
  • 2002年2月26日,Parallel GC和Concurrent Mark Sweep GC跟随JDK1.4.2一起发布·
  • Parallel GC在JDK6之后成为HotSpot默认GC。
  • 2012年,在JDK1.7u4版本中,G1可用。
  • 2017年,JDK9中G1变成默认的垃圾收集器,以替代CMS。
  • 2018年3月,JDK10中G1垃圾回收器的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟。
  • 2018年9月,JDK11发布。引入Epsilon 垃圾回收器,又被称为 “No-Op(无操作)“ 回收器。同时,引入ZGC:可伸缩的低延迟垃圾回收器(Experimental)
  • 2019年3月,JDK12发布。增强G1,自动返回未用堆内存给操作系统。同时,引入Shenandoah GC:低停顿时间的GC(Experimental)。·
  • 2019年9月,JDK13发布。增强ZGC,自动返回未用堆内存给操作系统。
  • 2020年3月,JDK14发布。删除CMS垃圾回收器。扩展ZGC在macos和Windows上的应用

7种经典的垃圾收集器

  • 串行回收器:Serial、Serial Old
  • 并行回收器:ParNew、Parallel Scavenge、Parallel old
  • 并发回收器:CMS、G1

p9ojwM4.jpg

垃圾收集器的组合关系

p9oj0sJ.jpg

  1. 两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
  2. 其中Serial Old作为CMS出现"Concurrent Mode Failure"失败的后备预案。
  3. (红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial old这两个组合声明为Deprecated(JEP 173),并在JDK 9中 完全取消了这些组合的支持(JEP214),即:移除。
  4. (绿色虚线)JDK 14中:弃用ParallelScavenge和SeriaOold GC组合(JEP 366)
  5. (绿色虚框)JDK 14中:删除CMS垃圾回收器(JEP 363)

如何查看默认的垃圾收集器

-XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)

使用命令行指令:jinfo -flag 相关垃圾回收器参数 进程ID

p9ojBL9.jpg

Serial回收器:串行回收

Serial收集器采用复制算法,串行回收和STW机制的方式执行内存回收

除了年轻代,还有用于执行老年代的Serial old收集器,同样采取了串行回收,但是用标记压缩算法

使用一个CPU或者一条收集线程去完成垃圾收集工作,在进行垃圾收集时,必须暂停其他所有工作线程

p9ojyIx.jpg

优势

简单而高效,对于限定单个CPU的环境来说,由于没有线程交互的开销,可以获取最高的单线程收集效率

HotSpot虚拟机中,使用-XX:+UseSerialGC指定年轻代和老年代使用串行收集器

对于交互强的应用而言,不会采取串行垃圾收集器

ParNew回收器:并行回收

除了采用并行回收,其他方面和Serial之间几乎没有任何区别,在单个CPU的环境下,ParNew收集器不比Serial 收集器更高效

p9ojgJK.jpg

-XX:UseParNewGC手工指定ParNew收集器执行内存回收任务,它表示年轻代使用,不影响老年代

-XX:ParallelGCThreads限制线程数量,默认开启和CPU数据相同的线程数

Parallel回收器:吞吐量优先

  • 也是并行回收
  • 和ParNew不同,它的目标是达到一个可控制的吞吐量
  • 自适应调节策略也是Parallel 与ParNew的一个重要区别
  • 适合后台运算不需要太多交互的任务,例如执行批量处理,订单处理,工资支付,科学计算的应用程序
  • Parallel old采取标记压缩算法,同样基于并行回收和STW机制

CMS回收器:低延迟

jdk1.5推出Concurrent Mark Sweep 并发的标记清除,第一次实现了让垃圾收集线程与用户线程同时工作

目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短

CMS的垃圾收集算法采用标记-清除算法,并且也会”Stop-the-World”

16.jpg

  • 初始标记:STW,仅仅只是标记处GC Roots能直接关联的对象,一旦标记完成后就会恢复之前被暂停的所有应用线程,由于直接关联对象比较小,所以这里速度非常快

  • 并发标记:从GCRoots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要停顿用户线程。可以与垃圾收集线程一起并发运行

  • 重新标记:为了修正并发标记期间,因用户程序继续运作导致标记产生变动的那一部分对象的标记记录

  • 并发清除:清理删除标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也可以与用户线程同时并发

  • 初始标记和重新标记阶段仍然需要STW机制

  • 由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此CMS收集器不能像其他收集器那样等到老年代几乎填满再进行回收,而是当堆内存使用率达到某一阈值时,便开始进行回收。

  • 要是CMS运行期间预留的内存无法满足程序需要,就会出现一次Concurrent Mode Failure失败,这时虚拟机启用备用方案,临时启用Serial old 收集器来重新进行老年代的垃圾收集,这样停顿时间就长了。

  • CMS采取标记清除算法,会产生内存碎片,只能够选择空闲列表执行内存分配

  • 为什么不采取标记压缩呢?

    • 因为并发清除时,如果用压缩整理内存,原来的用户线程使用的内存就无法使用了。标记压缩更适合STW场景下使用
  • 优点

    • 并发收集和低延迟
  • 缺点

    • 会产生内存碎片
    • 对CPU资源非常敏感
      • 在并发阶段会占用一部分线程导致应用程序变慢
    • 无法处理浮动垃圾
      • 并发标记阶段是与工作线程同时运行,如果并发阶段产生垃圾对象,CMS无法进行标记,导致新产生的垃圾对象没有被及时回收,只能在下一次执行GC时释放空间

小结

  • 如果想要最小化使用内存和并行开销,选择Serial GC
  • 如果最大化应用程序的吞吐量,选择ParallelGC
  • 如果想要最小化的GC的中断或停顿时间,选择CMS GC

JDK后续版本中CMS的变化

JDK9新特性:CMS被标记为Deprecate了(JEP291)

JDK14新特性:删除CMS垃圾回收器(JEP363)

G1回收器:区域化分代式

与其他GC收集器相比,G1使用了全新的分区算法

并行与并发

  • 并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW
  • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况

分代收集

  • 从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
  • 将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。
  • 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代;

空间整合

  • CMS:“标记-清除”算法、内存碎片、若干次Gc后进行一次碎片整理
  • G1将内存划分为一个个的region。内存的回收是以region作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显。

可预测的停顿时间模型(即:软实时soft real-time)

这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

  • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
  • G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
  • 相比于CMSGC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。

G1垃圾收集器的缺点

相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。

从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。

G1回收器的参数设置

  • -XX:+UseG1GC:手动指定使用G1垃圾收集器执行内存回收任务
  • -XX:G1HeapRegionSize 设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。
  • -XX:MaxGCPauseMillis 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms(人的平均反应速度)
  • -XX:+ParallelGCThread 设置STW工作线程数的值。最多设置为8(上面说过Parallel回收器的线程计算公式,当CPU_Count > 8时,ParallelGCThreads 也会大于8)
  • -XX:ConcGCThreads 设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。
  • -XX:InitiatingHeapOccupancyPercent 设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45。

G1收集器的常见操作步骤

  • 第一步:开启G1垃圾收集器
  • 第二步:设置堆的最大内存
  • 第三步:设置最大的停顿时间

G1中提供了三种垃圾回收模式:Young GC、Mixed GC和Full GC,在不同的条件下被触发。

G1收集器的适用场景

面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)

最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)。

用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好:

  • 超过50%的Java堆被活动数据占用;
  • 对象分配频率或年代提升频率变化很大;
  • GC停顿时间过长(长于0.5至1秒)

HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

分区Region:化整为零

使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB,2MB,4MB,8MB,16MB,32MB。可以通过-XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。

虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。

一个region有可能属于Eden,Survivor或者Old/Tenured内存区域。但是一个region只可能属于一个角色。图中的E表示该region属于Eden内存区域,S表示属于survivor内存区域,O表示属于Old内存区域。图中空白的表示未使用的内存空间。

G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过1.5个region,就放到H。

设置H的原因:对于堆中的对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。

每个Region都是通过指针碰撞来分配空间

G1垃圾回收器的回收过程

  • 年轻代GC(Young GC)
  • 老年代并发标记过程(Concurrent Marking)
  • 混合回收(Mixed GC)

如果需要,单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。

应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。

当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。

标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。

举个例子:一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。

Remembered Set

  • 一个对象被不同区域引用的问题
  • 一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?
  • 在其他的分代收集器,也存在这样的问题(而G1更突出)回收新生代也不得不同时扫描老年代?
  • 这样的话会降低MinorGC的效率;

解决方法:

无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:

每个Region都有一个对应的Remembered Set;

每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作;

然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);

如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;

当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。

G1回收过程一:年轻代GC

JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。

年轻代垃圾回收只会回收Eden区和Survivor区。

首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。

  1. 扫描根。根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。
  2. 更新RSet。处理dirty card queue(见备注)中的card,更新RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用。
  3. 处理RSet。识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
  4. 复制对象。此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
  5. 处理引用。处理Soft,Weak,Phantom,Final,JNI Weak 等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

G1回收过程二:并发标记过程

  1. 初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC。
  2. 根区域扫描(Root Region Scanning):G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在YoungGC之前完成。
  3. 并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被YoungGC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
  4. 再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning(SATB)。
  5. 独占清理(cleanup,STW):计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。这个阶段并不会实际上去做垃圾的收集
  6. 并发清理阶段:识别并清理完全空闲的区域。

G1回收过程三:混合回收

当越来越多的对象晋升到老年代o1d region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。

并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收

混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。

由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。

混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。

G1回收可选的过程四:Full GC

G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。

要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到Full GC,这种情况可以通过增大内存解决。

导致G1 Full GC的原因可能有两个:

  • Evacuation的时候没有足够的to-space来存放晋升的对象;
  • 并发处理过程完成之前空间耗尽。

G1回收器优化建议

年轻代大小

  • 避免使用-Xmn或-XX:NewRatio等相关选项显式设置年轻代大小
  • 固定年轻代的大小会覆盖暂停时间目标

暂停时间目标不要太过严苛

  • G1 GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间
  • 评估G1 GC的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量。

垃圾回收器总结

截止JDK1.8,一共有7款不同的垃圾收集器。每一款的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。

垃圾收集器 分类 作用位置 使用算法 特点 适用场景
Serial 串行运行 作用于新生代 复制算法 响应速度优先 适用于单CPU环境下的client模式
ParNew 并行运行 作用于新生代 复制算法 响应速度优先 多CPU环境Server模式下与CMS配合使用
Parallel 并行运行 作用于新生代 复制算法 吞吐量优先 适用于后台运算而不需要太多交互的场景
Serial Old 串行运行 作用于老年代 标记-压缩算法 响应速度优先 适用于单CPU环境下的Client模式
Parallel Old 并行运行 作用于老年代 标记-压缩算法 吞吐量优先 适用于后台运算而不需要太多交互的场景
CMS 并发运行 作用于老年代 标记-清除算法 响应速度优先 适用于互联网或B/S业务
G1 并发、并行运行 作用于新生代、老年代 标记-压缩算法、复制算法 响应速度优先 面向服务端应用

GC发展阶段:Serial => Parallel(并行)=> CMS(并发)=> G1 => ZGC

怎么选择垃圾回收器

  1. 优先调整堆的大小让JVM自适应完成。
  2. 如果内存小于100M,使用串行收集器
  3. 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
  4. 如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择
  5. 如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器 官方推荐G1,性能高。现在互联网的项目,基本都是使用G1。

面试

对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项。 这里较通用、基础性的部分如下:

  • 垃圾收集的算法有哪些?如何判断一个对象是否可以回收?
  • 垃圾收集器工作的基本流程。

另外,大家需要多关注垃圾回收器这一章的各种常用的参数

GC日志分析

参数列表

  • -XX:+PrintGC 输出GC日志。类似:-verbose:gc
  • -XX:+PrintGCDetails 输出GC的详细日志
  • -XX:+PrintGCTimestamps 输出GC的时间戳(以基准时间的形式)
  • -XX:+PrintGCDatestamps 输出GcC的时间戳(以日期的形式,如2013-05-04T21:53:59.234+0800)
  • -XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
  • -Xloggc:../logs/gc.log 日志文件的输出路径

打开GC日志

1
-verbose:gc

这个只会显示总的GC堆的变化,如下:

1
2
3
[GC (Allocation Failure) 80832K->19298K(227840K),0.0084018 secs]
[GC (Metadata GC Threshold) 109499K->21465K(228352K),0.0184066 secs]
[Full GC (Metadata GC Threshold) 21465K->16716K(201728K),0.0619261 secs]

参数解析

1
2
3
4
5
GC、Full GC:GC的类型,GC只在新生代上进行,Full GC包括永生代,新生代,老年代。
Allocation Failure:GC发生的原因。
80832K->19298K:堆在GC前的大小和GC后的大小。
228840k:现在的堆大小。
0.0084018 secs:GC持续的时间。

打开GC日志

1
-verbose:gc -XX:+PrintGCDetails

输入信息如下

1
2
3
4
5
[GC (Allocation Failure) [PSYoungGen:70640K->10116K(141312K)] 80541K->20017K(227328K),0.0172573 secs] [Times:user=0.03 sys=0.00,real=0.02 secs]
[GC (Metadata GC Threshold) [PSYoungGen:98859K->8154K(142336K)] 108760K->21261K(228352K),0.0151573 secs] [Times:user=0.00 sys=0.01,real=0.02 secs]
[Full GC (Metadata GC Threshold)[PSYoungGen:8154K->0K(142336K)]
[ParOldGen:13107K->16809K(62464K)] 21261K->16809K(204800K),[Metaspace:20599K->20599K(1067008K)],0.0639732 secs]
[Times:user=0.14 sys=0.00,real=0.06 secs]

参数解析

1
2
3
4
5
6
7
GC,Full FC:同样是GC的类型
Allocation Failure:GC原因
PSYoungGen:使用了Parallel Scavenge并行垃圾收集器的新生代GC前后大小的变化
ParOldGen:使用了Parallel Old并行垃圾收集器的老年代GC前后大小的变化
Metaspace: 元数据区GC前后大小的变化,JDK1.8中引入了元数据区以替代永久代
xxx secs:指GC花费的时间
Times:user:指的是垃圾收集器花费的所有CPU时间,sys:花费在等待系统调用或系统事件的时间,real:GC从开始到结束的时间,包括其他进程占用时间片的实际时间。

打开GC日志

1
-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimestamps -XX:+PrintGCDatestamps

输入信息如下

1
2
3
4
5
2019-09-24T22:15:24.518+0800: 3.287: [GC (Allocation Failure) [PSYoungGen:136162K->5113K(136192K)] 141425K->17632K(222208K),0.0248249 secs] [Times:user=0.05 sys=0.00,real=0.03 secs]

2019-09-24T22:15:25.559+0800: 4.329: [GC (Metadata GC Threshold) [PSYoungGen:97578K->10068K(274944K)] 110096K->22658K(360960K),0.0094071 secs] [Times: user=0.00 sys=0.00,real=0.01 secs]

2019-09-24T22:15:25.569+0800: 4.338: [Full GC (Metadata GC Threshold) [PSYoungGen:10068K->0K(274944K)][ParoldGen:12590K->13564K(56320K)] 22658K->13564K(331264K),[Metaspace:20590K->20590K(1067008K)],0.0494875 secs] [Times: user=0.17 sys=0.02,real=0.05 secs]

说明:带上了日期和实践

如果想把GC日志存到文件的话,是下面的参数:

1
-Xloggc:/path/to/gc.log

日志补充说明

  • “[GC”和”[Full GC”说明了这次垃圾收集的停顿类型,如果有”Full”则说明GC发生了”Stop The World”
  • 使用Serial收集器在新生代的名字是Default New Generation,因此显示的是”[DefNew”
  • 使用ParNew收集器在新生代的名字会变成”[ParNew”,意思是”Parallel New Generation”
  • 使用Parallel scavenge收集器在新生代的名字是”[PSYoungGen”
  • 老年代的收集和新生代道理一样,名字也是收集器决定的
  • 使用G1收集器的话,会显示为”garbage-first heap”
  • Allocation Failure 表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了。
  • [PSYoungGen:5986K->696K(8704K) ] 5986K->704K(9216K) 中括号内:GC回收前年轻代大小,回收后大小,(年轻代总大小) 括号外:GC回收前年轻代和老年代大小,回收后大小,(年轻代和老年代总大小)
  • user代表用户态回收耗时,sys内核态回收耗时,rea实际耗时。由于多核的原因,时间总和可能会超过real时间
1
2
3
4
5
6
7
8
9
10
11
Heap(堆)
PSYoungGen(Parallel Scavenge收集器新生代)total 9216K,used 6234K [0x00000000ff600000,0x0000000100000000,0x0000000100000000)
eden space(堆中的Eden区默认占比是8)8192K,768 used [0x00000000ff600000,0x00000000ffc16b08,0x00000000ffe00000)
from space(堆中的Survivor,这里是From Survivor区默认占比是1)1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
to space(堆中的Survivor,这里是to Survivor区默认占比是1,需要先了解一下堆的分配策略)1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)

ParOldGen(老年代总大小和使用大小)total 10240K, used 7001K [0x00000000fec00000,0x00000000ff600000,0x00000000ff600000)
object space(显示个使用百分比)10240K,688 used [0x00000000fec00000,0x00000000ff2d6630,0x00000000ff600000)

PSPermGen(永久代总大小和使用大小)total 21504K, used 4949K [0x00000000f9a00000,0x00000000faf00000,0x00000000fec00000)
object space(显示个使用百分比,自己能算出来)21504K, 238 used [0x00000000f9a00000,0x00000000f9ed55e0,0x00000000faf00000)

Minor GC日志

17.jpg

Full GC日志

举例

1
2
3
4
5
6
7
8
9
10
11
12
13
private static final int _1MB = 1024 * 1024;

public static void testAllocation() {
byte [] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 *_1MB];
allocation2 = new byte[2 *_1MB];
allocation3 = new byte[2 *_1MB];
allocation4 = new byte[4 *_1MB];
}

public static void main(String[] args) {
testAllocation();
}

设置JVM参数

1
-Xms10m -Xmx10m -XX:+PrintGCDetails

可以用一些工具去分析这些GC日志

常用的日志分析工具有:GCViewer、GCEasy、GCHisto、GCLogViewer、Hpjmeter、garbagecat等

垃圾回收器的新发展

目前的默认选项G1 GC在不断的进行改进,很多我们原来认为的缺点,例如串行的Fu11GC、Card Table扫描的低效等,都已经被大幅改进,例如,JDK10以后,Fu11GC已经是并行运行,在很多场景下,其表现还略优于ParallelGC的并行Ful1GC实现。

即使是Serial GC,虽然比较古老,但是简单的设计和实现未必就是过时的,它本身的开销,不管是GC相关数据结构的开销,还是线程的开销,都是非常小的,所以随着云计算的兴起,在Serverless等新的应用场景下,Serial GC找到了新的舞台。

比较不幸的是CMSGC,因为其算法的理论缺陷等原因,虽然现在还有非常大的用户群体,但在JDK9中已经被标记为废弃,并在JDK14版本中移除

ZGC(JDK11出现)未来的首选

  • 在尽可能堆吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾回收的停顿时间限制在10毫秒以内的低延迟
  • 并发标记,并发预备重分配,并发重分配,并发重映射
  • 除了初始标记是STW,其他地方几乎都是并发执行的

Shenandoah(Open JDK12)

  • 低延迟时间
  • 高运行负担下的吞吐量下降

18.jpg

ZGC详解

ZGC 是一款并行的、低延迟的、基于区域的垃圾收集器,它有以下几个特点12

  • 它可以支持最大16TB的堆空间,而且堆空间的大小不会影响停顿时间。
  • 它可以实现最大停顿时间不超过10毫秒,甚至在 JDK 16 中缩小到1毫秒以内,并且停顿时间是一个固定值,不会随着垃圾回收的进度而变化。
  • 它使用了一种基于着色指针的技术,可以将对象的复制和转移操作并发执行,而不是在停顿期间进行,这样就可以减少停顿时间。
  • 它使用了一种基于重映射的技术,可以在不影响应用程序的情况下,动态地改变对象的地址,这样就可以实现内存的压缩和碎片整理。

ZGC 的垃圾回收过程主要分为以下几个阶段34

  • 初始标记:在这个阶段,ZGC 会标记所有的根对象,这个阶段是需要停顿的,但是很短。
  • 并发标记:在这个阶段,ZGC 会并发地标记所有的存活对象,这个阶段是不需要停顿的,但是会占用一些 CPU 资源。
  • 最终标记:在这个阶段,ZGC 会处理并发标记期间产生的标记变化,这个阶段是需要停顿的,但是很短。
  • 并发预备:在这个阶段,ZGC 会并发地准备一些数据结构,为后续的并发处理做准备,这个阶段是不需要停顿的,但是会占用一些 CPU 资源。
  • 并发处理:在这个阶段,ZGC 会并发地执行对象的复制和转移操作,这个阶段是不需要停顿的,但是会占用一些 CPU 资源。
  • 并发重映射:在这个阶段,ZGC 会并发地更新所有的对象引用,使它们指向新的地址,这个阶段是不需要停顿的,但是会占用一些 CPU 资源。
  • 并发重置:在这个阶段,ZGC 会并发地重置一些数据结构,为下一次垃圾回收做准备,这个阶段是不需要停顿的,但是会占用一些 CPU 资源。

ZGC 的调优主要有以下几个方面3

  • 调整堆大小:ZGC 支持的堆大小范围是8MB到16TB,可以根据应用程序的内存需求和机器的内存容量来设置合适的堆大小,一般来说,堆大小越大,垃圾回收的频率越低,但是并发处理的时间越长。
  • 调整并行线程数:ZGC 的并行线程数默认是 CPU 核心数的一半,可以根据 CPU 的性能和负载来调整,并行线程数越多,垃圾回收的速度越快,但是会占用更多的 CPU 资源。
  • 调整分页大小:ZGC

Java垃圾回收器总结:

串行的垃圾回收器有Serial New和Serial Old两种,它们分别用于新生代和老年代的内存回收。它们采用复制算法(Copy)和标记-压缩算法,在执行垃圾回收时会暂停所有其他线程(Stop The World)。

并行的垃圾回收器有Parallel New、Parallel Scavenge、Parallel Old三种,它们也分别用于新生代和老年代的内存回收。它们采用复制算法和标记-压缩算法(Mark-Sweep),在执行垃圾回收时也会暂停所有其他线程。

除了串行和并行之外,还有一种并发(Concurrent)的垃圾回收器,它可以在应用程序运行的同时进行部分或全部的垃圾回收任务,从而减少停顿时间。这种垃圾回收器有CMS、G1、ZGC等。

CMS是一种以获取最短停顿时间为目标的老年代垃圾回收器,它采用标记-清除算法,并且在标记和清除阶段都可以与应用程序线程并发执行。

G1是一种面向整个堆内存的垃圾回收器,它将堆内存划分为多个大小相等的区域,并且根据区域中对象存活率进行优先级排序,从而实现高效且可预测的内存回收。

ZGC是一种最新推出的高性能低延迟的全堆并发垃圾回收器,它采用染色指针技术(Colored Pointer)和负载屏障技术(Load Barrier),可以实现毫秒级别甚至微秒级别的停顿时间,并且支持TB级别以上的堆内存容量。

各自优缺点

  • 串行垃圾回收器 (Serial GC):它使用单线程进行垃圾回收,适合内存小、CPU单核的场景,优点是简单高效,缺点是会造成停顿 (Stop-the-world)。
  • 并行垃圾回收器 (Parallel GC):它使用多线程进行垃圾回收,适合内存大、CPU多核的场景,优点是可以提高吞吐量 (Throughput),缺点是也会造成停顿,并且需要更多的内存空间。
  • 并发标记扫描垃圾回收器 (CMS GC):它使用多线程进行垃圾回收,并且在标记和清除阶段与用户线程并发执行,适合对响应时间敏感的场景,优点是可以减少停顿时间,缺点是会占用更多的CPU资源,并且可能产生内存碎片。
  • G1垃圾回收器 (G1 GC):它将堆划分为多个大小相等的区域,并且根据区域中对象的存活率进行垃圾回收,适合大内存、多核的场景,优点是可以实现可预测的停顿时间,并且避免了内存碎片,缺点是会占用更多的内存空间,并且需要更复杂的调优。

除了这些常见的类型外,还有一些新型的垃圾回收器4,例如:

  • ZGC:它使用了压缩指针和着色指针技术,在标记阶段与用户线程并发执行,在整理阶段只暂停用户线程很短的时间。它可以支持TB级别的堆内存,并且保证低延迟。
  • Shenandoah GC:它与ZGC类似,也使用了压缩指针和着色指针技术,在标记和整理阶段都与用户线程并发执行。它也可以支持TB级别的堆内存,并且保证低延迟。

JVM上篇内存与垃圾回收-垃圾回收相关概念及回收器
https://yztldxdz.top/2022/11/16/JVM上篇内存与垃圾回收-垃圾回收相关概念及回收器/
发布于
2022年11月16日
许可协议