JVM:堆内外存泄露排查

发布于:2024-12-08 ⋅ 阅读:(161) ⋅ 点赞:(0)

【堆内存排查】

  • pmap -X <pid>:找到堆的地址空间,获取真实的物理内存、swap占用。

  • jstat -gcutil <pid> 或 jstat -gc <pid>:堆、metaspace、gc等。

  • jmap -heap <pid>:堆信息。

  • jcmd <pid> GC.heap_info:堆信息。

  • Arthas(阿尔萨斯):https://arthas.aliyun.com/:堆内外信息。

  • async-profler/Arthas profiler命令:方法占用内存火山图。

  • jmap -histo <pid>:查看当前JVM堆中的对象。

    • jmap -histo <pid>:不会触发 full gc,会包括一些已经不可达但未被GC收集的对象。

    • jmap -histo:live <pid>:会触发一次 full gc,只包含存活对象。

    • jmap -histo:不会计算对象的引用占用内存。

  • -XX:+PrintTenuringDistribution:Young GC 后打印对象的年龄分布。

  • dump:jmap执行失败后使用。

【堆外存排查】

  • 【metaspace】
    • 【排查工具】
      • jstat -gcutil <pid> 或 jstat -gc <pid>
      • jmap -heap <pid>
      • jcmd <pid> GC.heap_info
      • Arthas(阿尔萨斯):memory
    • 【内存占用过多 → 查看类、类加载器】
      • Arthas(阿尔萨斯):classloader
      • jmap -clstats pid:类加载器的维度统计类的情况。
      • jstat -class pid:查看加载类的类数量和占用空间、卸载的类数量和占用空间等概况。
    • 【跟踪 class 加载记录】
      • VM参数:-verbose:class
      • VM参数:-XX:+TraceClassLoading -XX:+TraceClassUnLoading
    • 【关注点】
      • ​​​​​​​是否设置:-XX:MaxMetaspaceSize
      • metaspace内存占用过高:反射、Javasisit字节码增强、CGLIB动态代理、OSGi自定义类加载器(Groovy动态加载,JSON的ASMSerializer)。
  • 【DirectMemory】
    • 【排查工具】
      • ​​​​​​​Arthas(阿尔萨斯):memory
      • 通过JDK自带BufferPoolMXBean获取direct内存量。
      • jmap -histo <pid> | grep "java.nio.DirectByteBuffer"
    • 【关注点】
      • ​​​​​​​监控 java.nio.Bits#totalCapacity
      • 监控 io.netty.util.internal.PlatformDependent#DIRECT_MEMORY_COUNTER
      • 配置 -XX:MaxDirectMemorySize
      • 如果大量DirectMemory无法释放,去掉-XX:+DisableExplicitGC
  • 【JNI Memory】gperftools、Arthas(阿尔萨斯):stack
  • 【GC】gperftools
  • 【Code Cache】C2 Compiler
  • 【线程栈】
    • ​​​​​​​jstack查看线程数量,按照JVM参数预估。
    • pmap、smaps查看虚拟内存:Xss-8k(内核栈+thread_info=8k),算下pss+8k总和。​​​​​​​

监控系统里会有Non-Heap的监控,例如SkyWalking、Arthas的Non-Heap指标,都是通过JDK自带的MemoryMXBean方法获取的。所以一般监控系统采集的Non-Heap只是Heap以外的一部分内存!还需要留意NativeMemory等等内存。

1,JIT(即时编译器)

1.1,基本概念

常见的编译型语言如C++,通常会把代码直接编译成CPU所能理解的机器码来运行。而Java为了实现“一次编译,处处运行”的特性,把编译的过程分成两部分,首先它会先由javac编译成通用的中间形式——字节码(实现跨平台),然后再由解释器逐条将字节码解释为机器码来执行。所以在性能上,Java通常不如C++这类编译型语言。

为了优化Java的性能 ,JVM在解释器之外引入了即时(Just In Time)编译器:当程序运行时,解释器首先发挥作用,代码可以直接执行。随着时间推移,即时编译器逐渐发挥作用,把越来越多的代码编译优化成本地代码,来获取更高的执行效率。解释器这时可以作为编译运行的降级手段,在一些不可靠的编译优化出现问题时,再切换回解释执行,保证程序可以正常运行。即时编译器极大地提高了Java程序的运行速度,而且跟静态编译相比,即时编译器可以选择性地编译热点代码,省去了很多编译时间,也节省很多的空间。

