智乐活

JVM GC 与 内存分配策略

2018/03/28 Share

对象存活算法

Java 堆中存放着几乎所有的对象实例,垃圾收集器在对堆进行回收前,需要确定对象是否存活。

引用计数算法

给对象添加一个引用计数器,每当一个地方引用它时,计数器的值加 1;引用失效时减 1。

主流 JVM 没有选用此种算法管理内存,主要原因是它难以解决对象间循环引用的问题。

可达性分析算法

可达性分析算法是 JVM 主流实现中采用的算法。基本思路是通过一系列 GC Roots 对象为起点向下搜索,搜索所走过的路径称为 引用链 。当一个对象到 GC Roots 没有任何引用链相连时,会被判定为可回收对象。

GC Roots 对象包括以下几种:虚拟机栈中引用的对象;方法区中类静态属性引用的对象;方法区中常量引用的对象;本地方法栈中引用的对象。

引用类型

JDK 1.2 后,Java 中的引用分为强引用、软引用、弱引用和虚引用 4 种。

强引用在代码中普遍存在,如 Object o = new Object() ,只要强引用在,垃圾回收器就永远不会回收被引用的对象。

软引用用来描述有用但非必须的对象,在发生内存溢出异常之前被回收。

弱引用和软引用类似,强度更弱,只能生存到下一次垃圾收集之前。

虚引用不会对生存时间构成影响,也无法通过虚引用取得实例,设置虚引用的唯一目的就是能在这个对象被回收时受到系统通知。

回收方法区

方法区的回收主要包括废弃常量和无用的类。

废弃常量和堆中的对象类似,当发生垃圾回收时,如果常量池中的常量不存在任何引用,必要情况下回被清理。

判断一个类无用的条件很苛刻,需要所有实例都已经被回收、加载该类的 ClassLoader 已经被回收,并且该类对应的 java.lang.Class 对象没有在任何地方被引用,无法通过反射访问该类的方法,满足以上条件的无用类才能被回收。

垃圾收集算法

标记 — 清除算法

先标记出所有需要回收的对象,然后统一回收。两个阶段效率都不高,另外标记清除后会产生大量不连续的内存碎片。

复制算法

将可用内存分为大小相等两块,每次使用其中一块。内存用完时将还存活的对象复制到另一块上,再把已使用的空间一次清理掉。

新生代中大多对象朝生夕死,不需要按照 1 : 1 分配内存空间,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor 空间。回收时将 Eden 和 Survivor 中存活的对象一次性复制到另一块 Survivor 空间,再清理掉之前使用的两块内存空间。HotSpot 默认 Eden 和 Survivor 的大小比例为 8 : 1,可用空间为 90%。当 Survivor 内存不够时需依赖老年代进行分配担保。

标记 — 整理算法

标记整理算法更适合于老年代,标记之后不直接对可回收对象进行清理,而是让存活对象都向一段移动,然后清理掉边界以外的内存。

分代收集

当前商业虚拟机都采用分代收集,根据对象存活周期把内存划分为几块。一般把 Java 堆分为新生代和老年代,新生代采用复制算法,老年代使用标记清理算法或标记整理算法。

HotSpot 算法实现

HotSpot 实现上述算法时,须对算法的执行效率严格考量,才能保证虚拟机高效运行。

枚举根节点

主流虚拟机都使用 准确式 GC ,即知道内存中数据的具体类型,所以不需要一个不漏地检查所有执行上下文和全局的引用位置。JVM 有办法直接得知哪些地方存放着对象引用,HotSpot 使用一组称为 OopMap 的数据类型达到这个目的。

安全点

导致 OopMap 变化的指令很多,如果针对每次变化采取措施 GC 的成本会变得很高。实际上,HotSpot 没有为每条指令生成 OopMap,只在特定位置记录这些信息,这些位置称为 安全点 ,程序只有在安全点才能暂停。安全点既不能太少又不能太多,基本选在方法调用、循环跳转等具有让程序长时间执行特性的位置。

让程序在安全点暂停主要有 抢先式中断 和 主动式中断 两种方案。抢先式先把所有线程中断,再让不处于安全点的线程继续执行到安全点。主动式则是在安全点的位置有是否需要中断的标志,线程执行到安全点时依据标志中断挂起。

安全区域

安全区域指在一段代码片段内不会引起引用变化的区域,如线程处于 Sleep 状态挥着 Blocked 状态,线程无法响应 JVM 的中断请求。

