记一次生产环境用户服务内存泄漏问题分析
1. 整体思路
- 明确问题:user 模块出现卡顿,请求过慢,发现内存过高并且pod频繁重启
- 现场复现:通过压测复现(压测频繁使用的的4个接口)
- 分析问题:复现后,进行问题分析(pod重启?FGC,YGC频繁?)
2. 用到的相关工具
2.1 MAT
通过MAT 分析导出的dump文件,找出可能泄漏的点
2.2 JDK 下相关工具
-
jmap
- jmap -heap pid 查看当前堆中相关信息
- jmap -histo:live pid | head -n 20 打印当前时刻存活的前20条对象(由大到小排序的)
- jmap -dump:live,format=b,file=/usr/local/1.dump pid 导出当前jvm堆中相关dump文件
-
jstat
- jstat -gc pid 1000 1000 每秒打印一次gc信息,总共打印1000次
- jstat -gcnew pid 1000 1000 每秒打印一次新生代gc信息,总共打印1000次
-
jcmd
- jcmd pid VM.native_memory 打印jvm 堆外和堆内所有内存信息
- jcmd pid VM.native_memory baseline 标记当前数据
- jcmd pid VM.native_memory summary.diff 统计标记时刻到当前时刻各个内存增加情况
-
jstack
-
top -p pid ,然后按 H ,找到当前占用cpu 最多的线程,记下线程号,并将线程号转成16进制
-
jstack pid |grep -A 10 16进制id
-
2.4 jvm 相关基础
内存图:jvm 内存分布图
主要包含Heap(堆区),jvm 栈,本地方法栈,程序计数器,metaspace(元数据区),CodeCache jvm 代码缓存,Direct Memory 直接内存。其中,Heap 和栈,metaspace 可能会出现oom。
因此java进程内存大致=元空间(class+常量池)+直接内存+Code cache,我们大致从以下三个方面进行分析:
3. 堆内存分析过程
3.1 dump文件分析
使用MAT 分析工具,我们将生产的jvm内存快照jump下来,然后使用mat工具打开,分析界面如下图所示:
通过Mat工具的自动检测功能,提示ssl相关对象 可能存在内存泄漏
然后打开dominator_tree功能,如下图所示:按照包进行分组,发现ssl对象占用87M内存。所以该内存溢出的问题很有可能是由于https接口调用有关。通过从代码中查询,发现连接认证系统keycloak 时,使用的是ssl 连接。从配置中可以看出,连接keycloak 时,经过了网关,而连接网关时需要使用到https协议。将协议改为http协议,并且直接调用内网keycloak 对应的主节点的ip地址加对应端口,添加后,发现已经没有ssl 实例数了,并且没有再提示ssl 可能存在内存泄漏了。
通过下图可以看到,没有查询到ssl相关的对象:
再次使用自动检测功能,发现没有再报ssl相关包内存泄漏的问题。
4. 堆外存分析过程
3.3 问题二:容器重启
我们将https连接改为http协议后,通过压测工具持续访问用户服务,发现并没有解决pod重启的问题,于是怀疑是堆外内存增长造成的。于是通过在jvm参数中配置-XX:NativeMemoryTracking=detail,并且使用jcmd命令设置基线,观察内存增长变化
我们将限制内存空间调整到1.5G 后,排除掉重启带来的影响,再进行压测
下图是内存调整成1.5G限制后,压测前和压测5分钟后,堆外内存增长情况
压测一晚上后
从上图可以看出,压测时间越长,总的内存增长了越多,主要增长点是 19M(元空间),17M(线程),49M(代码缓存),内部21M,未知37M,其他2M左右,由此可以看出,堆外空间会一直增长,所以为了限制这部分大小,我们将Code cache区设置为固定大小。通过**-XX:InitialCodeCacheSize **和 **-XX:ReservedCodeCacheSize **确定用于代码高速缓存中的初始和最大可能大小。
5. YGC频繁,FGC 频繁
在我们设置了代码缓冲区大小后,pod容器也不再发生重启的问题了,但是接口超时还仍然存在。于是我们通过一个用户循环调用接口,发现接口响应很快。然后我们增加压力测试并发用户数至200,通过jstat命令监控是否是频繁GC导致的慢的问题。通过jstat监控命令发现,在频繁的发生的YGC,如下图所示:
然后通过jmap命令查看内存占用大小,发现eden区是满负荷状态
由上图看出,每秒进行20次左右YGC,而从内存分配来看,Eden 区66M,s0,s1 8M,old 532,可以看出,可以猜测到Eden区过小,导致YGC 频繁,从而间接导致FullGc也频繁
场景一:将JVM 参数调整为 -Xmx600M -Xms600M -Xmn300M -Xss256K
下图是调整后200并发压测30分钟的截图,可以看出 YGC 频率,基本在1s 5个左右,但是FGC 6次
YGC 1s 5个,总共执行了10500 次,花费428s,平均每次YGC耗时42ms左右
FGC 5分钟1次,总共执行了6次,花费1.68s,平均每次FGC 耗时280ms左右
由此可以得出
由上图可知,每秒产生了1200M的新生对象,有点匪夷所思。
然后我们将JVM 参数调整为 -Xmx1000M -Xms1000M -Xmn500M -Xss256K ,增大堆内存大小,并且调大了年轻代大小,每秒200个并发用户压测了一个小时
从上图可以看出,1s 5次YGC,目前还没有进行FGC
从此列可以看出,old 区每秒大概增加5k左右
从上图可以看出,目前old 区使用了124M,而FGC时的大小为500*0.92=460M,还需要使用460-124=336M,
336*1000/5 = 67200s= 18.6h
在加上目前基本压测了1.4h,所以可以推测出,FGC 每20h 一次,频率降低很多
结果:当JVM 参数为-Xmx1000M -Xms1000M -Xmn500M -Xss256K 后,FGC 频率降低很多,但YGC 频率基本没有变
结论:当新生代和old 区大小增加时,可以降低FGC 频率,但是不能解决根本问题,根本问题是每秒产生1200M数据
分析单个接口调用产生的新生代对象
-
getInfo 接口调用,每次调用Eden区增加15M内存,分析代码发现有多次没必要调用,并且有循环调用keyclock接口
-
getOrganizeInfo 接口,每次调用Eden区增加5M
-
getOrganizeUser 接口,每次调用Eden区增加0.5M左右
-
getOrganizeUsers 接口,每次调用Eden区增加5M左右
-
getOrganizeInfo 获取不存在的企业接口,每次调用Eden区增加5M
结论:代码逻辑需要修改,去掉重复请求,将循环多次调用改为一次请求
6. 总结
- 连接keycloak ssl--- 原因:连接时走的网关,网关默认时走https的,可能会影响性能,建议将keycloak 的调用url 改为keycloak 对应的主节点的ip地址,端口号
- 容器压测重启:原因是对外内存增加后,超出pod 限制1G,导致触发了pod 重启---堆外内存控制
- 当修改jvm参数为-Xmx1000M -Xms1000M -Xmn500M 后,FGC 频率明显降低,在不修改代码的前提下,可以通过增加内存来减缓FGC 频率
- 200并发压测,每秒产生1200M左右数据--代码逻辑问题,导致频繁YGC和FGC
7. 附录
7.1动态年龄判断规则
《深入理解Java虚拟机》中有如上的一段描述,讲的是动态对象年龄判定,避免-XX:MaxTenuringThreshold 设置过大导致大量对象无法晋升。
但是存在一个问题,如果说非得相同年龄所有对象大小总和大于Survivor空间的一半才能晋升,按照如下场景:
- MaxTenuringThreshold为15
- 年龄1的对象占用了33%
- 年龄2的对象占用33%
- 年龄3的对象占用34%。
得出推论:
- 按照晋升的标准。首先年龄不满足MaxTenuringThreshold,不会晋升。
- 每个年龄的对象都不满足50%,不会晋升。
Survivor都占用了100%了,但是对象就不晋升。导致老年代明明有空间,但是对象就停留在年轻代。但这个结论似乎与jvm的表现不符合,只要老年代有空间,最后还会晋升的。
把晋升年龄计算的代码摘出。我们来看看动态年龄的计算。
uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
//survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
total += sizes[age];//sizes数组是每个年龄段对象大小
if (total > desired_survivor_size) break;
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
...
}
代码中有一个TargetSurvivorRatio的值。
# 目标存活率,默认为50%
-XX:TargetSurvivorRatio
根据代码可以看到,动态年龄计算方式为:
- 通过这个比率来计算一个期望值,desired_survivor_size 。
- 然后用一个total计数器,累加每个年龄段对象大小的总和。
- 当total大于desired_survivor_size 停止。
- 然后用当前age和MaxTenuringThreshold 对比找出最小值作为结果。
总体表征就是,年龄从小到大进行累加,当加入某个年龄段后,累加和超过survivor区域*TargetSurvivorRatio的时候,就从这个年龄段网上的年龄的对象进行晋升。
所以上面的场景,年龄1的占用了33%,年龄2的占用了33%,累加和超过默认的TargetSurvivorRatio(50%),年龄2和年龄3的对象都要晋升。
动态对象年龄判断,主要是被TargetSurvivorRatio这个参数来控制。而且算的是年龄从小到大的累加和,而不是某个年龄段对象的大小。
7.2 堆外内存控制
7.2.1 元空间 Class
为了维护有关已加载类的某些元数据,JVM使用了称为*Metaspace的专用非堆区域。在Java 8之前,等效项称为PermGen或Permanent Generation*。Metaspace或PermGen包含有关已加载类的元数据,而不是包含在堆中的有关它们的实例的元数据。
这里重要的是,由于元空间是堆外数据区域,因此堆大小调整配置不会影响元空间的大小。为了限制元空间的大小,我们使用其他调整标志:
-
-XX:MetaspaceSize和-XX:MaxMetaspaceSize设置最小和最大元空间大小
-
在Java 8之前,使用*-XX:PermSize和-XX:MaxPermSize*来设置最小和最大PermGen大小
-
默认值依赖于平台。windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1,即没有限制。
-
与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生异常,虚拟机一样会抛出异常OOM:Metaspace
-
-XX:MetaspaceSize设置初始的元空间大小,对于一个64位的服务器端JVM来说,其默认的-XX:MetaspaceSize为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置,新的高水位线的值取决于GC后释放了多少元空间,如果释放的空间不足,那么在不超过MaxMetaspaceSize时,提高该值,如果释放空间过多,则适当降低该值。 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收的日志可以观察到Full GC多次调用,为了避免频繁GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。
7.2.2 线程数 Thread
JVM中最消耗内存的数据区域之一是堆栈,它与每个线程同时创建。堆栈存储局部变量和部分结果,在方法调用中起着重要作用。
默认的线程堆栈大小取决于平台,但是在大多数现代的64位操作系统中,大约为1 MB。此大小可通过**-Xss **调整标志进行配置。
与其他数据区域相比,当对线程数没有限制时,分配给堆栈的总内存实际上是不受限制的。 还值得一提的是,JVM本身需要一些线程来执行其内部操作,例如GC或即时编译。
7.2.3 代码缓存 Code
为了在不同平台上运行JVM字节码,需要将其转换为机器指令。在执行程序时,JIT编译器负责此编译。
JVM将字节码编译为汇编指令时,会将这些指令存储在称为*代码缓存***的特殊非堆数据区域中 。 可以像JVM中的其他数据区域一样管理代码缓存。-XX:InitialCodeCacheSize **和 **-XX:ReservedCodeCacheSize **标志确定用于代码高速缓存中的初始和最大可能大小。
什么是Code Cache Java代码在执行次数达到一个阈值会触发JIT编译,一旦代码块被编译成本地机器码,下次执行的时候会直接运行编译后的本地机器码。所以这本地机器码必须被缓存起来,而缓存这个本地机器码的内存区域就是Code Cache,它并不属于Java堆的一部分,除了JIT编译的代码之外,Java所使用的本地方法代码(JNI)也会存在codeCache中。
Code Cache 调优 由于Code Cache是一块内存区域,那么肯定有大小的限制,但是不同版本的JVM、不同的启动方式,Code Cache的默认大小也不同,可通过 jinfo-flagReservedCodeCacheSize 进行查看。
服务启动之后,随着时间的推移,肯定会有越来越多的方法被JIT编译成本地机器码,并存放到Code Cache,由于Code Cache大小是固定的,那么就存在被用完的风险。
一旦Code Cache被填满,就会出现下面情况:
JVM的JIT功能会被停止,将不会编译任何额外的代码。
被编译过的代码仍然以编译方式执行,但是尚未被编译的代码只能以解释方式执行了。
这种情况下,如果应用中还有很多代码以解释方式执行,其性能会大大降低。为了避免这种情况,就需要对Code Cache比较深入的理解。
JVM启动的时候,Code Cache所需内存会被单独初始化,这时候Java堆还会被初始化,所以Code Cache和Java堆是两块独立内存区域。
在 codeCache.cpp的 CodeCache::initialize()方法中,实现了Code Cache的初始化
Code Cache包含了3种数据:
NonNMethodCode
ProfiledCode
NonProfiledCode
通过 SegmentedCodeCache参数可以选择按照整体初始化,还是分段初始化。
通过 -XX:ReservedCodeCacheSize参数可以指定Code Cache的初始化大小,这个默认值在不同的JDK版本也不同,目前我这边调试的是OpenJDK11,默认大小是240M,这个已经够用了。
可以看下其它版本的默认大小:
对于那些只有32M、48M的就可能存在Code Cache不足的隐患,增加 ReservedCodeCacheSize可以是一个解决方案,但这通常只是一个临时的解决方案。
幸运的是,JVM提供了一种比较激进的codeCache回收方式:Speculative flushing。
在JDK1.7.0_4之后这种回收方式默认开启,而之前的版本需要通过一个参数来开启: -XX:+UseCodeCacheFlushing
在Speculative flushing开启的情况下,当Code Cache不足时:
最早被编译的一半方法将会被放到一个old列表中等待回收;
在一定时间间隔内,如果old列表中方法没有被调用,这个方法就会被从Code Cache清除;
很不幸的是,在JDK1.7中,Speculative flushing释放了一部分空间,但是从编译日志来看,JIT并没有恢复正常,并且系统整体性能下降很多,出现了大量超时。
在Oracle官网上,有这样一个Bug:bugs.java.com/bugdatabase…
由于算法问题,当Code Cache不足之后会导致编译线程无法继续,并且消耗大量CPU,导致系统运行变慢。
这个bug在7u101及8以后的版本已经得到修复。
7.2.4 垃圾收集 GC
JVM附带了几种GC算法,每种算法都适合不同的用例。所有这些GC算法都有一个共同的特征:它们需要使用一些堆外数据结构来执行任务。这些内部数据结构消耗更多的本机内存。
7.2.5 Symbols
让我们从字符串开始 , 它是应用程序和库代码中最常用的数据类型之一。由于它们无处不在,因此它们通常占据堆的很大一部分。如果大量的这些字符串包含相同的内容,那么堆的很大一部分将被浪费。
为了节省一些堆空间,我们可以存储每个String的一个版本, 并让其他版本引用存储的版本。 此过程称为字符串实习。由于JVM只能内生 编译时间字符串常量,因此 我们可以对要内生的字符串手动调用intern() 方法。
JVM将内联的字符串存储在特殊的本机固定大小的哈希表中,该哈希表称为String Table,也称为String Pool。我们可以通过**-XX:StringTableSize** 调整标志来配置表的大小(即桶数) 。
除了字符串表外,还有另一个本机数据区域,称为运行时常量池。 JVM使用此池存储必须在运行时解析的常量,例如编译时数字文字,方法和字段引用。
结论:
目前从user服务压测结果来看,堆外内存一般占用 256M(可控制,元空间)+ 64M(可控制codecache)+250个*0.4m(通过线程数控制) + 其他 150M(基本不怎么增长) = 570M,保险起见,设置堆外内存600M 空间
所以,对于目前基本结论是,按照堆外占用40%,因为堆外至少要600M,那么每个pod至少要1.5G,这样可以保证系统在200 并发下,正常运行而不会重启
7.3 ssl 对内存影响
内存影响
测试流程:相同频率getInfo 接口的调用,对比去掉ssl和没有去掉对比
结论: 从上面可以看出,相同频率的请求,有ssl的比没有ssl 对内存占用比较多,有ssl 的YGC 比没有ssl 的频繁