判断对象是否可回收
如何判断一个对象属于垃圾对象呢?
引用计数法
对于一个对象 A,只要有任意一个对象引用了 A,则 A 的计数器加 1,当引用失效的时候,引用计数器就减 1。如果 A 的应用计数器为 0,则对象 A 就不可能再被使用。
缺点:虽然循环引用的问题可通过 Recycler 算法解决,但是在多线程环境下,引用计数变更也要进行昂贵的同步操作,性能较低,早期的编程语言会采用此算法。
可达性分析算法
通过一系列的称为 GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的循环称为引用链。当一个对象到 GC Roots 没有任何引用链的时候,则证明此对象是不可达的,因此它们会被判定为可回收对象。
可以作为 GC Roots 的对象:
- 类静态属性中引用的对象
- 常量引用的对象
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
finalize 方法中复活
finalize()
方法只会被调用一次:
1 |
|
下述代码在内存中如何放置的示例:
1 |
|
假设以上代码是在函数体内运行的,那么:
四个引用
- 软引用:
java.lang.ref.SoftReference
可被回收的引用 - 弱引用: 发现即回收。由于垃圾回收器的线程通常优先级很大,因此并不一定很快地发现持有弱引用的对象。
- 虚引用: 跟踪垃圾回收过程
内存泄露
1 |
|
垃圾回收算法
标记 - 清除算法
从每个 GC Roots 对象出发,依次标记有引用关系的对象,最后将没有被标记的对象清除。
缺点:带来大量空间碎片,导致需要分配一个较大连续空间时,容易触发 GC。
碎片化:
标记 - 整理 (标记 - 压缩) 算法
从每个 GC Roots 对象出发,标记存活的对象,然后将存活的对象整理到内存空间的一端,形成连续的已使用空间,最后将已使用空间外的部分全部清理掉,消除空间碎片问题。
标记 - 复制算法
为了能够并行的标记和整理,将整个空间分为两块,每次只激活一块,垃圾回收只需把存活的对象复制到另一块未激活的空间上,将未激活空间标记为已激活,将已激活空间标记为未激活,然后清除原空间中的原对象。
分代收集算法
垃圾收集器一般根据对象存活周期的不同,将内存划分为几块,根据每块内存空间的特点,使用不同的回收算法,提供回收效率。
分区算法
将整个堆空间划分为连续的不同小空间,每一个小空间独立使用,独立回收。
优点:可以控制一次回收多少个小区间。
HotSpot 虚拟机垃圾收集器
Serial
新生代 Serial 收集器采用复制算法,使用单线程进行垃圾回收,回收时 Java 应用程序中的线程都需要暂停 (Stop-The-World),以等待回收完成。使用 -XX:+UseSerialGC
可以指定新生代采用 Serial 收集器,老年代采用 Serial Old 收集器。虚拟机在 Client
模式下运行时,它是默认的垃圾收集器。独占式回收。
它的日志格式如下:
ParNew
新生代 ParNew 将 Serial 收集器多线程化,在并发能力强的 CPU 上,产生的停顿时间短于串行回收器。开启 ParNew 回收器:
-XX:+UseParNewGC
:新生代 ParNew,老年代采用 Serial Old-XX:+UseConcMarkSweepGC
:新生代 ParNew,老年代采用 CMS
-XX:ParallelGCThreads
可以指定并行回收的线程数,这个线程数的默认值是:
1 |
|
它的 GC 日志格式如下:
Parallel
新生代 Parallel 采用复制算法,多线程、独占式,它与 ParNew 的不同之处:
- 关注系统的吞吐量
- 支持自适应 GC 调节
以下参数启用 Parallel 回收器:
-XX:+UseParallelGC
:新生代 ParallelGC,老年代:Serial Old-XX:+UseParallelOldGC
:新生代 ParallelGC,老年代 ParallelOldGC
用于控制吞吐量的两个重要参数:
-XX:MaxGCPauseMills
: 设置最大垃圾收集停顿时间。-XX:GCTimeRatio
: 设置吞吐量大小,范围 0 ~ 100。假设这个值是n
,那么默认不超过1 / (1 + n)
的时间百分比用于垃圾收集,n
默认为99
。
用于控制自适应调节 GC 的参数:
-XX:+UseAdaptiveSizePolicy
: 新生代、eden 和 survivor 的比例会动态调整。
它的 GC 日志格式如下:
Serial Old
老年代串行收集器 Serial Old 采用 ** 标记 - 整理 (标记 - 压缩)** 算法,也使用单线程进行垃圾回收。使用如下参数开启 Serial Old 回收器:
-XX:+UseSerialGC
:新生代、老年代都使用 Serial 回收器 (老年代用的是 Serial Old)-XX:+UseParNewGC
:新生代采用 ParNew,老年代采用 Serial Old-XX:+UseParallelGC
:新生代采用 ParallelGC,老年代采用 Serial Old
它的日志格式如下:
Parallel Old
老年代 Parallel Old 回收器采用标记 - 整理算法,多线程进行垃圾回收。使用 -XX:+UseParallelOldGC
可以在新生代采用 Parallel,老年代采用 Parallel Old 收集器。参数 -XX:ParallelGCThreads
可以用于设置垃圾回收时的线程数量。
它的 GC 日志格式如下:
CMS
CMS JDK9 被标记弃用,JDK14 被删除。
CMS 是一个基于标记 - 清除的算法,启用 CMS 的参数是 -XX:+UseConcMarkSweepGC
,默认启动的工作线程数是 (ParallelGCThreads + 3) / 4
。 CMS 不会等到堆内存饱和的时候才进行垃圾回收,而是当老年代的堆内存使用率达到某个阈值 -XX:CMSInitiatingOccupancyFraction
(默认是 68%
) 的时候便开始进行回收。CMS 基于标记 - 清除算法,因此执行垃圾回收完毕之后,会出现大量内存碎片,造成如果需要将内存分配给较大的对象,则必须被迫进行一次垃圾回收,以换取连续的内存空间。未解决这个问题,可以使用 -XX:+UseCMSCompactAtFullCollection
开关,使得 CMS 垃圾收集完毕之后,进行一次内存碎片整理;-XX:CMSFullGCsBeforeCompaction
参数可以用于设定进行多少次 CMS 回收后,执行一次内存压缩。
它的 GC 日志格式如下:
CMS 的代价:应用程序消耗更多的 CPU。
G1
G1 是专门针对以下应用场景设计的:
- 像 CMS 收集器一样,能与应用程序线程并发执行。
- 整理空闲空间更快。
- 需要 GC 停顿时间更好预测。
- 不希望牺牲大量的吞吐性能。
- 不需要更大的 Java Heap。
使用 -XX:+UseG1GC
可以打开 G1 收集器开关。参数 -XX:MaxGCPauseMills
可以调整最大停顿时间,另外一个参数 -XX:ParallelGCThreads
可以设置并行回收时,GC 的工作线程数量。
G1 引入的目的?
是为了缩短处理超大堆的停顿时间。
G1 相比 CMS?
- G1 是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。
- G1 的 Stop The World(STW) 更可控,G1 在停顿时间上添加了预测机制,用户可以指定期望停顿时间。
G1 和 CMS 执行的操作其实是一样的:
- 并发全局扫描标记检查存活的对象
- 哪些区域垃圾对象最多,G1 就先收集哪些区域,这也是它为什么称为 Garbage-First 的原因
G1 如何做到可预测的暂停时间?
G1 回收的第 4 步,它是 “选择一些内存块”,而不是整代内存来回收,这是 G1 跟其它 GC 非常不同的一点,其它 GC 每次回收都会回收整个 Generation 的内存 (Eden, Old), 而回收内存所需的时间就取决于内存的大小,以及实际垃圾的多少,所以垃圾回收时间是不可控的;而 G1 每次并不会回收整代内存,到底回收多少内存就看用户配置的暂停时间,配置的时间短就少回收点,配置的时间长就多回收点,伸缩自如。
G1 坏处
应用的内存非常吃紧,对内存进行部分回收根本不够,始终要进行整个 Heap 的回收,那么 G1 要做的工作量就一点也不会比其它垃圾回收器少,而且因为本身算法复杂了一点,可能比其它回收器还要差。
G1 的 Region
G1 的各代存储地址是不连续的,每一代都使用了 n
个不连续的大小相同的 Region,每个 Region 占有一块连续的虚拟内存地址。如下图所示:
在上图中,我们注意到还有一些 Region 标明了H
,它代表 Humongous,这表示这些 Region 存储的是巨大对象(humongous object,H-obj),即大小大于等于 region 一半的对象。
一个 Region 的大小可以通过参数-XX:G1HeapRegionSize
设定,取值范围从 1M
到 32M
,且是 2 的指数。如果不设定,那么 G1 会根据 Heap 大小自动决定。
G1 采用的算法
从 GC 算法的角度,G1 选择的是复合算法,可以简化理解为:
- 在新生代,G1 采用的仍然是并行的复制算法,所以同样会发生 Stop-The-World 的暂停。
- 在老年代,大部分情况下都是并发标记,而整理(Compact)则是和新生代 GC 时捎带进行,并且不是整体性的整理,而是增量进行的。
ZGC
JDK11 中推出的一款低延迟垃圾回收器,适用于大内存低延迟服务的内存管理和回收,SPECjbb 2015 基准测试,在 128G 的大堆下,最大停顿时间才 1.68 ms,停顿时间远胜于 G1 和 CMS。
默认垃圾收集器
参考 1 说: 对于 server-class 机器,默认 GC 从 serial 改为了 parallel 收集器。
参考 2 says: 从 Java 5.0 开始就会检测运行在 server-vm 还是 client-vm。对于 Java SE 6, 拥有 2 个 CPU、2GB 物理内存的机器属于 server-class 的机器。
Java 7 和 Java 8 使用的都是 Parallel GC,Java 9 使用的是 G1 垃圾收集器。
垃圾回收器怎么选择
- 最小化地使用内存和并行开销,请选择
Serial GC
- 最大化应用程序的吞吐量,请选择
Parallel GC
- 最小化
GC
的中断或者停顿时间,请选择CMS GC
并发和并行都可以表示两个或者多个任务一起执行,但是偏重点不同。并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的。而并行是真正意义上的 “同时执行”。
Full GC
什么情况下会触发 FullGC ? 参考
Full GC vs MajorGC
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
- Partial GC:并不收集整个 GC 堆的模式
- Young GC:只收集 young gen 的 GC
- Old GC:只收集 old gen 的 GC。只有 CMS 的 concurrent collection 是这个模式
- Mixed GC:收集整个 young gen 以及部分 old gen 的 GC。只有 G1 有这个模式
- Full GC:收集整个堆,包括 young gen、old gen、perm gen(如果存在的话)等所有部分的模式。
Major GC 通常是跟 full GC 是等价的,收集整个 GC 堆。但因为 HotSpot VM 发展了这么多年,外界对各种名词的解读已经完全混乱了,当有人说 “major GC” 的时候一定要问清楚他想要指的是上面的 full GC 还是 old GC。
GC 触发策略
最简单的分代式 GC 策略,按 HotSpot VM 的 serial GC 的实现来看,触发条件是:
- young GC:当 young gen 中的 eden 区分配满的时候触发。注意 young GC 中有部分存活对象会晋升到 old gen,所以 young GC 后 old gen 的占用量通常会有所升高。
- full GC:当准备要触发一次 young GC 时,如果发现统计数据说之前 young GC 的平均晋升大小比目前 old gen 剩余的空间大,则不会触发 young GC 而是转为触发 full GC(因为 HotSpot VM 的 GC 里,除了 CMS 的 concurrent collection 之外,其它能收集 old gen 的 GC 都会同时收集整个 GC 堆,包括 young gen,所以不需要事先触发一次单独的 young GC);或者,如果有 perm gen 的话,要在 perm gen 分配空间但已经没有足够空间时,也要触发一次 full GC;或者 System.gc()、heap dump 带 GC,默认也是触发 full GC。
HotSpot VM 里其它非并发 GC 的触发条件复杂一些,不过大致的原理与上面说的其实一样。当然也总有例外。Parallel Scavenge(-XX:+UseParallelGC)框架下,默认是在要触发 full GC 前先执行一次 young GC,并且两次 GC 之间能让应用程序稍微运行一小下,以期降低 full GC 的暂停时间(因为 young GC 会尽量清理了 young gen 的死对象,减少了 full GC 的工作量)。控制这个行为的 VM 参数是 - XX:+ScavengeBeforeFullGC。并发 GC 的触发条件就不太一样。以 CMS GC 为例,它主要是定时去检查 old gen 的使用量,当使用量超过了触发比例就会启动一次 CMS GC,对 old gen 做并发收集。
System.gc()
默认情况下 (即未开启 -XX:+DisableExplictGC
参数的情况下),调用 System.gc()
会显示触发 FullGC,同时对新生代和老年代进行回收。
老年代空间不足
当老年代空间新生代对象转入、创建大对象、大数组时,空间不足,会触发 FullGC,如果触发完依然不足,则抛出如下错误:
1 |
|
永生区空间不足
Permanet Generation 中存放的为一些 class 的信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation 可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。
CMS 晋升失败
CMS GC 时出现 promotion failed 和 concurrent mode failure
promotion failed 是在进行 Minor GC 时,survivor space 放不下、对象只能放入老年代,而此时老年代也放不下造成的;concurrent mode failure 是在
执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足造成的(有时候 “空间不足” 是 CMS GC 时当前的浮动垃圾过多导致暂时性的空间不足触发 Full GC)
堆中分配很大的对象
所谓大对象,是指需要大量连续内存空间的 java 对象,例如很长的数组,此种对象会直接进入老年代,而老年代虽然有很大的剩余空间,但是无法找到足够大的连续空间来分配给当前对象,此种情况就会触发 JVM 进行 Full GC。
jmap -histo:live
执行 jmap -histo:live
[强制 Full GC,不加 live 就不会] 或者 jmap -dump:live
[强制 full gc,不加 live 就不会]
对象何时进入老年代
老年对象达到年龄
新生代的对象,每经历一次 GC,年龄加 1,当年龄的最大值最多达到 MaxTenuringThreshold
(默认值 15) 的情况下,就可以晋升到老年代。
对象的实际晋升年龄是根据 survivor 区的使用情况动态计算的。
大对象
新生代空间无法容纳大对象,则会直接晋升到老年代。
参数 PretenureSizeThreshold
可以设置对象直接晋升到老年代的阈值,单位是字节,不过只对 Serial
和 ParNew
收集器有效,默认值为 0,即不指定最大晋升大小。
GC 有无问题
来自美团技术公众号 2019-2021 后端合集
评价指标
- 延迟(Latency):也可以理解为最大停顿时间,即垃圾收集过程中一次 STW 的最长时间,越短越好,一定程度上可以接受频次的增大,GC 技术的主要发展方向。
- 吞吐量(Throughput):应用系统的生命周期内,由于 GC 线程会占用 Mutator 当前可用的 CPU 时钟周期,吞吐量即为 Mutator 有效花费的时间占系统总运行时间的百分比,例如系统运行了 100 min,GC 耗时 1 min,则系统吞吐量为 99%,吞吐量优先的收集器可以接受较长的停顿。
目前各大互联网公司的系统基本都更追求低延时,避免一次 GC 停顿的时间过长对用户体验造成损失,简而言之,即为**一次停顿的时间不超过应用服务的 TP9999,GC 的吞吐量不小于 99.99%**。举个例子,假设某个服务 A 的 TP9999 为 80 ms,平均 GC 停顿为 30 ms,那么该服务的最大停顿时间最好不要超过 80 ms,GC 频次控制在 5 min 以上一次。如果满足不了,那就需要调优或者通过更多资源来进行并联冗余。
分析工具
命令行终端
- 标准终端类:jps、jinfo、jstat、jstack、jmap
- 功能整合类:jcmd、vjtools、arthas、greys
可视化界面
- 简易:JConsole、JVisualvm、HA、GCHisto、GCViewer
- 进阶:MAT、JProfiler
读懂 GC Cause
拿到 GC 日志,我们就可以简单分析 GC 情况了,通过一些工具,我们可以比较直观地看到 Cause 的分布情况,如 gceasy 等。
垃圾调优策略
面对不同的业务场景,垃圾回收的调优策略也不一样。例如,在对内存要求苛刻的情况下,需要提高对象的回收效率;在 CPU 使用率高的情况下,需要降低高并发时垃圾回收的频率。可以说,垃圾回收的调优是一项必备技能。
Mutator 类型
- IO 交互型:互联网上目前大部分的服务都属于该类型,例如分布式 RPC、MQ、HTTP 网关服务等,对内存要求并不大,大部分对象在 TP9999 的时间内都会死亡,Young 区越大越好。
- MEM 计算型:主要是分布式数据计算 Hadoop,分布式存储 HBase、Cassandra,自建的分布式缓存等,对内存要求高,对象存活时间长,Old 区越大越好。
对象 Survival Time 分布图,对我们设置 GC 参数有着非常重要的指导意义,如下图就可以简单推算分代的边界。
启动时 GC 次数较多
GC Cause 一般为 Allocation Failure,且在 GC 日志中会观察到经历一次 GC ,堆内各个空间的大小会被调整。
原因:在 JVM 的参数中 -Xms
和 -Xmx
设置的不一致,在初始化时只会初始 -Xms
大小的空间存储信息,每当空间不够用时再向操作系统申请,这样的话必然要进行一次 GC。另外,如果空间剩余很多时也会进行缩容操作,JVM 通过 -XX:MinHeapFreeRatio
和 -XX:MaxHeapFreeRatio
来控制扩容和缩容的比例,调节这两个值也可以控制伸缩的时机。
解决:尽量将成对出现的空间大小配置参数设置成固定的,保证 Java 虚拟机的堆是稳定的,如 -Xms
和 -Xmx
,-XX:-MaxNewSize
和 -XX:NewSize
,-XX:MetaSpaceSize
和 -XX:MaxMetaSpaceSize
等。
System.gc
增加 -XX:+DisableExplicitGC
此方法变成一个空方法:
1 |
|
如果没有加的话便会引发一次 STW 的 Full GC :
1 |
|
堆内存由 JVM 自己管理,堆外内存必须要手动释放,DirectByteBuffer
没有 Finalizer
,它的 Native Memory 的清理工作是通过 sun.misc.Cleaner
自动完成的,是一种基于 PhantomReference
的清理工具,比普通的 Finalizer
轻量些。
为 DirectByteBuffer
分配空间过程中会显式调用 System.gc
,希望通过 Full GC 来强迫已经无用的 DirectByteBuffer
对象释放掉它们关联的 Native Memory,下面是代码实现:
1 |
|
Old GC 对 Old 对象做 Reference Processing,Young GC 对 Young 对象做 Reference Processing,做这个 Reference Processing 的时候可以清理掉 DirectByteBuffer
。但是如果很长一段时间里没做过 GC 或者只做了 Young GC 的话则不会在 Old 触发 Cleaner 的工作,那么就可能让本来已经死亡,但已经晋升到 Old 的 DirectByteBuffer
关联的 Native Memory 得不到及时释放。如果打开了 -XX:+DisableExplicitGC
,清理工作就可能得不到及时完成,于是就有发生 Direct Memory 的 OOM。
降低 Minor GC 频率
通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此我们可以通过增大新生代空间 (-Xmn
) 来降低 Minor GC 的频率。
可能你会有这样的疑问,扩容 Eden 区虽然可以减少 Minor GC 的次数,但不会增加单次 Minor GC 的时间吗?如果单次 Minor GC 的时间增加,那也很难达到我们期待的优化效果呀。
我们知道,单次 Minor GC 时间是由两部分组成:T1(扫描新生代)和 T2(复制存活对象)。假设一个对象在 Eden 区的存活时间为 500ms,Minor GC 的时间间隔是 300ms,那么正常情况下,Minor GC 的时间为 :T1+T2
。
当我们增大新生代空间,Minor GC 的时间间隔可能会扩大到 600ms,此时一个存活 500ms 的对象就会在 Eden 区中被回收掉,此时就不存在复制存活对象了,所以再发生 Minor GC 的时间为:两次扫描新生代,即 2T1
。
可见,扩容后,Minor GC 时增加了 T1,但省去了 T2 的时间。通常在虚拟机中,复制对象的成本要远高于扫描成本。
如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加 Minor GC 的时间。如果堆中的短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。因此,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。
Minor GC 时间太长
减小新生代空间大小
降低 Full GC 的频率
减少创建大对象:在平常的业务场景中,我们习惯一次性从数据库中查询出一个大对象用于 web 端显示。例如,我之前碰到过一个一次性查询出 60 个字段的业务操作,这种大对象如果超过年轻代最大对象阈值,会被直接创建在老年代;即使被创建在了年轻代,由于年轻代的内存空间有限,通过 Minor GC 之后也会进入到老年代。这种大对象很容易产生较多的 Full GC。
增大堆内存空间:在堆内存不足的情况下,增大堆内存空间,且设置初始化堆内存为最大堆内存,也可以降低 Full GC 的频率。
脉脉 : 频繁 Full GC
(1)第一种
平时处理经验:
- 看下 JVM 参数的设置,是否有明显不合理的,用的什么类型的 GC,JDK 版本
- 看一下 gclog (JVM 带了参数
-xloggc
就会有),确定 Full GC 原因,比如 JDK 1.8 的Metaspace
区域默认只有 20.8 MB,项目如果加载类特别多,那么也会不断 Full GC 扩容。通过 gclog,可以发现很多信息,可以知道 Full GC 前后各个区域的空间占用情况如何。 - 如果是内存无法释放,就 DUMP 内存,通过 MAT 去分析是哪一块代码出现了问题。
(2)第二种
- 下线机器
jmap
看下实例,jstack
保留堆栈,有的问题到这里应该就可以解决了dump
,重启Tomcat
(3)第三种
- 建议 case by case 分析
- 你说 dump 分析,之前遇到的是 xx 场景,推测是大对象引起的,如何优化
- 不要一下子所有情况都说了
(4)第四种
- 运维肯定有 CPU 告警,大点的公司都有 Full GC 告警,没有上去就直接
jstat -gcutil
的。
老年代 GC 时间过长
通常使用 parallelGC
或者 parallelOldGC
的话,增加老年代空间无法显著降低 GC 时间,可以改用 CMS
。
选择合适的 GC 回收器
假设我们有这样一个需求,要求每次操作的响应时间必须在 500ms 以内。这个时候我们一般会选择响应速度较快的 GC 回收器,CMS(Concurrent Mark Sweep)回收器和 G1 回收器都是不错的选择。
而当我们的需求对系统吞吐量有要求时,就可以选择 Parallel Scavenge 回收器来提高系统的吞吐量。
JVM 命令
查看上次 GC 原因
查看当前对象数量
Dump Java 堆
第三方
参考
- 《深入理解 Java 虚拟机》
- 《实战 Java 虚拟机》
- 《码出高效:Java 开发手册》
- G1 垃圾收集器架构和如何做到可预测的停顿 (阿里)
- Java Hotspot G1 GC 的一些关键技术
- Major GC 和 Full GC 的区别是什么?触发条件呢?