从看 JVM 垃圾回收,看到了残酷的现实!
我正在参加「掘金·启航计划」
诗酒趁年华。
前言
大学那会我读过《深入理解Java虚拟机》,看得云里雾里,抓不到本质的东西,其实是没有理解垃圾回收的来龙去脉。
于是当我有了一定工作的浅薄履历,再看相关JVM的资料,再回过头去看一看书。
才发现,咦,有点意思!
因为GC就完成两件事情:
-
判断出垃圾对象。
-
释放出垃圾对象占用的内存空间。
那么问题来了,怎么来定义垃圾?
先来看第一件事,判断出垃圾对象,那么问题来了,怎么定义垃圾?
于是忍不住想,什么是垃圾?
会看到这样的定义:不能被任何途径使用的对象就是垃圾。
这句话不难理解,更简单的说就是没有价值的就是垃圾。(是不是也挺残酷的现实)
判断出垃圾对象-垃圾判断方法论
当弄明白了怎么来定义垃圾后,于是就来到了怎么判断出垃圾对象?
判断出垃圾对象有几种方式,但是它们都不是具体的实现方式,而是一系列方法论(算法)。
引用计数法
先是引用计数法,这个算法很简单,其实就是给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;当任何时候计数器为 0 的对象就是不可能再被使用的。
如下图所示,引用计数为 0,则为垃圾。
引用计数法方法 实现简单,效率高。 但是 JVM 主流垃圾回收器并没有使用引用计数法来管理内存,其最主要的原因是由于引用计数法的局限,它存在对象之间相互循环引用的问题。
对象相互引用问题
而所谓对象之间的相互引用问题,其实就是它们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC
回收器回收它们。
如下代码所示,除了对象 objA
和 objB
相互引用着对方之外,这两个对象之间再无任何引用。
public class ReferenceCountingGc {
Object instance = null;
public static void main(String[] args) {
ReferenceCountingGc objA = new ReferenceCountingGc();
ReferenceCountingGc objB = new ReferenceCountingGc();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
}
}
可达性分析算法
于是就来到了可达性分析算法,可达性分析算法不会出现对象间循环引用问题。
可达性分析算法也叫根搜索算法,其基本思想是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链 (Reference Chain
),当一个对象到 GC Roots
没有任何引用链相连时, 即该对象不可达,则说明此对象是不可用的,需要被回收。
如下图所示,GC Root
在对象图之外,是特别定义的 “起点”,不可能被对象图内的对象所引用。
如下图所示, obj 4
、obj 5
、obj 6
虽然互有关联, 但它们到GC Roots
是不可达的, 因此也会被判定为可回收的对象。
释放垃圾对象占用的内存空间-垃圾收集方法论
当我们经过判断出垃圾对象后,就来到了第二步,释放出垃圾对象占用的内存空间(将垃圾给清理掉)。
释放出垃圾对象占用的内存空间有多种方式,同样,它们都不是具体的实现方式,而是一系列方法论(算法)。
标记-清除算法
我们先来看最基础的垃圾收集算法,“标记-清除”(Mark-Sweep)
算法,该算法顾名思义,被分为 “标记” 和 “清除” 阶段,是在 1960 年由 Lisp 之父 John McCarthy 所提出。
标记-清除算法,顾名思义分为两个大步骤:
标记 后 清除。
标记(Mark):将判断出垃圾对象的(也就是所有需要回收的对象标记)。
清除(Sweep):在标记完成后,统一回收掉所有被标记的对象。
如下图所示,首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。
但标记-清除算法会带来两个明显的问题:
- 效率问题: 执行效率不稳定,如果堆中包含大量对象,而且其中大部分是需要被回收的,这时就必须进行大量的标记和清除的动作,导致标记和清除两个过程的执行效率随着对象数量的增长而降低,也就是执行效率和对象数量成反比。
- 空间问题:内存空间的碎片化问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当程序运行过程中需要分配较大对象时,因无法找到足够的连续内存而不得已提前触发另一次垃圾收集动作。
(标记清除好比将地上的垃圾一个个捡起后再扔掉)
总结,效率低,又整理得不干净。
但是,它却是最基础的收集算法,后续的算法都是对其不足进行改进得到(后面所介绍的两种算法都是基于此改进而来)。
标记-复制算法
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969 年 Fenichel 提出了一种称为 “半区复制”(Semispace Copying)
的垃圾收集算法。
也就是 “标记-复制”(Mark-Copy)
收集算法出现了,也被简称为复制算法。
其思想是这样,将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
(标记-复制算法,好比日常将物品放到干净的一边,再拿起扫把将垃圾一次清理掉,腾出空间来)
如下图所示:
但是,标记-复制并不适用于多数对象都是存活的情况,因为这将会产生大量的内存间复制的开销。
但对于多数对象都是可回收的情况,该算法只需要复制少量的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费太多。
标记-整理算法
接着来到了,标记整理算法(Mark-Compact)
,算法分为标记,整理和清除三个阶段:
- 首先标记出所有需要回收的对象。
- 在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动。
- 然后直接清理掉端边界以外的内存。
标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
这样的好处是不会产生内存碎片,但是很明显的缺点是两遍扫描、指针需要调整,因此效率偏低。
所以,可以看到,压根就没有一种能解决所有问题的方法(算法)。
分代收集算法
每一种算法它们都有自己的优势和劣势,也没有一种算法可以完全替代其他算法。所以,如果能根据垃圾回收对象的特性,使用合适的算法,才是最好的选择(俗话说,没有最好的垃圾收集算法,只有最适合的垃圾收集算法)。
于是就有了分代收集算法,当前虚拟机的垃圾收集都采用分代收集算法,也就是说,分代回收的思想几乎所有的虚拟机都使用,从 分代收集的理论设计角度来看是这样:
- 绝大部分的对象都是朝生夕死。
- 经过多次垃圾回收的对象就越难回收。
分代算法就基于这种思想,它将内存区间根据对象的特点分成几块,堆空间主要分为新生代和老年代,如下图所示:
那什么是新生代和老年代呢?
一般来说,JVM会将所有新建对象都放入称为新生代的内存区域,新生代的特点是对象朝生夕灭,大约90%的新建对象会被很快回收,所以新生代更适合使用复制算法。
而当一个对象经过几次回收后依然存活,对象就会被放入称为老年代的内存空间。
在老年代中,几乎所有的对象都是经过几次垃圾回收依然得以存活的。在极端情况下,老年代对象的存活率可以达到100%。如果依然使用复制算法回收老年代,将需要复制大量对象。再加上老年代的回收性价比也低于新生代,因此这种做法是不可取的。
所以对老年代的回收使用与新生代不同的标记压缩法或标记清除法,以提高垃圾回收效率。
总结来说,新生代回收的频率很高,但是每次回收的耗时很短,而老年代回收的频率比较低,但是会消耗更多的时间。
所以,新生代中,每次收集都会有大量对象死去,就可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以选择“标记-清除”或“标记-整理”算法进行垃圾收集是更合适的选择。
这样根据分代的思想,我们就可以根据每块内存区间(各个年代)的特点选择合适的垃圾收集算法。
不过,在JDK8之前和JDK8之后分代有所区别,如下图所示:
垃圾收集器-垃圾方法论的实现
我们前面说过,不管是垃圾判断还是垃圾收集,都只不过是一系列方法论(算法),而真正的实现是垃圾收集器。
所以,如果说垃圾判断算法和垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
而垃圾回收器不仅是内存回收的具体实现,所以其追求的目标,不仅是在不同条件下能选用适合的算法,也包括内存大小,是否多线程,STW(Stop The World)
等等。
其中对STW的追求,贯穿在每一代垃圾收集器上。
什么是 STW (Stop The World)
Stop The World(STW)
,在执行垃圾回收算法时,必须暂停所有的工作线程,直到它回收结束。
而这个暂停称为Stop The World
,给用户带来了恶劣的用户体验。
例如,程序每运行一个小时需要暂停响应 5 分钟。(这个也是早期 Java 被吐槽性能差的一个主要原因),所以每代垃圾回收器一直试图降低或消除 STW
的时间。
Serial 串行收集器
Serial(串行,也就是按顺序),是收集器中最基本,可以说历史悠久的垃圾收集器。
串行意味着它是 “单线程” ,只使用一条垃圾收集线程去完成垃圾收集工作,所以在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。
Serial 采用 新生代采用标记-复制算法,老年代采用标记-整理算法。 运行过程,如下图所示:
Serial 只适合对不大的内存回收,如果过大的内存回收速度很慢(STW 的时间变长)。
ParNew 并行收集器
于是,从单线程版本到多线程版本有了ParNew 收集器, ParNew 收集器其实就是 Serial 收集器的多线程版本。 因为除采用多线程来垃圾收集外,控制参数、收集算法、回收策略等等跟 Serial 收集器一致。
ParNew 采用 新生代采用标记-复制算法,老年代采用标记-整理算法。 运行过程,如下图所示:
ParNew多线程,在多 CPU 下,停顿时间比 Serial 少。
Parallel Scavenge 收集器
Parallel Scavenge(并行),关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器,它看上去几乎和 ParNew 都一样。
Parallel Scavenge 采用 新生代采用标记-复制算法,老年代采用标记-整理算法。,运行过程,如下图所示:
Parallel Scavenge 收集器配合自适应调节策略,可以把内存管理优化交给虚拟机去完成,同时也提供了许多参数可以用来找到最合适的停顿时间或最大吞吐量,
CMS 并发标记清除收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
如果特别关注服务的响应速度,希望系统停顿时间最短,CMS可以给用户带来较好的体验。
CMS(Concurrent Mark Sweep)收集器是 JVM 第一款真正意义上的并发收集器,它基本实现了让垃圾收集线程与用户线程同时工作。
CMS 收集器采用 “标记-清除”算法。运行过程,如下图所示:
CMS 其优点是并发收集、低停顿,但是我们也知道,标记清除算法的老毛病,会有内存碎片,当碎片较多,会给大对象的分配带来麻烦。
G1 收集器
G1(Garbage First),在 Oracle 官方被称为全功能的垃圾收集器。是面向服务器的垃圾收集器,主要针对多CPU,以及大容量内存的机器。可以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。
G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。
这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。
从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器。
最后
《从看 JVM 垃圾回收,看到了残酷的现实》,之所以会起这个标题,的确是我看了JVM垃圾回收之后,又身处于现实的喧嚣和动荡有感而发。
因为当我看JVM垃圾回收的时候,JVM 垃圾回收也在回望我。
但又像 罗曼·罗兰所说,“世界上只有一种英雄主义,就是看清生活的真相之后,依然热爱生活。”
也正如我开头的那句诗,想那么多干嘛,不如 诗酒趁年华。
我是一颗剽悍的种子,怕什么真理无穷,进一寸,有进一寸的欢喜。感谢各位伙伴的:关注、点赞、收藏和评论 ,我们下回见!
创作不易,勿白嫖。
公众号:一颗剽悍的种子
一颗剽悍的种子 | 文 【原创】
转载自:https://juejin.cn/post/7250073877724364856