说实话,身边不少写了好几年 Java 的老同事,一到排查线上问题还是会犯怵:CPU 飙到 100% 不知道先看哪,内存疯狂上涨不知道怎么抓证据,动不动抛 OutOfMemoryError 更是一头雾水。归根结底,是对 JVM 还不够熟。JVM 这个东西,平时看起来像个黑盒,可一旦出问题,它就是那个最需要被快速打开的黑盒。这篇文章我打算按自己的理解,把 JVM 的内存模型、垃圾回收、参数调优、工具排查、面试题和避坑经验全部串起来讲一遍,内容偏实用,适合后端开发、即将面试的候选人,也适合被线上问题折磨过的朋友参考。
我会尽量用“排障视角”而不是教科书视角来写。每个环节都会带上为什么、怎么做、踩过什么坑。如果你能把整篇读下来,再跟着操作一遍,基本就可以说自己“吃透”了大半。
1. 先搞清楚:JVM 到底是个啥
1.1 JDK、JRE 与 JVM 的关系,别再傻傻分不清
很多初学者会把 JDK、JRE 和 JVM 三个词混着说,面试时一追问就露馅。我习惯用一套“套娃”来解释:JDK 是 Java 开发工具包,它包含了编译工具 javac、打包工具 jar、调试工具 jdb,以及一套完整的 JRE;JRE 是 Java 运行环境,它只负责让已经编译好的程序跑起来,里面包含 JVM 和 Java 核心类库;而 JVM 是 Java 虚拟机,它才是真正执行字节码的那个引擎。
换句话说,写代码的时候你需要 JDK,部署运行的时候只需要 JRE,但最终承载你程序运行的是 JVM。很多人以为装一个 JDK 就“自带跨平台能力”,其实不对,真正在不同操作系统上做适配的是 JVM 的各种实现。比如 Windows 上有 Windows 版 HotSpot,Linux 上有 Linux 版 HotSpot,它们解释的是同一套字节码,但翻译出来的机器指令完全不同。
还要注意,从 JDK 9 开始,Oracle 把原来的 JRE 目录结构改了,不再对外提供独立的“肥大 JRE”,而是支持用 jlink 按需裁剪运行时镜像。这在容器化和云原生场景下很有用,但也让一部分老项目升级时踩了坑,因为有些部署脚本还死死地指定了jre/bin/java这个路径。
1.2 “一次编译,到处运行”的底气从哪来
Java 程序从源码到运行会经历几个阶段:.java文件经过javac编译成.class字节码;JVM 的类加载器把.class文件加载进内存;字节码校验器检查安全性;执行引擎再逐条解释字节码,或者用 JIT(即时编译器)把热点代码编译成机器码。
所以“一次编译,到处运行”这个说法,核心不是 Java 语言跨平台,而是 JVM 跨平台。只要你针对某个平台装一个对应的 JVM,同一个.class文件就能直接跑。这也是为什么 Java 在很长一段时间里能统治服务端:你不需要为每台服务器单独编译一版程序,只需要保证服务器上的 JVM 版本和运行参数不会离谱就行。
不过,跨平台不是没有代价。HotSpot 是 Oracle JDK 和 OpenJDK 里最常见的 JVM 实现,但它不是唯一的。OpenJ9 在启动速度和内存占用方面有优势,GraalVM 则搞出了 AOT 编译和 Polyglot。实际工作中,99% 的人接触的还是 HotSpot,所以本文聊的参数、工具、GC 也都是围绕 HotSpot 展开。
2. JVM 内存模型:一切排查问题的原点
2.1 运行时数据区都放了什么
JVM 的内存管理,第一步就是搞懂运行时数据区。按照虚拟机规范,它主要分这么几块:程序计数器、虚拟机栈、本地方法栈、堆、方法区。
程序计数器是一块很小的内存,用来记录当前线程执行的字节码行号。它不会出现 OutOfMemoryError,也是唯一一个没有 OOM 的区域。虚拟机栈描述的是 Java 方法执行时的线程私有内存,每个方法在执行时都会创建一个栈帧,里面包含局部变量表、操作数栈、动态链接、方法出口等信息。本地方法栈则服务于 native 方法,比如你调用一些 C/C++ 写的底层库时就会用到。
堆是 JVM 管理内存中最大的一块,所有线程共享,几乎所有对象实例和数组都在这里分配。方法区存放已被加载的类型信息、常量、静态变量、即时编译器编译后的代码等。在 JDK 8 之前,方法区叫永久代;JDK 8 之后改为元空间,并且从堆内存挪到了本地内存里,这个改动直接影响了很多内存溢出的表现。
这里要特别提一个容易混淆的概念:面试里说“JVM 内存模型”时,有时候指运行时数据区,有时候指 Java 内存模型 JMM。JMM 是并发领域的抽象,关注主内存和线程工作内存之间的交互,以及 volatile、happens-before 这些规则。这两者完全不是一回事。我最推荐的做法是:面试时先反问一句“你说的是运行时数据区,还是并发模型 JMM?”,既显得你懂,又避免答跑偏。
2.2 对象分配、TLAB 和逃逸分析
搞清楚了内存区域,再看对象是怎么分配的。绝大多数普通对象会优先在新生代的 Eden 区分配。这里有个 JVM 优化叫 TLAB,即线程本地分配缓冲区。因为堆是线程共享的,多个线程同时 new 对象可能产生竞争,为了减少竞争,JVM 会给每个线程在 Eden 区划一小块私有空间,这个空间就是 TLAB。对象在 TLAB 内分配时不需要加锁,只有 TLAB 空间不足时才需要到外部再申请。
大对象则不走这条常规路线。如果一个对象大小超过了预设阈值,或者新生代空间放不下,它会直接进入老年代。还有一些对象即使正常出生在 Eden,经过几轮 Minor GC 后仍然存活,并且年龄增长到阈值(默认 15),也会晋升到老年代。这个阈值可以通过-XX:MaxTenuringThreshold调整,但不建议乱调,除非你用 jstat 观察到了大量对象反复晋升导致老年代增长过快。
JIT 编译器还有一个“逃逸分析”优化。简单说,如果方法内部创建的对象没有被外部引用,也就是“没有逃逸出去”,JVM 可能不会真正在堆上创建它,而是把它拆分到栈上或者直接用寄存器存储。这样能减少堆分配压力。这也是为什么我经常劝刚入门的人别在代码里随手 long 一个巨大 HashMap 当缓存,因为对象逃逸后,JVM 的优化空间会被大幅压缩。
2.3 三种常见 OOM 到底是谁的锅
堆内存溢出是最常见的 OutOfMemoryError。典型场景是缓存数据无限增长、数据库查询结果一次性全加载进列表、消息队列消费速度跟不上生产速度。看到java.lang.OutOfMemoryError: Java heap space,第一反应应该是堆里对象太多或对象太大,而不是盲目把-Xmx调高。调参只是一时止痛,找到谁在不停创建对象才是根治。
栈溢出通常是无限递归或者方法调用层级过深导致,报错是StackOverflowError。它跟堆 OOM 的表现完全不同,错误信息里会直接告诉你栈深度不够。如果你在一个方法里写了无退出条件的递归,再大的 Xss 也没用,迟早还是溢出。所以排查时应该先看代码,再考虑是否需要调大线程栈。
元空间溢出在 JDK 8 之后也逐渐多起来,报错通常是OutOfMemoryError: Metaspace。常见于动态生成大量代理类、使用 CGLib 或反射频繁生产新类、热部署场景反复加载同一个类。这类问题靠调大MaxMetaspaceSize只能缓解,还得看是不是类加载器泄露了,比如某个自定义 ClassLoader 反复 new 却没有被卸载。
还有一类堆外内存溢出容易被忽视。Java NIO 里使用的 DirectByteBuffer 走的是堆外内存,默认大小等于堆大小,但不受-Xmx管控。如果忘记释放,可能会看到OutOfMemoryError: Direct buffer memory。排查时除了看堆 dump,还得盯住MaxDirectMemorySize。
3. 垃圾回收机制:为什么你不需要手动 free()
3.1 判断对象“该不该死”的两种思路
JVM 垃圾回收的第一步是判断哪些对象可以被回收。最直观的思路是引用计数法:每个对象维护一个计数器,被引用时加一,引用失效时减一,减到零就回收。但这个方案有个致命缺陷,它解决不了循环引用。A 引用 B,B 引用 A,其他对象都不引用它们,这两个对象互相撑着,计数器永远不为零,于是内存就泄漏了。
HotSpot 实际用的是可达性分析算法。它从一组称为 GC Roots 的根对象出发,沿着引用链往下搜索。凡是搜索不到的对象,都会被标记为可回收。GC Roots 包括虚拟机栈中引用的对象、静态属性引用的对象、JNI 引用的对象,以及活跃线程等。
顺着这个机制,就有了 Java 的四种引用类型:强引用、软引用、弱引用、虚引用。强引用就是new出来的对象,只要还有强引用,GC 绝不回收;软引用适合做缓存,内存不足时才回收;弱引用只要 GC 发生就回收,ThreadLocal 的 Key 就是弱引用;虚引用基本不控制对象生命周期,只用来跟踪对象被回收时收到通知。
3.2 分代收集和三种基础算法
JVM 之所以要分代,是因为大量对象的存活时间很短。如果能把这部分“朝生夕灭”的对象集中管理,GC 频率和停顿就会显著降低。所以 HotSpot 把堆分为新生代和老年代,新生代又细分为 Eden 区和两个 Survivor 区,比例默认是 8:1:1。
对应分代,有三种基础算法。标记-清除算法最简单,先标记可回收对象,再统一清除,但会产生内存碎片,下次分配大对象时可能找不到连续空间。复制算法把内存分成两块,每次只使用一块,GC 后把存活对象复制到另一块,然后整块清理,避免碎片,但会有空间浪费。标记-整理算法则在老年代使用,标记存活对象后把它们向一端移动,再清理边界之外的内存,这样既解决了碎片又不会浪费一半空间。
新生代用的就是复制算法,但不是两块 1:1,而是三块 8:1:1。每次回收时,把 Eden 和一块 Survivor 中存活的对象复制到另一块 Survivor,然后一次性清空 Eden 和用过的 Survivor。这样浪费的空间只有少量。如果 Survivor 装不下,就会由老年代提供“分配担保”,直接把多余对象送进老年代。
3.3 主流垃圾回收器:Serial、CMS、G1、ZGC
垃圾回收器的选择,本质是在吞吐量和停顿时间之间做权衡。Serial 是最古老的单线程收集器,GC 时必须暂停所有工作线程,适合客户端小应用。Parallel Scavenge 是 JDK 8 默认的新生代收集器,追求高吞吐量,适合后台批处理。CMS 曾经是互联网应用的主流,它实现了让大部分 GC 工作线程和业务线程并发执行,所以停顿较低,但会产生碎片,还可能因为并发失败退化成 Serial Old 导致 Full GC。
G1 从 JDK 9 开始成为默认回收器。它把堆划分为许多大小相等的 Region,不再是物理上的连续新生代和老年代,而是逻辑上动态分代。G1 可以设置预期的 GC 停顿时间,比如-XX:MaxGCPauseMillis=200,它会在后台统计每个 Region 的回收价值和成本,优先回收价值最大的 Region。这也是“Garbage First”名字的由来。
ZGC 是面向超大堆、超低延迟的收集器,核心是染色指针和读屏障,可以把停顿时间控制在几毫秒甚至亚毫秒级别,而且停顿时间不随堆大小增长。如果你的应用是低延迟交易系统,堆又很大,ZGC 值得考虑。但要注意,ZGC 的 CPU 开销偏高,在 CPU 核数很少的小容器里未必划算。
下表是我常用的选型思路:
| 收集器 | 全称 | 适用场景 | 缺点 |
|---|---|---|---|
| Serial | 串行回收 | 单核小内存、客户端应用 | 停顿长 |
| ParNew | 并行新生代 | 配合 CMS 使用 | 单核下表现一般 |
| CMS | 并发标记清除 | 低延迟、老年代回收 | 碎片多、可能并发失败 |
| G1 | 分代分区 | JDK9+ 默认,大堆低停顿 | 小堆下优势不明显 |
| ZGC | 低延迟 | 超大堆、毫秒级停顿 | CPU 开销高 |
日常调优,我不会一上来就换收集器。先看 GC 日志,确认瓶颈是在 GC 频率高、停顿时间长,还是老年代持续增长。如果老年代涨得快,换再好的收集器也救不了对象泄漏;如果只是停顿影响口子,G1 的停顿目标或者换 ZGC 才有意义。
4. JVM 参数与调优思路:从“能用”到“好用”
4.1 常用 JVM 参数看一眼就懂
JVM 参数是调优最直接的入口。我把最常用的分成三类:堆内存、栈内存、日志与故障处理。
堆内存参数里,-Xms设置初始堆大小,-Xmx设置最大堆大小。生产环境建议把两者设成一致,避免运行期堆大小频繁扩容和缩容,带来不必要的 GC 压力。-Xmn设置新生代大小,它影响 Minor GC 频率和对象晋升速度。-XX:MaxMetaspaceSize设置元空间上限,防止动态代理类无限制占用本地内存。
栈内存参数是-Xss,每创建一个线程就会分配一块栈空间。在 64 位系统上默认可能是 1MB,如果你在容器里开了 500 个线程,光栈内存就可能吃掉 500MB,明显不划算。很多框架会建议把-Xss调成 256KB 或 512KB。线程数少时可以不改,线程数多时要注意。
日志参数是排查问题的基础。我强烈建议线上统一加上-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/ops/logs/,这样一发生 OOM 就会自动留 dump 文件。JDK 8 里查看 GC 日志用-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/ops/logs/gc.log;JDK 9 以后改成-Xlog:gc*:file=gc.log:time,uptime,level,tags。语法变了,别拿老参数直接往新 JDK 上套。
4.2 线上 OOM 的排查步骤和 dump 分析
线上遇到 OOM,我的流程基本是定死的。第一步别慌,先看错误日志,确认错误类型是堆溢出、栈溢出、Metaspace 还是直接内存。第二步,如果没有自动 dump,趁进程还在赶紧用jmap -dump:format=b,file=/tmp/heap.hprof <pid>抓一份快照。第三步,用 MAT 或者 VisualVM 打开 dump,看 Dominator Tree,也就是谁占了大头。
这里有三个非常典型的坑值得单独讲。第一,jmap -dump在堆很大的时候会卡住,而且会让应用暂停,高并发服务慎用。如果服务还能撑,大概率靠自动 dump 更稳。第二,MAT 打开大 dump 需要的机器内存至少是堆大小的 1.5 倍,否则自己先 OOM。第三,dump 里的“内存大户”不一定就是根因,比如一个 HashMap 占了几百 MB,但它为什么会放这么多数据,往往要结合业务代码才能判断。
有一次我排查一个定时任务 OOM,dump 里看到的是ArrayList中有上千万个 DTO 对象。其实问题不是 DTO 本身,而是定时任务每次跑都全量查表,没有分批处理,还把这些对象放到了静态缓存里。把查询改成分批、加总数量限制后,堆立刻稳住了。这种问题,你把-Xmx从 4G 调到 8G 也只会延长崩溃时间。
4.3 线程池最大线程数不要盲目参考“JVM 剩余可用线程”
网上有个说法叫“线程池设置最大线程数是 JVM 剩余可用线程”,这个我是不太认同的。JVM 本身并没有暴露一个叫“剩余可用线程”的准确 API,你顶多通过Runtime.getRuntime().availableProcessors()拿到 CPU 核数。线程是否还能创建,取决于操作系统层面的进程线程数限制、内存空间、文件句柄等,而不是 JVM 里某个计数器。
线程池的线程数应该根据任务类型来定。CPU 密集型任务,线程数一般是CPU 核数 + 1,因为多出来的一个线程能在某些线程偶然等待时顶上。IO 密集型任务,等待时间和计算时间的比例很高,线程数可以放宽,常用CPU 核数 * 2起步,但最终必须通过压测验证。更关键的是队列和拒绝策略:如果任务量突然暴涨,有界队列 +CallerRunsPolicy通常比无界队列更安全,因为无界队列会让堆积的任务吃掉整个堆,最后 OOM。
线程本身也很吃内存。虽然 Java 线程栈可以通过-Xss控制,但每个线程还会占用操作系统的内存资源。我遇到过一台 4G 内存的测试机,开了 300 多个线程跑并发测试,结果业务还没出问题,先报了Unable to create native thread,这就是线程数突破系统上限导致的新线程创建失败。所以线程池参数一定要结合容器内存、堆外开销、线程栈大小综合评估,别盯着一个“剩余线程数”做文章。
4.4 给 IDEA 调大 JVM 运行内存,开发测试不再 OOM
很多人在本地开发时也经常遇到 IDEA 卡顿、构建 OOM、Tomcat 启动 OOM。IDEA 本身就是一个 JVM 应用,它自己的堆内存可以通过 Help -> Edit Custom VM Options 来调整。默认文件里通常是这样几行:
-Xms128m -Xmx2048m -XX:ReservedCodeCacheSize=512m -XX:+UseCompressedOops我会把开发机内存 16G 以下的机器配置改成这样:
-Xms1024m -Xmx4096m -XX:ReservedCodeCacheSize=512m -XX:+UseG1GC -Xss512k重点说下ReservedCodeCacheSize。它是 JIT 编译后的代码缓存区,太小会导致 JIT 编译频繁失效,IDE 运行一会儿就变慢,而且控制台可能会报CodeCache is full。调大这个值之后,IDEA 长时间运行明显稳定很多。
另外,如果你在 IDEA 里跑 Spring Boot 项目,启动参数里的-Xmx是独立配置的,不会自动继承 IDEA 的 VM options。常见做法是在 Run/Debug Configuration 的 VM options 里也加上-Xms512m -Xmx2048m。这样既能防止小项目启动频繁 Full GC,也不会一次把电脑内存吃爆。
5. 调优工具实操:jps、jstat、jmap、jstack、jconsole、VisualVM、Arthas
5.1 先看进程,再看 GC 统计
JDK 自带了一组命令行工具,虽然不够华丽,但排查问题非常快。第一步用jps找到 Java 进程 ID。很多新手上来就ps -ef | grep java,结果把一堆 agent 进程当业务进程,其实jps -l能直接显示主类和 jar 路径,更省事。
拿到 PID 后,最常用的观察命令是jstat。比如我想看 GC 情况,可以执行:
jstat -gcutil <pid> 1000 20这个命令每隔 1 秒打印一次,连续打 20 次。重点关注YGC、YGCT、FGC、FGCT和S0/S1/E/O/M这几列。E 是 Eden 使用率,O 是老年代使用率,M 是元空间使用率。如果老年代使用率持续升高,FGC 频繁触发,那基本可以断定有对象在往老年代堆积,要么是生命周期太长,要么就是内存泄漏。
jstat -gc <pid>则能看到当前堆各区域的容量和已使用空间,比如EU是 Eden 已使用,OU是老年代已使用。这类信息虽然简单,但能帮助判断当前堆大小设置是否合理。我曾经遇到一个服务,堆总量分配了 6G,老年代用满了 4G,但 Eden 一直只有 1G,新生代太小导致对象快速晋升,后来把新生代调大后 FGC 明显降了下来。
5.2 jmap 看堆、jstack 看线程
当需要确认堆里的对象构成时,用jmap -histo:live <pid>能按对象数量排序输出,直接看哪些类实例最多。这个命令会触发一次 Full GC,所以线上要谨慎。如果只是为了看存活对象,可以在低峰期执行。
抓完整的堆 dump 用这个命令:
jmap -dump:format=b,file=heap.hprof <pid>dump 文件最好落到独立的磁盘,因为堆越大文件越大,不能把根目录塞满。抓完 dump 后,用 VisualVM 或 MAT 分析。这里再强调一次,不要在主业务高峰期对几十 GB 的堆执行完整 dump,会让服务长时间暂停。
jstack是排查线程问题的利器。比如服务卡住、死锁、CPU 飙高,先执行jstack -l <pid>打印线程栈。如果 CPU 很高,可以用top -Hp <pid>找到耗 CPU 的线程 ID,再把线程 ID 转成十六进制,去 jstack 输出里找对应的 nid。用这个方法,我定位过很多次死循环和线程池饥饿问题。
jstack 还能直接暴露出死锁。一旦检测到死锁,输出里会有明确提示,指出是哪几个线程互相等待锁。有些应用表面看着没反应,其实不是机器死了,而是业务线程全部阻塞在某个第三方 SDK 的连接池等待上,这时 jstack 一眼就能看出来。
5.3 可视化和在线诊断:jconsole、VisualVM、Arthas
如果不想敲命令,JDK 自带的jconsole和VisualVM都能连上本地或远程 JVM。jconsole 可以实时看堆内存、线程、类加载数和 CPU 占用,适合本地环境快速确认问题。VisualVM 功能更强,可以装各种插件,也能看 dump。
远程连接时需要在启动参数里加 JMX 配置:
-Dcom.sun.management.jmxremote.port=9999 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false但要注意,生产环境裸奔 JMX 是很危险的事,必须配合权限控制和内网隔离。而且很多容器环境对 JMX 的端口暴露不友好,这时候我更推荐 Arthas。
Arthas 是一个挂在 Java 进程上的在线诊断工具,不需要改代码、不需要重启服务。它的dashboard命令能实时展示线程、内存、GC 和类加载情况;thread -n 3能直接打印 CPU 占用最高的前三个线程;jad能反编译线上类,避免本地代码和线上版本对不上;watch能观测某个方法的入参、返回值和异常;ognl可以动态调用线上对象的 getter,查看某个实例的内部状态。
我用 Arthas 解决过很多“本地复现不了”的问题,比如某个接口偶发超时,用trace命令追踪方法内部耗时就非常直观。另外,如果你想模拟故障来探底,混沌工程工具 ChaosBlade 也很实用,它可以用类似blade create jvm的方式给 JVM 注入延时或异常,观察系统在 GC 抖动时会不会触发限流,提前暴露容灾短板。
6. 高频面试题速查:背熟这几题,面试官基本满意
6.1 内存模型与对象生命周期题
面试里最常听到的第一题一定是“说说 JVM 的内存模型”。如果面试官没有特指 JMM,我会把运行时数据区讲一遍:程序计数器、虚拟机栈、本地方法栈、堆、方法区,然后补一句 JDK 8 的元空间变化。这样既完整又有细节,面试官通常不会再打断。
第二题是“对象创建的过程”。回答时要按这个顺序:类加载检查、分配内存、初始化零值、设置对象头、执行构造方法。很多候选人会漏掉“初始化零值”这一步,但它在并发下很有讲究,能解释为什么无锁读半成品对象可能看到默认值。
第三题常问“JDK 8 的永久代和元空间有什么区别”。核心答两点:元空间使用本地内存,不再受堆大小限制;永久代在 JDK 8 中被移除,字符串常量池也搬到了堆里。如果还能说出“元空间更适合容器场景,因为本机内存可以按需申请”,就更有深度。
6.2 GC 与类加载题
“如何判断对象可以被回收”是高频题。回答分三步:先提引用计数法的缺陷,再说可达性分析,最后列举 GC Roots 包括哪些。如果可以,再把四种引用类型串进来,说明强引用、软引用、弱引用、虚引用分别在什么场景使用。
“G1 和 CMS 有什么区别”是一道经典对比题。我会答:CMS 基于标记-清除,有内存碎片,适合老年代回收;G1 基于分区和复制,能避免碎片,可以预测停顿;G1 的堆被划分成多个 Region,新生代和老年代是逻辑上的动态区域。如果能提到“G1 在回收过程中需要维护记忆集和写屏障,内存占用和 CPU 开销会比 CMS 高”,说明你真的理解原理。
类加载机制里,“双亲委派模型”也必须会。回答要点是:类加载器从 Bootstrap 开始,逐级委托父加载器加载,父加载器无法完成时才由子加载器自己加载。好处是避免核心类被篡改,保证 Java 核心 API 的一致性。Tomcat 等 Web 容器为了隔离应用,会打破双亲委派,自定义类加载器优先加载 Web 应用的类。
6.3 调优场景题
“线上 OOM 你怎么排查”是典型的场景题。回答时最好有真实案例。我会说:先看错误类型,再看 GC 日志,抓堆 dump,用 MAT 分析 Dominator Tree,最后回到代码找泄漏点。如果能把第 4 节内容讲得有条理,面试官一般都很满意。
“CPU 飙升到 100% 怎么查”也是必考题。步骤是:top找到 CPU 高的进程,top -Hp找到线程,线程 ID 转十六进制,jstack定位线程栈,再到代码里找死循环或锁竞争。如果线程栈里全是同一个方法,问题基本就锁定了。
“线程池线程数怎么设置”这道题我一般会先反问:是 CPU 密集型还是 IO 密集型?然后给出估算思路,再强调真正靠压测,而不是套公式。如果候选人能说出“线程数越来不代表越快,因为上下文切换有开销”,就很加分。
7. 常见启动问题和避坑经历
7.1 “No JVM could be found on your system” 怎么破
这个报错经常出现在一些老式 Java 应用的启动器上,比如 Eclipse 某个版本、Oracle 一些客户端工具。字面意思是系统里找不到可用的 JVM,但实际原因往往和 JVM 本身没关系,而是环境变量或位数不匹配。
我按顺序排查看这几项。第一,打开命令行执行java -version,如果提示找不到命令,说明 JDK 没有加入 PATH,或者 JAVA_HOME 没设置。第二,确认 JAVA_HOME 指向的是 JDK 安装目录根路径,不是bin目录,也不是 JRE 目录。第三,确认应用启动器要求的是 32 位还是 64 位 JVM,如果安装的是 64 位 JDK,但启动器是 32 位,有些老软件也会报找不到 JVM。第四,检查是不是 JVM 目录后多了空格或反斜杠,Windows 环境变量里这点特别容易踩坑。
如果以上都没问题,还可以试试在启动器同目录下手动建立一个 jre 目录,放入一个匹配版本的 JRE。很多老软件就是这样,它只会去固定的几个目录找 JVM。这个办法虽然粗暴,但能解决大部分“我为啥找不到”的诡异问题。
7.2 头铁踩坑:堆外内存、元空间和线程栈
我早期调优特别喜欢盯着-Xmx,觉得堆调大一切就稳了。后来有个服务频繁重启,堆才用了 30%,结果还是 OOM,错误日志指向Direct buffer memory。我才警醒,NIO 的堆外内存也是内存,而且某些场景下比堆内存更容易被忽视。后来给它单独设置了-XX:MaxDirectMemorySize=1G,并加监控,问题才算收敛。
元空间也有类似情况。曾经遇到一个热部署场景,每次发布都加载新类,老类加载器没被回收,元空间持续上涨,最后直接把系统盘差不多占满了。这个问题的核心是类加载器泄漏,单纯调大 MaxMetaspaceSize 只是延后爆炸时间。
线程栈的问题则往往体现在“无法创建新线程”。在容器里,JVM 可能识别到的总内存是宿主机内存,或者用户进程的线程数被 cgroup 限制得很少。如果线程池配置得又大又无界,某个时刻会同时创建几百个线程,直接碰到系统上限。排查时不要只看 JVM 参数,还得确认ulimit -u和容器可用进程数。
7.3 给初学者的学习路径
如果你是刚接触 JVM,我建议不要一上来就啃一堆底层源码。先买一本《深入理解Java虚拟机》,注意是第 3 版,虽然它主要基于 JDK 8,但内存模型、类加载、GC 这些核心概念讲得非常扎实。读完前几章,再对着自己的项目用参数跑一遍,比纯看视频管用得多。
接下来做两件事:第一,用默认参数启动一个简单的 Spring Boot 应用,用 jps 找到 PID,再用 jstat 每 1 秒打一次 GC,观察新生代和老年代的变化;第二,故意写一个死循环的 HashMap 缓存,让它 OOM,加上-XX:+HeapDumpOnOutOfMemoryError自动抓 dump,自己用 MAT 打开看一遍。把这两个实验做完,你对 JVM 的体感会完全不一样。
最后再分享一个个人技巧:遇到 JVM 相关的异常,不要急着搜报错信息。先把日志里所有标注堆区域、GC 原因、线程状态的关键词摘出来,再对照官方工具的英文全称去理解。很多问题本质上就一句话:内存放不下,或者线程没跑起来。搞清楚这一层,你离“吃透 JVM”就不远了。