java 垃圾回收机制介绍
上一篇讲述了 JVM 的内存模型, 了解了到了绝大部分的对象是分配在堆上面的, 我们在编码的时候并没有显示的指明哪些对象需要回收, 但是程序在运行的过程中是会一直创建对象的, 之所以没有内存溢出是因为我们的虚拟机帮我我们自动进行了垃圾回收, 保证程序运行的时候有足够的空间来分配我们创建的对象.
JVM 被分为五大内存区域, 其中程序计数器, 虚拟机栈, 本地方法栈是线程私有的, 内存随着线程的销毁而退出. 堆和方法区是动态分配的, 由于方法区的垃圾收集收效甚微, 所以本章所说的垃圾回收主要指的是堆内存的垃圾回收.
什么样的对象会被回收
什么样的对象会被回收呢? 我们想象下在生活中, 什么样的东西会被我们扔进垃圾桶呢, 是不是已经不再使用的东西或者说是没有任何利用价值的东西, 在 java 中也是一样的, 就是不会再使用到的对象. 那么在 java 中, 怎么判断这个对象是不是不会再被使用呢? 显然, 这似乎要比现实生活中判断哪些东西是垃圾要复杂许多.
如何确定一个对象是垃圾
前面说到, 我们需要知道哪些对象是需要被回收的, 那么怎么判断这个对象是否需要回收呢?
引用计数法.
创建对象的时候, 给对象添加一个引用计数器, 每当有一个地方引用的时候, 就给计数器加 1, 当引用失效时, 就给计数器减 1, 当引用计数器为 0 的时候, 说明这个对象不会再被使用. 这种方法被称为引用计数法. 引用计数法的逻辑比较简单, 效率高, 但是却无法解决对象和对象之间的循环引用的问题.
可达性算法分析
可达性分析算法的基本思想是通过被称为 GC Roots 的起始点向下搜索, 搜索走过的链路被称为引用链, 如果没有任何一条链路到达这个对象, 那么这个对象就不会再被使用, 就可以将其回收.
在 java 语言中, 以下对象可以被称为 GC Roots:
虚拟机栈中引用的对象,
方法区的类的静态属性引用的对象
方法区中常量引用的对象
本地方法栈中 Native 方法引用的对象.
如图所示: Object1,Object2,Object3 通过 Gc Roos 是可达的, 所以这些对象是不可回收的, 而 Object4,Object5 通过 GC Roots 不可达, 这些对象是可以回收的.
垃圾回收算法
1. 标记 - 清除算法(Mark-Sweep)
标记清除算法是最基础的, 它分为两个阶段, 标记和清除. 先标记回收的对象, 然后清除这一部分对象的内存.
标记阶段堆中所有的对象都会被扫描一遍才能确定需要回收的对象, 比较耗时.
缺点:
(1)标记和清除两个过程都比较耗时, 效率不高
(2)会产生大量不连续的内存碎片, 空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时, 无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作.
2. 复制 - 回收算法
复制回收, 顾名思义, 就是将存活的对象复制出来, 然后清理剩下的内存. 这种算法不会产生内存碎片. 将内存划分为两块相等的区域, 把存活的对象直接复制到另一块内存, 之所以分配相等, 是因为在极端的情况下, 第一块内存区域的对象都是存活的. 但是这样内存的利用率非常低, 后来经过研究新生代中的对象基本都是存活率比较低, 基本 98% 的对象都会在垃圾回收的时候被回收掉. 所以将新生代划分为三个区域, eden 区, survivor0 和 survivor1 区, 默认按照 8:1:1 的比例分配, eden 区经过回收后, 将存活的对象复制到 survivor0 区, 这样就只会有 10% 的空间没有使用到, 但是, 我们无法保证每次回收的对象都低于 10%, 因此, 当 survivor 空间不够用的时候, 就需要依赖于其他的内存空间.
3. 标记 - 整理算法
复制 - 回收算法在对象存活比较少的情况下效率很高, 但是当对象存活率很高的时候就不适合使用了. 标记 - 整理算法与标记清除有点类似, 都是先标记, 但是标记 - 整理算法会将可回收的对象都向一端移动, 然后直接清理掉可回收对象边界以外的对象. 这样的好处是不会产生内存碎片.
4. 分代收集算法
其实这种算法可以看做是前几个算法的结合, 根据对象存活的特点, 将堆分为新生代和老年代. 新生代的对象存活率低, 存活对象少, 使用复制回收算法的效率高, 而老年代对象存活率高, 存活对象多, 显然是使用标记整理的算法效率高.
java 堆的内存模型
前面提到, 根据对象存活的特点以及使垃圾回收产生算法产生最大的收益, 将堆区分为两大块, 一个是 Old 区, 一个是 Young 区. Young 区分为两大块, 一个是 Survivor 区(S0+S1), 一块是 Eden 区. S0 和 S1 一样大, 也可以叫 From 和 To.
对象创建所在区域
一般情况下, 新创建的对象都会被分配到 Eden 区, 一些特殊的大的对象会直接分配到 Old 区.
比如有对象 A,B,C 等创建在 Eden 区, 但是 Eden 区的内存空间肯定有限, 比如有 100M, 假如已经使用了
100M 或者达到一个设定的临界值, 这时候就需要对 Eden 内存空间进行清理, 即垃圾收集(Garbage Collect),
这样的 GC 我们称之为 Minor GC,Minor GC 指得是 Young 区的 GC. 经过 GC 之后, 有些对象就会被清理掉, 有些对象可能还存活着, 对于存活着的对象需要将其复制到 Survivor 区, 然后再清空 Eden 区中的这些对象.
为什么要分为 surivor0 和 surivor1
下面根据垃圾收集算法, 详细讲解下为什么要分为 surivor0 和 surivor1, 难道一个 survivor 区不行吗?
假设只有一个 s0 区, eden 区回收之后, 一部分对象存放到了 s0 区, 此时 eden 区空间全部释放, 内存都是连续的. 但是因为 s0 区也会进行垃圾回收, 它有一部分存活的对象进入到了 Old 区, 还有一部分对象存活留下来, 这时候 s0 区就产生了内存碎片, 为了使 s0 区的内存空间相对连续, 再分配一个 s1 区, 大小和 s0 一样, 每次垃圾回收的时候, 将 eden 区和 s0 区存活的对象移动到 s1 区, 这样永远都能保证 s0 或者 s1 的内存空间是连续的. 当然, 这样的情况下会使得 s0 或者 s1 区有一个空间永远为空, 浪费 10% 的内存空间, 当然为了最大化的利用 young 区, 这样的浪费是被接受的. 所以, young 区一次 GC 流程是这样的: 在同一个时间点上, S0 和 S1 只能有一个区有数据, 另外一个是空的. 假设 s0 区有数据, 此时进行一次 GC 操作, s0 区中对象的年龄就会 + 1, 而 Eden 区中所有存活的对象会被复制到是 s1 区, s0 区中还能存活的对象会有两个去处. 若对象年龄达到之前设置好的年龄阈值, 此时对象会被移动到 Old 区, Eden 区和 s0 区没有达到阈值的对象会被复制到 s1 区, s0 区将又会变为空的.
整个 young 区的回收过程是这样的:
一个对象的一生
我是一个普通的对象, 我出生在 Eden 区, 周围还有一些和我长得很像的兄弟姐妹, 我在 Eden 区玩了一段时间后, 后来我的兄弟们越来越多, 多到住不下了, 于是我的 JVM 爸爸就把我赶出了 Eden 区, 我被发配到了 s0 区, 在 s0 区, 我认识了一个女生 Baby, 她说它的故乡也是 Eden 区, 她比我早来几年, 我们互相心生好感, 我们彼此约定白头偕老, 在这段蜜月期我们时不时的从 s0 区逛到 s1 区, 又从 s1 区逛到 s0 区, 可是好景不长, 有一天早上醒来, 我发现我的 Baby 不见了, 卧槽不见了, 我抓狂, 她给我留了个字条, 说 n 年后去 Old 区找她. 我很伤心, 但是我一直觉得老天用一根无形的丝线将我们联系在一起. 我想了一下, 两情若是久长时, 又岂在朝朝暮暮, 我心里有她就行. n 多年过去了, 我一直记得这个事情, 这一天终于到来了, 我立即收拾包袱来到了 Old 区, 找了许久, 可当我找到她的时候, 她已白发苍苍, 行将就木, 她说她终于等到我了, 我要是晚来几分钟连她最后一面都见不到. 说完她就拜拜了, 身体消散在 Old 区, 我心里已然了无牵挂, 决定追随我的爱人, 于是我也消散在这片天空, 泯然于世间, 仿佛我从来没有来过一样.
Minor GC,Major GC,Full GC
新生代的垃圾回收叫 Minor GC, 老年代的垃圾回收叫 Major GC,Full GC 是指清理整个堆空间, 包括年新生代和老年代. 由于老年代大部分场景是由新生代垃圾回收触发, 所以, Major GC 通常也会伴随着一次 Minor GC.
八种垃圾收集器
前面讲到了垃圾收集的算法, 这只是一种理论思想, 我们需要把思想转化为一种具体的垃圾收集工具, 垃圾收集器就是垃圾收集算法的具体实现. 它们分别是新生代的: Serial,ParNew,Parallel Scavenge 老年代的: Serial Old, Parallel Old,CMS 以及适用于新生代和老年代的 G1. 算上 jdk11 的 ZGC 目前一共是八种垃圾收集器. 目前现代互联网公司基本都采用 CMS 和 G1 作为线上的垃圾收集器, 因此本文后续篇幅将会着重介绍这两个垃圾收集器.
Serial 收集器
Serial 是最早的垃圾收集器, 这是一个单线程收集器, 它只适用一个 CPU 或者是一条收集线去执行回收任务.
如图所示, Serial 收集器在工作的时候必须暂停所有的用户线程, 也就是 STW(Stop The World), 用户线程必须在收集任务完成之后才能工作. 如果回收的时间过长的话是很影响用户体验的. Serial 适用于单个 CPU 的环境, 其实随着计算机的发展, 如今多核 CPU 已经很普遍, 就算是个人的 PC 也是多核的更别说线上的服务器了, 所以个人认为 Serial 以后使用的场景将会非常少.
2.ParNew 收集器
ParNew 是一个新生代的多线程的收集器, 它相当于是 Serial 的多线程版本. 它的一些参数配置和 Serial 基本完全相同. 只不过 ParNew 收集器在工作的时候, 是多个线程工作的, 如图所示:
ParNew 适合在多个 CPU 场景下使用, 而我们的线上服务器基本都是多核 CPU, 所以, 使用新生代的 ParNew 搭配老年代的 CMS 收集器还是挺常见的, 我所在的部门的系统线上就是使用的 ParNew+CMS 组合. 与 Serial 相同的是, ParNew 在进行垃圾回收的时候, 也会暂停所用的用户线程.
3.Parallel Scavenge 收集器
Parallel Scavenge 也是一个新生代收集器, 并且也是一个多线程收集器, Parallel Scavenge 关注的点是应用的吞吐量, 吞吐量 = 用户代码运行时间 / 用户运行代码时间 + GC 时间, 它提供了两个参数用来控制吞吐量, 分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis 参数和直接设置吞吐量大小的 - XX:GCTimeRatio 参数. GCTimeRatio 参数的值是一个大于 0 且小于 100 的整数, 也就是垃圾收集时间占总时间的比率, 相当于是吞吐量的倒数. 高吞吐量可以高效的利用 CPU 时间, 尽快完成计算任务, 因此, Parallel Scavenge 收集器也用于需要密集计算不需要进行用户交互的一些后台.
4.Serival Old 收集器
Serival Old 收集器是垃圾收集的老年代版本, 也是一个单线程收集器.
5.Parallel Old 收集器
Parallel Old 收集器是 Parallel Scavenge 的老年代版本. 可以使用 Parallel Scavenge+Parallel Old 组合, 在注重吞吐量和 CPU 资源敏感的场合可以优先考虑 Parallell SCavenge 和 Parallell Old 组合.
6.CMS(Concurrent Mark Sweep) 收集器
CMS(Concurrent Mark Sweep), 并发标记清除, 这是一种追求低停顿时间为的收集器. 互联网时代, 用户体验为王, 垃圾收集的时间越短, 给用户带来的体验就越好. CMS 收集器整个回收过程可以分为四个步骤:
初始标记(CMS inint mark)
并发标记(CMS concurrent mark)
重新标记(CMS remark mark)
并发清除(CMS concurrent sweep)
如图所示:
初始标记:
初始标记只是标记着 GC Roots 能直接关联到的对象, 这个过程需要对所有的对象进行标记, 为了防止标记的过程中有对象的状态发生改变, 需要暂停用户线程, 因为只是标记 GC Roots 能直接关联到的对象, 因此这部分的执行速度很快.
并发标记:
对初始标记中标记的存活对象进行 trace, 标记这些对象为可达对象, 这个阶段在标记的时候可以执行用户线程, 由于用户线程会和标记的线程一起工作, 可能会有新的垃圾对象产生而没有标记完整. 所以会将在并发阶段新生代晋升到老年代的对象, 直接在老年代分配的对象以及老年代引用关系发生变化的对象所在的 card 标记为 dirty, 避免在重新标记阶段扫描整个老年代.
重新标记:
重新标记阶段是为了修正并发标记阶段产生的垃圾对象, 这一部分是暂停用户线程的, 但是执行时间也很快.
并发清除:
这个阶段是是清除标记好的垃圾对象, 会和用户线程同时进行.
cms 垃圾收集允许一定的误差, 因为并发标清除的阶段会有用户线程同时工作, 又将会有新的垃圾对象产生. 但是它主要考虑的是低停顿时间. 由于整个过程中, 并发标记和并发清除, 收集器线程可以与用户线程一起工作, 所以总体上来说, CMS 收集器的内存回收过程是与用户线程一起并发地执行的.
cms 收集器很好的展示了它的优点, 低停顿, 但是, 它也存在着以下几个缺点.
吞吐量降低: 由于是和用户线程并行执行的, 会占用一部分的 CPU 资源, 会导致用户进程变慢影响吞吐量, 这也是和 Parallel Old 相反的地方.
产生浮动垃圾: 什么是浮动垃圾, 前面也提到了, 在并发清理的阶段, 由于清理的工作是和用户线程一起工作的, 那么就会在清理的阶段而再次产生垃圾对象, 但是前面的标记阶段已经结束, 所以清理阶段是无法清除这些新产生的垃圾对象的, 只能等待下一次的垃圾回收, 所以, 就必须要留有一部分的内存空间给这些对象存储. 如果预留空间不够的话, 会出现 "Concurrent Model Failure", 这时虚拟机会临时启用 Serial Old 收集器来收集, 这样就会造成停顿时间过长.
会产生内存碎片: 由于 CMS 是采用标记 - 清除算法来实现的, 由前面的图可知, 标记清除算法会使内存空间不连续, 如果有大的对象分配过来而刚好又没有足够的连续空间存储的话就会再次触发 Full GC. 为了解决这个问题 CMS 提供了参数 - XX:+UseCMSCompactAtFullCollection 来在 Full GC 之前进行压缩空间, 但是这不得不导致停顿时间变长.
G1(GarBage-First)收集器
G1 收集器是一款面向服务端的收集器, 也就是说, 它将低停顿时间作为终极目标. G1 与其他垃圾收集器的区别是它可以控制垃圾收集时间在某一个范围之内. 与 CMS 垃圾收集的运行过程类似, 它分为初始标记, 并发标记, 最终标记, 筛选回收. G1 之所以能够将停顿时间控制在一个指定的时间内, 就是因为它可以选择性的进行回收.
G1 尝试着去满足最小的停顿时间, 在 G1 中, 停顿时间是可以设置的, 是可控制的, 之所以可以建立可预测的停顿时间模型, 是因为 G1 避免了在 java 堆中进行全区域的垃圾收集. 传统的新生代老年代的内存模型被多个大小相等的独立区域 (Region) 所取代. 如下图所示, 虽然新生代和老年代的概念还保留着, 但是他们不再是物理隔离的了, 他们都是由 Region 所组成. G1 在清除阶段是有选择性的, 它会根据设置的停顿时间, 选择回报率最大的 Region.Region 可以说是 G1 回收器一次回收的最小单元. 即每一次回收都是回收 N 个 Region. 这个 N 是多少, 主要受到 G1 回收的效率和用户设置的软实时目标有关.
G1 的内存布局:
G1 中的巨型对象是指, 占用了 Region 容量的 50% 以上的一个对象. Humongous 区, 就专门用来存储巨型对象. 如果一个 H 区装不下一个巨型对象, 则会通过连续的若干 H 分区来存储. 因为巨型对象的转移会影响 GC 效率, 所以并发标记阶段发现巨型对象不再存活时, 会将其直接回收. 分区可以有效利用内存空间, 因为收集整体是使用 "标记 - 整理",Region 之间基于 "复制" 算法, GC 后会将存活对象复制到可用分区(未分配的分区), 所以不会产生空间碎片.
前面说到, G1 会选择性的回收 Region, 避免扫描整个堆. 但是正常情况下, 每一个 Region 之间可能都会有互相引用的对象, 这样的话在垃圾收集扫描的时候还是不可避免的扫描整个堆来确定哪些是垃圾对象, G1 是如何解决这一问题的呢? G1 通过让每一个 Region 都维护一个 Remembered Set 来避免全堆扫描, 在程序对引用类型的对象进行写操作的时候, 虚拟机会检查 Reference 引用对象是否在不同的 Region, 并且会把这些引用的信息记录在 Renembered Set 中.
整个 G1 的垃圾回收阶段可以分为:
初始标记: 标记 GC Roots 能直接关联到的对象, 需要暂停用户线程.
并发标记: 从 GC Root 开始对堆中的对象进行可达性分型, 标记出存活的对象, 用时比较久, 可与用户线程并发执行.
重新标记: 修正在并发标记阶段因用户线程运行发生改变的记录, 需要暂停用户线程.
筛选回收: 对各个 Region 的回收价值进行排序, 根据用户所设置的回收时间制定回收计划, 这个阶段可与用户线程并发执行.
如图所示:
;
G1 目前是 jdk9 的默认垃圾收集器, 一般在以下场景中, 需要考虑是否需要使用 G1 垃圾收集器:
(1)50% 以上的堆被存活对象占用
(2)对象分配和晋升的速度变化非常大
(3)垃圾回收时间比较长
ZGC
Z 垃圾收集器 (ZGC) 是可伸缩的低延迟垃圾收集器. ZGC 可以同时执行所有昂贵的工作, 而不会将应用程序线程的执行停止超过 10ms, 这使得它适合于要求低延迟和 / 或使用非常大的堆 (数 TB) 的应用程序.
目前 ZGC 没有分代, 每次 GC 都会标记整个堆, 将堆分为 2M(small), 32M(medium), n*2M(large)三种大小的页面 (Page) 来管理, 根据对象的大小来判断在哪种页面分配, 大部分对象标记和对象转移都是可以和应用线程并发. 只会在以下阶段会发生 stop-the-world:
GC 开始时对 root set 的标记时
在标记结束的时候, 由于并发的原因, 需要确认所有对象已完成遍历, 需要进行暂停
在 relocate root-set 中的对象时
虽然 ZGC 属于最新的 GC 技术, 但是只在特定情况下具有绝对的优势, 如巨大的堆和极低的暂停需求.
本篇文章只是对 java 的垃圾回收涉及到的方面作一个简单的概括, 并没有涉及到具体的算法的分析以及垃圾收集器的内部实现原理. 其旨在对 java 的垃圾回收机制有一个整体的了解, 下一章将介绍垃圾收集器用到的一些参数来为 GC 日志的分析和调优作准备.
参考书籍
深入理解 java 虚拟机 -- 周志明 著
来源: https://www.cnblogs.com/JackSparrow-/p/11624182.html