JVM之垃圾收集(Garbage Collection [Ⅰ])

255 阅读8分钟

垃圾收集 (Garbage Collection)

当List还在胚胎时期时,人们就在思考GC需要完成的3件事情:

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

我们为什么去了解GC和内存分配呢?

当需要排查各种内存溢出、内存泄露问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。

如何判断对象的生死

判断对象是否可用的两种方法。

判断对象是否可用的算法

引用计数算法(Reference Counting)

  • 算法描述:
    • 给对象添加一个引用计数器;
    • 每有一个地方引用它,计数器加 1;
    • 引用失效时,计数器减 1;
    • 计数器值为 0 的对象不再可用。
  • 缺点:
    • 很难解决循环引用的问题。即 objA.instance = objB; objB.instance = objA;,objA 和 objB 都不会再被访问后,它们仍然相互引用着对方,所以它们的引用计数器不为 0,将永远不能被判为不可用。

可达性分析算法(Reachability Analysis)

  • 算法描述:
    • 从 "GC Root" 对象作为起点开始向下搜索,走过的路径称为引用链(Reference Chain);
    • 从 "GC Root" 开始,不可达的对象被判为不可用。
  • Java 中可作为 “GC Root” 的对象:
    • 栈中(本地变量表中的reference)
      • 虚拟机栈中,栈帧中的本地变量表引用的对象;
      • 本地方法栈中,JNI 引用的对象(native方法);
    • 方法区中
      • 类的静态属性引用的对象;
      • 常量引用的对象;

【注】即便如此,一个对象也不是一旦被判为不可达,就立即死去的,宣告一个的死亡需要经过两次标记过程。

宣告对象死亡的两次标记过程

  • 当发现对象不可达后,该对象被第一次标记,并进行是否有必要执行 finalize() 方法的判断;
    • 不需要执行:对象没有覆盖 finalize() 方法,或者 finalize() 方法已被执行过(finalize() 只被执行一次);
    • 需要执行:将该对象放置在一个队列中,稍后由一个虚拟机自动创建的低优先级线程执行。
  • finalize() 方法是对象逃脱死亡的最后一次机会,不过虚拟机不保证等待 finalize() 方法执行结束,也就是说,虚拟机只触发 finalize() 方法的执行,如果这个方法要执行超久,那么虚拟机并不等待它执行结束,所以最好不要用这个方法。
  • finalize() 方法能做的,try-finally 都能做,所以忘了这个方法吧!

方法区的回收

永久代的 GC 主要回收:废弃常量无用的类

  • 废弃常量:例如一个字符串 "abc",当没有任何引用指向 "abc" 时,它就是废弃常量了。
  • 无用的类:同时满足以下 3 个条件的类。
    • 该类的所有实例已被回收,Java 堆中不存在该类的任何实例;
    • 加载该类的 Classloader 已被回收;
    • 该类的 Class 对象没有被任何地方引用,即无法在任何地方通过反射访问该类的方法。

垃圾收集算法

基础:标记 - 清除算法(Mark-Sweep)

  • 算法描述:
    • 先标记出所有需要回收的对象(图中深色区域);
    • 标记完后,统一回收所有被标记对象(留下狗啃似的可用内存区域……)。
  • 不足:
    • 效率问题:标记和清理两个过程的效率都不高。
    • 空间碎片问题:标记清除后会产生大量不连续的内存碎片,导致以后为较大的对象分配内存时找不到足够的连续内存,会提前触发另一次 GC。

解决效率问题:复制算法(Copying)

  • 算法描述:

    • 将可用内存分为大小相等的两块,每次只使用其中一块;
    • 当一块内存用完时,将这块内存上还存活的对象复制到另一块内存上去,将这一块内存全部清理掉。
  • 不足: 可用内存缩小为原来的一半,适合GC过后只有少量对象存活的新生代。

  • 节省内存的方法:

    • 新生代中的对象 98% 都是朝生夕死的,所以不需要按照 1:1 的比例对内存进行划分;
    • 把内存划分为:
      • 1 块比较大的 Eden 区;
      • 2 块较小的 Survivor 区;
    • 每次使用 Eden 区和 1 块 Survivor 区;
    • 回收时,将以上 2 部分区域中的存活对象复制到另一块 Survivor 区中,然后将以上两部分区域清空;
    • JVM 参数设置:-XX:SurvivorRatio=8 表示 Eden 区大小 / 1 块 Survivor 区大小 = 8

