掘金 后端 ( ) • 2022-06-16 18:40

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

前言

本文的来历是公司希望写点新员工培训的资料,GC分配给到了我,所以利用这个机会将GC相关的知识整理下,也顺便复习下这部分号称JAVA最没用也最有用的知识。

由于面向的是工作年限不长的新员工,所以本文不会将太多特别深的理论知识,直接上干货,让大家能快速掌握工作中需要了解的GC相关的知识以及GC友好的代码规范。至于进阶以及高级的内容大家可以按需学习,毕竟JVM是java的基础,也是java “where amazing happen”的地方,知识的广度和深度可想而知。现在很多大厂都在根据自己的业务来配置队伍定制开发和维护业务友好的专用JVM,由此可见一般。

正文

JVM的GC

JVM为什么要GC

在正式讲解之前,先科普下基础知识,了解的小伙伴直接略过往下看。

首先什么是GC? GC是英文Garbage-Collection(垃圾回收)的缩写。是JVM对java中的对象按规则进行回收释放的过程。

其次JVM为什么要GC呢? 在同为面向对象的C++中,如果你新建了一个对象,需要手动维护和管理整个对象的生命周期,如果处理不当就会造成内存泄漏,这对C++的新手来说是很大的挑战,所以C++的上手门槛还是有的;而java由于引入了JVM,对象回收的事情交给她就可以了,程序员只需要负责创建对象即可(此处说法为了便于理解并不是很严谨,在某些场景下还是需要注意下的,否则也是会引起内存泄漏的,下面的章节会详细说明)。JVM就像一个终极优秀的女佣,在接到你给的配置和策略后,忠实的完成自己处理和回收对象的本职工作。

GC相关的基本概念

上面简单介绍了下GC相关的知识,下面来学习下GC相关的几个概念:

  • 并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
  • 并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。
  • 吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%
  • STW: 即Stop-the-world。它是指 JVM 由于要执行 GC 而停止了应用程序的执行,并且这种情形会在任何一种 GC 算法中发生。当 Stop-the-world 发生时,除了 GC 的线程以外,其他的线程均处于等待的状态,直到 GC 任务完成。实际上,很多 GC 优化都是通过减少 Stop-the-world 的时间来提高程序的性能。

JVM 运行模式有如下这两种:

  • Client模式:启动快,进入稳定期后运行速度不如 Server 快。
  • Server模式:启动慢,进入稳定期后运行速度优于 Client . Server 模式采用的是重量级的虚拟机,对程序会进行更多的优化。

当JVM用于启动GUI界面的交互应用时适合于使用client模式,当JVM用于运行服务器后台程序时建议用Server模式。

现在大多数模式下默认使用的都是Server模式。如果实在不知道怎么选,那就交给JVM自己去处理,JVM如果不显式指定是-Server模式还是-client模式,JVM能够根据下列原则进行自动判断(适用于Java5版本或者Java以上版本)。

查看当前 JVM 运行模式的指令:java -version

GC常用的收集器

下面再来看下图解HotSpot虚拟机所包含的收集器:

图中展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,则说明它们可以搭配使用。虚拟机所处的区域则表示它是属于新生代还是老年代收集器。下面是详细的分类:

  • 新生代收集器:Serial、ParNew、Parallel Scavenge
  • 老年代收集器:CMS、Serial Old、Parallel Old
  • 整堆收集器: G1

下面就根据上面的分类来进行详细讲解,由于除了G1外都是分代的收集器,所以下面的内容按照新生代+老年代进行典型搭配讲解:

Serial 收集器 + Serial Old 收集器

Serial + Serial Old收集器是最基本的、发展历史最悠久的收集器。

特点:

  • 单线程、简单高效(与其他收集器的单线程相比),对于单个CPU的环境来说,两种收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。
  • 收集器进行垃圾回收时,必须暂停其他所有的工作线程,其中Serial采用复制算法,Serial Old采用标记-整理算法进行GC,直到它结束(Stop The World)。
  • 但是现在的服务器哪怕是PC都是多CPU或者多核的,所以该类型的收集器基本上已经被淘汰了,知道他们曾经来过即可。 应用场景:
  • 两者都适用于Client模式下的虚拟机。
  • Serial Old模式在Server模式下也有使用场景:
  1. 在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用。
  2. 作为CMS收集器的后备方案,在并发收集Concurent Mode Failure时使用。

开启此组合的配置项为-XX:+UseSerialGC

Serial / Serial Old收集器工作流程图:

Parallel Scavenge 收集器 + Parallel Old 收集器

这个组合就厉害多了,首先Parallel就代表着这是并行处理的收集器,效率比上面的两位高很多。其中Parallel Scavenge使用复制算法多线程的进行年轻代的GC;Parallel Old使用采用标记-整理算法多线程的进行老年代的GC。

另外这个组合的目标是可以通过设置吞吐量以及最大停顿时间来控制GC的进行,该组合通过提供一个GC自适应调节策略来达到上面的目标。

GC自适应调节策略: Parallel Scavenge收集器可设置-XX:+UseAdptiveSizePolicy参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为GC的自适应调节策略。

Parallel Scavenge收集器使用两个参数控制吞吐量:

  • XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
  • XX:GCRatio 直接设置吞吐量的大小

在JDK1.8时,开启此组合的配置项为-XX:+UseParallelGC

Parallel Scavenge/Parallel Old收集器工作流程图:

ParNew 收集器 + CMS 收集器

这是G1GC出现之前服务器端最常用的组合,ParNew负责年轻代的GC,CMS负责老年代的GC

ParNew 收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程外其余行为均和Serial收集器一模一样。

特点:

  • 多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
  • 和Serial收集器一样存在Stop The World问题

应用场景:

ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,因为它是除了Serial收集器外,唯一一个能与CMS收集器配合工作的。

ParNew收集器工作流程图:

CMS 收集器 CMS收集器是个既并发又并行的收集器,在多线程的情况下还能和用户线程并行执行,基于标记-清除算法实现垃圾回收,最大程度的减少了STW的时间。

工作流程主要有如下 4 个步骤:

  • 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿(Stop-the-world)
  • 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿
  • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿(Stop-the-world)
  • 并发清除:清理垃圾,不需要停顿

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿;需要停顿的阶段正常情况下耗时很短,所以CMS正常情况下能很好地控制停顿时间。

但 CMS 收集器也有如下缺点:

  • 吞吐量低
  • 无法处理浮动垃圾,可能造成Concurrent Model Failure失败
  • 标记-清除算法带来的内存空间碎片问题,导致大对象无法分配空间,继而触发另一次FULL GC

在JDK1.8时,开启此组合的配置项为-XX:+UseConcMarkSweepGC

CMS收集器的工作流程图:

G1 收集器

G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

使用复制 + 标记-整理算法收集新生代和老年代垃圾。

G1为了实现 STW 的时间可预测,首先要有一个思想上的改变。G1 将堆内存“化整为零”,新生代和老年代不再物理隔离,而是将堆内存划分成多个大小相等独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。回收器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是 新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

另外Region中还有一类特殊的Humongous区域,专门用来存储大对象。 G1认为只要大小超过了一个Region 容量一半的对象即可判定为大对象。

而对于那些超过了整个 Region 容量的超级大对象,将会被存放N个连续的Humongous Region之中,G1 的进行回收大多数情况下都把Humongous Region作为老年代的一部分来进行看待。

G1中两个可配置参数:

  • 分区大小:-XX:+G1HeapRegionSize:设置在使用G1收集器时细分Java堆的Region的大小。这个值可以在1mb到32mb之间。默认的Region大小是根据实时需要的堆大小决定的。随着size增加,垃圾的存活时间更长,GC间隔更长,但每次GC的时间也会更长
  • 最大GC 暂停时间:-XX:+MaxGCPauseMillis:设置最大GC暂停时间的目标(毫秒)。这是一个软目标,JVM将尽最大努力实现它。默认情况下,没有最大暂停时间值。

工作流程主要有如下 4 个步骤:

  • 初始标记: 仅标记GC Roots能直接到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象。 (需要线程停顿,但耗时很短)
  • 并发标记: 从GC Roots开始对堆中对象进行可达性分析,找出存活对象。 (耗时较长,但可与用户程序并发执行)
  • 最终标记: 为了修正在并发标记期间因用户程序执行而导致标记产生变化的那一部分标记记录。且对象的变化记录在线程Remembered Set Logs里面,把Remembered Set Logs里面的数据合并到Remembered Set中。 (需要线程停顿,但可并行执行)
  • 筛选回收: 对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。 (可并发执行)

开启G1垃圾收集器的方式:-XX:+UseG1GC

G1收集器工作流程图:

GC总结