程序进入安全区域时,先标记自己已经进入安全区域,如在这个时候发起 GC,不需要处理标记进去安全区域的线程。在线程离开安全区域时,需要检查是否完成枚举根节点或整个 GC 过程,已完成则继续执行,否则需要等到接收可以安全离开的信号为止。

垃圾收集器

垃圾收集器是内存回收的具体实现,目前没有最好的收集器,只有最合适的收集器,所以 JVM 实现了几个不同的收集器。

Serial / Serial Old 收集器

单线程收集器,只使用一个 CPU 和一个收集线程,垃圾回收时暂停其他所有的工作线程,直到收集结束。

与其他收集器的单线程比简单高效,对于运行在 Client 模式下的虚拟机是一个好选择。

新生代采用复制算法,老年代采用标记整理算法。

ParNew 收集器

Serial 的多线程版本,能与 CMS 收集器配置工作,所以是许多运行在 Server 模式下的首选新生代收集器。

在垃圾收集器的上下文中,先明确两个概念:

并行:多条垃圾线程并行工作,用户线程仍然处于等待状态

并发:用户线程和垃圾回收线程同时执行(不一定并行,可能会交替执行)

Parallel Scavenge / Parallel Old 收集器

使用复制算法的新生代多线程收集器,特点是更关注吞吐量,即运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。

Parallel Scavenge 收集有一个参数开关 -XX:+UseAdaptiveSizePolicy,打开后就不需要手动设置新生代大小、Eden 与 Survivor 区的比例等细节参数,JVM 会根据当前系统运行情况动态调整,以提供最合适的停顿时间或最大吞吐量。

CMS 收集器

Concurrent Mark Sweep 以获取最短收回停顿时间为目标,使用标记清除算法。收集过程分为 4 部:初始标记、并发标记、重新标记和并发清理。初始标记和重新标记耗时很少,并发标记和并发清理两部耗时较长,但都可以与用户线程一起并发执行。

CMS 收集器有 3 个明显缺点:1.对 CPU 资源敏感,并发阶段占用一部分 CPU 资源导致应用程序变慢。2.无法收集浮动垃圾,即并发清理阶段由于程序还在运行产生的垃圾,可能导致另一次 Full GC。3.标记清除算法导致收集结束后存在大量空间碎片,可配置 Full GC 执行多少次时伴随一次空间压缩。

G1 收集器

Garbge-First 面向服务端应用,具有并发并行、分代收集、空间整合、可预测停顿等特点。

使用 G1 时,Java 堆的内存分为多个大小相等的独立区域,虽然保留新生代和老年代的概念,但不再是物理隔离。G1 跟踪各个 Region 里垃圾堆积的价值,在后台维护一个优先列表,优先回收价值最大的 Region。

G1 的运作大致分为初始标记、并发标记、最终标记和筛选回收。

内存分配与回收策略

多数情况下,对象在新生代 Eden 区分配。Eden 没有足够的空间时进行一次 Minor GC。当存活对象无法放入 Survivor 区时,通过分配担保提前转移到老年代。

需要大量连续内存空间的大对象会直接进入老年代,如很长的字符串或数组。经常出现大对象容易导致内存还有不少空间时就触发 GC。

长期存活的对象进入老年代。每经过一次 Minor GC,对象中的年龄计数器会加 1,加到一定程度(默认 15)时晋升到老年代。如果 Survivor 空间中相同年龄的所有对象带下总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。

在发生 Minor GC 之前,JVM 会先检查老年代中最大可用连续空间是否大于新生代所有对象总和,如果大于,Minor GC 可以确定是安全的。否则要根据是否允许担保失败判断是否进行 Full GC。允许担保失败时,会根据之前晋升老年代的平均大小作为经验来判定是否进行尝试。

CATALOG
  1. 1. 对象存活算法
    1. 1.1. 引用计数算法
    2. 1.2. 可达性分析算法
  2. 2. 引用类型
  3. 3. 回收方法区
  4. 4. 垃圾收集算法
    1. 4.1. 标记 — 清除算法
    2. 4.2. 复制算法
    3. 4.3. 标记 — 整理算法
    4. 4.4. 分代收集
  5. 5. HotSpot 算法实现
    1. 5.1. 枚举根节点
    2. 5.2. 安全点
    3. 5.3. 安全区域
  6. 6. 垃圾收集器
    1. 6.1. Serial / Serial Old 收集器
    2. 6.2. ParNew 收集器
    3. 6.3. Parallel Scavenge / Parallel Old 收集器
    4. 6.4. CMS 收集器
    5. 6.5. G1 收集器
  7. 7. 内存分配与回收策略