Java虚拟机垃圾回收相关知识点全梳理(下)

969 阅读17分钟

一、前言

上一篇文章《Java虚拟机垃圾回收相关知识点全梳理(上)》我整理分享了JVM运行时数据区域的划分,垃圾判定算法以及垃圾回收算法,各种算法的适用场景。今天,我整理分享下JVM性能的度量指标,垃圾收集器的分类,最后分享一下JVM的调优建议。

二、性能度量指标

  • 吞吐量:表示系统减去系统回收时间占总时间的比率,比如系统运行了100秒,垃圾回收占用了1秒,那么吞吐量量就是(100-1)/100=99%。

  • 垃圾回收消耗:和吞吐量相反,垃圾回收器消耗指垃圾回收器耗时与系统运行总时间的比值。

  • 停顿时间:指垃圾回收器运行时,系统停顿的时间。

  • 回收频率:指垃圾回收器多长时间会运行一次。一般来说,对于固定的应用而言,垃圾回收器的频率应该是越低越好。通常增大堆空间可以有效降低垃圾回收发生的频率,但是可能会增加回收产生的停顿时间。

  • 反应时间:当一个内存对象被标记为垃圾对象后到这个对象被真正回收产生的时间。

根据这几个指标,我们可以知道,垃圾回收性能好的表现是:吞吐量高,垃圾回收消耗低,停顿时间少,回收频率低,反应时间快。但是,并没有这么完美的性能表现,这几个指标有些是互斥的,比如要降低回收频率,就要扩大空间,但是就会增加停顿时间;同样要想反应时间快,就必须要提高回收频率。所以,这些性能的追求就是一个博弈平衡的过程,我们可以根据我们追求的某一方面来进行调优,比如,对于客户端应用而言,应该尽可能降低其停顿时间,给用户良好的使用体验,为此,可以牺牲垃圾回收的吞吐量;对服务端程序来说,可能会更加关注吞吐量。

三、垃圾回收器

3.1 Serial 收集器

Serial 收集器是所有垃圾收集器中最古老的一种,也是JDK中最基本的垃圾收集器之一。Serial回收器主要有两个特点:第一:使用单线程进行垃圾回收;第二:独占式垃圾回收。

在串行收集器进行垃圾回收时,Java应用程序中的线程都需要暂停,等待垃圾回收完成。这种现象成为Stop-The-World。它将造成非常糟糕的用户体验,在实时性要求较高的应用场景中,这种现象往往是不能被接受的,但是它依然是在Client模式下默认的新生代收集器。在单核CPU环境下,由于没有线程间的切换,它甚至比并发收集器的性能都要好。(以下图片来源于网络)

图片来源于网络

3.2 ParNew 收集器

ParNew 收集器是Serial 收集器的多线程版本。它的回收策略、算法以及参数和串行回收器一样。它是许多Server模式下新生代首选的收集器,除了他的多线程回收功能外,还有一点的就是只有他能与CMS收集器配合工作。开启ParNew 收集器可以使用以下参数:

-XX:+UseParNewGC:新生代使用并行收集器,老年代使用串行回收器。

-XX:+UseConcMarkSweepGC:新生代使用并行回收器,老年代使用CMS。

并行收集器工作时的线程数量可以使用 -XX:ParallelGCThreads 参数指定。一般最好与CPU数量相当,避免过多的线程数,影响垃圾收集性能。 在默认情况下,当CPU数量小于8个时,ParallelGCThreads 的值等于 CPU 数量;当 CPU 数量大于8个时,ParallelGCThreads 的值等于 3+[(5*CPU_Count)/8]

图片来源于网络

3.3 Parallel Scavenge 收集器

Parallel Scavenge 收集器是新生代收集器,它是使用复制算法的收集器,同时也是多线程收集器。它和其他并发收集器不同的点是,Parallel Scavenge 收集器 关注吞吐量,其他的并行收集器关注的是降低停顿时间。 开启Parallel Scavenge 收集器可以使用以下参数:

-XX:+UseParallelGC:新生代使用并行回收收集器,老年代使用串行回收器。

-XX:+UseParallelOldGC:新生代与老年代都使用并行回收收集器。

并行回收收集器提供了两个重要的参数用于控制系统的吞吐量:

-XX:+MaxGCPauseMills:设置最大垃圾收集停顿时间,它的值是一个大于 0 的整数。收集器在工作时会调整 Java 堆大小或者其他一些参数,尽可能地把停顿时间控制在 MaxGCPauseMills 以内。这里需要注意的是如果希望减少停顿时间,而把这个值设置得非常小,虚拟机为了达到预期的停顿时间,JVM 可能会使用一个较小的堆 (一个小堆比一个大堆回收快),而这将导致垃圾回收变得很频繁,从而增加了垃圾回收总时间,降低了吞吐量。

-XX:+GCTimeRatio:设置吞吐量大小,它的值是一个 0-100 之间的整数。假设 GCTimeRatio 的值为 n,那么系统将花费不超过 1/(1+n) 的时间用于垃圾收集。比如 GCTimeRatio 等于 19,则系统用于垃圾收集的时间不超过 1/(1+19)=5%。默认情况下,它的取值是 99,即不超过 1%的时间用于垃圾收集。

除此之外,Parallel Scavenge 收集器与ParNew 收集器另一个不同之处在于,前者支持一种自适应的 GC 调节策略,使用-XX:+UseAdaptiveSizePolicy 可以打开自适应 GC 策略。在这种模式下,新生代的大小、eden 和 survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。在手工调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量 (GCTimeRatio) 和停顿时间 (MaxGCPauseMills),让虚拟机自己完成调优工作。

3.4 Serial Old 收集器

Serial Old 收集器是Serial收集器的老年代版本,从名字我们就可以知道,它是一个单线程收集器,使用“标记-整理”算法。该虚拟机的主要使用场景是在Client模式下使用。它是CMS收集器的后备方案,当CMS收集器进行收集的时候,发生了Concurrent Mode Failure时,会触发使用Serial Old 收集器进行Full GC,此时会带来长时间的STW,进而影响系统响应,这也是CMS收集器的一个缺点。

图片来源于网络

3.5 Parallel Old 收集器

Parallel Old 收集器也是一种多线程并发的收集器。和Parallel Scavenge 收集器一样,它也是一种关注吞吐量的收集器。Parallel Old 收集器使用标记-压缩算法。

图片来源于网络

3.6 CMS(Concurrent Mark Sweep) 收集器

CMS 收集器是一个以获取最大回收停顿时间为目标的收集器,CMS垃圾回收的过程主要分为5步:初始标记、并发标记、重新标记、并发清除和并发重置。其中初始标记和重新标记是需要进行“Stop The World”,而并发标记、并发清除和并发重置是可以和用户线程一起执行的。因此,从整体上来说,CMS 收集不是独占式的,它可以在应用程序运行过程中进行垃圾回收 。CMS收集器也有三大缺点:

  • 对CPU资源比较敏感,在并发阶段,虽然不会导致用户线程停顿,但是还是会占用部分CPU资源,从而导致程序变慢,吞吐量下降。
  • CMS无法处理浮动垃圾,因为CMS进行垃圾收集是和用户线程一起运行的,所以在收集的过程中就会产生垃圾,这部分垃圾就被称为浮动垃圾,浮动垃圾只能等待下一次垃圾收集期间进行收集。因为垃圾收集过程与用户线程一起运行,所以收集过程中还是要预留空间给用户线程使用,如果空间不够,就会出现“Concurrent Mode Failure” 失败,接着就会出现备选方案的Serial Old收集器进行Full Gc,会进行长时间的停顿,进而影响性能。
  • CMS收集器是“标记-清除”算法的收集器,所以在垃圾收集过后会带来大量的内存碎片,CMS提供了一种内存压缩参数+XX:+UseCMSCompactAtFullCollection(默认是开启的)开启后CMS会在进行Full GC 的时候进行内存整理,+XX:CMSFullGCsBeforeCompaction可以设置执行多少次不压缩内存后再进行压缩的Full GC。

来源于网络

3.7 G1(Garbage-First) 收集器

G1收集器是一款面向服务端的垃圾收集器,在jdk1.7后可以正式使用,可以通过命令-XX:+UnlockExperimentalVMOptions –XX:+UseG1G来启用G1收集器。G1收集器采用的是“标记-整理”算法,它也是一个进行可以预测停顿时间的垃圾收集器。可以通过参数设置停顿时间:

-XX:MaxGCPauseMills = 20

-XX:GCPauseIntervalMills = 200。

以上参数指定在200ms内,停顿时间不超过20ms。这两个参数是G1回收器的目标,G1回收器并不保证能执行它们。 G1收集器的区域分布如下图所示:

