Java 内存问题从工具到诊断平台
背景
Java 语言凭借 JVM 虚拟机强大的 GC 机制赋予了 Java 在编程上达成效率和性能强大竞争力,随着软件规模和复杂度的上升,我们在不断尝试新的框架、新的思路去完成任务,伴随而来的是我们走上了逆面向 GC 编程的路,GC 的算法的标准高效难以应对如此复杂的环境,在这种情况下,我们需要去了解这些 GC 背后的故事,通过剖析一个个案例,掌握一些规律,让其为我所用。
在之前的一些文章中,讲述了在过去一段时间内我们曾经碰到的关于内存的案例,以及通过一些工具来定位这些问题,但这些问题都不具备一定的代表性,排查问题的流程手段难以复用,有没有一种,能够覆盖大部分场景内存问题排查的流程,在问题发生的前期给问题定个性,确保问题在某个方向上得到认定,这便是本文的初衷。
往期文章参考
理论说
首先借用一张图来介绍一张经过分层的系统内存模型
VIRT:进程占用的虚拟内存
RES:进程占用的物理内存
SHR:进程使用的共享内存
RSS 是 Resident Set Size(常驻内存大小)的缩写,用于表示进程使用了多少内存(RAM 中的物理内存)。如果我们遇到进程 RSS 接近服务器的物理内存,那就意味着你需要关注应用的健康程度了,这意味着应用后面很有可能出现 OOM 的问题,比如进程被 OOM killer 杀死(在上述的文章参考中就碰到过 OMM killer),或者容器重启,或者因使用 Swap 而速度变慢。
将内存问题分为 3 类:
堆内存
非堆内存
堆外内存
工具说
工欲善其事必先利其器,在常规内存的监控上,常态化监控不仅仅是为了告警,在服务的优化、了解服务运行健康情况等场景也是必不可少的参考依据,当前关于 JVM 内存监控的工具很多,但绝大部分在围绕堆内存的分配上做文章,在突发问题诊断上的手段都面临高门槛和一定比例的性能影响,下面就针对常态监控和诊断性监控两个方面介绍。
常态监控
常态监控通过采集、传输、存储能够将运行时的快照数据长期保存,可以得到一定的趋势、环比、同比等信息,再根据服务发布、优化等进度进行时间线上的类比,比如以下平台针对内存的监控。
CAT-JVM 监控
Grafana-内存监控
诊断监控
诊断性监控工具更加趋向于一种互动性质的能力,比如通过 shell、基于 JVM Agent attach、内置按需实时采集读取的临时信息等操作,该部分工具种类繁多,手段各异,接下来分别从平台到小工具进行介绍我们在进行内存诊断监控上的手段。
堆外内存
Arena & MALLOC_ARENA_MAX
arena 相关知识:https://easyice.cn/archives/341
如果存在大量大小为 65536 或 60000 左右的内存区域,则很大可能是 ARENA 区域占用了太多的内存
原生参数
JDK1.8+ 修改 JVM 参数并重启 Java 进程开启 NativeMemory Tracking:-XX:NativeMemoryTracking=summary
关于 NMT 的介绍:https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr007.html
NMT 的一些子命令(summary/detail/baseline/diff)查看 Native Memory 的占用情况:sudo -u <user>jcmd <pid> VM.native_memory detail。
在使用 baseline 建立了基线的情况下用 detail.diff 看到的各内存区的变化情况:
JVM 各个区域所使用的内存大小,主要包含了 Java Heap、Class、Thread、Code、GC、Compiler、Internal、Other、Symbol 等,各部分作用如下:
1)Class:加载的类与方法信息,其实就是 metaspace,包含两部分:一是 metadata,被-XX:MaxMetaspaceSize 限制最大大小,另外是 class space,被-XX:CompressedClassSpaceSize 限制最大大小;
2)Thread:线程与线程栈占用内存,每个线程栈占用大小受-Xss 限制,但是总大小没有限制。在 x64 的 JVM 中,Xss 默认为 1024K,所以如果你的应用开启了 1000 个线程,那么这个 Thread 区占用将是 1024M,所以一般我们会把 Xss 设置为 256K 即满足要求;
3)Code:JIT 即时编译后(C1C2 编译器优化)的代码占用内存,受-XX:ReservedCodeCacheSize 限制;
4)GC:垃圾回收占用内存,例如垃圾回收需要的 CardTable,标记数,区域划分记录,还有标记 GC Root 等等,都需要内存。这个不受限制,一般不会很大,但也有例外,图 3 是 27G 的堆内存,使用 G1 垃圾回收器,你能看到 GC 区居然占用了 3.8G 的内存;
5)Compiler:C1 C2 编译器本身的代码和标记占用的内存,这个不受限制,一般不会很大;
6)Internal:命令行解析,JVMTI 使用的内存,这个不受限制,一般不会很大;
7)Symbol: 常量池占用的大小,字符串常量池受-XX:StringTableSize 个数限制,总内存大小不受限制;
我们需要需要注意的是 Class、Thread、GC 几个区域的大小。图 4 是各种 JVM 垃圾回收器消耗内存的比例,注意这部分内存是堆内存之外的:
针对上面的各个区域大小做加法,看一下是否接近于 RSS 的大小,如果是,恭喜你可以到此结束了。后续你需要做的就是针对内存占用比较大的 JVM 区去做优化,这里就不详细介绍了。
注:开启 NativeMemoryTracking 会造成 5%的性能下降,用完记得修改 JVM 参数并重启永久关闭,或者可以通过以下命令临时关闭:jcmd vm.native_memory stop <pid>。
G1 的内存开销甚至能占到堆内存的 20%,感兴趣的读者可以去阅读《JVM G1 源码分析和调优》一书查看相关内容。
JMX BufferPool
JMX 查看 direct.pool 大小
内存分配
在常规 JVM 问题定位上一般情况下较少会涉及到内核层面的内存分配问题,一方面对该层面的内存定位门槛较高,并且时间成本也高,特别当问题发生在生产环境,还需要增加该实例服务的读操作权限,这里用 jemelloc 来做例子(或者 gperftools 的 tcmalloc)
Jemelloc
官方 Wiki:https://github.com/jemalloc/jemalloc/wiki/Getting-Started
首先我们带着一个比较经典的例子来简单介绍 Jemelloc 的排查流程,其中的具体参数细节可根据 Wiki 有详细说明。
1. 安装
2. 添加环境变量
如果是 Mac 服务,此处要根据实际的文件和路径,比如/usr/local/lib/libjemalloc2.so.dawin
另外特别注意,生成的 dump 文件的大小参数设置需要仔细设置
3. 启动 JVM 或者 java -jar xxx.jar
4. dump 分析
5. 图示查看
如果想更加直观通过图形方式查看,可下载 graphviz(https://www.graphviz.org/download/) 进一步分析。
比如下图所示:
可以看到大部分泄露是来自于 java.util.zip.Inflater.inflateBytes。
再进一步
当系统出现内存问题,经过上述的手段基本上可以做到确认,但距离定位或者整改还有很长一段路要走,所以接下来,在将问题范围逐步缩小甚至还需要引入日常变更等上下文信息,然后利用更加针对性的工具进行更细致的排查,进一步定位诊断。
深入到在线源码分析过程诊断,非 Arthas (https://alibaba.github.io/arthas/ )莫属了,此处不再重复。
最后发现是写业务代码的同事非常错误地重复地在获取连接时新建 Driver 对象所导致的,这是一个开源项目 bug。Driver driver = (Driver) Class.forName("org.apache.kylin.jdbc.Driver").newInstance();
其次,再简单介绍一下来自于 Arthas 集成的三方功能(该功能和上述案例无关):Async-Profile (https://github.com/jvm-profiling-tools/async-profiler)
CPU 性能分析
火焰图怎么看?
一言以蔽之:火焰图里,横条越长,代表使用的越多,从下到上是调用堆栈信息。
在这个图里可以看到 main
方法上面的调用中 hotmethod3
方法的 CPU 使用是最多的。点击这个方法,还可以看到更详细的信息。
可以看到 replace
方法占用的 CPU 最多,也是程序中性能问题所在,是需要注意的地方。
Heap 内存分析
依旧是横条越长,代表使用的越多,从下到上是调用堆栈信息。从图里可以看出来 main 方法调用的 allocate 方法使用的内存最多,这个方法里的 Integer 类型数组占用的内存又最多,为 71%。
产品说
当一系列问题发生的时候,自始至终总归有一个根因触发了某个导火索,而我们总是在凭借哪些多多少的经验,寄希望于一些套路减少中间的曲折,俗称千里马常有而伯乐不常有,有经验的开发常有,有经验的诊断专家不常有,如何给开发者们提供一种面向运行时问题的导航式的套路产品,是有思路可寻的。
真实情况下,监控和诊断缺一不可,而监控和诊断通常隔江相望,但我们可以做一些试点,比如针对线程的部分监控,可以实时交互式获取对应的方法栈。
线程 TOP 清单
vjtools(唯品会)
诊断
GC 不管选择何种垃圾回收器,终归是需要明确 GC 从哪里开始,什么时候结束,比如 GC 的 GC Root 始终是垃圾回收的入口,而诊断类似,我们总是在发现问题的时候需要诊断,这些入口就是一系列我们认为有问题的点,经验告诉我们,应该选择线程
作为导航诊断的 Root 或者连接点。
线程在哪些地方可以成为连接点?从以往的各环境的问题定位结果上看,大部分情况在下列问题上都会切入到线程上进行问题的进一步定位。
全链路模型(监控、灰度、压测)
CPU 问题
内存问题
在全链路模型中,会用 TraceId 作为一次链路数据模型的 Key,但在诊断场景,更贴近代码的 ThreadId 会更加贴近运行时环境。
当链路记录在部分区段耗时过长,线程 ID 的数字可能会在第一时间暴露出并发问题,此时还需要线程池的队列监控进一步定位。
当链路在该区段没有结束节点,或者时长超长,则怀疑该线程是否处于 DeadLock 状态,需要配合 jstack 同类工具进行现场状态定位。
在因 Java 程序导致的 CPU 问题,一般情况下根据 ThreadId 对线程按照 CPU 排序是首选手段,比如线程 TOP 清单或者 Async-Profile 火焰图能得到初步结果。
同理内存问题,依旧可以通过 JMC 或者 VJTools 同类工具从 ThreadId 维度得到时间片范围内的内存分配增量(该部分数据保存在 Perf 数据中)。
诊断平台在非生产环境已经运行 1 年多情况下,依旧不具备完全的生产环境适配能力,在诸多功能的侵入式问题追踪上对服务的影响依旧不可控,这里我们退而求其次,使用更加精细化的功能组合,进一步将工具进行拆解再安装到平台上,诊断工具如果上升到产品层面则不应该孤立存在,在从监控到诊断上的复杂度远比服务的故障根源分析要复杂的多,如果能从服务根源分析定位出某个服务的问题,再在第一时间对该服务进行部分诊断操作(在时间允许情况下,一般以恢复服务为主),诊断平台之路道艰且阻,连接的不仅仅是运行时的数据和技术人员,更需要有策略有方针去引导,需要将经验落地在产品中形成迭代,自我肯定和否定。
(完)
参考:
https://easyice.cn/archives/346
https://blog.csdn.net/leonleow/article/details/88827620?utm_medium=distribute.pc_relevant.none-task-blog-2~default~baidujs_baidulandingword~default-1.base&spm=1001.2101.3001.4242
https://www.imooc.com/article/308471
https://www.yuanguohuo.com/2019/01/02/jemalloc-heap-profiling/
https://mp.weixin.qq.com/s/_SZCPS1uiCmNvh2JR4VpsQ
版权声明: 本文为 InfoQ 作者【徐敏】的原创文章。
原文链接:【http://xie.infoq.cn/article/0b03744a25fa2aa9ce023fd52】。文章转载请联系作者。
评论