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

垃圾收集器

可达性分析算法

通过一系列的称谓 GC Roots 的对象作为起始点, 从这些节点开始向下搜索, 搜索所有走过的路径为引用链, 当一个对象到 GC Roots 没有任何引用链项链时, 则证明此对象时不可用的。

Java 语言中, 可作为 GC Roots 的对象包括下面几种:

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

引用类型

从 JDK1.2 之后, Java 对引用的概念进行了扩充, 将引用分为强引用, 软引用, 弱引用, 虚引用, 这四种引用的强度一次逐渐减弱。

  1. 强引用就是指在程序代码之中普遍存在的, 类似 “Object obj = new Object()” 这类的引用, 只要强引用还存在, 垃圾回收器永远不会回收掉被引用的对象。
  2. 软引用是用来描述一些还有用但并非需要的对象, 对于软引用关联着的对象, 在系统将要发生内存异常之前, 将会把这些对象列进回收范围之中进行第二次回收, 如果这次回收还没有足够的内存, 才会抛出内存异常。
  3. 弱引用也是用来描述非必需对象的, 但是它的强度比软引用更弱一些, 被弱引用关联的对象只能生存岛下一次垃圾收集发生之前, 当垃圾收集器工作时, 无论当前内存释放足够, 都会回收掉只被弱引用关联的对象。
  4. 虚引用也称为幽灵引用或者幻影引用, 它是最弱的一种引用关系, 一个对象是否有虚引用的存在, 完全不会对其生存时间构成影响, 也无法通过虚引用来取得一个对象实例, 对一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

垃圾收集算法

标记—清除算法

标记—清除算法是最基础的收集算法, 它分为 “标记” 和 “清除” 两个阶段:首先标记出所需回收的对象, 在标记完成后统一回收掉所有被标记的对象, 它的标记过程其实就是前面的根搜索算法中判定垃圾对象的标记过程。标记—清除算法的执行情况如下图所示:

回收前状态:

回收前状态

回收后状态:

回收后状态

之后的算法都是基于这种思路改进的, 他的主要不足有两个:

  1. 一个是效率问题, 标记和清楚的两个过程的效率都不高。
  2. 另一个是空间问题, 标记清除之后会产生大量内存碎片, 导致之后程序分配大对象内存时, 无法找到足够的内存而不得不提前触发另一次垃圾收集动作。

复制算法

将可用内存按容量大小划分为大小相等的两块, 每次只使用其中的一块。当一块内存使用完了, 就将还存活着的对象复制到另一块上面, 然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收, 内存分配时也就不用考虑内存碎片等复杂情况。

复制算法

目的:为了解决效率问题。
缺点:将内存缩小为了原来的一半。

现代的商业虚拟机都采用这种收集算法来回收新生代, IBM 公司的专门研究表明, 新生代中对象 98% 对象是 “朝生夕死” 的, 所以不需要按照 1:1 的比例来划分内存空间, 而是将内存分为较大的 Eden 空间和两块较小的 Survivor 空间, 每次使用 Eden 和其中一块 Survivor。HotSpot 虚拟机中默认 Eden 和 Survivor 的大小比例是 8:1。也就是说只有10%的内存是“浪费的”。

标记 - 整理算法

复制收集算法在对象存活率较高时, 就要进行较多的复制操作, 效率就会变低。
根据老年代的特点, 提出了” 标记 - 整理 “算法。
标记过程仍然与” 标记 - 清除 “算法一样, 但后续步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向一端移动, 然后直接清理掉边界以外的内存。

标记 - 整理算法

分代收集算法

一般是把 Java 堆分为新生代和老年代, 这样就可以根据各个年代的特点采用最适当的收集算法。

在新生代中, 每次垃圾收集时都发现有大批对象死去, 只有少量存活, 那就选用复制算法。

在老年代中, 因为对象存活率高、没有额外空间对它进行分配担保, 就必须采用 “标记 - 清除” 或 “标记 - 整理” 算法来进行回收。

垃圾回收机制的一些知识

JVM 中的年代

JVM 中分为年轻代(Young generation)和老年代 (Tenured generation)。

一般情况下, 新创建的对象都会被分配到 Eden 区 (一些大对象特殊处理), 这些对象经过第一次 Minor GC 后, 如果仍然存活, 将会被移到 Survivor 区。对象在 Survivor 区中每熬过一次 Minor GC, 年龄就会增加 1 岁, 当它的年龄增加到一定程度时, 就会被移动到年老代中。

