垃圾收集器与内存分配策略

一、概述

  • 程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,所以不需要过多考虑回收的问题
  • Java 堆和方法区的内存分配和回收都是动态的,所以 GC 关注的是这一部分内存

二、对象已死吗——判断对象状态

  • GC 在堆进行回收前,需要确定哪些对象还活着,哪些已经不可能再被任何途径使用
1、引用计数法
  • 引用计数法 Reference Counting实现简单,判定效率也很高
  • 但是主流Java 虚拟机中并没有选择他来管理内存,因为他很难解决对象之间相互循环引用的问题
2、可达性分析算法
  • 主流的商用程序语言用可达性分析Reachability Analysis来判定对象是否存活的
  • 当一个对象到 GC Roots 没有任何引用链时,就会被判定为可回收对象
  • Java 中的 GC Roots 包括
    • 虚拟机栈中引用的对象
    • 方法区中静态属性引用的对象
    • 方法区中常量引用的对象
    • 本地方法栈中 JNI(native 方法)引用的对象
3、再谈引用

JDK1.2之后,java 的引用分为了四种:

  • 强引用Strong Reference:Object obj = new Object()。只要强引用存在,GC 将永远不会回收掉被引用的对象
  • 软引用Soft Reference:在系统将要发生内存溢出之前,将会对这些对象进行二次回收,如果之后还没有足够内存,就抛出内存溢出异常
  • 弱引用Weak Reference:被弱引用关联的对象只能存活到下一次 GC之前,无论内存是否足够,都会回收
  • 虚引用Phantom Reference:虚引用对对象的生存时间毫无影响,也不能通过虚引用来获取实例,唯一用处就是在这个对象被 GC 时收到一个系统通知
4、回收之前的缓刑——标记
  • 在可达性分析算法中,要通过两次标记过程才能宣告一个对象死亡
  • 第一次分析,对象没有与 GC Roots 相连的引用链,被进行第一次标记,并进行筛选
  • 通过该对象是否覆盖finalize()或已经被 VM 调用过,来筛选出不需要执行finalize 方法的对象
  • 剩下必须执行 finalize 方法的对象,
    • 会被放在一个 F-Queue队列中,并在一个由 VM 自动建立的, 低优先级的 Finalizer 线程去执行
    • 如果一个对象在 finalize 方法中执行缓慢,或者发生了死循环,将导致 F-Queue 中其他对象永久等待,甚至 GC 崩溃
  • 然后 GC 会对 F-Queue 中的对象进行第二次标记
  • 如果对象在 finalize 中重新与引用链上的任意对象建立关联,就将被移除回收队列,剩下的对象将被真的回收
  • 每个对象的 finalize 对象只会被系统自动执行一次
  • 不建议使用 finalize 方法,因为运行代价高昂,不确定性大,无法保证各个对象上的调用顺序,而且他的工作可以用 try-finally 做的更好
5、回收方法区

方法区(HotSpot 的永久代)回收效率很低。

主要回收废弃常量和无用的类。

废弃常量:常量没有任何地方被引用时

无用的类:

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

当无用的类满足这三个条件时,仅仅是可以被回收,不是必然。HotSpot 提供一系列虚拟机参数进行控制。

频繁自定义 ClassLoader 的场景都需要 VM 具备类卸载的功能,保证永久代不溢出。

三、垃圾收集算法

1、标记-清除算法 Mark-Sweep
  • 最基础的算法,分为标记和清除两个步骤,标记就是前文讲的两次标记。后续的算法都是基于这种思路进行改进的
  • 效率低,标记和清除效率都不高
  • 空间问题,清除之后会产生大量不连续的内存碎片。如果以后要分配较大对象时,会导致提前 GC
2、复制算法 新生代
  • 将内存一分为二,大小相等。当这一块内存用完了,就将存货对象复制到另一半,然后将这一块内存全部回收
  • 每次都是对半个区域进行回收,避免了内存碎片的问题,只要移动堆顶指针,按顺序分配内存即可。
  • 缺点就是可使用的内存变为原来的一半
  • 该算法主要用于新生代的回收
    • 内存划分为一块大的 Eden 和两块小的 survivor,每次使用 Eden和一块 Survivor
    • 当回收时,将 Eden 和 Survivor 中存活的对象复制到另一块 Survivor 上,然后清理掉Eden 和第一块 Survivor
    • 一般场景,98%的对象会被回收。但是当 Survivor 内存不够时,需要依赖老年代来进行分配担保
    • 如果另一块 Survivor 没有足够空间存放上一次新生代收集下的对象,那么这些对象将直接进入老年代
3、标记—整理算法 Mark-Compact 老年代
  • 先进行标记,然后让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
