概述
垃圾回收算法作用
- 找到内存中存活的对象
- 释放不再存活对象的内存,使程序能在此利用这部分空间
评价标准
STW:
Java 垃圾回收过程会通过单独的 GC 线程来完成,但是不管使用哪一种 GC 算法,都会有部分阶段需要停止所有的用户线程。这个过程被称之为 Stop The World 即 STW,如果 STW 时间过长则会影响用户的使用。
判断 GC 算法是否优秀的三方面
- 吞吐量
- 吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即 吞吐量 = 执行用户代码的时间 /(执行用户代码的时间 + GC 时间)。兔兔两数值越高,垃圾回收的效率就越高
- 最大暂停时间
- 最大暂停时间指的是所有在垃圾回收过程中的 STW 时间最大值。最大暂停时间越短,用户使用系统收到的影响就越短。
- 堆使用效率
- 不同垃圾回收算法,对堆内存的使用方式是不同的,比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清楚算法要优于复制算法。
总结:
上述三种评价标准:堆使用效率、吞吐量、最大暂停时间 不可兼得。
一般来说,堆内存越大(垃圾回收的工作量越多),最大暂停时间就越长。想要减少最大暂停时间(更频繁的 GC),就会降低吞吐量。
没有一个垃圾回收算法能兼顾上述三点评价标准,所以不同的垃圾回收算法它的侧重点是不同的,适用于不同的应用场景。
- 不同垃圾回收算法,对堆内存的使用方式是不同的,比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清楚算法要优于复制算法。
GC 算法
标记 - 清除算法(Mark Sweep GC)
两阶段
- 标记阶段
- 将所有存活的对象进行标记。Java 中使用可达性分析算法,从 GC Root 开始通过引用链遍历出所有存活对象。
- 清除阶段
- 从内存中删除没有被标记也就是非存活对象。
第一阶段: 从 GC Root 对象开始扫描,将对象 A、B、C 在引用链上的对象标记出来:
第二阶段: 将没有标记的对象清理掉,所以对象 D 就被清理掉了。
优缺点
- 优点:
- 实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。
- 缺点:
- 碎片化问题:由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小不够进行分配。
- 分配速度慢:由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得适合的空间。
碎片化问题:
如下图,红色部分已经被清理掉了,总共回收了 9 个字节,但是每个都是一个小碎片,无法为 5 个字节的对象分配空间。
分配速度慢问题:
我们需要用一个链表来维护,哪些空间可以分配对象,很有可能需要遍历这个链表到最后,才能发现这块空间足够我们去创建一个对象。如下图,遍历到最后才发现有足够的空间分配 3 个字节的对象了。如果链表很长,遍历也会花费较长的时间。
复制算法(Copying GC)
复制算法的核心思想是
- 准备两块空间 From 空间和 To 空间,每次在对象分配阶段,只能使用其中一块空间(From 空间)。
- 在垃圾回收 GC 阶段,将 From 中存活对象复制到 To 空间。
- 将两块空间的 From 和 To 名字互换。
复制算法的例子
1.将堆内存分割成两块 From 空间 To 空间,对象分配阶段,创建对象。
2.GC 阶段开始,将 GC Root 搬运到 To 空间
3.将 GC Root 关联的对象,搬运到 To 空间
4.清理 From 空间,并把名称互换
优缺点
- 优点:
- 吞吐量高,复制算法只需要遍历一次存活对象复制到 To 空间即可,比标记 - 整理算法少了一次遍历的过程,因而性能较好,但是不如标记 - 清除算法,因为标记清除算法不需要进行对象的移动
- 不会发生碎片化,复制算法在复制之后就会将对象按顺序放入 To 空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。
- 缺点:
- 内存使用效率低,每次只能让一半的内存空间来为创建对象使用。
标记整理算法(Mark Compact GC)
标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。
两阶段
- 标记阶段,将所有存活的对象进行标记。Java 中使用可达性分析算法,从 GC Root 开始通过引用链遍历出所有存活对象。
- 整理阶段,将存活对象移动到堆的一端。清理掉存活对象的内存空间。
优缺点
- 优点:
- 内存使用效率高,整个堆内存都可以使用,不会像复制算法只能使用半个堆内存
- 不会发生碎片化,在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间
- 缺点:
- 整理阶段的效率不高,整理算法有很多种,比如 Lisp2 整理算法需要对整个堆中的对象搜索 3 次,整体性能不佳。可以通过 Two-Finger、表格算法、ImmixGC 等高效的整理算法优化此阶段的性能。
分代回收算法(Generational GC)--- 重点
概述
现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法 (Generational GC)。
分代垃圾回收将整个内存区域划分为年轻代和老年代:
流程
- 分代回收时,创建出来的对象,首先会被放入 Eden 伊甸园区。
*- 随着对象在 Eden 区越来越多,如果 Eden 区满,新创建的对象已经无法放入,就会触发年轻代的 GC,称为 Minor GC 或者 Young GC。
- Minor GC 会把需要 eden 中和 From 需要回收的对象回收,把没有回收的对象放入 To 区。
-
- 接下来,S0 会变成 To 区,S1 变成 From 区。当 eden 区满时再往里放入对象,依然会发生 Minor GC。
-
- 此时会回收 eden 区和 S1(from) 中的对象,并把 eden 和 from 区中剩余的对象放入 S0。
- 注意:每次 Minor GC 中都会为对象记录他的年龄,初始值为 0,每次 GC 完加 1。
-
- 如果 Minor GC 后对象的年龄达到阈值(最大 15,默认值和垃圾回收器有关),对象就会被晋升至老年代。
-
-
- 当老年代中空间不足,无法放入新的对象时,先尝试 minor gc 如果还是不足,就会触发 Full GC,Full GC 会对整个堆进行垃圾回收。
-
- 如果 Full GC 依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出 Out Of Memory 异常。
垃圾回收器
概述
为什么分代 GC 算法要把堆分成年轻代和老年代?首先我们要知道堆内存中对象的特性:
- 系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收,比如用户获取订单数据,订单数据返回给用户之后就可以释放了。
- 老年代中会存放长期存活的对象,比如 Spring 的大部分 bean 对象,在程序启动之后就不会被回收了。
- 在虚拟机的默认设置中,新生代大小要远小于老年代的大小。
分代 GC 算法将堆分成年轻代和老年代主要原因有:
- 可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
- 新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记 - 清除和标记 - 整理算法,由程序员来选择灵活度较高。
- 分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收 (full gc),STW 时间就会减少。
垃圾回收器是垃圾回收算法的具体实现
由于垃圾回收器分为年轻代和老年代,除了 G1 之外其他垃圾回收器必须成对组合进行使用。
具体的关系图如下:
年轻代 -Serial 垃圾回收器
Serial 是是一种单线程串行回收年轻代的垃圾回收器。
- 回收年代和算法:
- 年轻代
- 复制算法
- 优点
- 单 CPU 处理器下吞吐量非常出色
- 缺点
- 多 CPU 下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
- 适用场景
- Java 编写的客户端程序或者硬件配置有限的场景
老年代 -SerialOld 垃圾回收器
SerialOld 是 Serial 垃圾回收器的老年代版本,采用单线程串行回收
-XX:+UseSerialGC 新生代、老年代都使用串行回收器。
- 回收年代和算法:
- 老年代
- 标记 - 整理算法
- 优点
- 单 CPU 处理器下吞吐量非常出色
- 缺点
- 多 CPU 下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
- 适用场景
- 与 Serial 垃圾回收器搭配使用,或者在 CMS 特殊情况下使用
年轻代 -ParNew 垃圾回收器
ParNew 垃圾回收器本质上是对 Serial 在多 CPU 下的优化,使用多线程进行垃圾回收
-XX:+UseParNewGC 新生代使用 ParNew 回收器, 老年代使用串行回收器
- 回收年代和算法:
- 年轻代
- 复制算法
- 优点
- 多 CPU 处理器下停顿时间较短
- 缺点
- 吞吐量和停顿时间不如 G1,所以在 JDK9 之后不建议使用
- 适用场景
- JDK8 及之前的版本中,与 CMS 老年代垃圾回收器搭配使用
老年代 CMS(Concurrent Mark Sweep) 垃圾回收器
CMS 垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间。
参数:XX:+UseConcMarkSweepGC
- 回收年代和算法:
- 老年代
- 标记清除算法
- 优点
- 系统由于垃圾回收出现的停顿时间较短,用户体验好
- 缺点
- 内存碎片问题
- 退化问题
- 浮动垃圾问题
- 适用场景
- 大型的互联网系统中用户请求数据量大、频率高的场景,比如订单接口、商品接口等
CMS 执行步骤
- 初始标记,用极短的时间标记出 GC Roots 能直接关联到的对象。
- 并发标记, 标记所有的对象,用户线程不需要暂停。
- 重新标记,由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记。
- 并发清理,清理死亡的对象,用户线程不需要暂停。
缺点
- CMS 使用了标记 - 清除算法,在垃圾收集结束之后会出现大量的内存碎片,CMS 会在 Full GC 时进行碎片的整理。这样会导致用户线程暂停,可以使用 -XX:CMSFullGCsBeforeCompaction=N 参数(默认 0)调整 N 次 Full GC 之后再整理。
- 无法处理在并发清理过程中产生的 " 浮动垃圾 ",不能做到完全的垃圾回收。
- 如果老年代内存不足无法分配对象,CMS 就会退化成 Serial Old 单线程回收老年代。
并发线程数
- 在 CMS 中并发阶段运行时的线程数可以通过 -XX:ConcGCThreads 参数设置,默认值为 0,由系统计算得出。
- 计算公式为 (-XX:ParallelGCThreads 定义的线程数 + 3) / 4,ParallelGCThreads 是 STW 停顿之后的并行线程数
ParallelGCThreads 是由处理器核数决定的
- 当 cpu 核数小于 8 时,ParallelGCThreads = CPU 核数
- 否则 ParallelGCThreads = 8 + (CPU 核数--8)*5/8
- 我的电脑上逻辑处理器有 12 个,所以 ParallelGCThreads = 8 +(12 - 8)* 5/8 = 10,ConcGCThreads = (-XX:ParallelGCThreads 定义的线程数 + 3) / 4 =(10 + 3)/ 4 = 3
最终可以得到这张图:
并发标记和并发清理阶段,会使用 3 个线程并行处理。重新标记阶段会使用 10 个线程处理。
由于 CPU 的核心数有限,并发阶段会影响用户线程执行的性能。
年轻代 -Parallel Scavenge 垃圾回收器
Parallel Scavenge 是 JDK8 默认的年轻代垃圾回收器,多线程并行回收,关注的是系统的吞吐量。具备自动调整堆内存大小的特点。
- 回收年代和算法:
- 年轻代
- 复制算法
- 优点
- 吞吐量高,而且手动可控。为了提高吞吐量,虚拟机会动态调整堆的参数
- 缺点
- 不能保证单次的停顿时间
- 适用场景
- 后台任务,不需要与用户交互,并且容易产生大量的对象。比如:大数据的处理,大文件导出
常用参数:
Parallel Scavenge 允许手动设置最大暂停时间和吞吐量。Oracle 官方建议在使用这个组合时,不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小。
-
最大暂停时间,
-XX:MaxGCPauseMillis=n
设置每次垃圾回收时的最大停顿毫秒数 -
吞吐量,
-XX:GCTimeRatio=n
设置吞吐量为 n(用户线程执行时间 = n/n + 1) -
自动调整内存大小,
-XX:+UseAdaptiveSizePolicy
设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小
老年代 -Parallel Old 垃圾回收器
Parallel Old 是为 Parallel Scavenge 收集器设计的老年代版本,利用多线程并发收集。
参数:-XX:+UseParallelGC 或
-XX:+UseParallelOldGC 可以使用 Parallel Scavenge + Parallel Old 这种组合。
- 回收年代和算法:
- 老年代
- 标记 - 整理算法
- 优点
- 并发收集,在多核 CPU 下效率较高
- 缺点
- 暂停时间会比较长
- 适用场景
- 与 Parallel Scavenge 配套使用
G1 垃圾回收器 - Garbage First ---- 重点
概述
JDK9 之后默认的垃圾回收器是 G1(Garbage First)垃圾回收器。Parallel Scavenge 关注吞吐量,允许用户设置最大暂停时间,但是会减少年轻代可用空间的大小。CMS 关注暂停时间,但是吞吐量方面会下降。
而 G1 设计目标就是将上述两种垃圾回收器的优点融合:
- 支持巨大的堆空间回收,并有较高的吞吐量。
- 支持多 CPU 并行垃圾回收。
- 允许用户设置最大暂停时间。
JDK9 之后强烈建议使用 G1 垃圾回收器。
G1 出现之前的垃圾回收器,年轻代和老年代一般是连续的,如下图:
G1 的整个堆会被划分成多个大小相等的区域,称之为区 Region,区域不要求是连续的。分为 Eden、Survivor、Old 区。Region 的大小通过堆空间大小/2048 计算得到,也可以通过参数 -XX:G1HeapRegionSize=32m 指定 (其中 32m 指定 region 大小为 32M),Region size 必须是 2 的指数幂,取值范围从 1M 到 32M。
G1 垃圾回收有两种方式:
- 年轻代回收(Young GC)
- 混合回收(Mixed GC)
年轻代回收
年轻代回收(Young GC),回收 Eden 区和 Survivor 区中不用的对象。会导致 STW,G1 中可以通过参数
-XX:MaxGCPauseMillis=n(默认 200)设置每次垃圾回收时的最大暂停时间毫秒数,G1 垃圾回收器会尽可能地保证暂停时间。
- 新创建的对象会存放在 Eden 区。当 G1 判断年轻代区不足(max 默认 60%),无法分配对象时需要回收时会执行 Young GC。
*- 标记出 Eden 和 Survivor 区域中的存活对象,
- 根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的 Survivor 区中(年龄 +1),清空这些区域。
*
*
* G1 在进行 Young GC 的过程中会去记录每次垃圾回收时每个 Eden 区和 Survivor 区的平均耗时,以作为下次回收时的参考依据。这样就可以根据配置的最大暂停时间计算出本次回收时最多能回收多少个 Region 区域了。- 比如 -XX:MaxGCPauseMillis=n(默认 200),每个 Region 回收耗时 40ms,那么这次回收最多只能回收 4 个 Region。
- 后续 Young GC 时与之前相同,只不过 Survivor 区中存活对象会被搬运到另一个 Survivor 区。
*
*
* 当某个存活对象的年龄到达阈值(默认 15),将被放入老年代。
*- 部分对象如果大小超过 Region 的一半,会直接放入老年代,这类老年代被称为 Humongous 区。比如堆内存是 4G,每个 Region 是 2M,只要一个大对象超过了 1M 就被放入 Humongous 区,如果对象过大会横跨多个 Region。
-
- 多次回收之后,会出现很多 Old 老年代区,此时总堆占有率达到阈值时(-XX:InitiatingHeapOccupancyPercent 默认 45%)会触发混合回收 MixedGC。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来完成。
-
混合回收
混合回收分为:初始标记(initial mark)、并发标记(concurrent mark)、最终标记(remark 或者 Finalize Marking)、并发清理(cleanup)
G1 对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是 G1(Garbage first)名称的由来。
G1 对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是 G1(Garbage first)名称的由来。最后清理阶段使用复制算法,不会产生内存碎片。
注意:如果清理过程中发现没有足够的空 Region 存放转移的对象,会出现 Full GC。单线程执行标记 - 整理算法,此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。
总结
参数 1:-XX:+UseG1GC
打开 G1 的开关,JDK9 之后默认不需要打开
参数 2:-XX:MaxGCPauseMillis=毫秒值
最大暂停的时
- 回收年代和算法:
- 年轻代 + 老年代
- 复制算法
- 优点
- 对比较大的堆如超过 6G 的堆回收时,延迟可控
- 不会产生内存碎片
- 并发标记的 SATB 算法效率高
- 缺点
- JDK8 之前还不够成熟
- 适用场景
- JDK8 最新版本、JDK9 之后建议默认使用