举例来说:刚学骑自行车时就像JVM第一次解释执行字节码,需要更多的时间和精力。但通过不断练习(JIT编译),你逐渐掌握了技巧,变得更高效。

解释性比编译性慢的原因:编译器在编译过程中通常会考虑很多因素。比如:汇编指令的顺序。假设我们要将两个寄存器的值进行相加,执行这个操作一般只需要一个CPU周期;但是在相加之前需要将数据从内存读到寄存器中,这个操作是需要多个CPU周期的。编译器一般可以做到,先启动数据加载操作,然后执行其它指令,等数据加载完成后,再执行相加操作。由于解释器在解释执行的过程中,每次只能看到一行代码,所以很难生成上述这样的高效指令序列。而编译器可以事先看到所有代码,因此,一般来说,解释性代码比编译性代码要慢。

1.2,底层原理

Java的执行过程整体可以分为两个部分,第一步由javac将源码编译成字节码,在这个过程中会进行词法分析、语法分析、语义分析,编译原理中这部分的编译称为前端编译。接下来无需编译直接逐条将字节码解释执行,在解释执行的过程中,虚拟机同时对程序运行的信息进行收集,在这些信息的基础上,编译器会逐渐发挥作用,它会进行后端编译——把字节码编译成机器码,但不是所有的代码都会被编译,只有被JVM认定为的热点代码,才可能被编译。

怎么样才会被认为是热点代码呢?JVM中会设置一个阈值,当方法或者代码块的在一定时间内的调用次数超过这个阈值时就会被编译,存入codeCache中。当下次执行时,再遇到这段代码,就会从codeCache中读取机器码,直接执行,以此来提升程序运行的性能。

1.3,编译器种类

【Client Compiler】注重启动速度和局部的优化。HotSpot VM带有一个 Client Compiler C1编译器。这种编译器启动速度快,但是性能比较Server Compiler来说会差一些。C1会做三件事:

  • 局部简单可靠的优化,比如字节码上进行的一些基础优化,方法内联、常量传播等,放弃许多耗时较长的全局优化。
  • 将字节码构造成高级中间表示(High-level Intermediate Representation,以下称为HIR),HIR与平台无关,通常采用图结构,更适合JVM对程序进行优化。
  • 最后将HIR转换成低级中间表示(Low-level Intermediate Representation,以下称为LIR),在LIR的基础上会进行寄存器分配、窥孔优化(局部的优化方式,编译器在一个基本块或者多个基本块中,针对已经生成的代码,结合CPU自己指令的特点,通过一些认为可能带来性能提升的转换规则或者通过整体的分析,进行指令转换,来提升代码性能)等操作,最终生成机器码。

【Server Compiler】更加关注全局的优化,性能会更好,但由于会进行更多的全局分析,所以启动速度会变慢。Server Compiler主要关注一些编译耗时较长的全局优化,甚至会还会根据程序运行的信息进行一些不可靠的激进优化。这种编译器的启动时间长,适用于长时间运行的后台程序,它的性能通常比Client Compiler高30%以上。目前,Hotspot虚拟机中使用的Server Compiler有两种:

  • 【C2 Compiler】在Hotspot VM中,默认的Server Compiler是C2编译器。
  • Graal Compiler】从JDK 9开始,Hotspot VM中集成了一种新的Server Compiler,Graal编译器。

【C2 Compiler】C2编译器在进行编译优化时,会使用一种控制流与数据流结合的图数据结构,称为Ideal Graph。Ideal Graph表示当前程序的数据流向和指令间的依赖关系,依靠这种图结构,某些优化步骤(尤其是涉及浮动代码块的那些优化步骤)变得不那么复杂。

Ideal Graph的构建是在解析字节码的时候,根据字节码中的指令向一个空的Graph中添加节点,Graph中的节点通常对应一个指令块,每个指令块包含多条相关联的指令,JVM会利用一些优化技术对这些指令进行优化,比如Global Value Numbering、常量折叠等,解析结束后,还会进行一些死代码剔除的操作。生成Ideal Graph后,会在这个基础上结合收集的程序运行信息来进行一些全局的优化,这个阶段如果JVM判断此时没有全局优化的必要,就会跳过这部分优化。

无论是否进行全局优化,Ideal Graph都会被转化为一种更接近机器层面的MachNode Graph,最后编译的机器码就是从MachNode Graph中得的,生成机器码前还会有一些包括寄存器分配、窥孔优化等操作。

