【笔记】JVM内存管理(二) - GC算法
判断一个对象是否存活
引用计数算法
给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。当计数器为0时即可认为对象无用了,可进行回收。
这种做法比较简单,但是这可能会引发循环引用的问题,当两个对象相互引用对方时,将导致引用计数器永远不为0,因此无法对它们进行回收。

因为循环引用不好解决,所以Java 虚拟机不使用引用计数算法。
可达性分析算法
可达性分析是基于图论的分析方法,它会找一组对象作为GC Root(根结点),并从根结点进行遍历,遍历结束后如果发现某个对象是不可达的(即从GC Root到此对象没有路径),那么它就会被标记为不可达对象,等待GC。比如下图中Object1、2、3、4都是存活的对象,而 Object5、6、7都是可回收对象:

Java虚拟机使用该算法来判断对象是否可被回收,在Java中GC Roots 一般包含以下内容:
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
引用类型
无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。
Java 具有四种强度不同的引用类型。
强引用
被强引用关联的对象不会被垃圾收集器回收。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。我们平时直接使用new关键字创建对象时就是对对象进行了强引用。
代码示例1
Object obj = new Object();
软引用
如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。软引用可用来实现内存敏感的高速缓存。
代码示例1
2
3Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联
弱引用
弱引用是一种生命周期比软引用更短的引用。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
当你想引用一个对象,但是这个对象有自己的生命周期,你不想介入这个对象的生命周期,这时候你就是用弱引用。
代码示例1
2
3Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
Tomcat 中的 ConcurrentCache 就使用了 WeakHashMap 来实现缓存功能。ConcurrentCache 采取的是分代缓存,经常使用的对象放入 eden 中,而不常用的对象放入 longterm。eden 使用 ConcurrentHashMap 实现,longterm 使用 WeakHashMap,保证了不常使用的对象容易被回收。
1 | |
虚引用
虚引用不同于其余三种引用,虚引用不会影响对象的生命周期,也无法通过虚引用获得对象的一个实例;如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动,它必须和引用队列联合使用。
代码示例1
2
3
4final ReferenceQueue<Object> referenceQueue = new ReferenceQueue<Object>();
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, referenceQueue);
obj = null;
引用队列
在检测到适当的可到达性更改后,垃圾回收器会将已注册的引用对象添加到引用队列中。所以当一个对象被回收,需要进行额外的处理时,就需要使用引用队列了。
从源码可以看到,ReferenceQueue的实现更像是栈,它有有入队(enqueue)和出队(poll&remove,其中remove阻塞等待提取队列元素)的API。当引用对象被回收时会将对象进行入栈操作,然后我们可以通过出栈操作来进行对象被回收时的最后操作。
代码示例1
2
3
4
5
6
7
8
9
10
11
12
13Thread thread = new Thread(() -> {
try {
int cnt = 0;
WeakReference<Object> k;
while((k = (WeakReference) rq.remove()) != null) {
System.out.println((cnt++) + "回收了:" + k);
}
} catch(InterruptedException e) {
//结束循环
}
});
thread.setDaemon(true);
thread.start();
方法区的回收
因为方法区主要存放永久代对象,而永久代对象的回收率比新生代差很多,因此在方法区上进行回收性价比不高。
对方法区的回收主要是对常量池的回收和对类的卸载。类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
可以通过-Xnoclassgc参数来控制是否对类进行卸载。
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。
在JDK1.8中,JVM摒弃了永久代,用元空间来作为方法区的实现。元空间的内存管理由元空间虚拟机来完成。先前,对于类的元数据我们需要不同的垃圾回收器进行处理,现在只需要执行元空间虚拟机的C++代码即可完成。在元空间中,类和其元数据的生命周期和其对应的类加载器是相同的。话句话说,只要类加载器存活,其加载的类的元数据也是存活的,因而不会被回收掉。
finalize()
finalize()类似C++的析构函数,用来做关闭外部资源等工作。但是try-finally等方式可以做的更好,并且该方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。
当一个对象可被回收时,如果需要执行该对象的finalize()方法,那么就有可能通过在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了finalize()方法自救,后面回收时不会调用finalize()方法。
垃圾收集算法
标记-清除

标记-清除(Mark-Sweep)算法是最基础的垃圾收集算法,后续的收集算法都是基于它的思路并对其不足进行改进而得到的。算法分成“标记”、“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
缺点:
- 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不触发另一次垃圾收集动作。
- 效率问题,因为内存碎片的存在,操作会变得更加费时,因为查找下一个可用空闲块已不再是一个简单操作。
复制

将可用内存按容量分成大小相等的两块,每次只使用其中的一块。当这一块内存用完,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。
缺点:只能有效地使用一半内存。
现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将内存划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。
标记-整理

复制算法在对象存活率较高时要进行较多的复制操作,效率将会变低。更关键的是:如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用复制算法。
根据老年代的特点,标记-整理(Mark-Compact)算法被提出来,主要思想为:此算法的标记过程与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
分代收集
当前商业虚拟机的垃圾收集都采用分代收集(Generational Collection)算法,此算法相较于前几种没有什么新的特征,主要思想为:根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法:
- 新生代 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
- 老年代 在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清除”或“标记-整理”算法来进行回收。
参考
周志明. 深入理解 Java 虚拟机 [M]. 机械工业出版社, 2011.
https://github.com/CyC2018/Interview-Notebook/blob/master/notes/Java%20%E8%99%9A%E6%8B%9F%E6%9C%BA.md
https://www.sczyh30.com/posts/Java/java-reference-type/
https://blog.csdn.net/u011936381/article/details/11709245
https://www.jianshu.com/p/73260a46291c
https://crowhawk.github.io/2017/08/10/jvm_2/