JVM内存回收策略
2021-01-11 / highPhone啊

判断对象“已死”

大多数对象实例都在堆中,垃圾收集器在对堆进行回收前,需要先确定哪些对象还“存活”,哪些对象已经“死去”(即不可能再被任何途经使用的对象)。判断对象已死常用算法有 引用计数法和可达性分析算法

引用计数法(Reference Counting)

在引用计数法中,会给每个对象添加一个引用计数器,每当有一个地方引用到这个对象时,这个对象的引用计数器加1;当有一个这个对象的引用失效时,引用计数器减1,那些引用计数器为0的对象,就是不可能再被使用的,引用计数法就认为这个对象“已死”。
引用计数法的优点是实现简单,判定效率也很高,在一些情况下它是一个不错的算法。但是它的缺点也非常明显:首先,为每个对象分配内存时,需要为引用计数器分配额外内存,最致命的是,引用计数法无法解决循环引用的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ReferenceCountingTest {

public Object reference = null;

public static void main(String[] args) {
ReferenceCountingTest objectA = new ReferenceCountingTest();
ReferenceCountingTest objectB = new ReferenceCountingTest();
objectA.reference = objectB;
objectB.reference = objectA;
objectA = null;
objectB = null;

}
}

如上面的代码,objectA和ObjectB中都有一个reference成员变量,objectA的reference指向objectB,objectB的reference指向objectA,当objectA和objectB都赋值为null后,在我们认为,这两个都已经是垃圾对象了,是可以被回收的,但是由于objectA和objectB相互被对方的referenc引用,所以他们的引用计数器都是1,如果使用引用计数法判断,那么这两个对象都是“存活”的,不会被回收。

备注:可以使Recycler-环状引用计数法来解决循环引用的问题(了解), 但是在多线程环境下,引用计数变更也要进行昂贵的同步操作,性能较低。

可达性分析(Reachability Analysis)算法

由于引用计数法无法解决循环引用的问题,所以在JVM中没有采用这个算法进行判读对象是否“已死”,而是使用了可达性分析算法
在可达性分析算法中,以一系列成为GC ROOT的对象为起点,从这些节点开始往下搜索,搜索的路径叫做引用链(Reference Chain)。对于一个对象,如果没有任何GC ROOT可以通过引用链找到这个对象(不可达),则证明这个对象是不可用的。这样,即使内存中有类似与上面循环引用的对象,如果通过GC ROOT不可达,那也可以正确判定这些对象是可回收的对象。

GC ROOT

在Java中,以下几种对象可以作为GC ROOT对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(Native方法)引用的对象。

引用

“引用”与判断对象是否存活息息相关。在JDK1.2之前,对于引用的定义是这样的:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。
在JDK1.2之后,Java对“引用”的概念进行了扩充,将“引用”分为了强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,引用强度依次减弱。

  • 强引用(Strong Reference)
    强引用在程序代码中普遍存在,类似与使用new创建对象实例这种引用就是强引用。只要一个对象存在强引用,垃圾收集器回收内存垃圾时就不会回收这个对象。

  • 软引用(Soft Reference)
    软引用用来描述一些还有用但非必须的对象。在系统将要发生OOM时,会把这些对象列进回收范围进行第二次回收,如果二次回收后还没有足够的内存,才会抛出OOM。JDK1.2后提供了java.lang.ref.SoftReference类实现软引用。应用场景: 软引用可用于实现内存敏感的高速缓存。如果内存充足,就可以一直把缓存放在内存中,加快数据访问速度。如果内存不足了,缓存就会被回收掉。

  • 弱引用(Weak Reference)
    被弱引用引用的对象,只能存活到下一次垃圾收集发生前,当下一进行垃圾收集时,无论内存是否充足,弱引用对象都会被回收。JDK1.2后提供了java.lang.ref.WeakReference来实现弱引用。

  • 虚引用(Phantom Reference)
    一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用来获取对象实例。设置虚引用通常是为了让这个对象被垃圾收集器回收时,能收到一个系统通知。在JDK1.2后,提供了java.lang.ref.PhantomReference类来实现虚引用。

垃圾收集算法

标记-清除(Mark-Sweep)算法

标记-清除算法算法分为两个阶段,第一阶段是追踪(Tracing)阶段:从GC Root开启沿着引用链遍历对象图,并标记(Mark)所遇到的对象。第二阶段是清除(Sweep)阶段,垃圾回收器检查堆中所有对象,并将所有未被标记的对象回收。标记-清除算法是基础收集算法,实现简单,不需要额外的移动对象的操作。它主要存在两个问题:标记和清除两个过程的效率不高、标记清除后可能会产生大量不连续的内存碎片。

复制(Copying)算法

复制算法是为了解决效率问题提出的,它将可用内存容量划分为大小相等的两块,每次只使用其中一块。当使用的一块中内存满了,就将还存活的对象复制到另一块空的内存中,复制完后清除已使用的一块内存空间。复制算法的优点是实现简单,运行高效,不用考虑内存碎片问题。缺点是需要浪费一半的内存空间,同时,如果存活对象中有大对象时,复制成本太高,也正是因为复制需要成本,所以复制算法适用于对对象存活率不高的区域进行垃圾回收,这样,需要复制的对象不多,效率会很高。

  • 经IBM公司专门的研究表明,JVM新生代中98%的对象都是”朝生夕死”,所以,新生代对象回收非常适合使用复制算法来进行垃圾回收。因为只有大约2%的对象存活,所以还可以对复制算法进行优化:在默认情况下,在新生代中内存按照8:1:1的比例分为了Eden Space、Survivor From、Survivor To三个区域,每次垃圾收集发生时,都将Eden Space、Survivor From区域中还存活的对象复制到Survivor To区中,然后清理Eden Space、Survivor From区域内存,这样每次留空(“浪费”)的内存只占用了新生代内存区域的10%。
  • 当然,在某些极端场景下,我们没有办法保证每次祸首都只有不多于10%的对象存活,当Survivor To区中内存不足以放下单次垃圾回收中还存活的对象时,这些对象将通过 分配担保(Handle Promotion) 机制直接进入老年代。

标记-整理(Mark-Compact)算法

标记-整理算法也叫标记-压缩算法,在标记阶段于标记-清除算法一样,但标记完成后,不会直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理端边界以外的内存。在老年代的垃圾收集中,对象存活的比率比较高,不适合使用复制算法,一般会使用标记-整理算法进行垃圾收集。

总结

上面三种算法中包含的操作,时间与空间效率总结如下表,用L代表存活对象数量,H代表堆大小:

收集算法包含动作是否移动对象空间开销时间开销
标记-清除(Mark-Sweep)算法Mark、Sweep低(有碎片)Mark时间与L成正比O(L) + Sweep时间与H成正比O(H)
复制(Copying)算法CopyingCoying与L成正比O(L)
标记-整理(Mark-Compact)算法Mark、Compaction低(没碎片)Mark时间与L成正比O(L) + Compaction时间与L成正比O(L)

把 mark、sweep、compaction、copying 这几种动作的耗时放在一起看,大致有这样的关系:

本文链接:https://highphone.xyz/8cd769c.html