时间 2020-01-30 10:24:28 公众账号
原文
主题 算法 JVM
1. 一些概念
1.1 垃圾 & 垃圾收集
垃圾: 在 JVM 语境下,"垃圾" 指的是死亡的对象所占据的堆空间.
垃圾收集: 所谓 "垃圾收集", 就是将已分配出去, 但不再使用的内存回收回来, 以便能再次分配.
1.2 对象是否死亡
如何判断一个对象是否死亡(即不可能再被任何途径使用)? 通常有以下两种方法:
1.2.1 引用计数法
引用计数法(Reference Counting): 为每个对象添加一个引用计数器, 用来统计指向该对象的引用个数. 当有地方引用它时, 计数器加一; 引用失效时减一. 当某个对象的引用计数为零时, 说明该对象已死亡, 便可以被回收了.
主要优点: 原理简单, 判定效率高.
主要缺点: 无法解决循环依赖的问题(对象之间相互循环引用).
1.2.2 可达性分析算法
可达性分析 (Reachability Analysis) 的基本思路如下:
通过一系列称为 GC Roots 的根对象作为起始节点集, 从这些节点开始, 根据引用关系向下搜索, 搜索过程所走过的路径称为 "引用链"(Reference Chain), 若某个对象到 GC Roots 间没有任何引用链相连 (或者用图论的话来说就是从 GC Roots 到这个对象不可达时) 则证明此对象是不可能再被使用的.
示意图如下:
GC Roots 可理解为「堆外指向堆内的引用」. 在 Java 技术体系中, 固定可作为 GC Roots 的对象包括以下几种:
虚拟机栈 (栈帧中的本地变量表) 中引用的对象
方法区中类静态属性引用的对象(比如引用类型的静态变量)
方法区中常量引用的对象(比如字符串常量池的引用)
本地方法栈中 Native 方法引用的对象
JVM 内部的引用(例如: 基本数据类型的 Class 对象, 常驻异常, 系统类加载器)
同步锁 (synchronized 关键字) 持有的对象
反应 JVM 内部情况的 JMXBean,JVMTI 中注册的回调, 本地代码缓存等
1.3 引用的分类
无论是引用计数法还是可达性分析算法, 二者都离不开「引用」.JDK 1.2 之后, Java 中「引用」的概念得以扩充, 主要分为以下四种:
强引用(Strongly Reference)
例如: Object obj = new Object()
特点: 无论任何情况, 只要强引用存在, 垃圾收集器永不回收被引用的对象
软引用(Soft Reference)
场景: 用于一些还有用, 但非必须的对象
时机: 被软引用关联的对象, 在系统将发生 OOM 前, 回收这些内存
实现: java.lang.ref.SoftReference
弱引用(Weak Reference)
场景: 非必须对象, 比软引用更弱
时机: 被弱引用关联的对象只能生存到下一次垃圾收集发生(无论内存是否充足都会回收)
实现: java.lang.ref.WeakReference
虚引用(Phantom Reference)
又称 "幽灵引用" 或 "幻影引用"
特点: 最弱的引用, 是否存在完全不会影响其生存时间, 无法通过它获取对象实例
唯一目的: 该对象被回收时收到一个系统通知
实现: java.lang.ref.PhantomReference
1.4 回收方法区
《Java 虚拟机规范》并未要求虚拟机在方法区实现垃圾收集. 方法区垃圾收集 "性价比" 较低.
但在大量使用反射, 动态代理, CGLib 等字节码框架, 动态生成 JSP 及 OSGi 这类频繁自定义类加载器的场景中, 通常都需要 JVM 具备类型卸载的能力, 以避免方法区内存压力过大.
方法区垃圾回收的主要内容包括: 废弃的常量和不再使用的类型. 它们的判定条件如下:
废弃的常量
无任何对象引用常量池中的常量
虚拟机中无任何其他地方引用该字面量
不再使用的类型
该类所有的实例 (包括子类) 都已被回收
该类的类加载器已被回收
该类的 Class 对象任何地方未被引用, 任何地方无法通过反射访问该类的方法
虚拟机参数:
- # 是否对类型回收
- -Xnoclassgc
- # 查看类加载和卸载
- -verbose:class -XX:+TraceClassLoading -XX:+TraceClassUnLoading
2. 垃圾收集算法
从如何判定对象消亡的角度出发, 垃圾收集器可分为 "引用计数式垃圾收集"(Reference Counting GC)和 "追踪式垃圾收集"(Tracing GC)两大类, 也称 "直接垃圾收集" 和 "间接垃圾收集".
这里的垃圾回收算法都属于 "追踪式垃圾收集" 的范畴.
2.0 分代收集理论
当代商业虚拟机的垃圾收集器, 多数都遵循了 "分代收集"(Generational Collection)的理论.
分代收集理论, 实质是一套符合大多数程序运行实际情况的经验法则, 有如下三条:
弱分代假说(Weak Generational Hypothesis): 绝大多数对象都是朝生夕灭的.
强分代假说(Strong Generational Hypothesis): 熬过越多次垃圾收集过程的对象就越难以消亡.
跨代引用假说(Intergenerational Reference Hypothesis): 跨代引用相对于同代引用来说仅占极少数.
根据以上几条规则, 可以推测:
若一个区域中大多数对象都是朝生夕灭, 把它们集中在一起, 每次回收只需关注如何保留少量存活的对象;
若一个区域剩下的都是难以消亡的对象, 把它们集中在一起, 便可以较低的频率回收该区域.
如何解决跨代引用问题?
依据跨代引用假说, 为了解决极少数跨代引用, 只需在新生代建立一个 "记忆集(Remembered Set)", 把老年代划分为若干小块, 标识出哪一块内存会存在跨代引用, 此后发生 Minor GC 时, 只有包含跨代引用的小块内存中的对象才会被加入到 GC Roots 进行扫描(避免扫描整个老年代).
一些 GC 概念:
部分收集(Partial GC): 目标不是完整收集整个 Java 堆的垃圾收集
新生代收集(Minor GC/Young GC): 只是新生代的垃圾收集.
老年代收集(Major GC/Old GC): 只是老年代的垃圾收集. 目前只有 CMS 收集器会有单独收集老年代的行为.
混合收集(Mixed GC): 是收集整个新生代以及部分老年代的垃圾收集. 目前只有 G1 收集器会有这种行为.
整堆收集(Full GC): 收集整个 Java 堆和方法区的垃圾收集.
下面介绍常见的垃圾收集算法.
2.1 标记 - 清除算法
标记 - 清除 (Mark-Sweep) 算法: 最早, 最基础的算法, 分为 "标记" 和 "清除" 两个阶段:
标记出所有需要回收的对象(或反之);
在标记完成后统一回收所有被标记的对象(或反之).
后续的收集算法大多都是以 "标记 - 清除" 算法为基础, 对其缺点进行改进而得.
该算法的示意图如下:
优缺点分析:
主要优点: 实现简单;
主要缺点:
执行效率不稳定, 标记和清除过程效率都不高(标记对象较多时);
内存空间碎片化问题(碎片太多可能会导致以后在程序运行过程中需要分配较大对象时, 无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作).
图片来自: http://www.cnblogs.com/dolphin0520/p/3783345.html
2.2 标记 - 复制算法
简称复制 (Copying) 算法. 现在的商用 Java 虚拟机大都优先采用了这种收集算法回收新生代.
"半区复制"(Semispace Copying)算法将可用内存按容量分为大小相等的两块, 每次只使用其中的一块. 当这一块用完时, 就将还存活的对象复制到另外一块上, 然后再把已使用的内存空间一次清理掉.
该算法主要为了解决 "标记 - 清除" 算法面对大量可回收对象时执行效率低的问题.
示意图如下:
"半区复制" 算法的优缺点:
优点: 实现简单, 运行高效且不易产生内存碎片;
缺点: 可用内存缩小为原来的一半, 空间浪费太多.
一般不需要按照 1:1 的比例划分内存空间, 而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间, 每次使用 Eden 和其中一块 Survivor. 当回收时, 将 Eden 和 Survivor 中还存活的对象一次性地复制到另外一块 Survivor 空间上, 最后清理掉 Eden 和刚才用过的 Survivor 空间.
HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1 (即 "浪费" 了 10% 的新生代空间).
由于无法保证每次每次回收都只有不多于 10% 的对象存活, 当 Survivor 空间不够用时, 需要依赖其他内存 (大多指老年代) 进行分配担保(Handle Promotion), 也就是直接进入老年代(相当于 "兜底方案").
2.3 标记 - 整理算法
复制算法在对象存活率较高时就要进行较多的复制操作, 效率将会降低.
标记 - 整理 (Mark-Compact) 算法: 标记过程与 "标记 - 清除" 算法一样, 但后续步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向一端移动, 然后直接清理掉端边界以外的内存.
标记 - 清除算法与标记 - 整理算法区别: 前者是一种非移动式的回收算法, 后者是移动式的.
示意图:
主要问题:
移动存活对象: 回收内存时会更复杂(Stop The World);
不移动存活对象: 分配内存时会更复杂(空间碎片问题).
2.4 分代收集算法
当前商业虚拟机的垃圾收集都采用 "分代收集"(Generational Collection)算法(不是一种具体的算法实现, 可以理解为「组合模式」): 根据对象存活周期的不同, 将内存划分为几块.
一般把 Java 堆分为新生代和老年代, 根据各个年代的特点采用最适当的收集算法.
新生代
每次垃圾收集时, 都有大批对象死去, 只有少量存活, 采用复制算法(只需复制少量存活对象).
老年代
对象存活率较高, 没有额外空间对它进行分配担保, 必须使用 "标记 - 清除" 或 "标记 - 整理" 算法进行回收.
3. 垃圾收集器
前面的收集算法只是内存回收的方法论, 而垃圾收集器才是内存回收的具体实现(可理解为 "接口" 与 "实现类" 的关系).
3.1 Serial 收集器
Serial 收集器是最基础, 历史最悠久的收集器. 特点:
单线程收集, 且垃圾收集时, 必须暂停其他所有的工作线程(Stop The World, STW), 直到它收集结束.
HotSpot 虚拟机运行在 Client 模式下默认新生代收集器.
优于其他收集器的地方: 简单而高效(与其他收集器的单线程比). 对于限定单个 CPU 的环境来说, Serial 收集器由于没有线程交互的开销, 专心做垃圾收集自然可以获得最高的单线程收集效率.
运行示意图如下:
3.2 ParNew 收集器
ParNew 收集器实质上是 Serial 收集器的多线程并行版本.
除了同时使用多条线程进行垃圾收集之外, 其余的行为包括 Serial 收集器可用的所有控制参数(-XX:SurvivorRatio, -XX:PretenureSizeThreshold,-XX:HandlePromotionFailure 等), 收集算法, Stop The World, 对象分配原则, 回收策略等都与 Serial 收集器完全一致.
运行示意图:
使用 / 禁用该收集器的 VM 参数
# 注: JDK 9 取消了 -XX:+UseParNewGC 参数 -XX:+/-UseParNewGC
3.3 Parallel Scavenge 收集器
Parallel Scavenge 收集器是新生代收集器, 也是使用标记 - 复制算法实现的, 并行收集的多线程收集器, 也称 "吞吐量优先收集器".
与 ParNew 类似, 但关注点不同:
CMS 等收集器: 尽可能地缩短垃圾收集时用户线程的停顿时间;
Parallel Scavenge 收集器: 达到一个可控的吞吐量(Throughput).
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间), 即运行用户代码时间所占比重.
响应速度快能提升用户体验; 而吞吐量高则能更高效地利用 CPU 资源, 尽快完成程序的计算任务(主要适合在后台运算而不需要太多交互的任务).
运行示意图如下:
虚拟机参数
# 最大垃圾收集停顿时间(毫秒) -XX:MaxGCPauseMillis # 设置吞吐量(0~100) -XX:GCTimeRatio # 自适应调节策略 -XX:UseAdaptiveSizePolicy
3.4 Serial Old 收集器
Serial 收集器的老年代版本, 单线程, 使用 "标记 - 整理" 算法. 主要用于客户端模式下的 HotSpot 虚拟机.
运行示意图如下:
3.5 Parallel Old 收集器
Parallel Scavenge 收集器的老年代版本, 支持多线程并发收集, 使用多线程和 "标记 - 整理" 算法实现.
运行示意图如下:
在注重吞吐量或者处理器资源较为稀缺的场合, 都可以考虑 Parallel Scavenge + Parallel Old 收集器的组合.
3.6 CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以「获取最短回收停顿时间」为目标的收集器.
它基于 "标记 - 清除" 算法实现, 运作过程分为四步:
初步标记(CMS initial mark): 只标记 GC Roots 能直接关联到的对象, 速度很快;
并发标记(CMS concurrent mark): 从 GC Roots 遍历整个对象图, 耗时较长, 但无需停顿用户线程(可与用户线程并发执行);
重新标记(CMS remark): 修正并发标记期间, 因用户线程导致标记产生变动的标记记录;
并发清除(CMS concurrent sweep): 清理删除标记阶段判断的已经死亡的对象, 可与用户线程并发执行.
运行示意图如下:
目前很大一部分 Java 应用集中在互联网网站或者 B/S 系统的服务上, 这类应用尤其重视服务的响应速度, 希望系统停顿时间最短, 以给用户带来较好的体验. CMS 收集器非常符合这类应用的需求.
CMS 的主要优缺点
主要优点: 并 发收集, 低停顿
主要缺点
对处理器资源非常敏感, 降低吞吐量.
无法处理 "浮动垃圾", 有可能出现 "Concurrent Mode Failure" 失败而导致另一次完全 Stop The World 的 Full GC 的产生.
内存空间碎片问题
浮动垃圾(Floating Garbage): 在 CMS 的并发标记和并发清理阶段, 用户线程是还在继续运行的, 程序在运行自然就还会伴随有新的垃圾对象不断产生, 但这一部分垃圾对象是出现在标记过程以后, CMS 无法在当次收集中处理掉它们, 只好留待下一次垃圾收集时再清理掉. 这部分垃圾就是 "浮动垃圾".
虚拟机参数
# 使用 CMS 收集器 -XX:+UseConcMarkSweepGC # 老年代使用空间的比例(需根据实际情况权衡) -XX:CMSInitiatingOccupancyFraction=80 # Full GC 时开启内存碎片的合并整理 -XX:+UseCMSCompactAtFullCollection
3.7 Garbage First 收集器
Garbage First(G1) 收集器是垃圾收集器发展史上里程碑式的成果, 开创了「面向局部收集」的设计思路和「基于 Region」的内存布局形式.
G1 收集器的定位是「CMS 收集器的替代者和继承人」. 由于实现较复杂, 后文另行分析. 这里只做简单描述:
JDK 7 Update 40 时, Oracle 认为它达到了足够成熟的商用程度;
JDK 8 Update 40 时; G1 收集器提供了并发的类卸载支持, 被 Oracle 称为 "全功能的垃圾收集器(Fully-Featured Garbage Collector)".
此外, 还有一些更为先进的低延迟收集器, 比如 OracleJDK 11 加入的 ZGC,RedHat 公司的 Shenandoah 收集器. 另外, 还有一个有点 "奇葩" 的 Epsilon 收集器, 等等.
衡量垃圾收集器优劣的指标主要有三个: 内存占用 (Footprint), 吞吐量(Throughput) 和延迟(Latency). 此三者构成了一个「三元悖论」(类似分布式系统中的 CAP 原则), 难以同时满足.
4. 小结
本文简要介绍了一些垃圾收集的相关概念, 常用的垃圾收集算法以及经典的垃圾收集器. 由于 G1 收集器实现稍复杂, 因此后面单独分析. 本文主要内容概括如下图:
(后台回复「垃圾收集」可获取高清图片链接)
来源: http://www.tuicool.com/articles/Anm2Qvf