Java 垃圾回收

1,440 阅读5分钟

垃圾回收的技术早于 Java 出现,现在已经高度自动化,深入了解垃圾回收的技术有助于分析内存溢出和泄漏的情况,当垃圾回收机制成为多线程情景下的性能瓶颈时也能做出调整和改善。

确定对象状态

垃圾回收的第一步是要确定哪些对象不再被使用,可以被回收。

引用计数法

通过给对象加一个引用计数器,有一处引用加一,有一处引用失效减一。这个方法实现简单,效率也比较高,但由于无法正确判断循环引用的对象的引用,虚拟机没有采取这种方法。

可达性分析

由一组特殊的对象 GC Roots 开始,沿着引用链向下分析,凡是不可达的对象都可以被回收。可以被归为 GC Roots 的对象有:

  • 虚拟机栈局部变量表中引用的对象
  • 方法区类的静态对象
  • 方法区的常量
  • 本地方法栈中 JNI 引用的对象

引用

JDK 1.2 之前,引用的概念局限于 reference 数据中的数值是另一块内存的起始地址,对象只有被引用和没有被引用两种状态。

JDK 1.2 之后,为了在内存足够时保留一些非必要的对象不被回收,引入了四种引用类型,分别是强引用、软引用、弱引用、虚引用,引用强度依次降低。

强引用 类似 Object obj = new Object() 这样的引用,只要引用还存在,被引用的对象就不会被回收。

软引用 对象有用但非必需,在发生内存溢出之前才会回收软引用的对象。用 SoftReference 类实现。

弱引用 对象非必需,弱引用的对象只能生存到下一次垃圾回收之前,无论内存是否够用都会被回收。用 WeakReference 类实现。

虚引用 虚引用的对象在被回收时会有系统通知。用 PhantomReference 实现。

finalize 的作用

在可达性分析中标记为不可达的对象还会进行一次筛选,如果对象没有覆盖 finalize 方法或对象的 finalize 方法已经被调用过,这个对象都会被归为没有必要执行 finalize 的对象。没有必要执行 finalize 方法的对象会被回收,有必要执行 finalize 方法的对象被放入队列中,由虚拟机自动创建的 Finalizer 线程依次执行对象的 finalize 方法,但虚拟机不保证等待每个对象的 finalize 方法都执行结束,以免因为部分方法执行过慢或进入死循环使得整个垃圾回收机制崩溃。

在执行 finalize 方法时,如果对象能与引用链上的对象建立关联就不会被回收。但是一个对象的 finalize 方法只能用一次,“自救”过的对象在下一次被判定为不可达时就会被回收。

实际中 finalize 方法运行代价高,也不能确定对象的调用顺序,其功能完全可以用 try finally 代替。

方法区的回收

方法区的回收虽然收集效率远低于堆,但也有其存在的必要性。这部分的回收主要是常量的回收和类的回收。回收常量的过程与回收对象类似。类需要满足三个条件才会被判定为可以被回收:

  • 类的所有实例都已被回收。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类的 java.lang.Class 对象没有在任何地方被引用,也就是无法在任何地方通过反射访问该类。

垃圾收集算法

标记-清除

分为标记和清除两个阶段,先标记所有需要回收的内存,然后再清除。这是最基础的方法,但效率不高,会产生内存碎片,当碎片多到一定程度时,大对象没有连续的内存来分配只能提前触发下一次垃圾收集。

复制

将内存分为两部分,每次只用一部分,这部分的空间用完时,将仍然需要的对象复制到另一部分内存,对第一部分进行整体清理。这个方法如果等分内存会导致内存的利用率低,相当于只有一半的内存可用,如果不等分,后续的复制对象规模不定,需要另外的内存作为保障。另外,对象存活率较高的场景中,频繁的复制对象也会成为效率的瓶颈。

HotSpot 虚拟机将内存分为 Eden 空间和 Survivor 空间,其大小比例为 8:1,每次用 Eden 空间和一个 Survivor 空间,回收时将对象复制到另一个 Survivor 空间,用老生代作为内存担保。

标记-压缩

标记所有存活的对象,将它们集中到一块连续的内存,然后清理其他部分的内存。适合对象存活率高的老生代内存。

分代收集

虚拟机普遍采用的方法,根据对象存活周期将内存分为不同区域。一般把 Java 堆分为新生代和老年代,根据不同代的特点用不同的收集算法。新生代对象存活率低,需要清除的内存多,用复制的方法。老年代对象存活率高,没有额外空间担保,不适合复制,需要标记-清理或标记-压缩的方法。