探秘 JVM:垃圾收集与内存分配策略

JVM 中的程序计数器、java 虚拟机栈、本地方法区属于线程私有,其生命周期控制在线程生命周期范围之内,并且 java 虚拟机栈和本地方法区则随着栈帧的出栈入栈由生到灭,所以这些区域的内存使用是确定性的(编译期已知),而 java 堆和方法区是线程共享的,对象创建也是动态的,所以 垃圾回收的主战场在 java 堆和方法区

一. 引用类型与可达性分析

1.1 java 中的引用类型

我们一般对于引用的定义是 存放另外一个对象地址的对象,而 java 对于引用的定义则更加细化,分为强引用、软引用、弱引用、虚引用四种,强度逐级由强到弱。

  • 强引用

我们通常在程序中看到的引用都是强引用,比如 Object obj = new Object(); 这类引用的特点就是只要存在,垃圾收集器就不会对被引用的对象执行回收操作。

  • 软引用:通过继承 SoftReference 类实现

描述一些还有用但非必须的对象,JVM 会在发生内存溢出异常之前将这些对象列进回收范围之中进行第二次回收,如果这部分对象回收这还仍然内存不足,才会抛出内存溢出异常。

  • 弱引用:通过继承 WeakReference 类实现

描述一些非必须的对象,被弱引用的对象会被下一次垃圾收集所回收,而不管这些当前是否内存足够,典型的应用场景就是 ThreadLocal,ThreadLocal 维护了一个线程私有的内存数据库来记录线程私有的对象,而对象的 key 是一个弱引用的对象。

  • 虚引用:通过继承 PhantomReference 类实现

虚引用的 唯一目的在于能在被引用的对象被回收时收到一个系统通知,一个对象的生命周期完全不受虚引用的影响,我们也无法通过虚引用来得到一个对象的实例。

1.2 对象可回收性判断

一个对象只有在无效的前提下才可以被回收,如何判定一个对象是无效的,主要有两种思路:引用计数法和可达性分析。

  • 引用计数法

引用计数法是比较简单的一种判定对象无效的策略,当一个对象被引用则计数加 1,反之则减 1,当一个对象的引用计数是 0 时,我们可以将其视为无效的并回收,但是这个方法存在 循环引用 的问题,当两个对象互相引用时,尽管没有被其他对象所引用,那么这两个对象的引用计数也至少是 1,无法被回收。

  • 可达性分析

可达性分析采用连通图的策略,我们可以把每个对象都看作是图上的一个节点,而引用则可以看作图上的边,在这个图中存在一些特殊的结点,被称为”GC Roots”,如果某个对象与任何一个”GC Roots”之间不连通,则视该对象无效,可以被回收。

在 JVM 中,可以被视为 “GC Roots” 的结点包括下面几种:

  1. 虚拟机栈(局部变量表)中引用的对象
  2. 方法区中类 static 属性引用的对象
  3. 方法区中 final 常量引用的对象
  4. 本地方法栈中 native 方法引用的对象

