本文在个人技术博客不同步发布, 详情可用力戳 http://www.17coding.info/article/16
亦可扫描屏幕右侧二维码关注个人公众号, 公众号内有个人联系方式, 等你来撩...
相关链接(注: 文章讲解 JVM 以 Hotspot 虚拟机为例, jdk 版本为 1.8)
1, 你必须了解的 java 内存管理机制 - 运行时数据区
2, 你必须了解的 java 内存管理机制 - 内存分配
3, 你必须了解的 java 内存管理机制 - 垃圾标记
4, 你必须了解的 java 内存管理机制 - 垃圾回收
前言
在前面三篇文章中, 对 JVM 的内存布局, 内存分配, 垃圾标记做了较多的介绍, 垃圾都已经标记出来了, 那剩下的就是如何高效的去回收啦! 这篇文章将重点介绍如何回收旧手机, 电脑, 彩电, 冰箱~ 啊呸(⊙o⊙)... 将重点介绍几种垃圾回收算法, HotSpot 中常用的垃圾收集器的主要特点和应用场景. 同时, 这篇文章也是这个系列中的最后一篇文章啦!
正文
上一篇文章中, 我们详细介绍了两种标记算法, 并且对可达性分析算法做了较多的介绍. 我们也知道了 HotSpot 在具体实现中怎么利用 OopMap+RememberedSet 的技术做到 "准确式 GC". 不管使用什么优化的技术, 目标都是准确高效的标记回收对象! 那么, 为了高效的回收垃圾, 虚拟机又经历了哪些技术及算法的演变和优化呢?(注: G1 收集器及回收算法本文不涉及, 因为我觉得后面可以单独写一篇文章来谈!)
回收算法
在这里, 我们会先介绍几种常用的回收算法, 然后了解在 JVM 中式如何对这几种算法进行选择和优化的.
标记 - 清除
"标记 - 清除" 算法分为两个阶段,"标记" 和 "清除". 标记还是那个标记, 在上一篇文章中已经做了较多的介绍了, JVM 在执行完标记动作后, 还在 "即将回收" 集合的对象将被统一回收. 执行过程如下图:
优点:
1, 基于最基础的可达性分析算法, 它是最基础的收集算法.
2, 后续的收集算法都是基于这种思路并对其不足进行改进而得到的.
缺点:
1, 执行效率不高.
2, 由上图能看到这种回收算法会产生大量不连续内存碎片, 如果这时候需要创建一个大对象, 则无法进行分配.
复制算法
"复制" 算法将内存按容量划分为大小相等的两块, 每次使用其中的一块. 当一块的内存用完了, 就将还存活的对象复制到另一块上面, 然后将已经使用过的存储空间一次性清理掉, 这样每次都是针对整个半区的内存进行回收, 不用考虑碎片问题. 执行过程如下图:
优点:
1, 每次针对半个区域进行回收, 实现简单, 运行高效.
2, 不会产生内存碎片问题.
缺点:
1, 内存会缩小为原来的一般, 代价高.
2, 当对象存活率较高时, 需要进行较多复制操作, 效率将会变低.
复制算法改良版
"复制算法改良版" 替代原来将内存一分为二的方案, 将内存分为一块较大的内存 (称为 Eden 空间) 和两块较小的内存(称为 Survivor 空间), 每次使用 Eden 空间和其中一块 Survivor 空间. 当回收时, 将 Eden 和其中一块 Survivor 中还存活的对象一次性复制到另外一块 Survivor 空间上, 最后清理掉 Eden 和刚才使用过的 Survivor 空间. 执行过程如下图:
优点:
1, 改善了普通复制算法的缺点, 提高了空间利用率.
标记 - 整理算法
"标记 - 整理" 算法的标记过程与 "标记 - 清除" 算法是一样一样的, 但后续步骤不是直接对可回收对象进行清理, 而是让所有的对象都向一端移动, 然后直接清理掉端边界以外的内存. 执行过程如下图:
优点:
1, 改善了 "标记 - 清除" 算法会产生内存碎片的缺点.
2, 不会像 "复制" 算法那样效率随对象存活率升高而变低.
缺点:
1, 依然没有解决 "标记 - 清除" 算法存在的缺点, 那就是回收效率问题. 还多了需要整理的过程, 效率更低.
分代收集算法
我们都知道, 在主流的虚拟机中都是采用分代收集算法来进行堆内存的回收, 在第一篇文章中我们也用了一张图展示了 JVM 堆内存的划分. 如下:
分代回收根据对象存活周期的不同将内存划分为几块, 这样就可以根据各个年代的特点采用最适当的收集算法. 一般把 Java 堆分为新生代和老年代.
新生代
在 Hotspot 虚拟机中, 新生代的收集器都是采用的改良版的复制算法进行垃圾回收. 将新生代一分为三, 一块 Eden 区和两块 Survivor 区. Eden 区与两块 Survivor 区的比例为 8:1:1. 这样划分的依据是什么呢? 基于弱代理论, IBM 研究表明新生代中 98% 的对象都是 "朝生夕死", 大多数分配了内存的对象并不会存活太长时间, 在处于年轻代时就会死掉.
在原始的复制算法中, 空间一分为二, 空间利用率为 50%, 也就是说有新生代中 50% 的空间会被浪费, 无法分配内存. Hotspot 虚拟机使用改良的复制算法, 并且设置合理的空间比例, 新生代中可用的内存空间为整个新生代容量的 90%, 只有 10% 的空间会被浪费, 大大的提高的新生代的空间利用率. 如果存活对象占用的内存大于新生代容量的 10% 怎么办? 这就需要依赖其他内存 (老年代) 进行分配担保了. 新生代回收动图如下:
老年代
由于老年代的对象存活周期一般相对较长, 不会像新生代对象那样 "朝生夕死", 所以对象存活率高是老年代的特点, 并且老年代也没有额外的空间可以分配担保, 所以不适合采用复制算法进行回收. 根据老年代的特点, 一般会使用 "标记 - 清理" 或 "标记 - 整理" 算法来进行垃圾回收.
收集器
上面我们介绍了在 JVM 中常用的垃圾回收算法及每一种算法的优缺点. 接下里会介绍在 HotSpot 虚拟机中常用的几种垃圾收集器, 垃圾收集器是垃圾回收算法的具体实现, 不同的商家, 不同版本的 JVM 所提供的垃圾收集器可能会存在差异. 这几种收集器分别是 Serial,ParNew,Parallel Scavenge,Serial Old,Parallel Old,CMS,G1. 在了解垃圾收集器之前, 我们先来区分几个概念:
并发收集器 VS 并行收集器
并行: 指多条收集线程同时进行收集工作, 但此时用户线程处于等待状态. 如 ParNew,Parallel Scavenge,Parallel Old.
并发: 指用户线程与垃圾收集线程同时执行(并不一定是并行, 可能会交替执行). 如 CMS,G1.
YoungGC VS OldGC VS MinorGC VS MajorGC VS FullGC
Minor GC,YoungGC:Minor GC 又称为新生代 GC, 所以等价于 Young GC, 在新生代的 Eden 区分配满的时候触发. 在 Young GC 后新生代中有部分存活对象会晋升到老年代, 有可能是年龄达到阈值 (默认为 15 岁, 在 JVM 里面 15 岁就步入老年生活了, O(∩_∩)O 哈哈~) 了, 也可能是 Survivor 区域满了, 如果是 Survivor 区域被填满, 会将所有新生代中存活的对象移动到老年代中!
Major GC,Old GC,Full GC:Old GC 从字面能理解是老年代的 GC, 但是对 Major GC 和 Full GC 存在多种说法, 有的认为 Major GC 等价于 Old GC 只是针对老年代的 GC, 有的认为 Major GC 和 Full GC 是等价的. 但是我个人认为 Major 是指老年代 GC, 而 Full GC 针对新生代, 老年代, 永久代整个的回收. 由于老年代的 GC 都会伴随一次新生代的 GC, 所以习惯性的把 Major GC 和 Full GC 划上了等号. 前面 Young GC 时候说到 "在 Young GC 后新生代中有部分存活对象会晋升到老年代", 万一老年代的空间不够存放新生代晋升的对象怎么办呢? 所以当准备要触发一次 Young GC 时, 如果发现统计数据之前 Young GC 的平均晋升大小比目前老年代剩余的空间大, 则不会单独触发 Young GC, 而是转为触发 Full GC, 也就是整堆的收集!
串行收集器
串行垃圾收集器是最基本, 发展历史最悠久的收集器. 主要包含 Serial 和 Serrial Old 两种收集器, 分别用来收集新生代和老年代. 串行收集器由于是单线程收集, 在进行垃圾收集时, 必须暂停 (Stop The World) 所有的工作线程, 直到 GC 线程工作完成. 运行示意图如下:
Serial 收集器: 主要针对新生代回收, 采用复制算法, 单线程收集.
Serial Old 收集器: 主要针对老年代回收, 采用 "标记 - 整理" 算法, 单线程收集.
串行收集器在单 CPU 的环境下, 没有线程切换的开销, 可以获得最高的单线程收集效率, 但是由于现在普遍都是多 CPU(或者多核)环境, 所以除了在桌面应用中仍然将串行收集器作为默认的收集器, 其他场景已经很少 (很少不代表没有, 后面 CMS 会讲到) 使用.
在上面我们谈到一个词, 需要暂停 (Stop The World) 所有的工作线程, 这个概念在后面也会多次提到, 为什么需要暂停呢? 一是为了方便 GC 动作, 不然在 GC 过程中又会额外产生新的垃圾, 或者分配新的对象. 二是因为 GC 过程中对象的地址会发生变化, 如果不暂停线程, 可能会导致引用出现问题.
并行收集器
并行收集器是串行收集器的多线程版本, 除了多线程外, 其余的行为, 特点和串行收集器一样. 主要包含 ParNew 收集器, Parallel Scavenge 收集器, Parallel Old 收集器. 运行示意图如下:
ParNew 收集器: 主要针对新生代回收, 采用复制算法, 多线程收集. 一般老年代如果使用 CMS 收集器, 则默认会使用 ParNew 作为新生代收集器.
Parallel Scavenge 收集器: 该收集器与 ParNew 收集器类似, 也是新生代收集器, 采用复制算法, 多线程收集. 其他收集器关注点是尽可能地缩短垃圾收集时用户线程停顿的时间, 但是 Parallel Scavenge 收集器的目标则是达到一个可控的吞吐量(吞吐量 = CPU 运行用户代码时间 /(CPU 运行用户代码时间 + CPU 垃圾收集时间)), 所以该收集器也成为吞吐量收集器. 由于该收集器没有使用传统的 GC 收集器代码框架, 是另外独立实现的, 所以无法和 CMS 收集器配合工作.
Parallel Old 收集器: 主要针对老年代回收, 采用 "标记 - 整理" 算法, 多线程收集. 该收集器是 Parallel Scavenge 收集器的老年代版本. 在 JDK1.6 之后用来替代老年的 Serial Old 收集器. 在注重吞吐量以及 CPU 资源敏感的场景, 一般会选择 Parallel Scavenge+Parallel Old 的组合进行垃圾收集.
CMS 收集器
前面介绍的几种收集器都相对比较简单, 也很好理解, 所以也没做过多的介绍. 接下来介绍的收集器相对前面几种收集器就要复杂一些, 并且使用较广, 所以介绍会较详细! 并发标记清理 (Concurrent Mark Sweep) 收集器也称为并发低停顿收集器或低延迟收集器. CMS 收集器采用的是 "标记 - 清理" 算法, 所以不会进行压缩操作. 我们先来了解一下 CMS 收集器的运作过程:
CMS 收集器运作过程
1, 初始标记(CMS initial mark)
仅标记 GC Roots 能直接关联的对象, 这个阶段为速度较快, 但是仍然需要 "Stop The World", 但是停顿时间较短!
2, 并发标记(CMS Concurrent mark)
进行 GC Roots Tracing 的过程, 也就是查找 GC Roots 能直接关联的对象所引用的内存. 在这个阶段, GC 线程与用户线程是同时运行的, 所以并不能保证能标记出所有存活的对象.
3, 重新标记(CMS remark)
由于并发标记阶段, 用户线程在并发运行, 所以可能在并发标记阶段产生新的对象, 所以在重新标记阶段也会需要 "Stop The World" 来标记新产生的对象, 且停顿时间比初始标记时间稍长, 但远比并发标记短.
4, 并发清除(CMS Concurrent sweep)
在并发清除阶段用户线程与清理线程也是同时工作, 清理线程回收所有的垃圾对象!
CMS 收集器缺点
上面了解了 CMS 收集器的运作过程, 不知道在了解过程中你有没有发现一些问题, 比如 CMS 收集器采用的是 "标记 - 清除" 算法, 那会不会产生很多的内存碎片? 比如在并发清理阶段, 用户线程还在运行, 会不会在清理的过程中又产生了垃圾? 总结 CMS 收集器的几个明显的缺点如下:
1, 对 CPU 资源非常敏感
并发收集虽然不会暂停用户线程, 但是因为会占用一部分 CPU 资源, 还是会导致应用程序变慢, 总吞吐量下降. CMS 的默认收集线程的数量 =(CPU 数量 + 3)/4. 所以, 当 CPU 数量大于 4 个时, 会有超过 25% 的资源用于垃圾收集. 当 CPU 数量小于或等于 4 个时, 默认一个收集线程.
2, 产生大量内存碎片
CMS 收集器采用 "标记 - 清除" 算法, 在清除后不会进行压缩操作, 这样会导致产生大量不连续的内存碎片, 在分配大对象时, 无法找到足够的连续内存, 从而需要提前触发一次 FullGC 的动作. 针对该问题, 提供了两个参数来设置是否开启碎片整理.
1),"-XX:+UseCMSCompactAtFullCollection" 参数
从名字能看出来, 在收集的时候是否开启压缩. 这个参数默认是开启的, 但是是否开启压缩还需要结合下面的参数!
2),"-XX:+CMSFullGCsBeforeCompaction" 参数
该参数设置执行多少次不压缩的 Full GC 后, 来一次压缩整理. 这个参数默认为 0, 也就是说每次都执行 Full GC, 不会进行压缩整理.
如果开启了压缩, 则在清理阶段需要 "Stop the world", 不能进行并发!
3, 产生浮动垃圾
上面说到过在并发清理阶段, 用户线程还在运行, 这时候可能就会又有新的垃圾产生, 而无法在此次 GC 过程中被回收, 这成为浮动垃圾.
4, "Concurrent Mode Failure" 失败
不知道大家在开发过程中有没有遇到过 "Concurrent Mode Failure" 失败的信息, 不管你有没有遇到过, 反正我是遇到过! 这个异常是什么原因导致的呢. 在并发标记和并发清除阶段, 用户线程与 GC 线程并发工作, 这会导致在清理的时候又会有用户的线程在拼命的创建对象, 本身垃圾回收时候肯定是可用内存不够了, 可万一这时候用户线程创建了大量的对象怎么办呢? 所以一般 CMS 收集器的垃圾回收的动作不会在完全无法分配内存的时候进行, 可以通过 "-XX:CMSInitiatingOccupancyFraction" 参数来设置 CMS 预留的内存空间! 如果预留的空间无法满足程序的需要, 就会出现 "Concurrent Mode Failure" 失败. 这时候 JVM 会启用后备方案, 也就是前面介绍过的 Serial Old 收集器, 这样会导致另一次的 Full GC 的产生, 这样的代价是很大的, 所以 CMSInitiatingOccupancyFraction 这个参数设置需要根据程序合理设置!
CMS 收集器应用场景
上面介绍了 CMS 收集器的缺点, 那它当然也有它的优点啦, 比如并发收集, 低停顿等等...... 所以 CMS 收集器适合与用户交互较多的场景, 注重服务的响应速度, 能给用户带来较好的体验! 所以我们在做 web 开发的时候, 经常会使用 CMS 收集器作为老年代的收集器!
来源: https://www.cnblogs.com/sujing/p/11110360.html