JVM 可以说是为了 Java 开发人员屏蔽了很多复杂性, 让 Java 开发的变的更加简单, 让开发人员更加关注业务而不必关心底层技术细节, 这些复杂性包括内存管理, 垃圾回收, 跨平台等, 今天我们主要看看 JVM 的垃圾回收机制是怎么运行的, 希望能够帮到大家,
哪些对象是垃圾呢?
Java 程序运行过程中时刻都在产生很多对象, 我们都知道这些对象实例是被存储在堆内存中, JVM 的垃圾回收也主要是针对这部分内存, 每个对象都有自己的生命周期, 在这个对象不被使用时, 这个对象将会变成垃圾对象被回收, 内存被释放, 那么如何判断这个对象不被使用呢? 主要有如下两种方法:
引用计数算法
这个方法什么意思呢? 就是给每个对象绑定一个计数器, 每当指向该对象的引用增加时, 计数器加 1, 相反减少时也会减 1, 当计数器的值变为 0 时, 该对象就会变成垃圾对象, 也就是最终没有任何引用指向该对象. 这种方法比较简单, 实现起来也容易, 但有一个致命缺点, 有可能会造成内存泄漏, 也就是垃圾对象无法被回收, 我们看下面代码, 创建两个对象, 每个对象的成员变量都持有对方的引用, 这就是一个循环引用, 形成了一个环, 此时虽然两个对象都不再使用了, 但每个对象的计数器并不为 0, 导致无法被回收, 那有办法解决吗? 当然有, 看下面的算法.
- class Person {
- public Object object = null;
- public void test(){
- Person person1 = new Person();
- Person person2 = new Person();
- person1.object = person2;
- person2.object = person1;
- person1 = null;
- person2 = null;
- }
- }
可达性分析算法
知道了上面算法的缺点, 那么可达性分析是怎么解决的呢? 在堆内存中, JVM 定义了一系列 GCroots 对象, 这些对象称为 GC 时个根对象, 沿着这些根对象像链表一样一直往下找, 凡是在这个链上的对象都是符合可达性的, 否则认为这个对象不可达, 那么这个对象就是一个垃圾对象, 也就是说垃圾对象和 GC 根对象没有直接或者间接关联关系, 如下图, 黄色的对象就是可以被回收的垃圾对象, 因为根 GC 根对象没有任何关联.
理解了可达性分析算法的原理, 那么估计有疑问了, 哪些对象能作为 GCroot 对象呢, 一起来看一下 JVM 中对 GCroot 对象定义的规范.
Java 虚拟机栈中引用的对象
堆中静态属性引用的对象(JDK8 以前时方法区中)
堆中常量引用的对象(JDK8 以前是方法区中)
本地方法 (Native 方法) 栈中引用对象的
垃圾回收算法解读
在确定了哪些垃圾对象可以被回收后, 垃圾收集器要做的就是开始回收这些垃圾, 那么如何在堆内存中高效的回收这些垃圾对象呢?, 加下来我们介绍几种算法思想
标记清除算法
标记清除是一种比较基础的算法, 其思想对内存中的所有对象扫描, 将垃圾对象进行标记, 最后将标记的垃圾对象清除, 那么这部分内存就可以使用了, 如下图, 第一行是回收前的内存状态, 第二行是回收后的内存状态, 发现了什么? 对, 就是内存碎片, 内存碎片会导致大对象分配失败, 假设我们接下来的对象都是使用 2M 内存, 则那个 1M 就会浪费掉.
标记整理算法
相对标记清除算法, 标记整理多了一步, 其思想也是对内存中的对象扫描, 标记存活对象和垃圾对象, 然后将对象移动, 使得存活的对象一边, 待回收的对象在一边, 然后再对待回收对象进行回收, 这样就解决了内存碎片问题, 但是对象频繁的移动会带来指针地址指向不断发生变化, 整理内存碎片会消耗较长时间, 引起应用程序的暂停.
分半复制算法
标记整理算法解决了内存碎片问题, 但内存整理也带来了新的问题, 复制算法能够缓解对象移动的问题, 但不能根本上解决, 复制算法本质上是空间换时间的一种算法, 将内存分为大小相等的两部分, 在其中一部分内存使用完之后, 将其中活着的对象移入到另一半内存中, 然后将这一半内存清空. 这种算法的代价浪费一半的内存, 比如 8G 内存, 只有 4G 是可以使用的.
分代算法(集所有优点, 弃缺点)
上面三种算法各有优缺点, 但都不能完美的解决垃圾回收中遇到的问题, 那能不能将上面三种算法的优点都集合起来形成一种新的组合呢? 是的, 分代算法就是这样的, 我们常用不考虑业务的架构都是耍流氓, 那么垃圾回收算法也需要结合对象的生命周期来决定, 我们都知道应用程序中大多数对象都是朝生夕死的, 分代算法将内存分为年轻代和年老代两个区域, 年轻代中采用复制算法, 因为年轻代中每次收集时都有大量对象死去, 只有少量对象存活, 所以采用复制算法这样移动的对象比较少, 年老代中采用标记清除算法, 年老代中的对象都是存活时间比较长的对象, 但当内存碎片比较严重时可以进行一次整理(结合使用),
前面提到复制算法会浪费一半的内存, 有没有办法浪费的少一点呢? 分代算法在年轻代中是怎么解决呢? 首先确定的每次垃圾收集时存活对象总是少量的, 年轻代中将内存分成了三部分, Eden 区域, Survivor1 区, Survivor2 区, 后两个区域用来存储存活的对象, 对象创建时总是在 Eden 区域, 每当 Eden 区域满了之后, 垃圾回收时开始将所有存活的对象放入其中一个 Survivor 区域, 并且将另一个 Survivor 区域和 Eden 区域清空, 如此, 两个 Survivor 区域只需要少量内存空间, 这样就可以充分利用内存了.
JVM 垃圾回收器详解
基于上面的垃圾回收算法, 有很多的垃圾收集器, JVM 规范对于垃圾收集器的应该如何实现没有任何规定, 因此不同的厂商, 不同版本的虚拟机所提供的垃圾收集器差别较大, 这里只看 HotSpot 虚拟机.
Serial 和 Serial Old 垃圾收集器
Serial 收集器历史非常悠久了, 它是在新生代上实现垃圾收集的, SerialOld 是在老年代上实现垃圾收集的
他们两都是单线程工作的(早期多核发展还不是这么好), 它在工作时必须暂停应用程序的线程, 也就是会发生 Stop The World, 直到垃圾回收工作完成
Serial 年轻代采用复制算法 ,Serial Old 老年代采用标记整理算法,
这种收集器的优点是简单, 工作起来非常高效, 对于单核 CPU 来说没有线程切换的开销, 专门做自己的事, 所以在单核 CPU 上或者内存较小时非常适用, 缺点也很明显, 当内存过大时, 应用程序暂停无法提供服务,"-XX:+UseSerialGC" 这个参数用来开启 Serial 垃圾收集器.
ParNew 垃圾收集器
ParNew 是 Serial 收集器的多线程版本, 除了是多线程, 其它的都一样(也会发生 Stop The World, 也是新生代的收集器). 它是目前唯一能够和 CMS 合作使用的新生代垃圾收集器.
Parallel Scavenge 和 Parallel Old 垃圾收集器
Parallel Scavenge 收集器是一个新生代收集器, Parallel Old 是一个老年代收集器, 前者使用的是复制算法, 后者使用的是标记整理算法, 他们又都是并行的多线程收集器.
Parallel Scavenge 和 Parallel Old 收集器关注点是吞吐量 (如何高效率的利用 CPU) 所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值.
在对 CPU (吞吐量)比较敏感的情况下, 建议使用这两者结合
CMS(Concurrent Mark Sweep)收集器
重点来了, CMS 收集器的目标是获取最短停顿的时间 (即 GC 时应用程序线程暂停的时间最短), 它是老年代收集器, 基于标记清除算法(产生内存碎片), 并发收集(多线程),CMS 是 HotSpot 在 JDK1.5 推出的第一款真正意义上的并发(Concurrent) 收集器; 第一次实现了让垃圾收集线程与用户线程 (基本上) 同时工作; 他的应用场景主要是在和用户交互较多的地方使用, 减少用户感受到的服务延迟.
CMS 收集器的运作过程比较复杂, 下面我们仔细了解一下这个过程, 看看 CMS 的优秀设计思想
上面提到 CMS 是基于标记清除算法, CMS 将标记分为了三部分, 清除一部分, 总共四部分
初始标记
首先这个过程是发生 STW 的, 也就是应用程序线程暂停, 其次这个过程是非常短暂的, 并且是单线程执行的, 这一步的主要做的事情标记 GCRoots 能直接关联老年代对象, 遍历新生代, 标记新生代中可达的老年对象
并发标记
这一阶段用户线程是运行的, 因为这一阶段应用程序线程还在执行, 所有还会持续产生新的对象, 这一阶段主要是根据初始阶段标记出来的可达的 GCRoots 直接关联对象继续递归遍历这些对象的可达对象, 但是不会标记产生的新对象, 为了避免后续重新扫描老年代, 这一阶段会把新产生的对象打一个标记(Dirty 脏对象), 后续只会扫描这些标记为 Dirty 的对象
这一阶段耗时最长了, 所以在这一阶段用户产生的垃圾对象足够多时 (也就是老年代已经无法存储了) 就会发生 concurrent mode failure, 当这一错误出现时 CMS 就会退化为另一个垃圾会收器 (Serial Old) 暂停用户线程, 单线程回收, 这也是 CMS 缺点之一
预清理
这一阶段用户线程是运行的, 主要是处理新生代已经发现的引用, 比如在上面的并发阶段, Enen 区域分配了一个新的对象 M,M 引用了老年代的一个对象 N, 但这个 N 之前没有被标记为存活, 那么此时这个 N 就会被标记, 同时也会把上一阶段的 Dirty 对象重新标记, 这一阶段也可以通过参数 CMSPrecleaningEnabled 来进行关闭, 默认是开启
可中断的预清理
这一阶段用户线程是运行的, 该阶段发生有一个前提, 就是新生代 Eden 区域内存使用必须大于 2M, 这个值可以通过如下参数控制.
CMSScheduleRemarkEdenSizeThreshold
可中断的预处理是什么意思呢? 就是这一阶段可以中断, 在该阶段主要循环做两件事, 一是处理 From 和 To 区域的对象, 标记可达的老年代对象, 二是扫描标记 Dirty 对象
中断就指的是这个循环是可以中断的, 条件有三个:
MSMaxAbortablePrecleanLoops 设置循环次数, 默认是 0, 表示无限制
CMSMaxAbortablePrecleanTime 设置执行阈值, 默认是 5 秒
CMSScheduleRemarkEdenPenetration, 新生代内存使用率到了阈值, 默认是 50%
并发重新标记
这一阶段也是 STW 的, 这个过程也会非常短暂, 为什么呢? 因为上面并发标记, 预清理已经标记了大部分存活对象, 这一阶段也是针对上面新产生的对象进行扫描标记, 可能产生的新的引用如下
老年代的新对象被 GCRoots 引用
老年代未标记的对象被新生代的对象引用
老年代已标记的对象增加新引用指向老年代其它未标记的对象
新生代对象指向老年的代的引用被删除
上述对象中可能有一些已经在 Precleaning 阶段和 AbortablePreclean 阶段被处理过, 但总存在没来得及处理的, 所以还有进行如下的处理
遍历新生代对象, 重新标记
根据 GC Roots, 重新标记
遍历老年代的 Dirty, 重新标记, 这里的 Dirty Card 大部分已经在 clean 阶段处理过
这个过程中会遍历所有新生代对象, 如果新生代对象较多, 可能比较耗时, 但是如果上面可中断预处理过程中发生了一次 YGC, 那么这次遍历就会轻松很多, 但是这一次并不可控制, CMS 算法中提供了一个参数: CMSScavengeBeforeRemark, 默认并没有开启, 如果开启该参数, 在执行该阶段之前, 会强制触发一次 YGC, 可以减少新生代对象的遍历时间, 回收的也更彻底一点. 但这个参数也有缺点, 利是降低了 Remark 阶段的停顿时间, 弊的是在新生代对象很少的情况下也多了一次 YGC, 就看运气了.
并发清除
这一阶段用户线程是运行的, 同时 GC 线程开始对为标记的区域做清扫, 回收所有的垃圾对象, 这一阶段用户线程还会产生新的对象, 这一部分变成垃圾对象后, CMS 是无法清理的, 这一部分垃圾对象也被称为浮动垃圾, 这也是 CMS 缺点之一
内存碎片问题
我们知道 CMS 是基于标记 - 清除算法的, CMS 只会删除无用对象, 会产生内存碎片, 那么内存碎片什么时候整理呢? 下面这个参数可以配置
-XX:CMSFullGCsBeforeCompaction=n
意思是说在经过 n 次 CMS 的 GC 时, 才会做内存碎片整理. 如果 n 等于 3, 也就是没经过 3 次后的 CMS-GC 会进行一次内存碎片整理, 这个默认值是 0, 代表着直到碎片空间无法存储新对象时才会进行内存碎片整理.
还有一种情况, 在进行 Minor GC 时, Survivor Space 放不下, 对象只能放入老年代, 而此时老年代也放不下造成的, 多数是由于老年带有足够的空闲空间, 但是由于碎片较多, 新生代要转移到老年带的对象比较大, 找不到一段连续区域存放这个对象导致的, 这个时候会发生 FullGC, 同时进行碎片空间整理.
针对 concurrent mode failure 解决办法
- -XX:CMSInitiatingOccupancyFraction=70
- -XX:+UseCMSInitiatingOccupancyOnly
我们都知道了 concurrent mode failure 产生的原因, 那么可以通过上面两个参数来防止这个问题产生 第二个参数是用来指定使用第一个参数的, 如果没有第二个参数, 则 JVM 垃圾回收时只有第一次会采用第一个参数, 后续会自行调整.
第一个参数代表设定 CMS 在对内存占用率达到 70% 的时候开始 GC,, 这个参数要好不管监控调整以达到一个合适的值, 如果过小则 gc 过于频繁, 如果过大则可能产生上面标题的问题(本身这个参数是用来解决这个问题, 设置不当可能会引发这个问题)
还有一个参数, 这个参数开启后每次 FulllGC 都会压缩整理内存碎片, 默认值是 false, 不开启
XX:+UseCMSCompactAtFullCollection
大多数情况下不需要设置这两个参数, JVM 会自行调优, 决定在什么时候 GC, 除非你觉得你比 JVM 的自动调优做的好, 那么你可以自行调优.
过早提升和提升失败
在 Minor GC 过程中, Survivor Unused 可能不足以容纳 Eden 和另一个 Survivor 中的存活对象, 那么多余的将被移到老年代, 称为过早提升(Premature Promotion), 这会导致老年代中短期存活对象的增长, 可能会引发严重的性能问题. 再进一步, 如果老年代满了, Minor GC 后会进行 Full GC, 这将导致遍历整个堆, 称为提升失败(Promotion Failure).
早提升的原因 Survivor 空间太小, 容纳不下全部的运行时短生命周期的对象, 如果是这个原因, 可以尝试将 Survivor 调大, 否则年轻代生命周期的对象提升过快, 导致老年代很快就被占满, 从而引起频繁的 full gc; 对象太大, Survivor 和 Eden 没有足够大的空间来存放这些大对象.
提升失败原因当提升的时候, 发现老年代也没有足够的连续空间来容纳该对象. 为什么是没有足够的连续空间而不是空闲空间呢? 老年代容纳不下提升的对象有两种情况: 老年代空闲空间不够用了; 老年代虽然空闲空间很多, 但是碎片太多, 没有连续的空闲空间存放该对象.
查看 JDK8 默认垃圾收集器
控制台输入如下命令
java -XX:+PrintCommandLineFlags -version
得到结果如下, 我们可以看到 -XX:+UseParallelGC 这个参数, 这个参数表示 JDK8 的年轻代使用垃圾收集器为 Parallel Scavenge, 老年代垃圾收集器为 Serial Old
- XX:InitialHeapSize=266390080
- -XX:MaxHeapSize=4262241280
- -XX:+PrintCommandLineFlags
- -XX:+UseCompressedClassPointers -XX:+UseCompressedOops
- -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
- java version "1.8.0_191"
- Java(TM) SE Runtime Environment (build 1.8.0_191-b12)
- Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)
来源: https://www.cnblogs.com/sy270321/p/12320860.html