4、分代收集算法 Generational Collection
  • 根据对象存活的周期将内存划分为几块
  • 在新生代中,对象存活率低,可以用复制算法
  • 在老年代中,对象存活率高,必须使用标记清理,或者标记整理算法

四、HotSpot 的算法实现——如何发起内存回收

1、枚举根节点
  • 可达性分析必须在一个能确保一致性的快照中进行,所以 GC 时必须停顿所有Java 执行线程
  • 可达性分析检查 GC Roots ,逐个检查很耗时
  • 所以目前主流的 JVM 都使用准确式 GC(虚拟机可以知道内存中某个位置的数据具体是什么类型),不需要逐个检查 GC Roots
  • HotSpot 采用一组成为 OopMap 的数据结构,来得知那些地方存放着对象引用
  • 在类加载成功的时候,HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来,GC 在扫描时就可以直接得知这些信息
2、安全点
  • 在 OopMap 的协助下,HotSpot 可以快速准确的完成 GC Roots 枚举
  • 只有在安全点,HotSpot 才会为指令生成 OopMap
  • 安全点的选定既不能太少让 GC 等太久,也不能过于频繁加大运行时负荷
  • 所以安全点一般是在方法循环、调用、异常跳转等让程序长时间执行的指令时产生的
  • 抢先使中断和主动式中断可以让线程到最近的安全点上,抢先式现在基本不用了
  • 主动式中断仅仅设置一个标志,各个线程去轮循这个标志,当发现中断标志为真,就自己中断挂起。轮循标志的地方和安全点是重合的
3、安全区域
  • 安全区域指的是一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始 GC 都是安全的。
  • 当线程处于 Sleep 或者 Blocked 状态下,需要使用安全区域
  • 当线程执行到 安全区域中的代码时,首先标记自己已经进入了安全区域,这样 GC 时就不会将他回收
  • 然后该线程需要在完成 GC Roots 枚举或者 GC 完成才能离开安全区域

五、垃圾收集器

Young Generation:Serial、ParNew、Parallel Scavenge

G1

Tenured Generation:CMS、Serial Old(MSC)、Parallel Old

1、Serial 收集器
  • 最基本的收集器,使用复制算法,只会使用一个 CPU 或者一条收集线程去完成垃圾收集工作
  • 在垃圾收集时,必须暂停其他所有的工作线程,直到他收集结束
  • 是 Client 模式下的默认新生代收集器
  • 简单高效,没有线程交互的开销
  • 收集几十兆到一两百兆的新生代,停顿时间可以控制在几十毫秒最多一百毫秒内
2、ParNew 收集器
  • 是 Serial 收集器的多线程版本,使用复制算法,使用多条线程进行垃圾收集,其余都和 Serial 一样

  • 是 Server 模式下默认的新生代收集器

  • 除了 Serial 之外,只有他能和 老年代的CMS (Concurrent Mark Sweep)配合工作

  • 默认开启的收集线程数与 CPU 的数量相同,可以使用 -XX:ParallelGCThreads参数来限制线程数

    并行 Parallel:多条垃圾收集线程同时并行工作,此时用户线程处于等待状态

    并发Concurrent:用户线程和垃圾收集线程同时执行,可能会交替执行,分别在不同的 cpu 上

3、Parallel Scavenge 收集器——吞吐量优先收集器,自适应调节策略
  • 使用复制算法的收集器,并行的多线程收集器
  • 致力于达到一个可控制的吞吐量。吞吐量= 运行代码时间 / (运行代码时间 + 垃圾收集时间)
  • 高吞吐量可以更高率的利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务
  • -XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间。是一个>0的毫秒数
  • -XX:GCTimeRatio:直接设置吞吐量大小。是一个0~100的整数,也就是垃圾收集时间所占的总时间的比率,吞吐量的倒数
  • -XX:+UseAdaptiveSizePolicy:打开这个参数,就不需要手动指定新生代大小,虚拟机会根据当前系统的运行状况,收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量
4、Serial Old 收集器
  • 是 Serial 收集器的老年代版本,单线程收集器
  • 使用标记整理算法
  • 主要适合 client 模式下的虚拟机使用,可以与 Paralle Scavenge 搭配使用
5、Parallel Old 收集器
  • 是 Parallel Scavenge 的老年代版本
  • 使用多线程和标记整理算法
  • 拯救了 Parallel Scavenge,之前由于 Serial Old 的拖累,无法充分利用多服务器多 CPU 的处理能力
  • Parallel Old + Parallel Scavenge 收集器,可以真正实现高吞吐量
6、CMS Concurrent Mark Sweep 收集器——并发低停顿收集器

