寻找垃圾算法

引用计数法
引用计数法(Reference Count)会给对象中添加一个引用计数器,每当有一个地方引用它的时候,计数器的值就 +1 ,当引用失效时,计数器值就 -1 ,计数器的值为 0 的对象不可能在被使用,这个时候就可以判定这个对象是垃圾。

当图中的数值变成0时,这个时候使用引用计数算法就可以判定它是垃圾了,但是引用计数法不能解决一个问题,就是当对象是循环引用的时候,计数器值都不为0,这个时候引用计数器无法通知GC收集器来回收他们,如下图所示:

这个时候就需要使用到我们的根可达算法。
可达性分析
根可达算法(Root Searching)的意思是说从根上开始搜索,当一个程序启动后,马上需要的那些个对象就叫做根对象,所谓的根可达算法就是首先找到根对象,然后跟着这根线一直往外找到那些有用的。常见的GC roots如下:
-
线程栈变量: 线程里面会有线程栈和main栈帧,从这个main() 里面开始的这些对象都是我们的根对象
-
静态变量: 一个class 它有一个静态的变量,load到内存之后马上就得对静态变量进行初始化,所以静态变量到的对象这个叫做根对象
-
常量池: 如果你这个class会用到其他的class的那些个类的对象,这些就是根对象
-
JNI: 如果我们调用了 C和C++ 写的那些本地方法所用到的那些个类或者对象

图中的 object5 和object6 虽然他们之间互相引用了,但是从根找不到它,所以就是垃圾,而object8没有任何引用自然而然也是垃圾,其他的Object对象都有可以从根找到的,所以是有用的,不会被垃圾回收掉。
GC Root
GC Roots 是一组必须活跃的引用。用通俗的话来说,就是程序接下来通过直接引用或者间接引用,能够访问到的潜在被使用的对象。GC Roots 包括:
- Java 线程中,当前所有正在被调用的方法的引用类型参数、局部变量、临时值等。也就是与我们栈帧相关的各种引用
- 所有当前被加载的 Java 类
- Java 类的引用类型静态变量
- 运行时常量池里的引用类型常量(String 或 Class 类型)
- JVM 内部数据结构的一些引用,比如 sun.jvm.hotspot.memory.Universe 类
- 用于同步的监控对象,比如调用了对象的 wait() 方法
- JNI handles,包括 global handles 和 local handles
GC Roots 大体可以分为三大类:
- 活动线程相关的各种引用
- 类的静态变量的引用
- JNI 引用
清理垃圾算法
清理垃圾算法又叫内存回收算法。
标记(Mark)
垃圾回收的第一步,就是找出活跃的对象。根据 GC Roots 遍历所有的可达对象,这个过程,就叫作标记。

如图所示,圆圈代表的是对象。绿色的代表 GC Roots,红色的代表可以追溯到的对象。可以看到标记之后,仍然有多个灰色的圆圈,它们都是被回收的对象。
清除(Sweep)
清除阶段就是把未被标记的对象回收掉。

但是这种简单的清除方式,有一个明显的弊端,那就是碎片问题。比如我申请了 1k、2k、3k、4k、5k 的内存。

由于某种原因 ,2k 和 4k 的内存,我不再使用,就需要交给垃圾回收器回收。

这个时候,我应该有足足 6k 的空闲空间。接下来,我打算申请另外一个 5k 的空间,结果系统告诉我内存不足了。系统运行时间越长,这种碎片就越多。在很久之前使用 Windows 系统时,有一个非常有用的功能,就是内存整理和磁盘整理,运行之后有可能会显著提高系统性能。这个出发点是一样的。
复制(Copying)

优点
- 因为是对整个半区进行内存回收,内存分配时不用考虑内存碎片等情况。实现简单,效率较高
不足之处
- 既然要复制,需要提前预留内存空间,有一定的浪费
- 在对象存活率较高时,需要复制的对象较多,效率将会变低
整理(Compact)
其实,不用分配一个对等的额外空间,也是可以完成内存的整理工作。可以把内存想象成一个非常大的数组,根据随机的 index 删除了一些数据。那么对整个数组的清理,其实是不需要另外一个数组来进行支持的,使用程序就可以实现。它的主要思路,就是移动所有存活的对象,且按照内存地址顺序依次排列,然后将末端内存地址以后的内存全部回收。

