聊聊 Java GC 算法
Java 和 C++ 之间有一堵由内存动态分配和垃圾回收技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来
今天来聊聊 Java GC(Garbage Collection,垃圾回收)中的常见算法
引用与GC的关系
正题开始前,先来了解一下 Java 中的引用。对象使用不同的引用类型,决定 GC 发生时是否会回收它
引用类型 | 特点 |
---|---|
强引用(Strong Reference) | Java中的默认引用类型。例如Object obj = new Object() ,只要强引用存在,对象永远不会被回收 |
软引用(Soft Reference) | 内存足够时,软引用不会被回收。只有当系统要发生内存溢出时,才会被回收。适合用于缓存场景 |
弱引用(Weak Reference) | 只要发生垃圾回收,弱引用的对象就会被回收 |
虚引用(Phantom Reference) | 一个对象有虚引用的存在不会对生存时间都构成影响,也无法通过虚引用来获取对一个对象的真实引用。唯一的用处:能在对象被GC时收到系统通知 |
如何判断对象是否可以被回收?
在GC开始之前,首先要做的事就是确定哪些对象『活着』,哪些对象『已死』
常见的两种算法用于判断该对象是否可以被回收
-
引用计数算法:每个对象中添加引用计数器。每当对象被引用,引用计数器就会加 1;每当引用失效,计数器就会减 1。当对象的引用计数器的值为 0 时,就说明该对象不再被引用,可以被回收了。强调一点,虽然引用计数算法的实现简单,判断效率也很高,但它存在着对象之间相互循环引用的问题(所以在后来的JVM版本中已经不采用这种算法了)。
-
可达性分析算法:GC Roots 是该算法的基础,GC Roots 是所有对象的根对象。这些对象作为正常对象的起始点,在垃圾回收时,会从这些 GC Roots 开始向下搜索,当一个对象到 GC Roots 没有任何引用链相连时,就证明此对象不再被引用。目前 HotSpot 虚拟机采用的就是这种算法。
可以作为 GC Roots 的对象:
- 虚拟机栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI(Native 方法)引用的对象
- Java 虚拟机中内部的引用
- synchronized 持有的对象
- JMXBean、JVMTI 中注册的回调、本地代码缓存
除此之外,不同的垃圾回收器可能还会加入一些『临时的』对象共同构建 GC Roots
基础的 GC 算法
标记-清除算法 (mark-sweep)
如下图所示,该算法分为『标记』和『清除』两个阶段
从 GC Roots 出发标记出存活的对象,然后遍历堆清除未被标记的对象
最基础的收集算法,标记-清除的效率中等,缺点也比较明显:会产生内存碎片
复制算法(copying)
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完,需要进行垃圾收集时,就将存活的对象复制到另一块上面,然后将第一块内存全部清除
相比较『标记-清除』不会有内存碎片的问题,但每次只使用一半的内存,内存利用率很低。当内存中存活的对象较多时,会进行大量的复制操作,效率会较低(对象的复制也是有成本的,需要复制的对象越多、越大,复制带来的代价也就越大)。适用于存活率低的情况
标记-整理算法(mark-compact)
和『标记-清除』算法有点类似,从 GC Roots 出发标记出存活的对象,然后是整理阶段,将存活移动到一端。整理阶段结束后我们可以知道一个临界点,另一端的内存空间就可以被重新分配了
该算法的优点:不像复制算法那样会浪费内存空间,也不会产生内存碎片。
写到这时,我很想知道『标记-整理』的效率如何。一番搜索后,发现该算法是这三种算法中效率最低的,因为『整理』这个过程会遍历整个堆三次,具体实现思路如下
『标记-整理』以 lisp2 算法为例,实现思路如下
- 标记阶段:首先从 GC Roots 出发,标记出所有存活的对象
- 设置 forwarding 指针:每个对象头中都有一个forwarding指针,指向该对象整理后的位置。遍历整个堆计算存活对象的 forwarding
- 更新指针:遍历整个堆,根据 forwarding 更新 GC Roots 指针以及所有存活对象的子对象的指针,将指针指向新的位置
- 移动对象:遍历整个堆,根据 forwarding 移动对象并且清除对象中的标记
聊聊我个人的理解,为什么2,3,4一定要遍历整个堆?都是针对存活对象的操作,直接遍历 GC Roots 不行吗?
- 设置 forwarding 阶段:一定要顺序遍历整个堆。如上图所示,如果仅是遍历 GC Roots 的话,你没法知道A这个区域是否可以覆盖
- 更新指针阶段:我觉得这个阶段仅遍历 GC Roots 的话,确实可行
- 移动对象阶段:由于 GC Roots 指针已经全部指向新的位置了,只能遍历整个堆
写到这,我又想为什么一定要三个步骤搞这么复杂,直接移动对象不行吗?
根据上面的理解,一定要遍历整个堆来确定存活的对象该移动到哪。就现有的结构来看,如果直接移动,子对象的指针还好说可以处理,GC Roots 中的指针就没法更新了
关于lisp2 实现的更多细节可以参考下这篇Blog 『gc-标记整理算法的两种实现』
小结
总结下上述三种基础GC算法的优缺点
维度 | 标记-清除 | 标记-整理 | 复制算法 |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
时间开销 | mark阶段与存活对象的数量成正比,sweep阶段与整堆大小成正比 | mark阶段与存活对象的数量成正比,compact阶段与整堆大小成正比,与存活对象的大小成正比 | 与存活对象大小成正比 |
空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要存活对象的2倍大小(不堆积碎片) |
移动对象 | 否 | 是 | 是 |
分代回收理论
垃圾回收器都不会只选择一种算法,JVM根据对象存活周期的不同,将内存划分为几块。一般是把堆分为新生代和老年代,根据年代的特点来选择最佳的收集算法。
HotSpot 中大部分垃圾回收器都采用分代回收的思想
- 新生代:复制算法
- 老年代:标记-清除 / 标记-整理 / 或者两者同时使用
堆大小=新生代+老年代(默认分别占堆空间为1/3、2/3),新生代又被分为Eden、from survivor (S0)、to survivor (S1),默认分配比例为 8:1:1
image.png对象的分配
对象的分配通常在 Eden 中(需要大量连续内存空间的 Java 对象,如很长的字符串或数据可以直接进入老年代,由 -XX:PretenureSizeThreshold
决定)
新生代的回收
当 Eden 区满后,会触发 Young GC(新生代回收),复制 Eden 区和 S0 区中存活的对象到 S1 或者老年代(其中到达年龄的会被放入老年代,未到达年龄的放入 S1 区)
每经历一次 Young GC,survivor 区中对象年龄 +1
然后清空 Eden 区和 S0 区,交换 S0 与 S1 的名字
若存活对象大于 S1 区容量,则会被直接放入老年代。若打开了自适应(-XX:+AdaptiveSizePolicy
),GC会自动重新调整新生代大小
老年代的回收
在发生 Full GC 或者 Old GC 时,会根据不同的垃圾回收器或者情况选择使用 标记-清除 / 标记-整理 来进行回收
除了 Young GC 之外,常见的还有
- Full GC(新生代、老生代、元空间或永久代的回收)
- Old GC(只有 CMS 有这个模式)
- Mixed GC(只有 G1 有这个模式)
通常情况下 Full GC 的触发条件,当准备要触发一次 Young GC时,如果发现统计数据说之前 Young GC 的平均晋升大小比目前老年代剩余的空间大,则不会触发 Young GC 而是转为触发 Full GC
小结
由于新生代的特点是大多数对象都是「朝生夕死」的,存活率低,所以非常适合复制算法。而 survivor 区存在的意义是为了确保「朝生夕死」的对象不会轻易进入老年代,当对象的年龄满足(经历了多次 Young GC)才会进入老年代。
又到了提问环节,为什么分代回收中需要有两个 survivor 区,一个不行吗?
答案是不行。假设只有一个 survivor,Eden 回收后存活的对象进入了 survivor。那么 survivor 区可以被回收的对象该怎么处理?难道要用标记清除和标记整理?那可太没有必要了,所以划分出两个 survivor 区,将新生代的复制算法贯彻到底
参考
『深入理解Java虚拟机』
『极客时间 - Java性能调优实战』
『Java-GC 垃圾收集算法』
『gc-标记整理算法的两种实现』
『Mark-compact algorithm - Wikipedia』
『为什么新生代内存需要有两个Survivor区』
『Major GC和Full GC的区别是什么?触发条件呢? - RednaxelaFX的回答 - 知乎』
『关于JVM垃圾搜集算法(标记-整理算法与复制算法)的效率? - RednaxelaFX的回答 - 知乎』