《深入理解 Java 虚拟机》笔记(2) 垃圾收集与内存分配

垃圾收集(Garbage Collection)

对象存活

在进行垃圾收集前,首先需要判断对象是否「存活」。常见的判断算法有以下两种:

  1. 引用计数法(Reference Counting) 给对象添加一个引用计数器,每当有一个地方引用此对象,计数器值+1;每当一个引用失效时,计数器值-1。计数器值为 0 的对象被判断为「不存活」。 这个算法存在「循环引用」的问题,即: objA.m = objB; objB.m = objA 时,objA 和 objB 永远不会被回收。
  2. 可达性分析算法(Reachability Analysis) 以一系列被称为'GC Roots'的对象为起点,沿着这些对象的引用链(Reference Chain)向下搜索,标记经过的对象。那些没有被标记的对象被判断为「不存活」。 Java 中'GC Roots'对象包括:
    • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
    • 方法区中类静态属性引用的对象。
    • 方法区中常量引用的对象。
    • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。

被判断为「不存活」的对象,还需要经历至少两次标记过程,才会被真正回收:

  1. 「不存活」的对象将首先被筛选,判断其是否需要执行 finalize() 方法。判断为需要执行的标准为:
    • 对象覆盖了 finalize() 方法
    • 对象的 finalize() 方法已经被虚拟机调用过
  2. 对象被判断需要执行 finalize() 方法后,会被放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。 finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记。对象要在 finalize() 中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可。

引用类型

Java 中引用分为四中类型:强引用、软引用、弱引用、虚引用。

  1. 强引用(Strong Reference) 强引用就是指在程序代码之中普遍存在的,类似 Object obj=new Object() 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  2. 软引用(Soft Reference) 软引用是用来描述一些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  3. 弱引用(Weak Reference) 弱引用也用来描述非必需对象。弱引用关联的对象只能存活到下一次 GC。
  4. 虚引用 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

方法区 GC

方法区(或说 HotSpot VM 中的永久代)实际也会进行 GC,只不过效率较低。 永久代的 GC 主要包括: 废弃常量、无用的类

  • 当常量池中的一个字面量不被任何一个地方引用,就会被判断为「废弃常量」
  • 一个类被判断为「无用的类」需要,以下三个条件:
    1. 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
    2. 加载该类的 ClassLoader 已经被回收。
    3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以「无用的类」进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot VM 提供了 -Xnoclassgc 参数进行控制。

垃圾收集算法

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

    首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

    标记-清除
    标记-清除

    缺点:

    • 标记和清除两个过程的效率都不高
    • 标记清除之后会产生大量不连续的内存碎片
  2. 复制算法(Copying)

    将内存划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

    复制
    复制
    • 优点:
      • 内存分配时不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效
    • 缺点:
      • 可用内存只有原来的一半

    现在的商业虚拟机都采用这种收集算法来回收新生代,不过不是按照 1:1 的大小划分内存空间。 新生代中将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1。

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

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

    标记-整理
    标记-整理

HotSpot GC 算法实现

  1. 可达性分析过程

    可达性分析时需要在一个确保「一致性」的快照中进行,即系统状况被冻结在某一个状态,这样分析结果的准确性才得以保证。所以分析过程中,需要停顿所有线程,来检查所有引用的位置。在 HotSpot 中,一组被称为 OopMap 的数据结构保存了引用的位置信息。

  2. 安全点(Safe Point)

    由于可能导致 OopMap 变化的指令非常多,为每一条指令生成对应的 OopMap 的代价太高,所以 HotSpot 只会在特定的位置——「安全点」记录引用位置信息。

    选取安全点的方案有两种:抢占式停顿(Preemptive Suspension)、主动式停顿(Voluntary Suspension)

    • 抢占式停顿不需要线程执行代码的配合,GC 发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它运行到安全点上
    • 主动式中断的思想是当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的。
  3. 安全区域(Safe Region)

    没有被分配 CPU 时间(处于 Sleep 状态或 Blocked 状态)的线程显然不能用 Safe Point 的方法来响应 JVM 的中断请求,这种情况需要用安全区域来解决。

    安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始 GC 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safe Point。

    在线程执行到 Safe Region 中的代码时,首先标识自己已经进入了 Safe Region。这样,当在这段时间里 JVM 要发起 GC 时,就不用管标识自己为 Safe Region 状态的线程了。在线程要离开 Safe Region 时,它要检查系统是否已经完成了根节点枚举(或者是整个 GC 过程)。如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开 Safe Region 的信号为止。

