代码改变世界

Java 垃圾回收算法

2017-08-09 23:37  l4y  阅读(711)  评论(0编辑  收藏  举报

在之前Java 运行期数据区一文中,介绍了运行时内存的各个部分。其中程序计数器、虚拟机栈、本地方法栈都随线程消亡,所以,这几个区域的内存分配和回收都具备确定性。而 Java 堆和方法区不同,我们只有在程序运行期间才能知道会创建哪些对象,这部分的内存分配和回收都是动态的,这也正是垃圾回收器关注的部分。

对象的生与死

垃圾回收器要在进行回收前,需要确定哪些对象的状态,是“存活”还是”死亡“。

引用计数法

给对象添加一个引用计数,每当有一个地方引用对象时,引用计数加 1 ;当引用实现时,引用计数减 1 ;引用计数位 0 的对象不能再背引用。

C++ 中的 unique_ptr 内部就是通过引用计数来实现的,不过该方法有个弊端,对象间可能存在循环引用。Java 没有采取改方法。

可达性分析(Reachability Analysis)算法

Java、C# 都通过该方法来判定对象是否存活。可达性分析算法的思路是通过一系列称为“GC Roots”的对象作为起始点,从这些点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用相连(从 GC Roots 到这个对象不可达)时,则证明这个对象是不可用的。

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

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

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判断对象是否存活都与“引用”有关。在 JDK 1.2 之后,Java 中的引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference) 4 种,这 4 种引用强度依次逐渐减弱。

  • 强引用只要存在,垃圾回收器永远不会回收掉被引用的对象。如 Object obj = new Object()
  • 软引用用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。JDK 提供 SoftReference 类来实现软引用。
  • 弱引用用来描述非必需对象,其强度比软引用更弱。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。JDK 提供 WeakReference 类来实现弱引用。
  • 虚引用是最弱的引用关系。一个对象是否有虚引用存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。JDK 提供 PhantomReference 类来实现虚引用。

方法区的回收

Java 运行期数据区一文中,我们介绍过,方法区是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

永久代的垃圾收集主要回收两部分内容:废弃常量无用的类。回收废弃常量与回收 Java 堆中的对象非常类似。而判定一个类是否是“无用的类”的条件则相对严苛。类需要同时满足3个条件才能算是“无用的类”:

  1. 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  2. 加载该类的 ClassLoader 已经被回收;
  3. 该类对象的 java.lang.Class 对象没有在任何地方呗引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以堆满足上述 3 个条件的无用类进行回收,注意,跟对象不一样,不是“无用就一定会回收”。

垃圾回收算法

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

算法氛围“标记”和“清除”两个阶段:首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象。该算法主要有两个不足:一是效率问题,笔记和清除两个过程的效率够不高;二是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前出发另一次垃圾收集动作。

复制(Copying)算法

为了解决效率的问题,“复制“算法出现了,该算法将可用内存按容量氛围大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行回收,内存分配时久不用考虑内存碎片等复杂情况,只要一动堆顶置针,按顺序分配即可,实现简单,运行高效。该算法的缺点是内存被缩小位原来的一半。

IBM 的研究表明,新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和 刚才用过的 Survivor 空间。

当然,我们无法保证一块 Survivor 的空间足够容纳所有的存活对象,所以需要依赖其他内存(老年代)进行分配担保(Handle Promotion)。分配担保的内容后边会介绍到。

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

根据老年代的特点,产生了“标记-整理”算法,标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,二是让所有存活的对象都向一段移动,然后直接清理掉端边界以外的内存。

分代收集(Generational Collection)算法

算法思想是:根据对象存活周期的不同将内存划分为几块。一般是把堆分为新生代和老年代,这样就可以根据各个年代的特点采用最合适的收集算法。

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。

参考:

《深入理解Java虚拟机》