通过可达性分析判定的无效则是给对象判定了一个死缓,而并没有立即执行回收,一些对象还可以在后续通过良好表现而”无刑释放”。真正需要对一个对象执行死刑需要经过 两次标记 过程:可达性分析是第一次标记,被判定为无效的对象将经过一次筛选,筛选出那些有必要执行 finalize() 方法的对象(有必要是指该对象覆盖了 finalize() 方法,且该方法还没有被 JVM 调用过,并且一个对象的 finalize() 方法只能被系统调用一次),这些方法被筛选出来之后就被放进 F-Queue 队列 中,稍后由一个 JVM 自动创建的、低优先级的 Finalizer 线程 去依次调用队列中对象的 finalize() 方法,所以我们可以认为 finalize() 方法是对象最后一个 “改过自新” 的地方,如果对象在这里重新让自己被引用则复活,否则就几乎只有等死了,JVM 依次执行队列中的 finalize() 方法的过程可以看作是第二次标记。下面是一个验证上述过程的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class FinalizeDemo {

public static FinalizeDemo saveHook;

@Override
protected void finalize() throws Throwable {
System.out.println("do finalize method");
super.finalize();
saveHook = this;
}

private static void isAlive() {
System.out.println(null == saveHook ? "Sorry, I'm died!" : "yes, I'm still alive!");
}

public static void main(String[] args) throws Exception {
saveHook = new FinalizeDemo();

/* 第一次改过自新 */
saveHook = null; // 去掉引用
System.gc(); // 触发finalize方法
TimeUnit.SECONDS.sleep(1); // finalize方法优先级较低,暂停等待1秒钟
isAlive();

/* 第二次改过自新 */
saveHook = null; // 去掉引用
System.gc(); // 触发finalize方法,因为finalize只会被调用一次,所以本次不会调用finalize,自救失败
TimeUnit.SECONDS.sleep(1); // finalize方法优先级较低,暂停等待1秒钟
isAlive();
}

}

执行结果:

1
2
3
do finalize method
yes, I'm still alive!
Sorry, I'm died!

上述示例在第一次 GC 时触发调用了对象的 finalize() 方法,我们在该方法中为 saveHook 变量带来了第二春(saveHook = this),所以该对象从“死缓”中被保释了出来,但是这货不老实,放出来之后又被抓进去了(saveHook = null),这一次就没有那么幸运了,因为一个对象的 finalize() 方法只能被系统调用一次,所以只能坐以待毙了。

一般不推荐在 finalize() 方法中添加自定义逻辑,因为该方法的执行是不确定的,推荐将这些逻辑写到 finally 块中,这样正常情况下一定会执行。

二. 垃圾回收算法

垃圾收集的过程会中断正常业务逻辑的执行来查找和回收垃圾对象,因为回收时机的不确定性,如果收集的过程耗时较长,会引起系统的卡顿,影响用户体验,所以一般收集过程不会一次性彻底执行,而是采用渐进式回收策略,将一次收集过程分摊到多次执行,控制每次执行的时间长度,以不让用户感觉到这一过程。

2.1 标记清除法

标记清除法是最基础的垃圾回收算法,它的执行过程可以分为 “标记” 和 “清除” 两个阶段,算法在第一阶段先标记出所有可以回收的对象,然后在第二阶段对这些对象进行统一回收。

标记清除法的缺点主要分为两个方面:

  1. 标记清除过程的效率都不高
  2. 清除操作会产生大量不连续的内存碎片,影响后续分配的效率

2.2 标记整理法

标记整理法与标记清除法在第一阶段的操作相同,区别在于第二阶段,标记整理会将所有存活的对象移动到一端,然后对剩余的内存进行清理。

2.3 复制算法

复制算法解决了 “标记法” 在第一阶段标记过程效率不高的问题,常规的复制算法将内存区域分为大小 1:1 的两份,每次仅在其中一块上分配,当需要 GC 时就将当前存活的对象全部移动到另外一块上去,然后对之前已使用的那一块内存进行一次性清理,其缺点就是内存利用率不高,只有 50%。

实际应用中往往将新生代大小分为三块:一块大的 Eden 区域和两块小的 Survivor 区域(From Survivor、To Survivor),每次都使用 Eden 和其中一块 Survivor 进行内存分配,当需要执行 GC 时,就将所有存活的对象全部移动到另外一块 Survivor 上去,然后清理掉之前的 Eden 和 Survivor,实际中可能会出现一块 Survivor 不够用的情况,这个时候就需要通过 空间分配担保 机制将多的对象送入老年代。

空间分配担保策略

