先提个醒,这篇内容不是“官方文档翻译”,而是这几年在实际项目中反复折腾 JVM 之后的一份沉淀。我见过太多人把“JVM 调优”挂在嘴边,结果遇到一次线上 OOM 就抓瞎,或者把参数抄来抄去、连每个参数到底在调什么都说不清楚。这篇从零开始把 JVM 的内存模型、垃圾回收、常用工具、参数配置讲透,再用几个真实场景带你把“调优”这件事落地,适合刚接触 JVM 的后端开发、准备面试的求职者,以及想系统排查线上问题的运维和架构师。
1. JVM 到底是什么,为什么我们绕不开它
1.1 从“一次编译到处运行”说起
JVM(Java Virtual Machine)是 Java 语言实现跨平台的核心。你写的.java文件编译成.class字节码,JVM 负责把字节码解释或编译成当前操作系统能执行的机器指令。所以同一个.class文件,在 Linux、Windows、macOS 上都能跑,前提是每个平台都有对应版本的 JVM。
很多人分不清 JRE、JDK、JVM 的关系。一句话:JDK 包含 JRE,JRE 包含 JVM。JDK 是开发工具包,里面有javac、jar、jconsole这些开发调试工具;JRE 是 Java 运行环境,只负责跑 Java 程序;JVM 是 JRE 里的最底层运行时。如果你只是部署一个 Java 应用,装 JRE 就够了;如果你要编译代码,必须装 JDK。
我见过最经典的报错是No JVM could be found on your system,通常是装了 JDK 但没配JAVA_HOME,或者 PATH 里指向了错误的版本。这个报错经常出现在 Eclipse、IDEA 启动时,甚至会出现在一些用 Java 写的安装程序里。排查思路很简单:先确认java -version能不能正常输出,再看JAVA_HOME和PATH是否符合预期。
1.2 为什么要单独学“JVM 调优”这件事
JVM 自带默认参数,大部分小项目不调也能跑。但一旦遇到高并发、大数据量、长时间运行的场景,默认配置就会暴露问题。最常见的是 OOM(OutOfMemoryError)、GC 停顿过长、CPU 被 GC 线程占满、内存占用只增不减。
调优并不是把参数调得越大越好。一个典型的反面教材:为了怕 OOM,把-Xmx调成物理内存的 80%,结果系统剩余内存不足,导致频繁 swap,整体性能反而雪崩。调优的本质是在“内存占用”“GC 频率”“应用吞吐量”“响应延迟”之间找平衡。先搞清楚业务场景,再去调参数,这才是正确的顺序。
从面试角度说,JVM 几乎是后端技术面试的必考题,而且问得越来越细:内存模型、GC Root 有哪些、CMS 和 G1 的区别、如何排查 OOM、线程池最大线程数和 JVM 可用线程有什么关系……这些内容都会在后面展开。
2. JVM 内存模型与对象生命周期:底层逻辑先搞懂
2.1 运行时数据区到底分了哪几块
JVM 的内存布局是调优的地基。按规范,运行时数据区分为:程序计数器、虚拟机栈、本地方法栈、堆、方法区(在 JDK 8 以后是元空间 MetaSpace),外加直接内存。
- 程序计数器:记录当前线程执行字节码的行号,线程私有,几乎不占内存。
- 虚拟机栈:线程私有,每调用一个 Java 方法就创建一个栈帧,存放局部变量表、操作数栈、动态链接、方法出口。栈深度超过限制会抛
StackOverflowError。 - 本地方法栈:为 JVM 调用 native 方法服务。
- 堆:几乎所有对象实例都在这里分配,是 GC 管理的主要区域,也是调优最关注的部分。
- 方法区:存储类元信息、常量、静态变量、JIT 编译后的代码等。JDK 8 后用 MetaSpace 实现,默认使用本地内存,不再受
-XX:MaxPermSize限制。 - 直接内存:NIO 使用的
DirectByteBuffer就分配在这里,不算堆内存,不受堆参数控制,但受物理内存限制。
从线程视角看,堆和方法区是共享区域,其他都是线程私有区域。这也是为什么-Xmx、-Xms调整的是堆大小,而线程数飙升时我们更关注栈大小(-Xss)和操作系统线程限制。
2.2 对象是怎么被创建、存活和回收的
一个对象从new出来到被回收,大致路径是:优先在栈上分配(如果对象小且无逃逸,JIT 可能做标量替换),不满足条件就在 Eden 区分配;Eden 区满后触发 Minor GC,存活对象进入 Survivor 区(S0/S1);经过多次 GC 后还存活的对象进入老年代;大对象直接进入老年代。
这个生命周期决定了参数设计逻辑:新生代撑不住频繁的 Minor GC,可以调大-Xmn;大对象太多导致老年代提前占满,需要关注-XX:PretenureSizeThreshold;Survivor 区太小会导致对象频繁晋升老年代,进而触发 Full GC。
GC Root 是判断对象死活的关键。JVM 通过可达性分析,从 GC Root(比如虚拟机栈中引用的对象、静态变量引用的对象、常量引用的对象、JNI 引用的对象)往下遍历,不可达对象就被标记为可回收。这个机制理解透了,你就不会再去片面依赖-XX:MaxDirectMemorySize或者把引用类型玩出花来。
3. 垃圾回收机制与收集器选型
3.1 基础回收算法和它们的组合逻辑
JVM 垃圾回收不是只用一种算法,而是分代组合:新生代用复制算法,老年代用标记-清除或标记-整理。
复制算法浪费一部分空间,但效率高、没有内存碎片,适合朝生夕死的对象。标记-清除会产生不连续碎片,导致后续大对象分配困难,于是有了标记-整理:存活对象向一端移动,直接解决碎片问题。市面上主流的收集器都是在这些基础算法之上做并发、并行优化的。
都知道的收集器有 Serial、ParNew、Parallel Scavenge、CMS(Concurrent Mark Sweep)、G1(Garbage First)、ZGC、Shenandoah。选型思路很直接:
- 单核 CPU 或者客户端小应用,Serial + Serial Old 反而简单可靠。
- 多核、追求吞吐量的后台计算任务,优先 Parallel Scavenge + Parallel Old。
- 低延迟的 Web 服务,JDK 8 时代常用 ParNew + CMS,但 CMS 存在碎片和停顿不可控的问题。
- JDK 11+ 新项目直接默认 G1,很多时候不用额外折腾;JDK 17+ 的 ZGC 在低延迟场景也很能打。
3.2 GC 日志怎么看,停顿时间和吞吐量怎么权衡
调优的第一步不是改参数,而是先看 GC 日志。启动时加上:
-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -Xloggc:/path/to/gc.logJDK 9+ 建议用统一日志参数:
-Xlog:gc*:file=/path/to/gc.log:time,uptime,level,tags拿到日志后重点看三个指标:GC 频率、单次停顿时间、GC 总耗时。比如[GC (Allocation Failure) [PSYoungGen: 6144K->688K(6144K)] 6144K->832K(19968K), 0.0023360 secs],表示新生代 GC 后内存从 6MB 降到 688KB,总堆从 6MB 降到 832KB,停顿 2.3ms。如果日志里频繁出现Full GC,而且老年代回收后依然占满,就要怀疑内存泄漏或者堆大小设置不当。
吞吐量指的是非 GC 时间占总运行时间的比例。G1 提供了-XX:MaxGCPauseMillis来控制目标停顿时间,但这不是硬性保证,只是一个期望值。不要为了把停顿压到 10ms 而把整个堆设得越来越大,那样反而可能让 GC 扫描成本上升。
4. 常见 OOM 报错场景与排查方法
4.1 OOM 不是只有一种,对应的处理思路完全不同
很多人一见到OutOfMemoryError就以为是堆不够。实际上,OOM 分好几类,每一类的排查方向都不一样。
java.lang.OutOfMemoryError: Java heap space:堆内存不足。先分析是内存泄漏还是内存溢出,用jmap -dump配合 MAT 分析。Java heap space伴随频繁 GC 但回收不掉:大概率是对象泄漏。GC overhead limit exceeded:GC 时间超过 98%,但回收不到 2% 内存,说明内存已经病入膏肓。Metaspace:元空间不够,常见于动态生成大量类、CGLIB 代理、热部署场景。Unable to create new native thread:创建线程失败,不是堆不够,是系统线程数或进程线程数达到上限。Direct buffer memory:直接内存不足,常见于 NIO 使用不当。
排查之前先记住:不要一上来就扩大内存,先找到谁占用了内存、为什么占用。
4.2 一次线上 OOM 的排查思路实录
我以前遇到过一个案例:服务运行两天后接口越来越慢,最后报GC overhead limit exceeded。常规操作是重启,但重启后第三天又会复现。于是我们做三步排查:
第一步,看 GC 日志,确认 Full GC 频率从每分钟几次飙升到每秒多次,而且老年代回收后占用率依然接近 100%。
第二步,用jmap -dump:format=b,file=heap.hprof <pid>导出堆快照。如果进程即将挂掉,建议在启动参数里加-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump,让 JVM 在 OOM 时自动导出。
第三步,用 MAT 打开快照,重点看 Dominator Tree。最终发现是一个包含大量String对象的Map始终没有被清除,原因是缓存 key 使用了未重写hashCode的自定义对象,导致put进去后永远找不到,内存只增不减。定位后修复代码,缓存失效策略也补上了。
这里提醒一点:不要把堆快照直接下载到本地分析,几 GB 的文件导入 MAT 非常痛苦。优先在服务器上用命令行工具做初步分析,或者用支持远程分析的平台工具。
5. 调优工具全家桶:jps、jstat、jmap、jstack、jconsole、visualvm、arthas
5.1 命令行工具:服务器上最可靠的救命稻草
没有图形界面的 Linux 服务器上,命令行工具是最实在的。简单列一下用法和适用场景:
# 查看 Java 进程,-l 显示完整主类名,-v 显示启动参数 jps -lv # 查看进程 GC 统计,带时间间隔和次数 jstat -gcutil <pid> 1000 10 # 查看类加载统计 jstat -class <pid> # 导出堆快照 jmap -dump:format=b,file=jvm_$(date +%Y%m%d).hprof <pid> # 查看堆概要 jmap -heap <pid> # 打印线程堆栈,找死锁、定位线程卡顿 jstack <pid> > jstack_$(date +%Y%m%d).txtjstat是我最常用的工具。jstat -gcutil <pid> 1000 10可以每秒打印一次 GC 情况,连着看十秒,瞬间能看出 Eden、老年代的占用趋势,以及 Minor GC、Full GC 的次数和耗时。如果看到Full GC次数不断上涨,不用等 OOM 就能判断出问题了。
jstack在排查死循环、线程池满、数据库连接池耗尽时很有用。输出文件里的"http-nio-8080-exec-123"就是线程名,java.lang.Thread.State能告诉你线程是RUNNABLE、WAITING还是BLOCKED。我之前排查过一个响应缓慢的服务,jstack发现几十个线程全部卡在同一个 JDBC 调用上,最后定位到连接池配置过小。
5.2 图形化工具与 Arthas 的实战价值
JConsole 和 VisualVM 适合本机开发调试,可以直接查看堆内存走势、线程状态、CPU 使用率。VisualVM 还能装插件,比如 Visual GC,直观看到新生代、老年代每个区的变化。不过线上环境往往不允许开 GUI 端口,这时候 Arthas 的价值就体现出来了。
Arthas 是 Java 诊断利器,不用重启服务就能做很多事情:
# 启动 Arthas,选择目标进程 java -jar arthas-boot.jar # 查看堆内存信息 memory # 查看线程状态,找出 CPU 占用最高的线程 thread -n 3 # 反编译线上类,确认是否运行的是预期代码 jad com.example.MyService # 监控方法调用耗时,定位性能瓶颈 trace com.example.MyService sendMessageArthas 有一条命令我非常推荐:thread -n 3,它会直接列出 CPU 占用最高的三个线程的堆栈,这对排查“CPU 飙高但不知哪段代码”的问题效率极高。比先top -Hp再手工转十六进制线程 id 要快得多。
工具不在多,关键在于组合使用。一般流程是:先用jps或ps找到进程,用top -Hp看 CPU/内存消耗,用jstat看 GC,用jstack看线程,用jmap看堆,必要时用 Arthas 做在线诊断。熟练这套流程,大部分性能问题都能迎刃而解。
6. 常用 JVM 参数解析与典型配置
6.1 堆内存、栈内存、元空间参数怎么设
先看一份常见的启动参数模板,然后逐个解释:
-Xms4g -Xmx4g -Xmn2g -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -Xss512k -XX:SurvivorRatio=8 -XX:MaxDirectMemorySize=2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200-Xms4g和-Xmx4g:初始堆和最大堆。生产环境建议设为相同值,避免运行时动态扩容带来的性能抖动。设多大取决于物理内存和业务需求,一般建议堆占物理内存的 50%~70%,给操作系统和其他进程留足空间。-Xmn2g:新生代大小。新生代越大,Minor GC 频率越低,但为了给老年代留空间,不能无限大。SurvivorRatio=8表示 Eden 占新生代的 8/10,两个 Survivor 各占 1/10。-XX:MetaspaceSize和MaxMetaspaceSize:元空间初始值和最大值。很多团队只设置MaxMetaspaceSize,忽略了初始值。如果不设置初始值,JVM 可能按默认值逐步扩容,导致应用启动早期频繁触发元空间 GC。建议两个都设置。-Xss512k:每个线程栈大小。线程数多的应用可以适当减小,比如从默认的 1M 减到 512k,节省内存。但调太小会导致栈溢出,需要测试验证。-XX:MaxDirectMemorySize:控制直接内存上限。不设置时默认等于-Xmx,NIO 场景容易踩坑。-XX:UseG1GC和-XX:MaxGCPauseMillis=200:JDK 8 需要显式开启 G1,JDK 9+ 默认就是 G1。
6.2 线程池最大线程数和 JVM 可用线程的关系
热搜里有一条“线程池设置最大线程数是jvm剩余可用线程”,这个说法不准确,但背后确实有相关约束。
Executors.newFixedThreadPool(n)里的n是应用层线程池最大线程数,只受你代码逻辑控制。JVM 可创建的线程数受操作系统限制:Linux 下每个进程有最大线程数限制,同时-Xss会影响每线程占用的虚拟内存。更严格地说,系统可用内存越少、-Xss越大,能创建的线程就越少。
所以排查“无法创建新线程”时,要关注三类因素:
- 操作系统层面:
ulimit -u限制用户进程数,/proc/sys/kernel/threads-max限制系统总线程数。 - JVM 层面:
-Xss设置过大,会快速耗尽进程虚拟内存。 - 业务层面:线程池排队的任务无限积压,导致线程数不断膨胀,最终打死 JVM。
我曾经见过一个坑:用无界队列 +newCachedThreadPool,短平快的任务瞬间创建了几千个线程,直接把容器内存打满。后来改成有界队列 + 显式线程池 + 拒绝策略,问题才解决。线程池最大线程数要结合任务的 IO/CPU 密集程度和队列容量来估算,而不是拍脑袋给个数字。
6.3 IDEA 运行内存设置与开发期 OOM 防治
开发环境里 IDEA 本身也是个 JVM 应用,吃内存是出了名的。经常写代码卡顿、编译报 OOM,不一定是项目问题,可能是 IDEA 的 JVM 参数太小。
IDEA 的启动参数在安装目录的bin/idea64.exe.vmoptions(Windows)或idea.vmoptions(macOS/Linux)里。典型调整方式:
-Xms1024m -Xmx2048m -XX:ReservedCodeCacheSize=512m -XX:+UseG1GCReservedCodeCacheSize是给 JIT 编译后的代码缓存用的,如果太小会导致“JIT 停止编译”,应用运行变慢。开发机内存 16GB 以上时,-Xmx2048m是起步,大项目可以调到 4096m。但是别盲目调到 8G,IDEA 只是开发工具,给太多会压缩其他应用的内存空间,照样卡。
开发期防止项目 OOM,最有效的是在启动配置里加上合理的堆参数,并在代码里尽早暴露问题。比如在 spring boot 应用启动参数里设:
-Xms512m -Xmx512m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./logs/把堆调得小一点,反而更容易提前发现内存泄漏。早期跑测试时把堆压到接近极限,比上线后在大堆里苟住糊弄过去要靠谱得多。
7. 实战调优案例与面试高频问题整理
7.1 一个高并发服务的 JVM 参数调整过程
某次处理一个订单秒杀接口,单机 QPS 500,但存在周期性超时。现象是每 10 分钟出现一次“毛刺”,接口响应从 50ms 涨到 5 秒。分析过程如下:
jstat -gcutil发现新生代 Minor GC 每 2 秒一次,Full GC 每 5 分钟一次,且 Full GC 停顿达到 4 秒。- 看堆配置:
-Xmx2g -Xmn512m,老年代 1.5GB,G1 模式下停顿目标未设置。 - 进一步看对象分配:大对象比例很高,很多订单对象都是短时间内创建的,而且存在一批 10MB 左右的 byte 数组。
- 最终调整:
-Xmx4g -Xmn1500m,开启 G1 并设置-XX:MaxGCPauseMillis=100,同时优化了订单对象的创建方式,把不必要的 byte 数组改为复用缓冲池。 - 调整后,Minor GC 频率降到每 10 秒一次,Full GC 基本消失,毛刺问题解决。
这个案例说明,调参和改代码是配合的。参数不等于银弹,业务代码里的低效对象创建一样能拖垮 GC。
7.2 JVM 面试题里面的几个“陷阱”和参考答案
面试题很多,这里挑几个容易被问懵的:
- “JVM 如何判断对象可以回收?”答可达性分析,从 GC Root 遍历,不可达即可回收。顺带提到弱引用、虚引用,表现会更完整。
- “CMS 和 G1 的区别?”CMS 基于标记-清除,会产生碎片,并发阶段和业务线程并行;G1 是分区式,可以设置停顿时间,能优先回收垃圾多的区域,整体上更适合大堆和响应要求高的场景。
- “什么情况下对象会进入老年代?”大对象直接进入老年代;Survivor 区放不下时进入;动态年龄判定超过阈值进入;Minor GC 后存活对象年龄超过
MaxTenuringThreshold进入。 - “内存泄漏和内存溢出的区别?”内存泄漏是对象用完了没被释放,GC 回收不掉,最终导致溢出;溢出是内存确实不够用了,可能是泄漏导致,也可能是堆配置太小或负载太高。
- “JVM 进程已经死了,怎么查原因?”看日志,重点看
hs_err_pid*.log和-XX:+HeapDumpOnOutOfMemoryError生成的堆快照,还有系统日志。如果进程被 kill,还要看 OOM Killer 的记录。
7.3 踩坑实录:这些“常规参数”用不对反而出问题
最后分享几个我实际踩过的参数大坑,希望你能避开。
第一个是-XX:MaxTenuringThreshold。以前网上很多帖子说调大这个能减少老年代过早占用,于是有人设成 15。但对象年龄到达 15 需要经过 15 次 Minor GC,如果新生代很小、对象很多,Survivor 根本装不下,还是会被提前晋升。这个参数要和 Survivor 区大小配合调整,单独调没有意义。
第二个是-XX:+UseCompressedOops。默认开启的指针压缩能省内存,但在某些反射、Unsafe 操作场景下会踩到地址对齐的坑。别为了“优化”随意关闭。
第三个是过度设置-Xms -Xmx为巨大值。服务器物理内存 8G,你把堆设成 6G,再开一堆线程池、Netty DirectBuffer,很快系统就开始使用 swap,整个服务变得极其缓慢。调优前先看free -h,厘清内存占用大头。
调优本身就是个“底线思维”的过程:先保证不崩溃,再追求稳定,然后才是性能。不要一上来就追求极致的 GC 停顿或吞吐量,先想办法让系统可观测,用指标说话,才是正确的路径。