JVM 对象存活和垃圾收集算法

本文是对《深入理解 Java 虚拟机》中第 3 章前半部分的回顾与总结,这部分也是 JVM 的重点。
垃圾收集(Garbage Collection,GC)主要围绕以下三点进行展开:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

对象存活

垃圾收集器在对堆进行回收的之前,需要知道哪些对象还存在,哪些对象不可能再通过任何途径进行使用,即判断对象是否存活。

判断对象是否存活的方法

引用计数法(Reference Counting)

描述:给对象添加一个引用计数器,每当该对象被引用的时候,计数器就加 1;当引用失效时,计数器就减 1,直到计数器为 0 时,该对象不再被引用。
优点:实现简单,效率高。
缺点:难以解决对象之间相互循环引用的问题。

可达性分析法(Reachability Analysis)

该方法类似于树结构,通常将称为 GC Roots 的对象作为起始点,从这些点开始向下搜索,搜索的路径称为 引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连接时,则说明此对象是不可用的。
如下图1所示,虽然 Object5Object6Object7 之间是互相有关联的,但是它们到 GC Roots 是不可达的,所以它们被判定为可回收对象。

此外,可作为 GC Roots 对象的有:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中 Native 方法(JNI)引用的对象。
public class GCRootDemo {
    private byte[] byteArray = new byte[100 * 1024 * 1024];
 
    private static GCRootDemo gc2;
    private static final GCRootDemo gc3 = new GCRootDemo();
 
    public static void m1(){
        GCRootDemo gc1 = new GCRootDemo();
        System.gc();
        System.out.println("第一次GC完成");
    }
    public static void main(String[] args) {
        m1();
    }
}
gc1:是虚拟机栈中的局部变量
gc2:是方法区中类的静态变量
gc3:是方法区中的常量
都可以作为GC Roots 的对象。

四种不同的引用方式

以上两种方法都和 引用 有关,在 JDK 1.2 之前,如果 reference 类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。但我们希望当内存还足够时,对象能保留在内存中;如果内存空间在进行垃圾收集后还是紧张的话,则可以抛弃这些对象。所以在 JDK 1.2 之后就引入了如下四种不同的引用类型:

强引用:

  • 我们常用的编码如:Object obj = new Object();中的obj就是强引用,通过关键字new创建的普通对象都是强引用,只要某个对象有强引用与之关联,JVM必定不会回收这个对象,即使在内存不足的情况下,JVM宁愿抛出OutOfMemory错误也不会回收这种对象。
  • 只要强引用指向一个对象,就能表明对象还存在,垃圾收集器就不会碰这种对象
  • 对于一个普通的对象,如果没有其他的引用关系,只要超出了引用的作用域或者显示的将强引用赋值为null,就可以被垃圾回收收集了。
public class Test {
    public static void main(String[] args) {
        new Test ().f1();
    }
      
    public void f1() {
        Object object = new Object();
        Object[] objArr = new Object[99999999];
    }
}

软引用

软引用 说的是 一些有用但非必需的对象。对于一个软引用的对象,在系统将要发生内存溢出之前,将会把这些对象列入回收范围中进行第二次回收。如果回收后还没有足够的内存,则会抛出内存溢出异常。

  • 软引用生命周期比强引用短一些
  • 当JVM认为内存不足时,会去试图回收软引用指向对象
//当内存不足时,等价于:
if(JVM.内存不足()){
    obj = null;
    System.gc();
}
  • 如果JVM认为内存空闲,会暂时保留软引用,但也不一定会回收全部的软引用,更倾向于回收那些在内存中停留时间比较久的软引用。
  • 软引用可用来实现内存敏感的高速缓存。这个特性很适合用来实现缓存:比如网页缓存、图片缓存等。在JDK 1.2之后,提供了java.lang.ref.SoftReference类来实现软引用。
  • 软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。
		Object object=new Object();
		ReferenceQueue<Object> queue=new ReferenceQueue<Object>();
		SoftReference<Object> srf=new SoftReference<Object>(object, queue);
		System.out.println(srf.get());//java.lang.Object@15db9742,返回强引用对象
		//srf指向的对象持有强引用和软引用,不会被回收,因此不会加入到queue中
		System.out.println(queue.poll());//null
		
		// 清除强引用,触发GC  
		object = null;  
		System.gc();  
	    
		//该对象现在只有一个软引用
		System.out.println(srf.get()); //java.lang.Object@15db9742,返回强引用对象
	  
		//即使发生gc,该对象也不一定会被回收加入queue,只有内存空间不足才会回收软引用对象,因此queue中获取不到对象
		//发生gc到将对象加入到queue中需要一段时间,这里sleep等待,方便下面poll方法(非阻塞)获取值
		Thread.sleep(200);
		System.out.println(queue.poll()); //null

弱引用