在 Minor GC 前,JVM 会检查老年代最大连续空间是否大于新生代所有对象的总空间,如果大于则可以确保 Minor GC 安全执行,否则就会继续查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许则继续检查老年代最大连续空间是否大于历次升级到老年代对象的平均大小,如果大于则尝试执行 Minor GC,如果小于或者 HandlePromotionFailure 设置值不允许,则执行一次 Full GC,伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
if(老年代最大连续空间 > 新生代所有对象大小之和) {
执行 Minor GC
} else {
if(HandlePromotionFailure == 允许担保失败) {
if(老年代最大连续空间 > 历次升级到老年代对象的平均大小) {
尝试执行Minor GC
} else {
执行Full GC
}
} else {
执行Full GC
}
}

2.4 分代回收

实际应用中,往往采取分而治之的策略, java 堆可以分为新生代和老年代,分代回收不是新的算法,而是针对不同的代采取上面介绍的不同算法,比如新生代中对象生死比较频繁,适合采用复制算法,而老年代中对象大部分时间都是处于存活状态,而且很多都是大对象,所以比较适合采用 “标记法”。

关于分代的补充:

JVM 中分代主要分为 新生代老年代,以及 永久代 三块,其中 新生代和老年代位于堆内存 中,而 永久代则是方法区的范畴

默认新生代与老年代占比堆内存的比例为 1:2 (可以通过参数 –XX:NewRatio 指定),即新生代占比 1/3 的堆内存空间,老年代占比 2/3 的堆内存空间。新生代可以细分为 Eden 和两个 Survivor 区域(From Survivor、To Survivor)。默认的 Edem : from : to = 8 : 1 : 1 (可以通过 –XX:SurvivorRatio 指定),即 Eden 占比 8/10 的新生代空间大小,而 from 和 to 各占 1/10 的新生代空间。JVM 每次只会使用 Eden 和其中的一块 Survivor 区域进行对象分配,所以总有一块 Survivor 区域是空闲着的,因此新生代实际可用的内存大小为 90% 的新生代空间。

三. 内存分配与回收策略

3.1 对象优先在 Eden 上进行分配

一般情况下对象会首先在新生代 Eden 上进行分配,当 Eden 空间不足时,JVM 将发起一次 Minor GC。

Minor GC 和 Full GC的区别

  • Minor GC:新生代上的 GC,频繁发生且速度较快

  • Full GC (也称 Major GC):老年代上的 GC,一般情况下都会伴随发生至少一次 Minor FC,Full GC 的速度一般比 Minor GC 慢 10 倍。

3.2 大对象直接进入老年代

对于大对象(需要大量连续内存空间的对象),JVM 采取的策略是直接进入老年代,因为在新生代的对象只有在一定的 GC 次数之后仍然存活才能进入老年代,而在此之前大对象就会频繁在两个 Survivor 块之间复制,这样会降低效率,但是对于一个朝生夕灭的大对象,直接进入老年代也不是好事,实际上就算在新生代上也不是好事,所以编程中我们应该尽量避免这种朝生夕灭的大对象。

3.3 长期存活的对象进入老年代

因为采用分代存储,所以 JVM 需要知道哪些对象应该放在新生代,哪些对象应该放在老年代,JVM 一般会根据对象熬过的 GC 次数作为判定依据,并且会为该对象设置一个 对象年龄计数器,对于一个对象首先在 Eden 上进行分配,然后第一次 Minor GC 后仍然存活,并且能够被 Survivor 容纳,则进入 Survivor,同时设置对象年龄计数器为 1,后面每熬过一次 Minor GC,则对象年龄计数器加 1,当年龄达到一定值(默认为15)则进入老年代。

JVM 并非完全死板的执行上诉策略,而是会进行 动态对象年龄判定如果 Survivor 空间中相同年龄的所有对象大小之和大于 Survivor 空间的一半,则年龄大于或等于这一年龄的对象可以直接进入老年代


转载声明 : 版权所有,商业转载请联系作者,非商业转载请注明出处
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议
Powered by hexo & Theme by hiero   Copyright © 2015-2019 浙ICP备 16010916  号,指 · 间 All Rights Reserved.