上面介绍了几种常用的GC组合,当前服务端的配置基本上都集中在后两种组合上,那么问题来了,如何进行选择呢,此处给个最简单的区别方法,以堆内存的大小作为评判标准:

  • CMS适合回收堆空间1G ~ 20G 左右的场景
  • G1适合回收堆空间6个G ~ 上百G的场景
  • 综上,小于6G的堆空间使用CMS即可;大于20G的可以使用G1;6G-20G之间的可以根据实际情况灵活选择

GC友好的代码

如果说上面的垃圾回收策略和服务端或者后端工程师关系比较密切的话,那么下面要说的就和所有java程序员有关了,如果在GC策略调优到达瓶颈后,性能的差别就会产生在代码的编写质量了。下面就来说说GC友好代码有哪些策略。

新建对象时的选择

尽量使用更多生命周期短的、小的、不改变指向(immutable)的对象;如果无法达到上述目标,那尽量做好对象重用以及对象管理,防止内存泄漏。原因如下:

  • Java的垃圾收集器喜欢短生命周期的对象,对象如果在新生代内,在垃圾收集发生前就死掉了,垃圾收集器就什么都不用做了
  • 大对象的分配效率更低,而且对非压缩算法的垃圾收集器(CMS这种经常被用到的就是这种类型),更容易造成碎片,从而导致更频繁的GC
  • 对象重用增加了代码的复杂度,降低了可读性

所以为中间结果分配小对象看起来并不是一件坏事,起码对于GC是这样的。

将用完的对象手动设置为NULL有用吗?

其实没什么卵用,还使得代码的可读性下降了不少。JIT Compiler会自动分析local变量的生命周期。

只有一种情况可能需要手动处理,比如有一个巨大的常驻内存的集合缓存对象,里面某些数据在业务完成后必须要手动释放,否则该集合对象可能会越来越大,也就是传说中的内存泄漏,最终的结果可能就是OOME了。

当然,异常处理的catch模块或者finally模块中资源释放的代码还是必须的。

尽量别手动调用System.gc()以及finalize()

在某些高级组件的高级功能中可能会调用这两个方法,平时我们的编码中基本上用不上,所以还是别抢JVM的工作了,让JVM自己去处理吧。

WeakReference&&SoftReference的使用

这是个平时不怎么起眼,偶然知道了又觉得很有用的Java特征。

大家都知道Java里所有对象除int等基本类型外,都是Pass by Reference的指针,实例只要被一个对象连着,就不会被收集。

WeakReference就是真正意义上的C++指针,只是单纯的指向一个对象,而不会影响对象的引用计数;SoftReference更特别,在内存足够时,对象会因为SoftReference的存在而不被收集,但内存不足时,对象就还是会被收集。

另外还可以使用ReferenceQueue的机制,使得对象被回收后再次获取时能获得通知,在某些场景下还是很有用处的。

所以某些缓存类的数据可以使用上面两种对象对象进行存储,WeakReference在GC时直接就被回收,SoftReference则会根据内存情况来决定是否回收该对象。

如何避免内存泄漏

内存泄漏乍一听很高大上的概念,但是java不是有垃圾收集器吗?怎么还泄漏呢?还真能,下面来分析下java内存泄漏的几种原因:

  • 被生命周期极长的集合类不当持有,号称是Java内存泄漏的首因。这些集合类的生命周期通常极长,而且是一个辅助管理性质的对象,在一个业务事务运行完后,如果没有将某个业务对象主动的从中清除的话,这个集合就会吃越来 越多内存,可以用WeakReference,如WeakHashMap,使得它持有的对象不增加对象的引用数。
  • Scope定义不对,方法的局部变量定义成类的属性,类的静态变量等,导致变量常驻内存无法释放。
  • 异常时没有加finally{}来释放某些资源,JDBC时代也是很普遍的事情,当前很多三方组件如消息中间件等连接也是如此。

内存泄漏检测工具:

  • 可视化以及插件化的有JProfiler等
  • 服务器端或者命令行工具则更多了,jdk就提供了包括jstat、jmap以及jconsole等工具,大家可以按需使用

总结

前文说了GC是java中最没用也是最有用的知识,说它没用是因为大多数程序员,尤其是刚入行的程序员,GC的调优理他们很遥远,基本上不会接触到,也不会耽误写代码。说它有用是因为这是java的jvm的一个核心内容,在降低了java使用门槛的同时也是java amazing的地方,当你要进阶成高级程序员或者架构师时,这部分内容就会变得十分有必要的了,所以学习下这部分知识肯定不亏,起码在java程序员面试中这基本上是必问的知识。