使用复制算法GC之后, , Eden 区和 From 区已经被清空。这个时候, “From” 和 “To” 会交换他们的角色, 也就是新的 “To” 就是上次 GC 前的 “From”, 新的 “From” 就是上次 GC 前的 “To”。不管怎样, 都会保证名为 To 的 Survivor 区域是空的。Minor GC 会一直重复这样的过程, 直到 “To” 区被填满, “To” 区被填满之后, 会将所有对象移动到年老代中。

Minor GC 和 Full GC 的区别

Minor GC: 指发生在新生代的垃圾收集动作, 该动作非常频繁。

Full GC/Major GC: 指发生在老年代的垃圾收集动作, 出现了 Major GC, 经常会伴随至少一次的 Minor GC。Major GC 的速度一般会比 Minor GC 慢 10 倍以上。

空间分配担保

在发生 Minor GC 之前, 虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间, 如果这个条件成立, 那么 Minor GC 可以 确保是安全的。如果不成立, 则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许, 那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于, 则将尝试进行一次 Minor GC, 尽管这个 Minor GC 是有风险的。如果小于, 或者 HandlePromotionFailure 设置不允许冒险, 那这时也要改为进行一次 Full GC。

垃圾收集器

7种收集器:

收集器

Serial 收集器

是最基本、发展历史最悠久的收集器。这是一个单线程收集器。但它的 “单线程” 的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作, 更重要的是它在进行垃圾收集时, 必须暂停其他所有的工作线程, 直到它收集结束。

Serial 收集器

是虚拟机运行在 Client 模式下的默认新生代收集器。

优势:简单而高效(与其他收集器的单线程比), 对于限定单个 CPU 的环境来说, Serial 收集器由于没有线程交互的开销, 专心做垃圾收集自然可以获得最高的单线程效率。

ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本。

是许多运行在 Server 模式下的虚拟机中首选的新生代收集器, 其中一个与性能无关但很重要的原因是, 除了 Serial 收集器外, 目前只有它能与 CMS 收集器配合工作。

ParNew 收集器默认开启的收集线程数与 CPU 的数量相同。

Parallel Scavenge 收集器

Parallel Scavenge 收集器是一个新生代收集器, 使用复制算法, 又是并行的多线程收集器。
最大的特点是:Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量。

所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值, 即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。

高吞吐量则可以高效率地利用 CPU 时间, 尽快完成程序的运算任务, 主要适合在后台运算而不需要太多交互的任务。

Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本, 同样是一个单线程收集器, 使用 “标记 - 整理” 算法。这个收集器的主要意义也是在于给 Client 模式下虚拟机使用。

如果在 Server 模式下, 它主要还有两大用途:

  1. 与 Parallel Scavenge 收集器搭配使用

  2. 作为 CMS 收集器的后备预案, 在并发收集发生 Conurrent Mode Failure 使用。

Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本, 使用多线程和 “标记 - 整理” 算法。

在注重吞吐量以及 CPU 资源敏感的场合, 都可以优先考虑 Parallel Scavenge+Parallel Old 收集器

CMS(Concurrent Mark Sweep)收集器

是 HotSpot 虚拟机中第一款真正意义上的并发收集器, 它第一次实现了让垃圾收集线程与用户线程同时工作。

关注点:尽可能地缩短垃圾收集时用户线程的停顿时间。

CMS 收集器是基于 “标记 - 清除” 算法实现的, 整个过程分为 4 个步骤:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除

其中, 初始标记, 重新标记这两个步骤仍然需要 “Stop The World”。初始标记仅仅只标记一下 GC Roots 能直接关联到的对象, 速度很快。并发标记阶段就是 进行 GC Roots Tracing 的过程。

重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记几率, 这个阶段的停顿时间一般会比初始标记阶段稍长, 但远比并发标记时间短。

整个过程耗时最长的阶段是并发标记, 并发清除过程, 但这两个过程可以和用户线程一起工作。

CMS流程

缺点:

  1. CMS 收集器对 CPU 资源非常敏感。在并发阶段, 它虽然不会导致用户线程停顿, 但是会因为占用了一部分线程(或者说 CPU 资源)而导致应用程序变慢, 总吞吐量会降低。

  2. CMS 收集器无法处理浮动垃圾, 可能出现 “Conurrent Mode Failure” 失败而导致另一次 Full GC 的产生。由于 CMS 并发清理阶段用户线程还在运行着, 伴随程序运行自然就还会产生新的垃圾, 这一部分垃圾出现在标记过程之后, CMS 无法在档次收集中处理掉它们, 只好留待下一次 GC 时再清理掉。这部分垃圾就称为 “浮动垃圾”。因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集, 需要预留一部分空间提供并发收集时程序运作使用。在 JDK1.5 的默认设置下, CMS 收集器当老年代使用了 68% 的空间后就会被激活。如果预留空间无法满足程序需要, 就会出现一次 “Concurrent Mode Failure” 失败, 这时虚拟机将启动后备预案 Serial Old。

  3. CMS 是一款基于 “标记 - 清除” 算法实现的收集器, 所以会有大量空间碎片问题。

