聊一聊 JVM 的 GC

引言

JVM 中的 GC 在技术博客中应该算是个老生常谈的话题,网络上也存在着许多质量参差不齐的文章,可以看出来大都是“复制粘贴”的风格。在写这篇文章的时候,我问了问自己“现在我算不算是在制造数据垃圾?”。

我为什么要写呢?其实写这篇博文的主要目的不是给别人看的,而是想要记录一下自己对于 JVM 中 GC 的理解与认识。我认为有一类文章存在的意义主要是用来记录的,记录自己对一个事物认识与思考的过程。如果你能够将对此事物的理解转换为成体系化的有清晰脉络结构的文字,那么应该说你确实是了解它的。所以,这篇文章就是用来记录的,如果我的思考过程恰巧也有助于你对于 GC 的理解那自然是再好不过了。

正文

JVM 和 GC

首先要给没有做足功课的同学介绍一下 JVM 和 GC 这两个名词。

JVM 的全称是 Java Virtual Machine,也就是 Java 虚拟机,它的主要作用是执行字节码文件(class)。不过要清楚的一点是,虽然它叫 Java 虚拟机,但它并不是只能运行 Java 语言代码所编译的字节码文件。就如前面说“主要作用是执行字节码文件”,所以无论是什么语言只要你的代码编译后所生成的文件格式符合 JVM 的规范,那么就能在 JVM 上执行。例如,Kotlin、Groovy、Scala等语言,编译后都可以运行在 Java 虚拟机中。另外,有一个叫做《JVM 虚拟机规范》的东西,根据这个规范谁都可以实现自己的 Java 虚拟机。所以在技术发展的历史长河中,出现过不止一款 Java 虚拟机,像 Classic VM、Exact VM、HotSpot VM。其中 HotSpot VM 也是我们最熟悉、最常用的一款 ,后面我们提到的 Java 虚拟机也都默认为 HotSpot VM。

那 GC 是什么呢?GC 的全称是 Garbage Collect,即“垃圾收集”,记住不是大街上的“收垃圾”!弄懂“垃圾收集”之前得先搞明白 GC 和 JVM 是个什么关系?根据《Java 虚拟机规范》的规定,它所管理的内存会被划分为几个不同的区域(详情参考《Java内存区域与内存溢出异常》这篇文章)。这几个区域中有两个区域,一个叫“堆”,一个叫“方法区”(JDK 8之前)。由于这两块区域的内存分配与使用不确定性较大,且堆占用的内存也特别大,所以《Java 虚拟机规范》会要求虚拟机对这两块区域实现“垃圾收集”。不过因为方法区具有特殊性(存储内容导致进行回收的性价比较低),对方法区“垃圾收集”的实现不做强制要求,但是一定要实现堆上的“垃圾收集”。所以,接下来我们要说的就是堆上的“垃圾收集”。

new 关键字

写过 Java 的同学,大都知道通过 new 关键字来创建一个对象。由于高级语言把底层工作做的实在太好,导致大多数同学并不了解 new 背后的细节是什么?所以,在这里我们得先清楚 JVM 在“看”到你的 new 关键字后会执行哪些操作?首先进行类加载检查,往后依次是分配内存初始化零值设置对象头执行 init 方法。由于写这篇文章不是为了把 JVM 讲解的面面俱到的,所以对于上面的几个步骤不做详细介绍了。我们看到第二个步骤就是分配内存,这才是我们关注的重点。这里所进行的分配内存,则会从堆上划分一块空间用于创建后的新对象的使用。(注:有两种分配方式,空闲列表和指针碰撞)

看到这里,大家应该明白创建一个新对象的时候是会占用堆上的一部分空间的,即使所占用的空间很小,总归是占了。毕竟机器上的内存空间是有限的,不可能说让你无休止的只用不还。所谓,有借有还,再借不难嘛。举个例子,在常见的 Web 项目中,伴随着用户的一次 Http Request 可能就会创建许多对象,但用户在收到期望的 Http Response 后,中间创建的一些对象基本就不会用到了。在 Java 编程中并没有要求程序员负责自己所创建对象的回收,所以这部分工作就交由了 JVM 虚拟机来负责。而这里说要回收的对象,也就是前面提到的“垃圾收集”中的垃圾。

如何判断对象是否需要回收?

