ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JVM内存模型与调优实战:从内存泄露排查到Tomcat启动参数配置

JVM内存模型与调优实战:从内存泄露排查到Tomcat启动参数配置 简介这份JVM学习资源面向Java开发者与虚拟机原理学习者围绕Java虚拟机这一Java平台核心组件展开帮助读者理解字节码执行、跨平台运行机制及底层实现细节。压缩包共84个文件约5.92MB以dll动态库、exe可执行程序、jar包、properties配置文件和gif图示为主另含txt说明、字体与多语言资源等覆盖类加载器、运行时数据区、执行引擎、本地方法接口与本地库等模块并涉及垃圾收集算法、分代收集策略及堆栈内存调优等知识点。资源中附带tools.jar、rt.jar等核心类库与jvm.cfg等配置文件便于对照目录结构梳理JVM组成与运行机制。目前已有188人学习下载适合希望深入理解JVM工作原理、排查性能问题并提升代码运行效率的开发者参考。1. 从一个 .rar 包说起JVM 到底该从哪里下手拿到一个叫JVM.rar_jvm的压缩包很多人的第一反应是解压看看里面有什么。但真正做过 JVM 调优和排查的人会告诉你压缩包本身不重要重要的是你打算用 JVM 解决什么问题。是线上服务 GC 频繁导致接口毛刺是no suitable jvm was found to start the application这种启动直接翻车还是面试前想把 jvm 内存模型、jvm 内存区域这些概念串成一条线不同诉求对应的切入路径完全不同。这篇笔记面向的是需要把 JVM 真正用起来的后端开发和运维。我会从内存结构讲到参数配置从内存泄露排查讲到 Tomcat 启动时的 JVM 参数设置每一步都落到能复现的命令和配置上。不堆概念只讲我实际调过的参数和踩过的坑。如果你手上正好有一个 JVM 相关的压缩包或者一堆面试题看完应该能理出一条自己的排查链路。2. JVM 内存区域与内存模型先把地图画清楚2.1 运行时数据区的五个部分各自装什么JVM 内存区域是排查一切问题的起点。很多人背过「堆、栈、方法区、程序计数器、本地方法栈」但真到线上出问题的时候分不清到底是堆溢出还是元空间溢出方向就全错了。堆是所有线程共享的存放对象实例和数组也是 GC 的主战场。栈是线程私有的每个方法调用会压入一个栈帧栈帧里放局部变量表、操作数栈、动态链接和返回地址。方法区在 JDK 8 之后由元空间实现存类元信息、常量、静态变量。程序计数器记录当前线程执行的字节码行号是唯一不会 OOM 的区域。本地方法栈服务于 native 方法。这里有一个容易混淆的点jvm 内存模型和 jvm 内存区域不是一回事。内存区域说的是「数据放在哪」内存模型JMM说的是「多线程下数据怎么可见、怎么有序」。JMM 定义了主内存和工作内存的抽象以及 volatile、synchronized、final 这些关键字背后的 happens-before 规则。排查并发问题时看 JMM排查内存溢出时看内存区域别搞反。2.2 用 jmap 和 jstat 把内存分布跑出来光看概念没用得能看到实际的内存分布。下面这组命令是我在排查线上问题时最常用的组合。# 查看 Java 进程 PID jps -l # 查看堆内存各区域使用情况每 1 秒输出一次共 10 次 jstat -gcutil pid 1000 10 # 生成堆转储快照用于后续分析 jmap -dump:live,formatb,fileheap.hprof pid # 查看堆内存配置和使用概况 jmap -heap pidjstat -gcutil输出的列里E是 Eden 区使用率O是老年代使用率M是元空间使用率YGC和FGC分别是年轻代和老年代 GC 次数FGCT是 Full GC 总耗时。如果FGC持续增长且FGCT很大说明老年代有对象一直回收不掉大概率是内存泄露。jmap -dump:live里的live参数表示只 dump 存活对象文件会小很多但会触发一次 Full GC线上执行要谨慎。jmap -heap能直接看到新生代和老年代的容量分配用来确认参数有没有生效。注意JDK 9 之后jmap -heap对某些 GC 组合支持不好建议用jcmd pid GC.heap_info替代。2.3 堆参数怎么设从 -Xms 到 -XX:MaxMetaspaceSize参数设置是 JVM 落地最核心的一环。我一般遵循「先固定、再观察、后调整」的原则不要一上来就抄网上的万能配置。# 一个典型的 4C8G 服务启动参数 java -Xms4g -Xmx4g \ -Xmn2g \ -XX:MetaspaceSize256m \ -XX:MaxMetaspaceSize256m \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/dump/ \ -jar app.jar-Xms和-Xmx设成一样大避免堆频繁伸缩带来的性能抖动。-Xmn设新生代大小G1 下其实更推荐用-XX:MaxGCPauseMillis让 JVM 自己调但如果你明确知道对象朝生夕死的比例手动设-Xmn也合理。-XX:MaxMetaspaceSize一定要设上限否则元空间无限增长会吃光物理内存。-XX:HeapDumpOnOutOfMemoryError是后悔药OOM 时自动 dump事后才有得查。G1 的MaxGCPauseMillis是目标值不是保证值设成 200 不代表每次 GC 都能在 200ms 内完成。如果实际停顿远超这个值要么是堆太大要么是对象存活率太高得从代码层面找原因。3. 内存泄露排查从现象到定位的完整链路3.1 先分清内存泄露和内存溢出内存泄露和内存溢出是两件事但经常被混着说。内存溢出OOM是结果内存泄露是原因之一。泄露指的是对象已经不再使用但 GC Roots 还有引用链指向它导致回收不掉。溢出则是堆或元空间真的装不下了。典型的内存泄露现象是老年代使用率随着时间推移持续上升Full GC 后也降不下来最终抛出java.lang.OutOfMemoryError: Java heap space。如果是元空间泄露报的是Metaspace。如果是栈溢出报的是StackOverflowError那是递归太深不是泄露。3.2 用 MAT 和 jhat 分析堆转储文件拿到 hprof 文件后我一般用 Eclipse MAT 打开。MAT 的 Dominator Tree 能直接告诉你哪个对象占的内存最多Leak Suspects 报告会自动给出疑似泄露点。# 如果不想装 MAT可以用 JDK 自带的 jhat已废弃但能用 jhat -J-Xmx2g heap.hprof # 然后浏览器访问 http://localhost:7000MAT 里重点看两个视图Histogram 按类统计实例数和内存占用Dominator Tree 按支配关系展示对象引用。如果某个业务类的实例数异常多比如一个OrderCache对象有几百万个实例那基本就是缓存没设过期或者没设上限。还有一个技巧是用jmap -histo:live pid直接看存活对象的类统计不用 dump 文件速度快很多适合快速判断。# 查看存活对象类统计按内存占用排序 jmap -histo:live pid | head -30输出里#instances是实例数#bytes是占用字节数。如果看到某个自定义类排在前列且实例数远超预期就往那个方向查。3.3 一个真实的 ThreadLocal 泄露案例ThreadLocal 泄露是我见过最多的内存泄露场景。线程池里的线程生命周期很长ThreadLocal 的 value 如果没手动 remove就会一直挂在 Thread 的 ThreadLocalMap 上。// 错误写法线程池中使用 ThreadLocal 不清理 private static final ThreadLocalUserContext CONTEXT new ThreadLocal(); public void handleRequest() { CONTEXT.set(loadUser()); // 业务逻辑 // 忘记 CONTEXT.remove() }正确做法是在 finally 块里 removepublic void handleRequest() { try { CONTEXT.set(loadUser()); // 业务逻辑 } finally { CONTEXT.remove(); } }ThreadLocalMap的 key 是弱引用但 value 是强引用。key 被回收后value 还在就形成了keynull但 value 不为 null 的 Entry线程不结束这个 Entry 就不释放。线程池场景下线程几乎不结束泄露就越积越多。提示用jmap -histo:live看到java.lang.ThreadLocal$ThreadLocalMap$Entry数量异常基本可以确认是 ThreadLocal 泄露。4. Tomcat 启动设置 JVM 参数别只会改 catalina.sh4.1 三种设置方式的优先级和适用场景Tomcat 启动时设置 JVM 参数有好几种方式优先级和生效范围不一样搞混了会出现「改了没生效」的玄学问题。第一种是直接改bin/catalina.sh在文件开头加JAVA_OPTS。这种方式最直接但升级 Tomcat 时会被覆盖。第二种是创建bin/setenv.shTomcat 启动时会自动 source 这个文件升级不影响是我最推荐的方式。第三种是通过环境变量export JAVA_OPTS...适合容器化部署时在 Dockerfile 或 K8s 的 env 里注入。# bin/setenv.sh 示例 export JAVA_OPTS-Xms2g -Xmx2g \ -XX:MetaspaceSize256m \ -XX:MaxMetaspaceSize256m \ -XX:UseG1GC \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/dump/ \ -Dfile.encodingUTF-8 \ -Duser.timezoneAsia/Shanghai注意JAVA_OPTS和CATALINA_OPTS的区别JAVA_OPTS在启动和停止时都会用CATALINA_OPTS只在启动时用。如果你有一些只想在启动时生效的参数用CATALINA_OPTS更合适。4.2 no suitable jvm was found 的排查顺序no suitable jvm was found to start the application这个报错本质是 Tomcat 找不到合适的 JVM。排查顺序我一般是这样先确认JAVA_HOME有没有设echo $JAVA_HOME看输出。如果为空Tomcat 的setclasspath.sh就找不到 java 命令。然后确认$JAVA_HOME/bin/java是否存在且可执行。再确认JAVA_HOME指向的是 JDK 还是 JRE——Tomcat 编译 JSP 需要 JDK只有 JRE 会报这个错。还有一种情况是JAVA_HOME路径里有空格比如C:\Program Files\Java脚本解析时会出问题。Linux 下相对少见Windows 下很常见。# 排查三连 echo $JAVA_HOME ls -l $JAVA_HOME/bin/java $JAVA_HOME/bin/java -version如果这三步都正常再检查setenv.sh里有没有把JAVA_HOME覆盖成错误的值。我遇到过有人在setenv.sh里写了JAVA_HOME/usr/lib/jvm/default但那个软链接指向了一个不存在的目录。4.3 容器里跑 Tomcat 的参数陷阱容器里跑 Tomcat 和物理机最大的区别是内存感知。JDK 8u191 之前JVM 看不到容器的 cgroup 内存限制会按物理机内存来设默认堆大小结果就是容器被 OOM Kill。# 容器中必须显式设置堆大小并开启容器感知 java -XX:UseContainerSupport \ -XX:MaxRAMPercentage75.0 \ -XshowSettings:vm \ -jar app.jar-XX:UseContainerSupport在 JDK 8u191 和 JDK 10 默认开启但显式写上更保险。-XX:MaxRAMPercentage75.0表示堆最多用容器内存的 75%留 25% 给元空间、线程栈、直接内存和系统本身。-XshowSettings:vm会在启动时打印实际的堆配置用来验证参数有没有生效。注意MaxRAMPercentage和Xmx同时设置时Xmx优先级更高。容器场景建议只用MaxRAMPercentage让 JVM 根据容器限制自动算。5. 避坑与常见问题那些让我加班到凌晨的 JVM 配置5.1 GC 日志没开出事只能拍脑袋现象线上接口突然大量超时重启后恢复但没有任何 GC 日志不知道当时发生了什么。原因启动参数里没加 GC 日志相关配置JVM 默认不输出 GC 日志。解决JDK 8 用-Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStampsJDK 9 用-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags。日志文件要配轮转否则会撑满磁盘。JDK 8 用-XX:UseGCLogFileRotation -XX:NumberOfGCLogFiles5 -XX:GCLogFileSize50M。5.2 堆外内存溢出堆 dump 里什么都看不到现象进程被系统 Kill但堆 dump 文件正常堆内存使用率也不高。原因堆外内存溢出比如 Netty 的直接内存、JVM 元空间、线程栈、JNI 调用分配的内存。堆 dump 只包含堆内对象堆外看不到。解决用-XX:MaxDirectMemorySize限制直接内存用-XX:MaxMetaspaceSize限制元空间用-Xss限制单线程栈大小。排查时用pmap -x pid看进程内存映射或者用 NMTNative Memory Tracking启动加-XX:NativeMemoryTrackingsummary然后jcmd pid VM.native_memory summary查看各区域占用。5.3 改了 setenv.sh 但参数没生效现象在setenv.sh里加了JAVA_OPTS重启 Tomcat 后jps -v看不到新参数。原因catalina.sh里可能已经定义了JAVA_OPTSsetenv.sh的 source 顺序在它之后但某些 Tomcat 版本里JAVA_OPTS会被覆盖。或者setenv.sh没有可执行权限。解决确认setenv.sh有执行权限chmod x确认catalina.sh里 sourcesetenv.sh的位置在JAVA_OPTS默认赋值之后。最稳妥的方式是用CATALINA_OPTS而不是JAVA_OPTS减少冲突概率。重启后用jps -v或ps -ef | grep tomcat确认参数已经带上。5.4 元空间无限增长MaxMetaspaceSize 没设现象服务运行几天后元空间使用率持续上升最终 OOM报Metaspace。原因-XX:MaxMetaspaceSize没设或者设得太大元空间默认无上限受物理内存限制。频繁动态生成类比如 CGLIB 代理、反射、脚本引擎会导致元空间持续增长。解决显式设置-XX:MetaspaceSize256m -XX:MaxMetaspaceSize256m。MetaspaceSize是首次触发元空间 GC 的阈值设太小会导致频繁 Full GC设太大又浪费内存。一般和MaxMetaspaceSize设成一样避免动态调整。如果确认是动态类生成导致的还要从代码层面限制代理类的创建。5.5 容器里 Xmx 设了但被 OOM Kill现象容器内存限制 4G-Xmx设了 3G但容器还是被 OOM Kill。原因JVM 内存不只是堆。堆 3G 之外还有元空间、线程栈、直接内存、GC 自身开销、JIT 编译缓存。这些加起来可能超过 1G总内存超过容器限制就被 Kill。解决用-XX:MaxRAMPercentage75.0替代-Xmx让 JVM 按容器内存比例算堆大小。同时限制元空间和直接内存。用-XshowSettings:vm确认实际堆大小。如果还是被 Kill用jcmd pid VM.native_memory summary看堆外内存分布找出大头。6. 用 jcmd 做一次完整的 JVM 体检最后一章讲一个我日常最常用的技巧用jcmd做一次完整的 JVM 体检。jcmd是 JDK 7 之后自带的诊断工具集成了jps、jmap、jstack、jstat的大部分功能而且对 JDK 版本的兼容性更好。先列出所有 Java 进程jcmd -l然后对目标进程执行体检命令# 查看 JVM 启动参数和系统属性 jcmd pid VM.system_properties jcmd pid VM.flags # 查看堆信息 jcmd pid GC.heap_info # 查看类加载统计 jcmd pid GC.class_histogram # 查看线程栈 jcmd pid Thread.print # 查看原生内存跟踪需要启动时加 -XX:NativeMemoryTrackingsummary jcmd pid VM.native_memory summary # 手动触发 GC谨慎使用 jcmd pid GC.run # 生成堆转储 jcmd pid GC.heap_dump /data/dump/heap.hprofVM.flags能看到所有生效的 JVM 参数包括默认值和显式设置的值用来确认参数有没有生效非常方便。GC.heap_info输出新生代、老年代、元空间的容量和使用量比jmap -heap更稳定。GC.class_histogram等价于jmap -histo:live但不需要额外传live参数。我一般会把这些命令写成一个脚本出问题时一键采集#!/bin/bash PID$1 DIR/data/diagnose/$(date %Y%m%d_%H%M%S) mkdir -p $DIR jcmd $PID VM.flags $DIR/flags.txt jcmd $PID GC.heap_info $DIR/heap_info.txt jcmd $PID GC.class_histogram $DIR/class_histogram.txt jcmd $PID Thread.print $DIR/thread_dump.txt jcmd $PID VM.native_memory summary $DIR/native_memory.txt echo 诊断文件已保存到 $DIR这个脚本在线上出问题时能快速拿到第一手信息比事后拍脑袋强得多。采集完把整个目录拉下来分析GC 日志、堆信息、线程栈、原生内存分布全都有基本能覆盖 80% 的 JVM 问题场景。最后说一个我自己的习惯每次上线新服务先把 GC 日志和 HeapDumpOnOutOfMemoryError 配上再配一个定时采集jcmd GC.heap_info的监控。这三样东西平时看着没用出事的时候就是救命稻草。JVM 调优没有银弹参数是死的业务是活的多观察、多记录、少拍脑袋比背一百个参数都管用。希望帮到你。本文还有配套的精品资源点击获取
返回列表