G1 收集器

是当今收集器技术发展的最前沿成果之一。是一款面向服务端应用的垃圾收集器。
特点:

  1. 并行与并发
    能充分利用多 CPU, 多核环境下的硬件优势, 缩短 Stop-The-World 停顿的时间, 同时可以通过并发的方式让 Java 程序继续执行。

  2. 分代收集
    可以不需要其他收集器的配合管理整个堆, 但是仍采用不同的方式去处理分代的对象。

  3. 空间整合
    G1 从整体上来看, 采用基于 “标记 - 整理” 算法实现收集器
    G1 从局部上来看, 采用基于 “复制” 算法实现。

  4. 可预测停顿
    使用 G1 收集器时, Java 堆内存布局与其他收集器有很大差别, 它将整个 Java 堆划分成为多个大小相等的独立区域。

G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值), 在后台维护一个优先列表, 每次根据允许的收集时间, 优先回收价值最大的 Region。

内存分配策略

对象优先在 Eden 上分配

大多数情况下, 对象优先在新生代 Eden 区域中分配。当 Eden 内存区域没有足够的空间进行分配时, 虚拟机将触发一次 Minor GC(新生代 GC)。Minor GC 期间虚拟机将 Eden 区域的对象移动到其中一块 Survivor 区域。虚拟机提供了 - XX:+PrintGCDetails 这个收集器日志参数, 告诉虚拟机在发生垃圾收集行为时打印内存回收日志, 并且在进程退出的时候输出当前的内存各区域分配情况。

大对象直接进入老年代

所谓大对象是指需要大量连续空间的对象。虚拟机提供了一个 XX:PretenureSizeThreshold 参数, 大于这个值的对象直接在老年代中分配。

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

虚拟机采用分代收集的思想管理内存, 那内存回收时就必须能识别那些对象该放到新生代, 那些该到老年代中。为了做到这点, 虚拟机为每个对象定义了一个对象年龄 Age, 每经过一次新生代 GC 后任然存活, 将对象的年龄 Age 增加 1 岁, 当年龄到一定程度(默认为 15)时, 将会被晋升到老年代中, 对象晋升老年代的年龄限定值, 可通过 - XX:MaxTenuringThreshold 来设置。为了能更好地适应不同程序的内存状况, 虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代, 如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半, 年龄大于或等于该年龄的对象就可以直接进入老年代, 无须等到 MaxTenuringThreshold 中要求的年龄。

空间分配担保

在发生 Minor GC 之前, 虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间, 如果这个条件成立, 那么 Minor GC 可以确保是安全的。如果不成立, 则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许, 那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于, 将尝试着进行一次 Minor GC, 尽管这次 Minor GC 是有风险的;如果小于或者 HandlePromotionFailure 设置不允许冒险, 那这时也要改为进行一次 Full GC。

“冒险” 是冒了什么风险, 新生代使用复制收集算法, 但为了内存利用率, 只使用其中一个 Survivor 空间来作为轮换备份, 因此当出现大量对象在 Minor GC 后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活), 就需要老年代进行分配担保, 把 Survivor 无法容纳的对象直接进入老年代。与生活中的贷款担保类似, 老年代要进行这样的担保, 前提是老年代本身还有容纳这些对象的剩余空间, 一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的, 所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值, 与老年代的剩余空间进行比较, 决定是否进行 Full GC 来让老年代腾出更多空间。取平均值进行比较其实仍然是一种动态概率的手段, 也就是说, 如果某次 Minor GC 存活后的对象突增, 远远高于平均值的话, 依然会导致担保失败(Handle Promotion Failure)。如果出现了 HandlePromotionFailure 失败, 那就只好在失败后重新发起一次 Full GC。虽然担保失败时绕的圈子是最大的, 但大部分情况下都还是会将 HandlePromotionFailure 开关打开, 避免 Full GC 过于频繁, 在 JDK 6 Update 24 之后, HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略, 观察 OpenJDK 中的源码变化, 虽然源码中还定义了 HandlePromotionFailure 参数, 但是在代码中已经不会再使用它。JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC, 否则将进行 Full GC。