但是需要注意,这只是一个理想状态。对象的引用关系一般都是非常复杂的,我们这里不对具体的算法进行描述。你只需要了解,从效率上来说,一般整理算法是要低于复制算法的。
扩展回收算法
https://juejin.cn/post/6896035896916148237
目前JVM的垃圾回收器都是对几种朴素算法的发扬光大(没有最优的算法,只有最合适的算法):
- 复制算法(Copying):复制算法是所有算法里面效率最高的,缺点是会造成一定的空间浪费
- 标记-清除(Mark-Sweep):效率一般,缺点是会造成内存碎片问题
- 标记-整理(Mark-Compact):效率比前两者要差,但没有空间浪费,也消除了内存碎片问题

标记清除(Mark-Sweep)

首先从 GC Root 开始遍历对象图,并标记(Mark)所遇到的每个对象,标识出所有要回收的对象。然后回收器检查堆中每一个对象,并将所有未被标记的对象进行回收。
不足之处
- 标记、清除的效率都不高
- 清除后产生大量的内存碎片,空间碎片太多会导致在分配大对象时无法找到足够大的连续内存,从而不得不触发另一次垃圾回收动作
标记整理(Mark-Compact)

与标记清除算法类似,但不是在标记完成后对可回收对象进行清理,而是将所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
优点
- 消除了标记清除导致的内存分散问题,也消除了复制算法中内存减半的高额代价
不足之处
- 效率低下,需要标记所有存活对象,还要标记所有存活对象的引用地址。效率上低于复制算法
标记复制(Mark-Coping)
标记-复制算法将内存分为两块相同大小的区域(比如新生代的Survivor区),每次在其中一块区域分配元素,当这块区域内存占满时,就会将存活下来的元素复制到另一块内存区域并清空当前内存区域。
- 缺点:浪费一半的内存空间
- 优点:简单高效
JVM在Eden区保存新对象,在GC时,将Eden和Survivor中存活对象复制到Survivor的另一个分区。这是JVM对复制算法的一个优化。只浪费了1/10的内存空间【JVM的Eden区和Survivor区的比例为 8:2】

分代收集(Generational Collection)
分代收集就是根据对象的存活周期将内存分为新生代和老年代。
- 新生代对象“朝生夕死”,每次收集都有大量对象(99%)死去,所以可以选择标记-复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集
- 老年代对象生存几率比较高,存活对象比较多,如果选择复制算法需要付出较高的IO成本,而且没用额外的空间可以用于复制,此时选择标记-清除或者标记-整理就比较合理
研究表明大部分对象可以分为两类:
- 大部分对象的生命周期都很短
- 其他对象则很可能会存活很长时间
根据对象存活周期的不同将内存划分为几块。对不同周期的对象采取不同的收集算法:
- 新生代:每次垃圾收集会有大批对象回收,所以采取复制算法
- 老年代:对象存活率高,采取标记清理或者标记整理算法
① 年轻代(Young Generation)
年轻代使用的垃圾回收算法是复制算法。因为年轻代发生 GC 后,只会有非常少的对象存活,复制这部分对象是非常高效的。但复制算法会造成一定的空间浪费,所以年轻代中间也会分很多区域。

如图所示,年轻代分为:1个伊甸园空间(Eden ),2个幸存者空间(Survivor )。当年轻代中的 Eden 区分配满的时候,就会触发年轻代的 GC(Minor GC)。具体过程如下:
- 在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区(以下简称from)
- Eden 区再次 GC,这时会采用复制算法,将 Eden 和 from 区一起清理。存活的对象会被复制到 to 区,然后只需要清空 from 区就可以了
在这个过程中,总会有1个 Survivor 分区是空置的。Eden、from、to 的默认比例是 8:1:1,所以只会造成 10% 的空间浪费。这个比例,是由参数 -XX:SurvivorRatio 进行配置的(默认为 8)。
② 老年代(Old/Tenured Generation)
老年代一般使用“标记-清除”、“标记-整理”算法,因为老年代的对象存活率一般是比较高的,空间又比较大,拷贝起来并不划算,还不如采取就地收集的方式。对象进入老年代的途径如下:
-
提升(Promotion)
如果对象够老,会通过“提升”进入老年代
-
分配担保
年轻代回收后存活的对象大于10%时,因Survivor空间不够存储,对象就会直接在老年代上分配
-
大对象直接在老年代分配
超出某个大小的对象将直接在老年代分配
-
动态对象年龄判定
有的垃圾回收算法,并不要求 age 必须达到 15 才能晋升到老年代,它会使用一些动态的计算方法。
比如,如果幸存区中相同年龄对象大小的和,大于幸存区的一半,大于或等于 age 的对象将会直接进入老年代。
















