前言
谈起JVM, 那么就不得不提垃圾收集(Garbage Collection 通常被称为“GC”).
什么是垃圾收集呢?
想解答这个问题, 我们最好将问题拆解开
- 如何确定垃圾?
- 如何回收垃圾?
- 何时回收垃圾?
下面围绕这三件事, 我们站在JVM层面梳理下垃圾收集的机制.
如何确定垃圾?
从JVM层面来看, 它管理的是生命周期内的全部实例对象, 那么所谓的垃圾其实就是“无用的对象”.
那么它是如何确定“无用对象”的?
引用计数法
在 Java 中,引用和对象是有关联的, 必须使用引用来操作对象.
People zs = new People();
zs.setName("张三");
zs是个引用, 和真正的对象“new People()”关联
因此, 简单的办法是通过引用计数来判断一个对象是否可以回收.
所以在JVM中, 每个对象都在对象头结构中维护了一个引用计数属性
- 对象被引用一次时, 计数就加1
- 对象的引用被释放时,计数就减1
- 对象的计数为0的时, 这个对象就可以被回收了
引用计数法听着虽然简单易懂, 判定效率也高.
但是,当前主流的虚拟机都没有采用这个算法来管理内存,其中最主要的原因是它很难解决对象之间互相循环引用的问题.
循环引用问题
所谓对象之间互相循环引用,如下面代码所示:
除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用.
但是它们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们.
PS: 实际上以下示例是能回收的, 因为JVM没有采用引用计数法
public class ReferenceCountingGc {
public Object instance = null;
public static void main(String[] args) {
ReferenceCountingGc obj1 = new ReferenceCountingGc();
ReferenceCountingGc obj2 = new ReferenceCountingGc();
obj1.instance = obj2;
obj2.instance = obj1;
obj1 = null;
obj2 = null;
}
}
可达性分析
为了解决引用计数法的循环引用问题, Java 使用了可达性分析的方法.
可达性分析就是通过一系列的称为 “GC Roots”的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链.
当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的.
如下图中的 Object 6 ~ Object 10 之间虽有引用关系,但它们到 GC Roots 不可达, 因此为需要被回收的对象.
在Java中, GC Roots包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
要注意的是:
- 不可达对象不等价于可回收对象
- 不可达对象变为可回收对象至少要经过两次标记过程,两次标记后仍然是可回收对象,则将面临回收
如何回收垃圾?
确定垃圾, 那么如何回收呢?
这就不得不谈一系列的垃圾回收算法, 算法实现会因各个平台虚拟机的差异而不同, 这里我们只谈几种主流的算法思想.
标记-清除算法 (Mark-Sweep)
这是最基础的收集算法, 分为标记、清除两个阶段
主要算法思想就是
通过算法标记出回收对象(举例: hotspot使用可达性分析),进而回收标记的对象占用的空间
从示例图不难看出, 该算法的最大缺陷就
- 内存碎片化
后续碰到分配大对象时(连续的内存空间), 必然导致内存不够从而触发额外的GC.
另外就是标记和清除两个过程本身的效率都不高.
标记-复制算法(copying)
复制算法算是Mark-Sweep算法的升级版, 主要就是为了解决Mark-Sweep算法“内存碎片化”的问题.
该算法的主要思想如下
按内存容量将内存划分为等大小的两块. 每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去, 把已使用的内存清掉.
这种算法虽然实现简单,内存效率高,不易产生碎片,但是存在两个较严重问题
可用内存被压缩到了原来的一半
存活对象较多的话, Copying算法的效率较低
标记-整理算法(Mark-Compact)
为了解决以上两种算法的缺陷, 进而提出了标记整理算法.
算法的主要思想如下
分为标记、整理两个阶段
标记阶段和Mark-Sweep相同, 不同点是标记后不会清理对象, 而是将存活对象移向内存的一端.
然后清除端边界外的对象.
分代收集算法
上面介绍了几个算法都各有优缺点, 但没有哪个是绝对优势的.
只能说每个算法都有各自的应用场景.
而在JVM垃圾回收领域, 面对各种内存回收的复杂场景, 显然, 不可能存在一种算法就能达到最优解.
此时聪明的开发者就提出了一种想法, 既然无法“一招通杀”, 那么, 我就“分而治之”.
通过一定规则把内存区域划成几块, 这样某些小块的内存回收场景就存在某个“最优解回收算法”, 每块都是最优解, 那么总体上不就是最优解么?!
于是, 分代收集算法应运而生.
严格来说, 分代收集算法并不是个垃圾回收算法, 而是把对象按生命周期来进行内存划分的思想.
该算法的主要思想如下
根据对象存活的不同生命周期, 将内存划分为几块不同的区域.
一般情况下将Java堆划分为新生代和老年代
新生代的对象特点是大部分对象都是朝生夕死,生命周期很短, 每次垃圾回收时有大量对象需要被回收
老年代的对象特点是生命周期较长,每次垃圾回收时只有少量对象需要被回收
结合新生代、老年代的特点, 于是适配了合适的垃圾回收算法
新生代与复制算法
目前大部分JVM 的 GC 对于新生代都采取 Copying 算法,
因为新生代每次垃圾回收都要回收大部分死亡对象,存活的对象少, 所以要复制的操作比较少.
这样的特点刚好能发挥Copying 算法的效率.
新生代的划分并没有严格按Copying 算法的1:1划分法, 而是将新生代划分为一块较大的 Eden区和两个较小的 Survivor区(From区, To区)(一般也称为S1和S2区),
默认内存占比为 Eden:S1:S2 是8:1:1
每次使用Eden区和其中的一块 Survivor 区,当进行垃圾回收时,将该两块区中还存活的对象复制到另一块 Survivor区中.
老年代与标记整理算法
老年代本身存放的对象都是熬过了一轮轮GC的, 都是“存活几率”较高的, 老年代最终存放着大量的对象, 所以每次只需对少量死亡对象进行回收, 因而采用 Mark-Compact 算法.
一次完整的GC过程如下
实例理解:
-
新New的对象一般出现在Eden区
- PS: 少数大对象(需要连续的内存空间) 会直接进老年代
- PS: Hotspot可配置: -XX:PretenureSizeThreshold=2m , 即2m以上的对象直接进老年代
-
慢慢的Eden区满了, 此时触发一次GC, 将还存活的对象复制到某个空的S区, 称为S1区
- PS: S1和S2身份随时互换, 只有空的我们称为S1区, 两个S区必然有一个是空的
- PS: 也就是假设年轻代空间比例8:1:1
慢慢的S1区也满了, 此时触发GC, 已满的S1区和Eden区还存活的对象
对象的内存分配主要在新生代的 Eden区和 From区, 少数情况(比如new了个大对象, 新生代放不下了)会直接分配到老生代
- 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后,Eden Space 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 From Space 进行清理。
- 如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。
- 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。
- 当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被 移到老生代中。
请关注我的订阅号
参考
- 《深入理解JAVA虚拟机:JVM高级特性与最佳实践》