Graal Compiler】相比C2编译器,Graal有这样几种关键特性:

  • JVM会在解释执行的时候收集程序运行的各种信息,然后编译器会根据这些信息进行一些基于预测的激进优化,比如分支预测,根据程序不同分支的运行概率,选择性地编译一些概率较大的分支。Graal比C2更加青睐这种优化,所以Graal的峰值性能通常要比C2更好。
  • 使用Java编写,对于Java语言,尤其是新特性,比如Lambda、Stream等更加友好。
  • 更深层次的优化,比如虚函数的内联、部分逃逸分析等。

Graal编译器可以通过Java虚拟机参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler启用。当启用时,它将替换掉HotSpot中的C2编译器,并响应原本由C2负责的编译请求。

1.4,分层编译

Java 7开始引入了分层编译的概念,它结合了C1和C2的优势,追求启动速度和峰值性能的一个平衡。从JDK 8开始,JVM默认开启分层编译。分层编译将JVM的执行状态分为了五个层次。五个层级分别是:

  • 解释执行。
  • 执行不带profiling的C1代码。
  • 执行仅带方法调用次数以及循环回边执行次数profiling的C1代码。
  • 执行带所有profiling的C1代码。
  • 执行C2代码。

profiling就是收集能够反映程序执行状态的数据。其中最基本的统计数据就是方法的调用次数,以及循环回边的执行次数。

通常情况下,C2代码的执行效率要比C1代码的高出30%以上。C1层执行的代码,按执行效率排序从高至低则是1层>2层>3层。这5个层次中,1层和4层都是终止状态,当一个方法到达终止状态后,只要编译后的代码并没有失效,那么JVM就不会再次发出该方法的编译请求的。服务实际运行时,JVM会根据服务运行情况,从解释执行开始,选择不同的编译路径,直到到达终止状态。

  • 图中第①条路径,代表编译的一般情况,热点方法从解释执行到被3层的C1编译,最后被4层的C2编译。
  • 如果方法比较小(比如Java服务中常见的getter/setter方法),3层的profiling没有收集到有价值的数据,JVM就会断定该方法对于C1代码和C2代码的执行效率相同,就会执行图中第②条路径。在这种情况下,JVM会在3层编译之后,放弃进入C2编译,直接选择用1层的C1编译运行。
  • 在C1忙碌的情况下,执行图中第③条路径,在解释执行过程中对程序进行profiling ,根据信息直接由第4层的C2编译。
  • 前文提到C1中的执行效率是1层>2层>3层,第3层一般要比第2层慢35%以上,所以在C2忙碌的情况下,执行图中第④条路径。这时方法会被2层的C1编译,然后再被3层的C1编译,以减少方法在3层的执行时间。
  • 如果编译器做了一些比较激进的优化,比如分支预测,在实际运行时发现预测出错,这时就会进行反优化,重新进入解释执行,图中第⑤条执行路径代表的就是反优化。

总的来说,C1的编译速度更快,C2的编译质量更高,分层编译的不同编译路径,也就是JVM根据当前服务的运行情况来寻找当前服务的最佳平衡点的一个过程。

2,C2 Compiler 

2.1,原因解释

C2 Compiler 是JVM在server模式下字节码编译器,JVM启动的时候所有代码都处于解释执行模式,当某些代码被执行到一定阈值次数,这些代码(称为热点代码)就会被 C2 Compiler编译成机器码,编译成机器码后执行效率会得到大幅提升。流量进来后,大部分代码成为热点代码,这个过程中C2 Compiler需要频繁占用CPU来运行,当大部分热点代码被编译成机器代码后,C2 Compiler就不再长期占用CPU了,这个过程也可以看作抖动。

2.2,JitWatch分析

安装hsdis(HotSpot disassembler)

  • 验证hsdis
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -version

如果只输出下列内容表示没安装或者安装错误:

Java HotSpot(TM) 64-Bit Server VM warning: PrintAssembly is enabled; turning on
DebugNonSafepoints to gain additional output
  • 将 hsdis-amd64.dll 放入 C:\Program Files\Java\jdk1.8.0_261\jre\bin\server
  • 将 hsdis-amd64.dylib 放入 C:\Program Files\Java\jdk1.8.0_261\jre\lib
