垃圾回收算法进化史
如果你写过 C 语言,应该会对 malloc、free 等函数有印象,堆内存的申请和释放都需要开发者手动调用这些内存分配函数,即使是到了 C++(非托管),也还是要通过 new 和 delete 进行手动的内存管理,这样导致开发者在进行内存分配时不得不十分谨慎,万一申请的内存忘记释放,便造成了内存泄露。加之内存泄露问题的定位困难(申请内存的地方众多)、不容易复现(通常内存泄露是一点一点累积的,需要较长的时间才会出现内存大量占用,有时甚至只在高并发或异常情况下才会发生)、难以用临时方案解决(如果是性能问题,可以临时通过扩容来解决,而内存泄露基本只能定时重启服务)并且危害极大(内存占满时服务基本也挂了)……可见开发者苦内存释放久矣。
这件事本身并不复杂,只是在不需要某块内存空间时,手动调用函数进行释放即可。但由于申请内存的地方众多,由人来做难免出现纰漏。如果可以把这个释放内存的操作变成自动的,那么开发者只需申请内存,释放则由编程语言运行时自动完成,这样就可以将开发者从内存释放中解放出来,也降低了发生内存泄露的风险。
那么要怎么实现呢?第一个要解决的问题就是:怎么判断一个内存空间,或者说一个对象是不是“不再需要”?
一种思路是"引用计数"——例如 Python 就使用这种算法。即创建对象时,将计数设置为 1,以后每增加一个新的引用(例如一个方法局部变量引用了它)就将计数 +1,反之如果减少一个引用(例如一个方法执行完了,其中定义的局部变量的引用也就消失了)就将计数器 -1,当计数器变成 0 时,说明没有任何代码能引用这个对象了,它也就成了内存垃圾,可以被回收。
这种方式的原理和实现都很简单,性能也很好,然而存在一个问题,那就是循环依赖。例如方法里创建的局部对象 a 和 b,让 a.aa = b, b.bb = a,这样 2 个对象都因为存在引用而计数器始终为 1,但其实当方法结束后,这两个对象成了孤岛,已经不可能再被调用到了,这样也就造成了内存泄露。
另一种实现算法是"可达性分析"——例如 Java 和 Go 就是使用这种算法。试想,我们的代码想要访问某个字段或者调用某个方法,必然只能是全局变量、当前执行的方法的局部变量、方法入参或(面相对象语言的)类成员字段等,以及这些对象直接或间接引用的对象,其他对象都是我们的代码无法访问到的,也就是需要被回收的垃圾。
这种算法可以解决循环依赖的问题,当然实现起来也会更复杂,消耗更多的资源。
既然引用计数无法解决循环引用的问题,那么使用这种算法的 Python 岂不是会因为循环引用发生内存泄露?
实际上,Python 只是对 int、float、str 等简单类型使用引用计数,对于可能出现循环引用的 list、dict 等类型仍然使用可达性分析算法。
找出内存垃圾后,下一个要解决的问题就是怎么清理这些垃圾。
直接原地删除?这种方式称为“标记—清除”,实现很简单,可以用较小的代价、尽快地释放不需要的内存。
但却带来一个问题:图中将 B 和 D 原地删除后,总的空闲容量是 3,但当要在这个内存中分配空间来存放大小为 3 的对象却是做不到的,因为对象的存储空间必须连续,而内存中已经没有足够大的连续空间。更极端的情况,假设空间总的大小为 100,所有下标为奇数的位置都被占用,偶数位置都是空闲,此时总的空闲空间是 50,却无法提供空间给一个大小为 2 的对象。这种问题称为内存碎片,会导致内存利用率低下。
我们似乎可以将垃圾回收的工作提前,不要到内存快满的时候才进行,而是一有垃圾就立刻回收,这样新申请的内存可以立刻分配在刚刚释放的地方。假设对象的申请和对象的释放交替进行,新申请的对象马上分配在刚回收的空间上,那么上面那个极端的例子就会变成所有对象都集中在前半部分,后半部分是一个大小为 50 的连续内存,也就不存在内存碎片的问题。
然而,且不说频繁地进行垃圾回收对性能造成很大影响,即便能这么做,也无法保证内存的分配和释放是这么完美地“配合”的(一次回收后紧接一次申请,且申请和释放的大小都一样),很多时候业务逻辑上就是会出现只有内存快要满的时候,程序逻辑上才存在可以回收的对象。因此这种方式无法根本上解决内存碎片问题。
一种比较容易想到的解决方法是,在每次清理完对象后,我们将还存活的对象复制到其他地方,让它们重新“紧凑”,下一次垃圾回收时又以同样的方式复制回之前的地方,如此往复,这种算法称为“标记—复制”。
这种方式解决了内存碎片的问题,但也要求我们腾出一半的空间不使用(使用第一块空间时,第二块空间是无法使用的)。因此有了另一种方法,不再将存活的对象复制到其他内存块,而是直接将所有存活对象往最前面对齐,例如数组下标 2 被清理了,就将 3 开始的所有元素向前移动一格,这种方法称为“标记—整理”。这样减少了一些不必要的复制(例如 0~20 的对象都在一次垃圾回收后存活,用之前的方案需要将这些对象原封不动地复制到另一块,而现在只需要移动空洞后面的对象),也不再需要腾一半的空间作为复制目的地了。
然而这种“标记—整理”的方法在实际中用得并不多,原因是相对“标记—复制”方法来说,它的标记和整理阶段不能并行。也就是说只有把整个内存空间遍历完成后,才能确认哪些对象是可以回收的,此时才能使用存活的对象覆盖需要被回收的对象。这样无法利用多核多线程的优势,必然成为整个垃圾回收的效率的瓶颈。而“标记—复制”的方式则没有这个问题,每当遍历到一个对象,也就确认了这个对象还存活,可以立即将这个对象复制到另一块内存,标记和复制的过程可以并发进行。
如此看来,“标记—复制”有必须牺牲一半内存空间的问题,“标记—整理”有无法并行的问题,有办法同时解决这两个问题吗?
答案是肯定的,JVM 就使用了这样一种方案:将内存分为 3 个区域,一个 Eden 区和 2 个 survivor 区。其中两个 survivor 区(称为 from 和 to)就和“标记—复制”算法的两个内存区一样,只有其中一个(from 区)在使用,不同的是所有的新对象都分配在 Eden 区,进行垃圾回收时,将 survivor 区和 Eden 区所有存活对象都复制到另一个 survivor 区(to 区),如此反复。由于是“标记—复制”算法,我们可以对标记和复制做并发处理;同时,尽管任何时候 2 个 survivor 区中的一个必然无法使用,但我们可以将 survivor 区的空间设置得小一些(当然也不能过小,这样会无法存放所有存活对象),例如 JVM 默认将 Eden 区和 2 个 survivor 区的大小设置为 8 : 1 :1,这样被“浪费”的空间(1/10)也会比原来的“一半”要少很多。
这样就尽善尽美了吗?并不。当我们的程序达到稳定时,经常会有一些对象是长期存在的,甚至伴随着整个程序的生命周期,例如一些全局变量、静态变量、框架类等。这些对象会在每次垃圾回收时从一个 survivor 区被复制到另一个区,当这些对象占用的总内存非常大时(这是一般应用程序的常见现象),甚至出现垃圾回收的大部分时间都消耗在这些对象的复制上。这样看来,复制算法能有较好性能的一个前提是:每次进行垃圾回收时,存活的对象是少数,即大多数对象是创建后不久就会销毁的,这样需要被复制的对象也是少数。对于长期存活的对象,并不适合这种算法。
本质上来说,我们是需要对生命周期很短对象和长期很长的对象分别管理,这种方式称为内存的分代管理。
划分两个空间分别存放生命周期长(老年代, Old Generation)和生命周期短(新生代, New Generation)的对象是很简单的,但是在分配对象内存时怎么知道它的生命周期是长还是短呢?
可以简单地提供两种内存分配 API,让程序员来决定要把对象分配在哪个区,但是这样又给程序员带来了额外的工作量,申请内存时不得不考虑这个对象的生命周期长短,另外随着需求的变更,一个“短对象”可能转而变成“长对象”,反之亦然,这又导致内存管理效率低下。
实际上有自动化的方式实现,例如对所有的对象都首先假定其生命周期很短,并将其分配在新生代,此时将对象的年龄标记为 0,每经过一次垃圾回收,若对象依然存活,就将其年龄 +1,等到对象年龄达到一定大小(例如 15),说明对象已经存活了好一段时间了,此时我们可以认为这个对象是生命周期很长的对象,并将其移动到老年代(这个移动过程称为晋升)。
这种方式有一个比较棘手的情况,那就是大对象,如果它们的生命周期很长,将它们分配在新生代的话,需要经过很多次复制,达到晋升年龄才能被移动到老年代,这之前的内存复制将消耗大量资源。因此垃圾回收器通常会对这些大对象做特殊处理,在其达到晋升年龄之前就直接移动到老年代。怎么判断一个对象是大对象呢?同样可以采用一些自动化的方法,例如当对象比所有对象大小的平均数的 2 倍还大时就认为是大对象;也可以让程序员根据自己应用的实际情况去设置超过多大的对象是大对象,例如 JVM 就提供了 -XX:PretenureSizeThreshold 等参数进行相关设置。
设置了评估后比较合理的 Survivor 区后,运行时仍然发生一次 GC 后的存活对象无法全放进 Survivor 区时怎么办呢?
在 JVM 里,此时会将这些存不下的对象临时放到老年代(老年代通常比新生代大得多,毕竟大多数程序都是长生命周期的对象占多数。如果老年代也放不下,那就只能抛出内存溢出的错误了)。这些用于临时存放新生代对象的老年代区域,仍然属于老年代,等到新生代存活对象减少到可以全部放在 survivor 区时,这些空间依然会还给老年代。当然如果长期出现需要占用老年代空间的情况,也许要考虑一下是不是 Survivor 区大小设置不合理。
这种方式让 JVM 更健壮,不至于在内存充足的情况下,仅仅因为新生代(或者说 Survivor 区)空间不够而出现内存溢出错误。
为了做分代管理,我们引入了新生代和老年代的概念,新生代的垃圾回收方案已经讨论过了,老年代的垃圾要怎么回收呢?
在我们的设想里,老年代的对象都是生命周期较长的,因此我们不会频繁去尝试回收这部分对象。大多数情况下我们只会对新生代进行垃圾回收,这种垃圾回收称为 Minor GC。得益于新生代相对较小,短生命周期的对象较多,因此垃圾回收比较迅速,收益较大(有机会回收较多的内存)。但是当内存空间将满时,就不得不进行整个堆的垃圾回收(包括新生代和老年代),这样的垃圾回收称为 Major GC 或 Full GC,由于整个堆的空间通常很大,对象众多,回收的收益不确定,所以一次 Major GC 通常比较耗时。频繁的 GC (特别是 Major GC)或多或少会对用户程序造成影响,应该尽量避免。
GC 对用户程序造成的影响具体是什么呢?以 JVM 为例,负责垃圾回收的 JVM 和用户代码是在同一个进程里运行,进程是资源分配的最小单位,因此 JVM 和用户代码自然要共享操作系统提供的内存、CPU 、硬盘 IO 等资源,一个更为明显、更常见的、我们通常也更关注的问题就是垃圾回收造成的用户代码的停顿,加大程序的响应延时。
可是我们都知道如今的 CPU 大多是多核,可以真正地实现线程的并行,垃圾回收线程和用户代码线程应该并行才对,怎么会造成用户线程暂停呢?
实际上为了保证垃圾回收的正确性和效率,在垃圾回收时是需要停止用户线程的,这个停止用户线程的操作称为 STW(Stop The World)。例如当垃圾回收线程标记完所有存活对象后,用户线程运行产生了新的垃圾对象,但这些垃圾对象要等到下一次垃圾回收时才能被清理,这个问题(在 CMS 回收器中)称为浮动垃圾;更严重的情况是,在可达性分析完成后,用户线程创建了新的对象,这些对象不是垃圾,却因为没有被垃圾回收线程标记,导致被当成垃圾回收……
可见,垃圾回收时暂停用户线程,这是目前不得不做的事情。然而延迟敏感型应用却是不能接受较长时间延时的,例如现今最常见的各种 Web 系统,这些系统都需要跟用户交互,较长的延时很影响用户体验,因此怎么降低垃圾回收带来的延迟,常常是垃圾回收器需要的重点考虑的问题。
首先能想到的就是并行和并发。尽管垃圾回收时不得不暂停用户线程,但是我们可以使用多线程来进行垃圾回收工作,例如上面就提到,使用“标记—复制”算法,可以并行地进行标记和复制工作,利用多核优势,让垃圾回收的工作完成得更快,这样 STW 的时间自然也就降下来了;JVM 中的 Parallel 收集器就是使用多个线程来进行垃圾回收,相比使用单线程做垃圾回收的 Serial 回收器就能更好地利用多核优势。
我们也可以通过设计垃圾回收算法,将垃圾回收整个过程分为多个阶段(而不只是标记和清除),这些阶段中能够与用户进程并行的阶段全部和用户线程并行,从而减少整体需要 STW 的时间。
例如 JVM 中号称可以与用户线程并行的垃圾回收器 CMS(Concurrent Mark Sweep)就是这样的一个代表。
它具体是怎么做到的呢?首先从名字可以看出,它使用的是“标记—清除”算法,原因也很好理解:它是老年代的垃圾回收器(因此实际生产中还需要搭配一个新生代的垃圾回收器),而老年代对象的特点是数量多、生命周期长,且老年代的空间通常较大,使用“标记—复制”或者“标记—整理”的方式很容易出现大量的对象复制,而“标记—清除”的方式效率更高,能减少 Full GC 的延迟。当然前面也提到这种方式会带来内存碎片问题。当剩余空间无法满足分配要求时,会抛出 Concurrent Mode Failure,接着 STW 并进行一次整理(通过另一个无法与用户线程并行的老年代垃圾回收器 Serial Old 实现),这种情况会导致突然的用户线程长时间停顿。因此 CMS 提供了参数,可以设置经过多少次 Full GC 后就主动进行一次“标记—整理”算法。
其次它将将垃圾回收分为初始标记、并发标记、重新标记、并发清理 4 个阶段。初始标记为枚举 GC root,此时需要 STW ,但由于不会往下遍历(或者说只遍历一层,只是把全局变量、方法等参数记录下来,不会寻找其引用的对象),因此造成的延时很小;接着并发标记阶段与用户线程并行,从 GC root 开始标记所有可达对象;然后重新标记阶段需要 STW,该阶段用于修正用户线程并行导致的某些对象状态变化,这个过程的工作量和延时与用户并发修改的对象引用数量有关;最后并发清除阶段与用户线程并行,直接清除垃圾对象。
CMS 通过将垃圾回收过程拆分为多个阶段的方式,尽可能让用户线程与垃圾回收器并行,从而降低 GC 延迟。
怎么实现并发的对象遍历却不会出现并发问题(存活的对象被清除)?
这里不得不提到一个可达性分析的遍历算法——三色标记法,即将对象分为白、灰、黑 3 种颜色。
一开始所有对象都是白色,表示该对象没有被遍历过;初始标记阶段将把所有 GC root 对象标记为灰色,表示该对象已经被遍历到,但持有的引用还没有遍历完;并发标记阶段将从所有灰色对象开始,遍历其持有的引用,当一个白色对象被遍历到,将转变为灰色,当一个灰色对象的所持有的引用都遍历过后,它就转变为黑色对象;并发清除阶段就是清除没有被遍历到的白色对象。
三色标记法(或者说 CMS 收集器)为什么还需要一个 STW 的重新标记阶段?
并发标记阶段是和用户线程并发执行的,这个过程可能出现如下问题:
1. 部分垃圾对象没能被回收。例如用户线程并发将一个黑色对象的唯一引用去除了,但该对象已经被标记为黑色,不会在本次回收中被清除,也就形成了浮动垃圾。这种问题相对影响较小。
2. 存活对象被当成垃圾回收。现在有一个灰色对象 a 引用了白色对象 b,用户线程并发地让一个黑色对象 c 引用了 b,同时去除对象 a 对 b 的引用,因为对象 c 还引用 b,所以对象是存活的,但由于 c 已经是黑色对象,不会再遍历它的引用,所以 b 会保持白色状态并被当成垃圾清除。这是极度危险的情况。
为了解决上述问题,必须增加一个重新标记阶段,在 STW 的前提下将并发标记阶段发生了变化的对象再遍历一遍,确保判断对象是否存活的正确性。
对象引用是由用户线程修改的,垃圾回收线程怎么判断哪些对象是需要重新遍历的?
这里需要用到写屏障技术(底层是通过 putfield 字节码对应 cpp 文件实现,不同的垃圾回收器会走不同的写屏障分支),修改一个黑色对象的字段时,会将其重新变回灰色对象,并在重新标记阶段再次遍历,也就避免了存活对象被当成垃圾回收。
CMS 仍然有一个问题,那就是垃圾回收一旦开始,就必须完成整个堆(的老年代)的回收,在堆比较大时,即使是标记 GC root 和重新标记阶段也会有较长的耗时;另外也需要程序员评估自己的应用情况,设置合适的参数避免 Concurrent Mode Failure 问题,在一些流量高峰时刻仍然可能突然创建大量请求对象,此时内存碎片问题导致不得不 STW 进行内存整理,从而出现较大延迟,而延迟又加剧了请求的堆积,导致内存占用持续变大,延迟越来越大。
另外我们发现一次 Full GC 的延迟是不可控的,我们很难找到堆内存大小、服务 qps 等参数与实际的 GC 延时的对应关系。
有没有办法同时做到:
1. 尽可能早地进行内存整理,避免内存碎片
2. 不会因为堆变大而 Full GC 变长
3. 可以较精确地设定预期的 GC 延时
答案是肯定的。其实出现这几个问题的本质问题在于堆特别大的时候,每次要等到整个堆都完成垃圾回收后用户线程才能继续。
想象饭店的场景:饭店里有很多桌子,新来的客人都会安排到一个桌子,当客人离开时,就会马上清理他用过的桌子并给后来的客人使用,这就像是“标记—清除”算法。同样当饭店桌子满了,后面的客人也需要等待 (STW)服务员清理桌子(Full GC),但实际上饭店不会等到整个饭店的所有桌子都清理完,而是每清理完一个桌子,就会将这个桌子重复利用,接待下一个客人,这样大大减少了每个客人的等待时间。
JVM 中用于取代 CMS 的 G1 回收器就是基于这样的思想来实现。首先将整个堆分为很多固定大小的连续内存块,对象被分配在某些块中。每次垃圾回收将只对某些 Region 进行,此时使用的是“标记—复制”算法,将一个 Region 中的存活对象复制到另一个空闲的 Region 中,接着将之前的 Region 标记为空闲状态。
因为是使用“标记—复制”算法,因此可以让标记和复制过程并发,且避免了内存碎片问题,前面提到新生代最合适的垃圾回收算法也是“标记—复制”算法,因此 G1 是一个可以独立负责新生代和老年代的垃圾回收器。
更重要的是,G1 支持通过参数来设置最大停顿时间(这是个软实时参数,即不保证一定小于,只是尽可能保证),之所以能实现这样的控制,是因为垃圾回收的目标,从整个堆细化到 Region,无论堆多大,Region 的大小都是固定值,G1 只需在设定的停顿时间里回收尽可能多的 Region 即可,如果这次 gc 回收 10 个 Region 后略微超出设定的时间,那么下次则尝试少回收一个 Region,反之亦然。因为每次 GC 的停顿是可控的,我们可以让 GC 更频繁地进行,尽早回收垃圾对象而不用担心出现长时间延时,从而减少用户线程因无法分配内存而不得不等待垃圾回收线程的风险。
另外划分 Region 后也让新生代和老年代不再需要物理上连续,每个线程可以独自认领一块或多块 Region,需要进行内存分配时优先从自己占领的 Region 获取,相比每次都从全局申请内存的方式,在独立 Region 分配内存没有其他线程竞争,也就不需要同步和加锁,提升了内存分配的性能,这种优化称为线程本地分配缓冲(TLAB, Thread Local Allocation Buffer)。
既然 G1 可以精确设定延时,那是不是设置得越小越好?
并不是。其实 G1 的低延时是用吞吐量来换取的,如果将延时设置得特别小,例如小到 1 个 Region 的回收都要多次暂停 GC 才能完成,徒增了很多线程切换和唤醒线程的成本,甚至导致垃圾迟迟不能得到回收,垃圾越堆越多,最后因为内存不够,用户线程还是不得不等垃圾回收的完成。
但是这里有几个新的问题,怎么知道一个 Region 里的对象是否存活呢?又怎么选择本次垃圾回收要处理的 Region 呢?
为了避免全堆的扫描,并在设定的延迟里回收尽可能多的垃圾,G1 对每个 Region 维护了一个 Remember Set(RSet),其内容是(多条)哪个 Region 有对象引用了当前 Region 里的对象,每次通过扫描 Rset 记录的那些 Region 来确认当前 Region 里的对象是否存活。在并发标记阶段完成后,就可以知道每个 Region 的垃圾对象占比是多少,此时会优先选择回收价值最高的 Region(例如 100% 都是垃圾)。
虽然 G1 通过使用“标记—复制”算法解决了内存碎片的问题,但是也带来了新的延迟。首先回忆一下,CMS 使用的是“标记—清除”,只需要原地标记某个对象已经删除即可,而“标记—复制”算法,其标记和复制过程是可以并行的,因此它可以用多线程进行垃圾回收,但它是否可以和用户线程并行呢?
先考虑怎么复制一个对象?如果是先从 from region(被垃圾回收的 region)删除旧对象,然后在 to region(存活对象将被复制到的 region)创建该对象,那么删除后如果用户线程立刻访问该对象,会发现已经找不到对象,这是不能接受的;如果先在 to region 复制出一个新对象,那么在 gc 线程完成对象复制以后、GC 线程修改所有旧指针到新对象之前,用户线程如果并发修改了旧对象,这个修改会随着旧对象被删除而永久丢失;同样如果写操作修改了新对象,而读取时没有重定向到新对象,也会发生不一致问题。
因此为了保证结果的正确性,G1 不得不在进行存活对象的复制时 STW。
这个问题并不是不能解决,例如 JVM 最新的 Shenandoah 和 ZGC 回收器就给出了各自的解决方案。
Shenandoah 的解决方案是引入一个 Brooks Pointer,即在对象的对象头中存储一个转发指针,平时这个对象总是指向自己,进行垃圾回收时则指向 to region 中的那个复制出的新对象。这样无论是读还是写一个正在进行复制的对象,都会先检查其转发指针的值,并最终将读写操作重定向到新对象。当对象复制完毕,并且所有旧对象的引用都更新为新对象时,就可以完全清除旧对象。
读写对象是用户线程的操作,垃圾回收器线程怎么对这些操作进行拦截和重定向呢?这个问题似曾相识,没错,这里也是通过读写屏障来实现的。这样使得对象复制过程可以与用户线程并发,但因为读写屏障的大量使用(尤其是发生在对象读取时的读屏障,每次都需要多了一次寻址操作),导致应用程序整体的吞吐量有一定的下降。
ZGC 的解决方案则更加巧妙,不仅解决了复制时的停顿问题,还将 GC 屏障对吞吐量的影响将到了最低。ZGC 使用的是染色指针技术,即在对象的指针上(而不是对象头)使用 4 个比特位用于对象状态的标记(64 位机器下指针为 64 位,但是由于硬件限制,高 18 不可用,ZGC 在剩下的 46 位中取 4 位用于染色指针,剩余用于取址的位是 42 位,即 ZGC 最大可管理 4TB 大小的堆)。接着使用读屏障(load barrier)确保每次加载的都是该对象重定向后的结果,即读取对象的字段时,将被读屏障捕获,检查对应的指针中相应标志位是否被标记,如果没有则直接返回原地址,否则必须到一个全局的转发表里找出对应的新对象的地址并返回。
与 Brook Pointer 不同的是,而染色指针可以直接从指针上判断对象状态,确定是否需要重新寻址,即只是遍历指针,而Brook Pointer 必须先从指针寻址到对应的对象,接着从对象头中读出 Brook Pointer,再重定向到转发指针对应的地址;同时当 ZGC 发现一个指针需要重定向时,会将这个指针修改为新对象的地址,这样下次使用指针就不再需要进行地址转换,这个过程称为染色指针的“自愈”。
另外,因为ZGC 把新旧对象的转换关系放在全局的转发表里,且染色指针有自愈能力,因此当 ZGC 完成存活对象的复制后,旧 Region 就可以立刻回收(而 Brooks Pointer 的方案则不得不保留原 Region,直到 GC 将整个堆所有的旧指针都修改为新地址);同时 ZGC 只为正在重分配的对象维护全局转发表(而 Brooks Pointer 是每个对象都要维护的),使得 ZGC 在这一点上可以使用较少的内存。
当然,染色指针技术也是有代价的,因为它自定义了指针的高比特位,所以无法将指针压缩为 32 位,从而无法使用指针压缩技术,所以使用 ZGC 通常会有较大的内存占用。
最后,尽管 ZGC 是 JVM 最新的垃圾回收器,但并不代表它就是“最好”的垃圾回收器。Serial 回收器作为单线程的垃圾回收器,拥有最少的内存占用,特别适合单 CPU 场景,例如一些嵌入式场景;Parallel 则拥有最好的吞吐量,多线程的方式可以充分地利用多核 CPU 资源,很适合一些注重吞吐量而不在意延时的批处理场景;CMS 使用的是“标记—清除”算法,在不考虑内存碎片的情况下拥有更高的性能,并且清除时不需要暂停用户线程,因此适合有大量长生命周期对象或老年代非常大(以至于内存碎片几乎不会导致没有连续内存可以分配)的场景;G1 是一种吞吐量和延时均衡的垃圾回收器,在 CMS 的基础上以略微降低吞吐量的方式减少发生 Concurrent Mode Failure 的可能性,并提供自定义 GC 暂停时间的能力;Shenandoah 和 ZGC 也是进一步用吞吐量换低延时,且实现了 GC 停顿时间与堆大小无关,非常适合对延时要求很高的场景,例如用户交互系统的服务端、一些延时敏感的中间件;实际上 JDK10 开始 JDK 还提供 Epsilon 这样一个不进行垃圾回收的垃圾回收器——也许称之为内存分配器更合适,即使是这样一个简单的、“不干活”的收集器,在需要剥离垃圾回收器影响的性能测试和压力测试场景,以及某些负载极小、运行时间也很短的场景下,也是最好的选择……
因此不要不假思索地选择最新或停顿时间最短的垃圾回收器,选择当前平台与环境下最能满足需求的收集器才是最佳实践。
米来的岁岁年
#Java##jvm##技术栈##面试##面经#