垃圾收集器

HotSpot VM 实现的垃圾收集器
HotSpot VM 实现的垃圾收集器

垃圾收集器间的连线表明它们可以搭配使用。

垃圾收集器中用到的名词解释:

  • 并行(Parallel): 指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent): 指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个 CPU 上。
  1. Serial 收集器

    单线程收集器,采用「标记-复制算法」,在 GC 时会暂停其他所有工作线程,直到 GC 结束。是 Client 模式下新生代的默认收集器。

  2. ParNew 收集器

    Serial 收集器的并行版,是许多运行在 Server 模式下的 VM 的首选新生代收集器(主要是因为除了 Serial 收集器外只有它能与 CMS 收集器配合工作)

  3. Parallel Scavenge 收集器

    与 ParNew 收集器类似,也是使用「标记-复制算法」的并行收集器,但是它的目标是达到一个可控制的吞吐量(throughput)。 \[吞吐量 = \frac{CPU 用于运行用户代码的时间}{CPU 总的消耗时间}\]

    停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

  4. Serial Old 收集器

    Serial 收集器的老年代版本,采用「标记-整理算法」的老年代单线程收集器,同样一般用于 Client 模式。

  5. Parallel Old 收集器

    Parallel Scanvenge 收集器的老年代版本,采用「标记-整理算法」的老年代并行收集器,同样注重吞吐量。

  6. CMS 收集器

    CMS(Concurrent Mark Sweep)收集器是一个以达到最短回收停顿时间的采用「标记-清除算法」的并发收集器。 运作过程分为以下四个步骤:

    1. 初始标记(CMS initial mark): 标记 GC Roots 直接关联的对象,时间很短
    2. 并发标记(CMS concurrent mark): 进行 GC Roots Tracing 过程,可以与用户线程共同执行,时间较长
    3. 重新标记(CMS remark): 修正并发标记期间用户线程导致变动的标记,时间略长于初始标记但远短于并发标记
    4. 并发清除(CMS concurrent sweep)

    CMS 虽然是一款优秀的收集器,但有以下三个缺点:

    • 对 CPU 资源敏感,会降低总吞吐量
    • 无法处理浮动垃圾(Floating Garbage)。浮动垃圾是指并发标记阶段用户线程产生的新的垃圾,这些垃圾 CMS 只能等到下一次 GC 清除掉。
    • CMS 收集器采用的「标记-清除算法」会留下大量的空间碎片,给老年代中分配大对象带来麻烦。

内存分配机制

Java 采用分代分配、分代回收的内存机制对象分局存活的时间分为新生代(Young Generation)和老年代(Old Generation)。

  1. 新生代

    对象被创建时,首先在新生代分配内存。可以通过 -Xmn 参数设置新生代大小。 新生代分为 1 个 Eden 区和 2 个 Survivor 区,Eden 区和 Survivor 区默认大小比 8:1,可以通过-XX:SurvivorRatio= 决定 Eden 区与 Survivor 区的大小比。

    对象分配内存时经历的过程如下:

    1. 如果是大对象直接分配到老年代(-XX:PretenureSizeThreshold 参数设置阈值),否则分配到 Eden 区。
    2. 当 Eden 区满时,执行 Minor GC,清除消亡的对象,并将存活的对象复制到一个 Survivor 区(记为 from)。这个过程中无法放入 Survivor 区的对象将通过分配担保机制提前转移到老年代中。
    3. 下一次 Eden 区满时,执行 Minor GC,清除 Eden 区和 from 区中消亡的对象,然后将两个区的对象复制到另一个 Survivor 区(记为 to)。
    4. 在这个循环的过程中,每经过一次 Minor GC,仍然存活的对象年龄 + 1。当对象的年龄达到一定值(默认为 15,参数 -XX: maxTenuringThreshold),它将晋升到老年代。
    1. 老年代

      老年代用于储存年龄较大的大对象,一般使用「标记-整理算法」。 在发生 Minor GC 时,虚拟机会检查每次晋升进入老年代的大小是否大于老年代的剩余空间大小,如果大于,则直接触发一次 Full GC,否则,就查看是否设置了 -XX:+HandlePromotionFailure(允许担保失败),如果允许,则只会进行 MinorGC,此时可以容忍内存分配失败;如果不允许,则仍然进行 Full GC