Java HotSpot(TM) 64-Bit Server VM warning: PrintAssembly is enabled; turning on
DebugNonSafepoints to gain additional output
Compiled method (c1)      77    1       3       java.lang.String::isLatin1 (19 b
ytes)
 total in heap  [0x0000020e17a50010,0x0000020e17a503e8] = 984
 relocation     [0x0000020e17a50170,0x0000020e17a501a0] = 48
 main code      [0x0000020e17a501a0,0x0000020e17a502e0] = 320
 stub code      [0x0000020e17a502e0,0x0000020e17a50318] = 56
 metadata       [0x0000020e17a50318,0x0000020e17a50320] = 8
 scopes data    [0x0000020e17a50320,0x0000020e17a50350] = 48
 scopes pcs     [0x0000020e17a50350,0x0000020e17a503e0] = 144
 dependencies   [0x0000020e17a503e0,0x0000020e17a503e8] = 8
Loaded disassembler from hsdis-amd64.dll
----------------------------------------------------------------------
java/lang/String.isLatin1()Z  [0x0000020e17a501a0, 0x0000020e17a50318]  376 byte
s
[Disassembling for mach='i386:x86-64']
[Entry Point]

......

安装JitWatch:Releases · AdoptOpenJDK/jitwatch · GitHub,下载1.4.2+可能会出现乱码。

启动JitWatch:java -jar jitwatch-ui-1.4.2-shaded-win.jar

应用配置JVM参数:

-XX:+UnlockDiagnosticVMOptions
-XX:+TraceClassLoading
-XX:+LogCompilation
-XX:LogFile=/tmp/mylogfile.log
-XX:+PrintAssembly

测试程序:首先要写一个足够复杂的类,让JIT编绎器认为它需要进行优化,不然产生的日志可能没什么内容。我在这里被坑了不少时间,怎么死活都没有日志。

  • 正确结果:得到日志,正常结束程序:Process finished with exit code 0。
  • 错误结果:Process finished with exit code -1073740791 (0xC0000409) 。说明没有正确配置hsdis。

JitWatch界面:

  • Open Log 关联第二步中生成的日志。
  • Config 中关联源码跟字节码,务必点击save保存。
    • Source locations:ProjectName/src/main/java
    • Class locations:ProjectName/target/classes
  • 点击starter
    • 左:源代码
    • 中:字节码
    • 右:汇编语言

其他功能:

  • JIT编译的线程(Thread)

  • Free Code Cache空间(Cache)

  • 对象排序(TopList)

  • 本地方法分析(NMethods)

  • 编译信息(Histo)

  • 编译时间轴(TimeLine)

2.3,解决方案(不放弃C2)

【方案一】最直接有效的方法是“预热(warm up)”可以使用Jmeter等压测工具模拟线上访问流量,让C2 Compiler预先将热点代码编译成机器码, 减少对正式环境流量的影响。

【方案二】设置JVM启动参数:-XX:CICompilerCount=threads。默认是2, 可以设置4或6。在默认值下抖动时CPU已经满载,设置成更多的线程也不一定起作用,但对于CPU“高而不满”的情况会有用,能减少抖动时间。

【方案三】修改codeCache的默认大小:-XX:ReservedCodeCacheSize=300M

【方案四】关闭分层编译:-XX:-TieredCompilation -server

C2 CompilerThread9 长时间占用CPU解决方案 - 沧海一滴 - 博客园

3,Jemalloc 排查内存

3.1,安装说明(CentOS)

使用以下命令编译安装:

wget https://github.com/jemalloc/jemalloc/archive/refs/tags/5.3.0.tar.gz
tar zxvf 5.3.0.tar.gz
cd jemalloc-5.3.0/
./autogen.sh 

# 这里启动堆剖析功能
./configure --enable-prof 
make -j 
make install
  • 缺少autoconf:autogen.sh: line 5: autoconf: command not found
sudo yum install autoconf
  • 缺少C编译器:configure: error: no acceptable C compiler found in $PATH
sudo yum groupinstall "Development Tools" -y
  • 权限不足:cannot create regular file '/usr/local/bin/jeprof': Permission denied
sudo make install

3.2,堆分析

  • 设置 LD_PRELOAD 环境变量,进行注入。
# 这里要设置成自己安装的路径
export LD_PRELOAD=/usr/local/lib/libjemalloc.so
  • 注入完毕后,进行采样配置。