解决空间碎片问题:标记 - 整理算法(Mark-Compact)

  • 算法描述:
    • 标记方法与 “标记 - 清除算法” 一样;
    • 标记完后,将所有存活对象向一端移动,然后直接清理掉边界以外的内存。
  • 不足: 存在效率问题,适合老年代。

进化:分代收集算法(Generational Collection)

  • 新生代: GC 过后只有少量对象存活 —— 复制算法
  • 老年代: GC 过后对象存活率高 —— 标记 - 整理算法

HotSpot 中 GC 算法的实现

通过前两小节对于判断对象生死和垃圾收集算法的介绍,我们已经对虚拟机是进行 GC 的流程有了一个大致的了解。但是,在 HotSpot 虚拟机中,高效的实现这些算法也是一个需要考虑的问题。

通过之前的分析,GC 算法的实现流程简单的来说分为以下两步:

  1. 找到标记死亡的对象;
  2. 清理对象。

想要找到死掉的对象,我们就要进行可达性分析,也就是从 GC Root 找到引用链的这个操作。

  • 第一步,枚举 GCRoots: 需要虚拟机知道哪些地方存放着对象应用。不能每一次枚举 GC Roots 都需要把整个栈上位置都遍历一遍,毕竟并不是所有位置都存放在引用。

【为了提高 GC 的效率】HotSpot 使用了一种 OopMap 的数据结构。 OopMap 记录了栈上本地变量到堆上对象的引用关系

OopMap 也需要被更新的,当内存中的对象间的引用关系发生变化时,就需要改变 OopMap 中的相应内容。可是能导致引用关系发生变化的指令非常之多,如果我们执行完一条指令就改下 OopMap,这 GC 成本实在太高了。

  • 【安全点(Safepoint)】: 我们都知道,JVM 中每条指令执行的是很快的,所以一个超级长的指令流也可能很快就执行完了,所以 真正会出现 “长时间执行” 的一般是指令的复用,例如:方法调用、循环跳转、异常跳转等,虚拟机一般会将这些地方设置为安全点更新 OopMap 并判断是否需要进行 GC 操作。

在进行枚举根节点的这个操作时,为了保证准确性,我们需要在一段时间内 Stop The World( GC 停顿),因为如果在我们分析可达性的过程中,对象的引用关系还在变来变去,那是不可能得到正确的分析结果的。即便是在号称几乎不会发生停顿的 CMS 垃圾收集器中,枚举根节点时也是必须要停顿的。这里就涉及到了一个问题:

我们让所有线程跑到最近的安全点再停顿下来进行 GC 操作呢?

主要有以下两种方式:

  • 抢先式中断:
    • 先中断所有线程;
    • 发现有线程没中断在安全点,恢复它,让它跑到安全点。
  • 主动式中断: (主要使用)
    • 设置一个中断标记;
    • 每个线程到达安全点时,检查这个中断标记,选择是否中断自己。

除此安全点之外,还有一个叫做 “安全区域” 的东西,一个一直在执行的线程可以自己 “走” 到安全点去,可是一个处于 Sleep 或者 Blocked 状态的线程是没办法自己到达安全点中断自己的,我们总不能让 GC 操作一直等着这些个 ”不执行“ 的线程重新被分配资源吧。对于这种情况,我们要依靠安全区域来解决。

安全区域是指在一段代码片段之中,引用关系不会发生变化,因此在这个区域中的任意位置开始 GC 都是安全的。

当线程执行到安全区域时,它会把自己标识为 Safe Region,这样 JVM 发起 GC 时是不会理会这个线程的。当这个线程要离开安全区域时,它会检查系统是否在 GC 中,如果不在,它就继续执行,如果在,它就等 GC 结束再继续执行。