特征:

  • 基于标记—清除算法
  • 分为四个步骤
    • 初始标记 需要 stop
    • 并发标记 可以和用户线程一起并发执行
    • 重新标记 需要 stop
    • 并发清除 可以和用户线程一起并发执行

缺点:

  • 占用线程 CPU 资源,会导致程序变慢,吞吐量变低
  • i-CMS 增量并发收集器让并发标记清理和用户线程交替运行,减少对资源占用时间,但是效果一般,不提倡使用
  • 无法处理浮动垃圾,在并发清理时会不断有新垃圾产生,可能出现 Concurrent Mode Failure 失败而导致领一次 Full GC 的产生
  • 标记—清除算法会导致大量内存碎片产生
  • -XX:+UseCMSCompactAtFullColletion:用于在 CMS 要 FullGC 时开启内存碎片整理合并。
  • -XX:CMSFullGCsBeforeCompaction:用于设置执行多少次不压缩的 FullGC后,跟着来一次带压缩的 GC
7、G1收集器

G1是一款面向服务端应用的垃圾收集器。

特点:

  • 并行与并发:充分利用多核多 CPU 的优势来缩短 STOP-THE-WORLD 停顿时间。
  • 分代收集:G1可以独立管理整个 GC堆,但是可以用不同方式去处理不同对象
  • 空间整合:G1从总体上来看是「标记--整理」算法,从局部来看是基于「复制」算法,所以不会产生内存碎片,收集后能提供规整的可用内存
  • 可预测的停顿:G1除了追求低停顿之外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒
  • G1将整个Java堆分为多个大小相等的独立区域,新生代和老年代是一部分 Region 的集合。
  • G1跟踪各个 Region 里垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
  • G1的每个 Region 都有一个与之对应的 Remembered Set,来避免全堆扫描

G1收集器的步骤:

  1. 初始标记:标记一下GC Roots 能直接关联到的对象,需要很短的停顿线程
  2. 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,找出存活的对象,耗时长,可与用户程序并发执行
  3. 最终标记:为了修正正在并发标记期间因用户程序导致标记产生变化的那一部分
  4. 筛选回收:首先对各个 Region 的回收价值和成本进行排序,根据用户期望的 GC 时间来制定回收计划
8、理解 GC 日志

[GC [FullGC表示这次 GC 的停顿类型,如果有 Full ,表明这次发生了Stop-The-World。

[DefNew [Tenured [Perm表示GC发生的区域,新生代,老年代和永久代。名字随收集器的不同而变化。

六、内存分配与回收策略 (serial/serial old 复制和标记整理算法)

1、对象优先在 Eden 分配

  • 大多数情况下,对象在新生代 Eden 区中分配,当 Eden 没有足够的内存空间进行分配时,虚拟机将发起一次 Minor GC
  • -XX:+PrintGCDetails:这个收集日志参数,告诉虚拟机在发生GC 行为时打印内存回收日志,并在进程退出的时候输出当前的内存各区域分配情况。一般是通过日志工具进行分析。
  • 当Eden 不够时,触发 Minor GC,进行复制算法收集,当发现 Survivor 1内存也不够时,就将 eden 和 Survivor 0中的对象都分配到老年代,然后将新分配的放到新生代。

2、大对象直接进入老年代

  • 大对象:需要大量连续内存空间的Java对象。程序中应该避免出现短命的大对象,经常出现大对象会提前触发 GC。
  • -XX:PretenureSizeThreshold:设置一个值,令大于这个值的对象直接进入老年代分配

3、长期存活的对象将进入老年代

  • 虚拟机给每个对象定义了一个 age计数器,如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳,那就移动到 Survivor 空间中,此时 age=1,每存活一次 Minor GC,age+1。默认限度是15,当超过15岁时,就会晋升到老年代中。
  • -XX:MaxTenuringThreshold:当 age 大于这个值,就会进入老年代。

4、动态对象年龄判定

  • 如果在 Survivor 空间中相同年龄的所有对象的大小总和,大于 Survivor 空间的一半,那么>=该年龄的对象就可以直接进入老年代,无需等待到 MaxTenuringThreshold。

5、空间分配担保

  • 在发生 Minor GC 之前,虚拟机都会先检查老年代最大可以用的连续空间是否大于新生代所有对象总和
    • 如果成立,则 Minor GC 是安全的。

    • 如果不成立,则虚拟机会查看「HandlePromotionFailure」设置值是否允许担保失败

    • 如果允许,则会继续检查老年代的空间,是否大于以前 eden 的平均值

      • 如果大于,则尝试进行一次 minor GC
      • 如果小于,或者不允许,则改为 FullGC

      JDK1.6之后,改为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则进行 Full GC