export MALLOC_CONF="prof:true,lg_prof_interval:26,lg_prof_sample:20,prof_prefix:jeprof.out"
 - prof:true, 打开堆剖析的功能。
 - lg_prof_interval:26,生成内存转储的平均间隔(以 2 为底的对数),以分配活动字节数为单位。这里设置为 26,即每申请 2^26 字节的内存就生成一份到本地的内存转储配置。
 - lg_prof_sample:20,采样内存的平均间隔(以 2 为底的对数),以分配活动字节数为单位。这里设置为 20,即每申请 2^20 字节的内存就会采样一次。默认情况下是 2^19(512KB)。
 - prof_prefix: jeprof.out,设置生成的采样结果的文件名前缀。
  • 配置完毕后,执行程序,便会生成 jeprof.out 开头的内存转储文件了。接下来需要对这些内存转储文件进行分析。
jeprof --show_bytes --gif $JAVA_HOME/bin/java jeprof.*.heap > temp/app-profiling.gif
  • 在使用 jeprof 命令生成 svg 图时,可能报错 dot not found,这是因为系统上缺少生成 svg 图的工具,使用命令 sudo yum install graphviz gv 安装即可。
  • 在通过环境变量配置 LD_PRELOAD 和 MALLOC_CONF 参数时,会有一个问题,即之后执行的所有命令都会被注入 jemalloc.so,同时生成相应的转储文件。
# 使用环境变量设置参数
export MALLOC_CONF="prof:true,lg_prof_interval:32,lg_prof_sample:20,prof_prefix:jeprof.out" 

# 仅在执行我们的程序时,才注入jemalloc
LD_PRELOAD="/usr/local/lib/libjemalloc.so" <exec_name>

3.3,堆内存泄露

内存泄漏分析与堆分析的使用方法差不多,首先进行配置,然后注入 jemalloc。

export MALLOC_CONF="prof_leak:true,lg_prof_sample:20,prof_final:true"

与堆分析的参数有两点区别:

  • prof_leak,用来分析内存泄露。
  • prof_final: true,在程序退出的时候生成一次 dump。 配置完毕后,使用 LD_PRELOAD 环境变量注入我们要执行的程序。执行完毕后生成相应的 dump 文件。

接下来使用 jeprof 工具进行分析。

./jeprof --lines --svg <exec_name> jeprof.heap > <exec_name>.svg

jemalloc 统计的数据就是 new,malloc 函数传入参数指定的内存大小,但程序虽然申请了那么多内存,却不一定会用那么多,也因此系统不一定会分配那么多的物理内存。所以使用 jemalloc 分析内存时,可能与系统统计的 rss 内存不一致。这点要注意。

4,async-profler

4.1,安装说明

直接下载2.9版本:https://github.com/async-profiler/async-profiler/releases

帮助文档:命令 + 操作 + 参数 + PID

Usage: ./profiler.sh [action] [options] <pid>
Actions:
  start             start profiling and return immediately
  resume            resume profiling without resetting collected data
  stop              stop profiling
  status            print profiling status
  list              list profiling events supported by the target JVM
  collect           collect profile for the specified period of time
                    and then stop (default action)
Options:
  -e event          profiling event: cpu|alloc|lock|cache-misses etc.
  -d duration       run profiling for <duration> seconds
  -f filename       dump output to <filename>
  -i interval       sampling interval in nanoseconds
  -j jstackdepth    maximum Java stack depth
  -b bufsize        frame buffer size
  -t                profile different threads separately
  -s                simple class names instead of FQN
  -g                print method signatures
  -a                annotate Java method names
  -o fmt            output format: summary|traces|flat|collapsed|svg|tree|jfr
  -v, --version     display version string

  --title string    SVG title
  --width px        SVG width
  --height px       SVG frame height
  --minwidth px     skip frames smaller than px
  --reverse         generate stack-reversed FlameGraph / Call tree

  --all-kernel      only include kernel-mode events
  --all-user        only include user-mode events
  --sync-walk       use synchronous JVMTI stack walker (dangerous!)

<pid> is a numeric process ID of the target JVM
      or 'jps' keyword to find running JVM automatically

Example: ./profiler.sh -d 30 -f profile.svg 3456
         ./profiler.sh start -i 999000 jps
         ./profiler.sh stop -o summary,flat jps

4.2,火山图分析

sh profiler.sh -d 30 -e Unsafe_AllocateMemory0 -f unsafe_allocate.html 45641
  • -d:设置监控时间,单位s。
  • -e:设置监控内容,这里设置的是JVM的内容,来自Jemalloc中。官方指定的参数:cpu(CPU)|alloc(内存)|lock|cache-misses。
  • -f:设置输出文件名称。

5,gperftools


网站公告

今日签到

点亮在社区的每一天
去签到