
先提个醒这篇内容不是“官方文档翻译”而是这几年在实际项目中反复折腾 JVM 之后的一份沉淀。我见过太多人把“JVM 调优”挂在嘴边结果遇到一次线上 OOM 就抓瞎或者把参数抄来抄去、连每个参数到底在调什么都说不清楚。这篇从零开始把 JVM 的内存模型、垃圾回收、常用工具、参数配置讲透再用几个真实场景带你把“调优”这件事落地适合刚接触 JVM 的后端开发、准备面试的求职者以及想系统排查线上问题的运维和架构师。1. JVM 到底是什么为什么我们绕不开它1.1 从“一次编译到处运行”说起JVMJava Virtual Machine是 Java 语言实现跨平台的核心。你写的.java文件编译成.class字节码JVM 负责把字节码解释或编译成当前操作系统能执行的机器指令。所以同一个.class文件在 Linux、Windows、macOS 上都能跑前提是每个平台都有对应版本的 JVM。很多人分不清 JRE、JDK、JVM 的关系。一句话JDK 包含 JREJRE 包含 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 自带默认参数大部分小项目不调也能跑。但一旦遇到高并发、大数据量、长时间运行的场景默认配置就会暴露问题。最常见的是 OOMOutOfMemoryError、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:PretenureSizeThresholdSurvivor 区太小会导致对象频繁晋升老年代进而触发 Full GC。GC Root 是判断对象死活的关键。JVM 通过可达性分析从 GC Root比如虚拟机栈中引用的对象、静态变量引用的对象、常量引用的对象、JNI 引用的对象往下遍历不可达对象就被标记为可回收。这个机制理解透了你就不会再去片面依赖-XX:MaxDirectMemorySize或者把引用类型玩出花来。3. 垃圾回收机制与收集器选型3.1 基础回收算法和它们的组合逻辑JVM 垃圾回收不是只用一种算法而是分代组合新生代用复制算法老年代用标记-清除或标记-整理。复制算法浪费一部分空间但效率高、没有内存碎片适合朝生夕死的对象。标记-清除会产生不连续碎片导致后续大对象分配困难于是有了标记-整理存活对象向一端移动直接解决碎片问题。市面上主流的收集器都是在这些基础算法之上做并发、并行优化的。都知道的收集器有 Serial、ParNew、Parallel Scavenge、CMSConcurrent Mark Sweep、G1Garbage 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 exceededGC 时间超过 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:formatb,fileheap.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、arthas5.1 命令行工具服务器上最可靠的救命稻草没有图形界面的 Linux 服务器上命令行工具是最实在的。简单列一下用法和适用场景# 查看 Java 进程-l 显示完整主类名-v 显示启动参数 jps -lv # 查看进程 GC 统计带时间间隔和次数 jstat -gcutil pid 1000 10 # 查看类加载统计 jstat -class pid # 导出堆快照 jmap -dump:formatb,filejvm_$(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:MetaspaceSize256m -XX:MaxMetaspaceSize512m -Xss512k -XX:SurvivorRatio8 -XX:MaxDirectMemorySize2g -XX:UseG1GC -XX:MaxGCPauseMillis200-Xms4g和-Xmx4g初始堆和最大堆。生产环境建议设为相同值避免运行时动态扩容带来的性能抖动。设多大取决于物理内存和业务需求一般建议堆占物理内存的 50%~70%给操作系统和其他进程留足空间。-Xmn2g新生代大小。新生代越大Minor GC 频率越低但为了给老年代留空间不能无限大。SurvivorRatio8表示 Eden 占新生代的 8/10两个 Survivor 各占 1/10。-XX:MetaspaceSize和MaxMetaspaceSize元空间初始值和最大值。很多团队只设置MaxMetaspaceSize忽略了初始值。如果不设置初始值JVM 可能按默认值逐步扩容导致应用启动早期频繁触发元空间 GC。建议两个都设置。-Xss512k每个线程栈大小。线程数多的应用可以适当减小比如从默认的 1M 减到 512k节省内存。但调太小会导致栈溢出需要测试验证。-XX:MaxDirectMemorySize控制直接内存上限。不设置时默认等于-XmxNIO 场景容易踩坑。-XX:UseG1GC和-XX:MaxGCPauseMillis200JDK 8 需要显式开启 G1JDK 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.vmoptionsWindows或idea.vmoptionsmacOS/Linux里。典型调整方式-Xms1024m -Xmx2048m -XX:ReservedCodeCacheSize512m -XX:UseG1GCReservedCodeCacheSize是给 JIT 编译后的代码缓存用的如果太小会导致“JIT 停止编译”应用运行变慢。开发机内存 16GB 以上时-Xmx2048m是起步大项目可以调到 4096m。但是别盲目调到 8GIDEA 只是开发工具给太多会压缩其他应用的内存空间照样卡。开发期防止项目 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.5GBG1 模式下停顿目标未设置。进一步看对象分配大对象比例很高很多订单对象都是短时间内创建的而且存在一批 10MB 左右的 byte 数组。最终调整-Xmx4g -Xmn1500m开启 G1 并设置-XX:MaxGCPauseMillis100同时优化了订单对象的创建方式把不必要的 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 停顿或吞吐量先想办法让系统可观测用指标说话才是正确的路径。