图片来源于网络

在G1中把java堆分成了多个大小相等的独立区域(Region),虽然保留了新生代和老年代的概念,但是他们都不是物理隔离的,只是逻辑上还有区分。

G1收集器进行垃圾收集分为4个阶段,初始标记,并发标记,最终标记,筛选回收。初始标记需要停顿用户线程,但是时间很短;并发标记是从GC Roots对堆中的对象进行可达性分析,这个阶段比较耗时,但是可以与用户线程并发执行;最终标记是修正在并发标记中产生的变动;筛选回收就是对标记好的垃圾对象进行价值和成本排序,根据用户设定的期望来进行回收(比如我们上面设置的200ms停顿时间不超过20ms)。

3.8 ZGC(Z Garbage Collector) 收集器

ZGC 被称为“一个可伸缩低延迟的垃圾回收器”,这个垃圾回收器有什么神奇之处呢?它的主要特点就是能把回收时间控制在10ms以内,而且不受堆大小的影响,所以它可以支持TB级别的垃圾回收。

ZGC也是和G1收集器一样,并没有进行分代,而是把整个内存分成了多个region,官方后续会尝试采用分代的设计,目前完全因为是不分代这是最简单的设计。一次完整的 ZGC 回收周期分为以下几个阶段(Phase):

  • Pause Mark Start:标记根对象;

  • Concurrent Mark:并发标记阶段;

  • Concurrent Relocate:并发重定位;

    • 活动对象被移动到了一个新的 Heap Region B-region 中,之前旧对象所在的 Heap Region A-region 即可复用;如果 B-region 中对象之间的引用关系将会在这一阶段被更新;
    • 在重定位过程中,新旧对象的映射关系(同一对象在不同 Region 中的映射关系)被记录在了 Forwarding Tables 中。
  • Pause Mark Start:这个阶段实际上已经进入了新的 ZGC Cycle,同样也是标记根对象;

  • Concurrent Remap:并发重映射。 这个阶段除了标记根对象直接引用的对象外,还会根据上个 ZGC Cycle 中生成的 Forwarding Tables 更新跨 Heap Region 的引用;

ZGC还是有停顿的,在Pause Mark Start 阶段进行根对象扫描(Root Scanning)时会出现短暂的暂停。 流程示意图如下(图片来源于网络)

四、一些JVM调优建议

4.1将新对象预留在年轻代

众所周知,由于 Full GC 的成本远远高于 Minor GC,因此某些情况下需要尽可能将对象分配在年轻代,这在很多情况下是一个明智的选择。虽然在大部分情况下,JVM 会尝试在 Eden 区分配对象,但是由于空间紧张等问题,很可能不得不将部分年轻对象提前向年老代压缩。因此,在 JVM 参数调优时可以为应用程序分配一个合理的年轻代空间,以最大限度避免新对象直接进入年老代的情况发生。这里实际上是为了避免“朝生夕灭”的大对象发生,尽可能的把设置合理新生代空间,把“朝生夕灭 ”对象留在新生代中。

4.2 将大对象直接分配再老年代

我们分配对象一般都是分配在年轻代,分配大对象在年轻代,需要年轻代提供足够的空间,这个时候会导致原有的大量小对象进入老年代,占用老年代空间。基于以上原因,可以将大对象直接分配到年老代,从而保留为年轻代保留了空间,保证了年轻代原有的目的,这样也可以提高 GC 的效率。如果一个大对象同时又是一个短命的对象,假设这种情况出现很频繁,那对于 GC 来说会是一场灾难。原本应该用于存放永久对象的年老代,被短命的对象塞满,这也意味着对堆空间进行了洗牌,扰乱了分代内存回收的基本思路。因此,在软件开发过程中,应该尽可能避免使用“朝生夕灭”这样短命的大对象。可以使用参数-XX:PetenureSizeThreshold 设置大对象直接进入年老代的阈值。当对象的大小超过这个值时,将直接在年老代分配。参数-XX:PetenureSizeThreshold 只对串行收集器和年轻代并行收集器有效,并行回收收集器不识别这个参数。

4.3 设置对象进入老年代的年龄