弱引用软引用 还要弱一点,它描述的也是非必需的对象。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。

  • 弱引用的生命周期比软引用更短,在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了具有弱引用的对象,无论当前内存空间是否不足,都会回收它的内存
  • 在JDK 1.2之后,提供了WeakReference类来实现弱引用。(ThreadLocal采用的就是弱引用)
  • 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
		Object object=new Object();
		ReferenceQueue<Object> queue=new ReferenceQueue<Object>();
		WeakReference<Object> srf=new WeakReference<Object>(object, queue);
		System.out.println(srf.get());//返回该弱引用的强引用,java.lang.Object@15db9742
		//srf指向的对象持有强引用和弱引用,不会被回收,因此不会加入到queue中
		System.out.println(queue.poll());//null
		
		// 清除强引用,触发GC  
		object = null;  
		System.gc();  
	    
		//该对象现在只有一个弱引用,发生gc,回收弱引用对象,srf为空
		System.out.println(srf.get()); //null
	  
	    
		//发生gc到将对象加入到queue中需要一段时间,这里sleep等待,方便下面poll方法(非阻塞)获取值
		Thread.sleep(200);
		System.out.println(queue.poll());  //java.lang.ref.WeakReference@6d06d69c

幻象引用(虚引用)

  • 幻象引用也叫虚引用
  • 幻象引用用是最弱的一种引用关系,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,它随时可能会被回收,它只有一个构造函数和一个 get() 方法,而且它的 get() 方法仅仅是返回一个null,也就是说将永远无法通过虚引用来获取对象,虚引用必须要和 ReferenceQueue 引用队列一起使用

总结

引用类型被垃圾回收时间用途生命时间
强引用从来不会对象的一般状态JVM停止运行时终止
软引用在内存不足时对象缓存内存不足时终止
弱引用在垃圾回收时对象缓存GC运行后终止
虚引用UnknownUnknownUnknown

finalize() 方法

可达性分析法 在判断一个对象是否存活的时候,至少需要经过两次标记和筛选的过程:
第一次标记和筛选:如果对象在进行 可达性分析法 后发现没有与 GC Roots 相连接的引用链,即对象不可达,那它将会被第一次标记并进行筛选。此时,如果进行筛选,则将对象从 即将回收 的集合中取出;如果不进行筛选的话,对象就继续留在 即将回收 的集合里,等待被回收。

筛选的条件是该对象是否有必要执行 finalize() 方法。

  1. 如果有必要执行的话,则筛选出来,进入第二次标记和筛选阶段。
  2. 如果没有必要执行的话,则不筛选,判定该对象死亡,并等待回收。

当对象没有覆盖 finalize() 方法的时候,或 finalize() 方法已经被虚拟机调用过了,则虚拟机将这两种情况视为没有必要去执行。

第二次标记和筛选:当该对象被判定为有必要执行 finalize() 方法的时候,就会被放在一个叫 F-Queue 的队列里,并在稍后会被一个由虚拟机自动建立、低优先级的 Finalizer 线程去执行它。如果在这时 对象在 finalize() 过程中重新与引用链上的任何一个对象建立关联了(即:与 GC Roots 直接关联或者间接关联) ,则该对象就会被移出 即将回收 的集合,也就判断为该对象是 存活 状态。
整个过程如下图所示:

值得注意的是,任何一个对象的 finalize() 方法只会被系统自动调用一次,如果对象面临下一次回收,则它的 finalize() 方法不会再次被执行。

方法区的回收

方法区(或 HotSpot 虚拟机中的永久代)的垃圾收集效率是比较低的,其主要回收 废弃常量无用的类
如何判断一个常量是不是 废弃常量
假如一个字符串 abc 已经进入到常量池中,如果当前没有任何一个 String 对象引用常量池中的 abc,同时其它地方也没有引用这个字面量 abc,则再进行内存回收的时候,就判定该常量是 废弃常量,即 abc 就会被系统清理出常量池。常量池中的其它 类(接口)方法字段 的符号引用也是类似的。
如果判断一个类是不是 无用的类
如果一个类是 无用的类,则需要同时满足以下 3 个条件:

  • Java 堆中不存在该类的任何实例,即该类的实例都已经被回收了。
  • 加载该类的类加载器(ClassLoader)已经被回收了。
  • 该类所所对应的 java.lang.Class 对象没有在任何地方被引用,也就是说,无法在任何地方通过反射的方式访问该类的方法。

针对类来说,并不是满足以上三个条件就会被回收,也并不是像 对象 一样不使用了就必然会回收,而是说仅仅是处于 可被回收的状态。具体回不回收,可以通过配置虚拟机参数的方式进行。

垃圾收集算法

标记-清除算法

标记-清除算法(Mark-Sweep)由 标记清除 两个步骤组成:

  • 标记出所有需要回收的对象(标记方法如上面所示)
  • 在标记完成后统一清除(回收)所有被标记过的对象

处理过程如下图3所示:

缺点:

  • 效率问题:标记和清除这两个过程的效率都不高。
  • 空间问题:标记清除后会产生大量不连续的内存碎片,这会导致以后再程序运行的过程中,如果需要分配较大内存的时候,无法找到足够的连续内存而不得不提前触发另外一次的垃圾收集动作。

复制算法

为了解决上面出现的 效率空间 问题,提出了一种 复制算法, 具体步骤如下:

  • 首先将 可用内存 按容量划分为大小相等的两块,每次只使用其中的一块;
  • 然后当这一块的内存用完了之后,就将还存活着的对象复制到另一块上面;
  • 最后在把已使用过的内存空间一次性清理掉。