“有借有还,再借不难”,但是如何判断是否把已分配给对象的内存还回去呢?其实就是要判断对象还有没有存在的价值,没有的话就得赶紧把地方“腾倒”出来。如果你创建的对象,孤零零的没有任何地方引用它也就可以认为应该结束它的“生命”了。基于这一原则,出现了引用计数算法。简单来说,就是在对象中设置一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一。所以只要发现某对象的引用计数器为零,那么就认为它是不可能被再使用的,它在堆中所占有的内存就可以被还回去了。聪明的同学肯定一眼就看出来了这个算法的缺点。那就是当两个对象存在互相引用的时候,会导致它们的计数器都不为零,也就会变成“长生不老”的对象。所以,JVM 并没有采用这种算法。

除引用计数算法外,还有一个可达性分析算法。这个算法的基本思路是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,那么就证明此对象没有存在的价值了。算法并不复杂,其中 GC Roots 的选取比较值得注意。在 Java 技术体系中,常作为 GC Roots 的有以下几种:虚拟机栈中引用的对象;方法区中类静态属性和常量引用的对象;Java 虚拟机内部的引用等。当然也不止上述对象,JVM 会根据不同的垃圾收集器和收集区域动态调整 GC Roots 集合。

至此,我们知道了为什么要进行 GC ?主要在 JVM 所管理的内存的哪个区域进行 GC?以及如何判断对象是否可以被回收?(仔细想一想,回答不上来就得再翻到前面好好看一下了

分代收集

那接下里应该准备做什么啊?既然知道如何找到需要回收的对象,肯定是该琢磨着怎么样回收。

先别着急着说“回收不就是把分配出去的内存收回来啊”。要知道,在计算机的世界里有一些规矩一是讲究高效率做事;二是希望既想马儿跑,又想马儿少吃草。所以,JVM 中实现的垃圾收集器在工作时占用的资源要尽量少,切勿本末倒置;另外收集过程应尽量减小对用户线程的影响,保证用户线程能够高效工作,从而带给用户良好的体验。

基于上述的要求,聪明的人们开始思考应该怎么样更快、更好的 GC。这里首先会介绍一个“分代收集”理论,或许有些突然但是并不难理解,是人们为了更好的实现收集器提出的。分代收集依赖两个分代假说:一是弱分代假说,绝大多数对象都是朝生夕灭的;二是强分代假说,熬过越多次垃圾收集过程的对象越难被回收。仔细想想,是不是也挺合理的。常用的几款经典垃圾收集器就将 Java 堆划分为了不同的区域,根据对象年龄分别存储不同区域。

有好奇的同学可能会说“我不划分区域”又能怎么样呢?

你这么想一下,假如不划分区域就相当于所有对象都在这么一块内存上存储着。当开始收集的时候,肯定要进行标记(不同的收集器标记策略不同,不过按当前经典垃圾收集器的策略总会在某个标记阶段暂停所有用户线程的)需要回收的对象,在标记过程时需要暂停访问此内存区域的所有用户线程,这个“一刀切”的策略固然简单,但却不是最好的。假如,根据各对象的年龄分别存储在不同的区域,“朝生夕灭”的对象放在一个区域,“年龄大”的对象放在一个区域。这样针对不同的区域可以使用不同的策略来回收,对于“活跃”区域的收集频率可以高一些,针对某个区域 GC 时也并不会影响用户线程访问其它区域等。

在商用的 Java 虚拟机中,一般都至少会把 Java 堆划分为新生代(Young Generation)老年代(Old Generation)两个区域。新生代,就是前面我们说的存放“朝生夕灭”的对象的区域。当然每次在新生代区域回收过后,达到一定“年龄”的对象则会被放到老年代中。现在,我们可以来分析这两块区域各自的特点了。

在新生代上的 GC 被称为 Minor GC/Young GC,由于此处的对象不易“存活“,从而在一次收集过后应该会产生大量的空闲内存。在老年代上的 GC 被称为 Major GC/Old GC,每次收集可能只能回收很少对象。除了会单独在新生代和老年代上收集,在内存严重不足时还可能会触发整堆收集(Full GC),要尽量减少 Full GC 的出现。

垃圾回收算法

既然知道了为什么要对 Java 堆划分区域以及各区域的特点,我们可以认识一下三种“垃圾回收”算法。你也可以认为是“垃圾回收”的方法论。

“标记-清除”算法

“标记-清除”算法,标记指的则是标记对象(标记待回收或非回收的都行,只要能区分出两类对象即可),清除则是把无用的对象从内存中清除掉。可以看出,这是比较简单的一种策略,先发现后清除。不过,这种算法会产生许多内存碎片,由于过多内存碎片的存在可能在出现较大对象时无发分配从而导致再次触发一次垃圾收集动作;而且随着对象数量的增加标记和清除的时长也会增加。

“标记-复制”算法

“标记-复制”算法,标记不做过多介绍了。说一下复制策略,这个算法是将内存空间分为两部分,各占一半。但是只给 JVM 使用一部分的内存,另一部分空着。另一部分什么时候用呢?就是在使用的这部分内存上发生 GC 时,把未标记的对象(存活)全都复制到另一部分空闲内存中并放到一起,然后在把之前使用的那部分内存数据全都清理掉,轮换着使用两块空间。其实这是以牺牲空间为代价,来解决的内存碎片问题。这种方式对于内存分配也特别友好,如果不在乎空间浪费的问题,这似乎是个特别好的方法。不过,在“寸内存,寸金”的计算机中,怎么可能不在乎空间!所以,有人提出了优化版本的算法——Appel 式回收。改进后的版本,并没有简单直接的将空间对等分为两部分,而是分为了三部分。Appel 式回收把新生代分为了一块较大的 Eden 空间和两块较小的 Survivor 空间,工作时只使用 Eden 和 其中一块 Survivor 空间(HotSpot VM 中默认 Eden:Survivor 为 8:1)。如果发生了 GC,那么就将 Eden 和 Survivor 中存活的对象复制到另一块空闲的 Survivor 空间中。改进后的算法大大减少了备用空间的大小,通过这种划分解决了十分浪费内存空间的问题。能够进行这种划分也得益于新生代每次 GC 会回收掉大部分对象的特点。不过,可能会出现备用空间 Survivor 过小,导致一次 GC 后放不下所有的存活对象的情况,所以还需要老年代做分配担保。这个算法就比较适合在新生代使用。

“标记-整理”算法

“标记-整理”算法,正如其名,在标记完成之后,先将所有的存活对象移向内存空间的一端,然后再清除边界以外的内存。通过在标记后对内存的“整理”动作,从而避免了“标记-清除”算法产生大量内存碎片的问题。算法示意图如下:

“标记-整理”算法示意图

经典垃圾收集器

“光说不练假把式,光练不说傻把式”,所以不能只讲理论层面的知识,接下来介绍几款垃圾收集器。总之,前面所讲的所有内容最终都是要为实现垃圾收集器服务的,毕竟收集器才是最终“干活”的。

经典垃圾收集器

看到上图,可能对你认识这几款收集器并没有太大作用。所以,还需要下面这幅图来帮助你理解和记忆。

HotSpot 虚拟机的垃圾收集器

Serial 收集器

Serial 收集器(标记-复制),从图中可以看出它是工作在新生代的收集器。由于“出生早”,所以策略也很简单,在进行 GC 时只有一个 GC 线程工作(单线程),而且还需要暂停其他所有的工作线程(Stop The World)。当然,也不能只看到它的缺点,目前它仍是 HotSpot VM 客户端模式下默认的新生代垃圾收集器,由于策略简单它内存资源消耗最少的收集器。在单核处理器的服务器中,由于没有线程交互的开销,正好可以获得最高的单线程收集效率。

ParNew 收集器

ParNew 收集器(标记-复制),作为 Serial 的多线程版本并没有带来更多的创新,通常与 CMS 配合工作。它默认开启的收集线程数与处理器核心数量相同,不过也可以使用 -XX:ParallelGCThreads 参数来设置GC 线程数量。

Parallel Scavenge 收集器

Parallel Scavenge 收集器(标记-复制),同样是工作在新生代能够并行收集的多线程收集器。它追求使 JVM 达到一个可控制的吞吐量(运行用户代码时间与运行用户代码加上执行GC实际的比值)。

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

为了进行有效控制,Parallel Scavenge 提供了两个参数来进行精确控制,一个是用来控制最大垃圾收集停顿时间的 -XX: MaxGCPauseMillis 参数;一个是用于设置吞吐量大小的 -XX: GCTimeRatio 参数。

-XX: MaxGCPauseMillis 参数可以设置为一个大于 0 的毫秒数,收集器尽力保证每次GC所花费的时间不超过这个值。当然如果设置的过小,收集器为了保证不超过此值往往会频繁的进行 GC。

-XX: GCTimeRatio 参数可以设置为一个大于 0 小于 100 的整数。假设设置为 n,那么1/(n+1) 则是垃圾收集时间占总时间的比率,即系统将花费不超过总时间的1/(n+1)用于垃圾收集。(GC 参考文档中描述为“-XX: GCTimeRatio=nnn, The ratio of garbage Collection time to application time is 1/(1+nnn)”)。通过简单的公式推导也可以证明 1/(n+1) 为吞吐量的倒数。

另外,这款收集器还有一个优点是只要设置好基本的参数如堆大小,最大停顿时间,吞吐量,其它具体细节参数的调节可以由它本身完成,这是 Parallel Scavenge 的自适应调节策略。

Serial Old 收集器

Serial Old 收集器(标记-清除),看名字就能知道这是 Serial 的老年代版本,同样也是单线程工作。

Parallel Old 收集器

Parallel Old 收集器(标记-整理),通过与 Parallel Scavenge 收集器搭配工作形成“吞吐量优先”的收集器组合。在一些注重吞吐量或者处理器资源稀缺的场合,可以优先考虑此组合。

CMS 收集器

CMS 收集器(标记-清除)全称为 Concurrent Mark Sweep ,此处理器以获取最短回收停顿时间为目标。对于部署服务器上以网站形式提供服务的系统,可以采用此收集器从而给用户带来良好的交互体验。

CMS 的 GC 分为四个步骤:1.初始标记;2.并发标记;3.重新标记;4.并发清除。在初始标记和重新标记阶段会“Stop The Word”,不过这两阶段耗时都很小。虽然它具有并发收集、低停顿的优点,但同时对于处理器资源十分敏感。另外,在进行并发标记和并发清除时,由于同时用户线程也在执行所以还会产生新的待回收对象,这种对象称之为“浮动垃圾”。因为采用了标记-清除算法,同样也面临着内存碎片的问题。

Garbage First 收集器

Garbage First 收集器(标记-整理、标记-复制)也被称为 G1 收集器,其实这是一款与前面所有收集器划分内存区域的出发点都不同的收集器,它开创了收集器面向局部的设计思路和基于 Region 的内存布局形式。作为一款主要面向服务端应用的垃圾收集器,开发团队对于它的期望就是替代 CMS 。

G1 仍是遵循分代收集理论设计,但是没有直接把 Java 堆分成新生代与老年代这种布局。而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每个区域都可以根据需要扮演新生代的 Eden、Survivor空间,或者老年代空间。G1 对扮演不同角色的 Region 采用不同的策略进行处理。不过,在 JVM 中总会有一些大对象存在,所以还有一类特殊的 Region——Humongous 区域,用来存储大对象。对于大对象的定义是大小超过了一个 Region 容量一半的对象。G1 通过把管理的 Java 堆的粒度细化到 Region 大小,这样每次收集到的内存空间都是 Region 大小的整数倍。通过细化后,再配合相应的数据结构就能有计划的控制停顿时间来进行收集。

G1 的GC分为以下四个步骤:1.初始标记;2.并发标记;3.最终标记;4.筛选回收。如果机器有较大的内存空间可以使用,还是推荐使用 G1 进行 GC的。

其它收集器

有一些低延迟收集器包括 Shenandoah 收集器、ZGC 收集器。

总结

本文主要是讲了关于 JVM 中 GC 相关的知识,限于篇幅有些地方并没有进行详细介绍。所以,如果要认真学习相关知识还是需要阅读专门的书籍。另外,在附录中又补充了一些不太适合放入上文的知识点。

附录

关于引用

在正文中,由于文章结构原因并未对引用做过多介绍。所以,在附录里进行了补充说明。我们可以看到,在正文中出现了许多次“引用”这个词语。其实在 JVM 中,为了便于管理和进行区别把引用分为了四类,按照引用强度由强到弱的顺序排列分别是强引用 、软引用、弱引用、虚引用。

什么是强引用?举个列子,Object obj = new Object();这就属于强引用。只要引用关系还存在,垃圾收集器就不可能回收被引用的对象。

软引用则是用来描述一些非必须的对象,只有当系统在发生内存溢出之前垃圾收集器才会将软引用关联的对象回收掉。

弱引用的强度更低,一旦发生GC 被关联的对象就会被回收掉。

虚引用是最弱的一种引用关系,它对于对象的存活完全没有影响,它存在的意义是为能在这个对象被收集器回收是收到一个系统通知。

回收对象

当对象被第一次标记后,其实也不一定就要回收掉,还是有“起死回生”的机会的。第一次被标记后,随后还会再进行一次筛选,筛选的条件就是此对象是否有必要执行 finalize() 方法。如果需要执行,那么在执行 finalize() 时重新与 GC Roots 集合中的对象建立关联关系即能在第二次小规模标记时避免被回收。当然,如果对象没有覆盖 finalize() 方法,或者已经被调用过 finalize() 方法那么它就不可能“起死回生”了。

参考资料

[1] 《深入理解 Java 虚拟机》

发布者

Avatar photo

常轩

总要做点什么吧!

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注