堆中的每一个对象都有自己的年龄。一般情况下,年轻对象存放在年轻代,老年对象存放在老年代。为了做到这点,虚拟机为每个对象都维护一个年龄。如果对象在 Eden 区,经过一次 GC 后依然存活,则被移动到 Survivor 区中,对象年龄加 1。以后,如果对象每经过一次 GC 依然存活,则年龄再加 1。当对象年龄达到阈值时,就移入老年代,成为老年对象。那么设置一个合适的老年代的年龄就有利于提升系统性能,可以通过-XX:MaxTenuringThreshold 来设置,默认值是 15。虽然-XX:MaxTenuringThreshold 的值可能是 15 或者更大,但这不意味着新对象非要达到这个年龄才能进入老年代。如果在Survivor空间中相同年龄所有对象的大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

4.4 稳定的堆与震荡的堆

一般来说,稳定的堆大小对垃圾回收是有利的。获得一个稳定的堆大小的方法是使-Xms 和-Xmx 的大小一致,即最大堆和最小堆 (初始堆) 一样。如果这样设置,系统在运行时堆大小理论上是恒定的,稳定的堆空间可以减少 GC 的次数。因此,很多服务端应用都会将最大堆和最小堆设置为相同的数值。稳定的堆大小虽然可以减少 GC 次数,但同时也增加了每次 GC 的时间。让堆大小在一个区间中震荡,在系统不需要使用大内存时,压缩堆空间,使 GC 应对一个较小的堆,可以加快单次 GC 的速度。基于这样的考虑,JVM 还提供了两个参数用于压缩和扩展堆空间。

XX:MinHeapFreeRatio: 设置堆的最小空闲比例,默认是40,当堆空间的空闲空间小于这个数值时,jvm会自动扩展空间。

-XX:MaxHeapFreeRatio: 设置堆的最大空闲比例,默认是70,当堆空间的空闲空间大于这个数值时,jvm会自动压缩空间。

当-Xmx 和-Xms 相等时,-XX:MinHeapFreeRatio 和-XX:MaxHeapFreeRatio 两个参数无效。

4.5 尝试使用大的内存分页

CPU 是通过寻址来访问内存的。32 位 CPU 的寻址宽度是 0~0xFFFFFFFF ,计算后得到的大小是 4G,也就是说可支持的物理内存最大是 4G。但在实践过程中,碰到了这样的问题,程序需要使用 4G 内存,而可用物理内存小于 4G,导致程序不得不降低内存占用。为了解决此类问题,现代 CPU 引入了 MMU(Memory Management Unit 内存管理单元)。MMU 的核心思想是利用虚拟地址替代物理地址,即 CPU 寻址时使用虚址,由 MMU 负责将虚址映射为物理地址。MMU 的引入,解决了对物理内存的限制,对程序来说,就像自己在使用 4G 内存一样。内存分页 (Paging) 是在使用 MMU 的基础上,提出的一种内存管理机制。它将虚拟地址和物理地址按固定大小(4K)分割成页 (page) 和页帧 (page frame),并保证页与页帧的大小相同。这种机制,从数据结构上,保证了访问内存的高效,并使 OS 能支持非连续性的内存分配。在程序内存不够用时,还可以将不常用的物理内存页转移到其他存储设备上,比如磁盘,在windows下,这部分空间叫做虚拟内存,Linux下叫做SWAP分区。

在 Solaris 系统中,JVM 可以支持 Large Page Size 的使用。使用大的内存分页可以增强 CPU 的内存寻址能力,从而提升系统的性能。

java –Xmx2506m –Xms2506m –Xmn1536m –Xss128k –XX:++UseParallelGC
 –XX:ParallelGCThreads=20 –XX:+UseParallelOldGC –XX:+LargePageSizeInBytes=256m
–XX:+LargePageSizeInBytes:设置大页的大小。

过大的内存分页会导致 JVM 在计算 Heap 内部分区(perm, new, old)内存占用比例时,会出现超出正常值的划分,最坏情况下某个区会多占用一个页的大小

4.6 根据场景选择合适的收集器

对于对响应时间不敏感的场景,可以选择吞吐量优先的收集器来提升性能,比如Parallel Old 收集器。如果是对响应时间要求高的场景,就需要选择低停顿的垃圾回收器,比如CMS,G1,ZGC(虽然目前还不是非常成熟)。

五、总结

这篇文章内容比较,主要分享了虚拟机的性能度量指标,垃圾回收器的分类,一些调优建议。最后放一张本文的脑图进行总结:

六、参考