如下图3所示:

该方法每次需要对整个半区进行内存回收,无须考虑内存碎片的问题,只需要移动堆顶指针,按顺序分配内存即可。
由于该方法导致使用的内存变为原来的一半,在对象存活率较高时需要进行多次的复制操作,效率会变低。所以适用于对象存活率较低以及需要频繁进行垃圾回收的区域,如 新生代 区域。
在 Java 内存区域概述 中,我们知道 新生代 包括 Eden SpaceSurvivor 0 Space 以及 Survivor 0 Space
当进行内存划分的时候,每次使用 Eden Space其中一块 Survivor 区域,当进行回收的时候,将 Eden SpaceSurvivor还存活的对象 一次性复制到另一块 Survivor 区域,最后再清理掉 Eden Space 和刚才用过的 Survivor 区域。
假如在 复制到另一块 Survivor 区域 的过程中该空间不够了,则需要依赖 老年代 来进行 分配担保(Handle Promotion)。即将 另一块 Survivor 区域 没有足够空间存放上一次新生代收集下来的存活对象,通过 分配担保机制 存储到 老年代

标记-整理算法

标记-整理(Mark-Compact)算法适用于 老年代,具体步骤如下:

  • 标记出所有需要回收的对象
  • 将所有存活的对象都向一端移动
  • 清理掉端边界以外的内存

如下图所示:

该算法解决了 标记-清除 算法中存在的效率低、内存碎片问题。由于 将所有存活的对象都向一端移动,所以就减少了内存碎片,保留了较大内存空间;而通过 直接清理掉边界以外的区域 的方式实现了较高的效率。

分代收集算法(Generational Collection)

当前大多数垃圾收集都采用的分代收集算法,这种算法并没有什么新的思路,只是根据对象存活周期的不同将内存划分为几块,每一块使用不同的上述算法去收集。
在jdk8以前分为三代:年轻代、老年代、永久代。
在jdk8以后取消了永久代的说法,而是元空间取而代之。一般年轻代使用复制算法(对象存活率低),老年代使则采用 标记-清理标记-整理 的算法。(对象存活率高)。

年轻代(复制算法为主)

尽可能快的收集掉生命周期短的对象。整个年轻代占1/3的堆空间,年轻代分为三个区,Eden、Survivor-from、Survivor-to,其内存大小默认比例为8:1:1(可调整),大部分新创建的对象都是在Eden区创建。当回收时,先将Eden区存活对象复制到一个Survivor-from区,然后清空Eden区,存活的对象年龄+1;当这个Survivor-from区也存放满了时,则将Eden区和Survivor-from区存活对象复制到另一个Survivor-to区,然后清空Eden和这个Survivor-from区,存活的对象年龄+1;在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阈值(默认为15,新生代中的对象每熬过一轮垃圾回收,年龄值就加1,GC分代年龄存储在对象的header中)的对象会被移到老年代中,没有达到阈值的对象会被复制到To Survivor区。
此时Survivor-from区是空的,然后将Survivor-from区和Survivor-to区交换,即保持Survivor-from区为空(此时的Survivor-from是原来的Survivor-to区), 如此往复。年轻代执行的GC是Minor GC。
年轻代的迭代更新很快,大多数对象的存活时间都比较短,所以对GC的效率和性能要求较高,因此使用复制算法,同时这样划分为三个区域,保证了每次GC仅浪费10%的内存,内存利用率也有所提高。

老年代(标记-整理算法为主)

在年轻代经过很多次垃圾回收之后仍然存活的对象(默认15岁),就会被放入老年代中,因为老年代中的对象大多数是存活的,所以使用算法是标记-整理算法。老年代执行的GC是Full GC。

永久代/元空间

jdk8以前:
永久代用于存放静态文件,如Java类、方法等。该区域回收与上述“方法区内存回收”一致。但是永久代是使用的堆内存,如果创建对象太多容易造成内存溢出OOM(OutOfMemory)。

jdk8以后:
jdk8以后便取消了永久代的说法,而是用元空间代替,所存内容没有变化,只是存储的地址有所改变,元空间使用的是主机内存,而不是堆内存,元空间的大小限制受主机内存限制,这样有效的避免了创建大量对象时发生内存溢出的情况。

Minor GC和Full GC

之前多次提到Minor GC和Full GC,那么它们有什么区别呢?

  • Minor GC即新生代GC:发生在新生代的垃圾收集动作,因为Java有朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
  • Major GC / Full GC:发生在老年代,经常会伴随至少一次Minor GC。Major GC的速度一般会比Minor GC慢10倍以上。

Minor GC发生条件:

  • 当新对象生成,并且在Eden申请空间失败时;

Full GC发生条件:

  • 老年代空间不足
  • 永久代空间不足(jdk8以前)
  • System.gc()被显示调用
  • Minor GC晋升到老年代的平均大小大于老年代的剩余空间
  • 使用RMI来进行RPC或管理的JDK应用,每小时执行1次Full GC

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议