
你们公司的Java应用容器化之后是不是出现过这种诡异情况Docker里明明只给了512MB内存Spring Boot应用启动没多久就被系统杀掉docker logs最后几行写着Killed或者退出码137。很多人第一反应是代码有内存泄漏查了一圈堆转储、数据库连接池最后才发现问题是JVM压根没意识到自己活在容器里——它还在按宿主机的物理内存去计算堆大小。这个认知差就是JVM入门绕不开的第一课。我见过太多同学学了两年Java天天写Spring却说不清JDK和JRE的区别不知道-Xmx和-XX:MaxRAMPercentage到底谁生效更别提G1收集器内部怎么工作。这篇文章不搞面试八股文就站在一个后端开发的实际视角把JVM至少需要掌握的五块内容捋一遍JDK/JRE/JVM的区别、运行时内存模型、垃圾收集器选型、关键参数背后的原理、以及容器时代特有的大坑。无论你是准备面试还是已经在生产环境里救火都值得花十分钟看完。1. 先理清JVM、JDK、JRE三兄弟很多人的基础认知是错的1.1 一次javac和java背后发生了什么很多人听到“JDK和JRE有什么区别”这种面试题就头疼觉得是概念背诵。但其实只要手写过一次完整的Java程序你早就接触过这三者了。你写完Hello.java后要执行javac Hello.java这个javac就是JDK里的编译工具随后你用java Hello运行程序这时候真正干活的是JVM。而JRE就是JVM加上Java自带那一套标准类库什么java.util、java.io、java.net这些东西。整个过程通俗讲就是javac把人类写的Java源码翻译成字节码.class文件这个字节码不是任何真实CPU的机器指令所以操作系统看不懂。JVM再把这些字节码一条条解释成当前操作系统能识别的机器码或者通过JIT编译器把热点代码编译成本地指令。那JRE在哪出现呢当你把一个Java程序打包部署到服务器比如一个Spring Boot的fat jar你只需要装一个JRE就能跑因为服务器上不需要javac去编译源码只要有JVM和类库解释执行字节码就够了。JDK则是给开发用的它等于JRE加上编译器、调试器、jmap、jstack等一系列开发诊断工具。所以那些动不动在服务器上装完整JDK的做法除非你要在服务器上现场编译否则纯属多此一举。1.2 跨平台的本质字节码才是最大公约数“一次编译到处运行”这句话谁都会说但你要理解它为什么成立。每个操作系统上的JVM实现都不一样——Windows有Windows版Linux有Linux版macOS有macOS版但它们的输入都是同一套字节码规范。字节码就像是国际通用的盲文不同国家的出版社用不同印刷机来印但摸到字的人学到的是同样的意思。这里有个细节值得注意字节码不是机器码所以JVM执行它时有两层选择。第一层是解释执行也就是一行一行翻译成机器码启动快但跑不快第二层是JIT编译当某个方法被调用得非常频繁JVM会认为这个方法是热点代码把它整个编译成本地机器码缓存起来后面直接执行机器码速度能提升一两个数量级。后面讲-XX:CompileThreshold这个参数时会再展开这里你先有个印象。1.3 工具链JVM不兼容的常见报错Gradle和JDK版本相爱相杀入门JVM还有一个非常实际的场景就是本地开发工具的JVM版本冲突。比如你拉一个新项目用Gradle构建报错信息长这样The projects Gradle version 6.7.1 is incompatible with the Gradle JVM version 17这个报错的意思是项目指定的Gradle 6.7.1只能支持到Java 15左右的版本而你的IDE默认用的JDK是17或者更高Gradle启动时根本解析不了这么新的class文件格式。类似的还有IDEA启动时弹一个[error] could not get jvm parameters and dynamic configurations properly的框多数情况下也是IDE缓存里的JVM参数配置跟当前JDK版本对不上或者idea64.exe.vmoptions这类配置文件被改坏了。我的处理习惯是先看项目里gradle-wrapper.properties的distributionUrl确认Gradle版本再在IDE里把Gradle JVM设置为项目要求的JDK版本。比如项目Gradle 6.7.1我就配JDK 11或JDK 8。如果项目允许升级Gradle那直接升到7.x以上再配JDK 17就顺了。至于could not get jvm parameters这类IDE报错先试File - Invalidate Caches and Restart清理缓存不行就删掉IDE配置目录里的vmoptions让IDE重新生成默认配置基本上都能解决。别一上来就重装IDE多数时候是配置问题而不是软件坏了。2. 运行时数据区详解堆、栈、元空间到底怎么分工2.1 先泼一盆冷水JVM内存模型和Java内存模型是两回事中文互联网上“JVM内存模型”这个词被用滥了很多人把两样东西混在一起。一个是Java内存模型英文缩写JMMJava Memory Model它是语言规范层面的东西解决的是多线程场景下共享变量的可见性和有序性问题涉及volatile、synchronized、happens-before规则。另一个是JVM运行时数据区也就是JVM在运行一个Java程序时把内存划分成了哪几个区域每个区域干什么用、谁线程私有、谁线程共享。这两者完全不同。JMM是逻辑规则描述线程和主内存之间怎么通信运行时数据区是物理划分是JVM自己管理内存的“地契”。面试如果问你“说一下JVM内存模型”建议先反问一句“您指的是JMM还是运行时数据区”这能体现你真的懂。这篇文章讲的启动参数、调优、OOM排查全部是基于运行时数据区的。2.2 堆所有对象实例的家也是调优的主战场堆是JVM管理的内存中最大的一块线程共享所有通过new创建出来的对象和数组都躺在这里。它是垃圾收集器的主要回收区域所以也叫“GC堆”。堆内部继续分代逻辑上是年轻代和老年代。年轻代又进一步分成Eden区和两个Survivor区S0和S1默认比例是Eden:S0:S1 8:1:1。大部分对象刚创建时都进Eden区Eden满了触发Minor GC存活对象复制到Survivor区。每熬过一次Minor GC对象年龄加一达到-XX:MaxTenuringThreshold设定的阈值默认15还没死就晋升到老年代。为什么这么设计因为JVM经过长期观察发现绝大多数对象都是朝生夕死的。比如一个HTTP请求进来创建一堆临时对象请求结束这些对象就没用了。把这类对象集中在年轻代用复制算法回收效率高还不会有内存碎片。老年代放那些活得更久的对象比如连接池里的缓存对象、Spring容器里的单例Bean。OOM里最常见的一句报错就是java.lang.OutOfMemoryError: Java heap space基本就是堆被垃圾对象塞满或者确实存在内存泄漏无法释放。2.3 栈线程的私人空间参数传递的通道每个线程启动时JVM都会给它分配一块私有的虚拟机栈。每次调用一个方法JVM就往栈里压入一个栈帧。栈帧里装着这个方法的局部变量表、操作数栈、动态链接、方法出口等信息。方法执行完栈帧弹出。所以递归调用为什么会把栈撑爆因为每层递归都是一个未返回的方法调用都会压入一个栈帧。递归深度超过栈容量立刻抛出StackOverflowError。你可以用-Xss参数控制每个线程的栈大小常见设置在512KB到1MB之间。线程数特别多的应用比如一个服务开了几千个线程栈大小就会直接影响内存占用——你算算5000个线程每个1MB就是5GB全算在进程的虚拟内存里很吓人。还有一个细节Java方法的参数传递永远都是值传递。基本类型直接传值引用类型传的是引用的拷贝但这个“引用值指向的对象”是同一个。理解栈帧里的局部变量表怎么存引用你就能彻底想明白这个面试考点。2.4 方法区与元空间JDK8之后的变化你记住了吗方法区存放的是类的元信息、字段和方法信息、常量池、静态变量等。这块区域在HotSpot虚拟机的实现历史上变化很大JDK8之前叫永久代PermGenJDK8开始被元空间Metaspace取代。永久代和元空间最大的区别是位置。永久代用的是JVM堆的本地内存实际上在堆外但被纳入堆管理所以它的大小受-XX:MaxPermSize限制很容易出现java.lang.OutOfMemoryError: PermGen space。元空间直接使用本地内存默认情况下上限是系统物理内存理论上不会OOM但会吃掉宿主机内存。所以线上环境我给你个建议一定要设置-XX:MaxMetaspaceSize防止某些动态生成类的框架比如CGLIB、反射大量使用把元空间撑爆连带拖垮整个宿主机。字符串常量池也是高频面试点。JDK6之前字符串常量池在永久代JDK7开始被挪到了堆。别小看这个变化它在某些场景下直接决定了你是不是会OOM在永久代里字符串常量池很小塞不了多少字符串挪到堆后容量大了但如果你用String.intern()疯狂驻留字符串照样能把堆给耗光。2.5 OOM最常见的三种样子与排查方向我整理了线上最常见的三类OOM报错你得能一眼判断出问题在哪一层。第一类Java heap space堆空间不足。方向是看堆转储用jmap -dump:formatb,fileheap.hprof pid导出堆再用MAT或JProfiler分析是哪一个对象超量。这种多半是内存泄漏或者某个容器对象放了太多数据。第二类Metaspace元空间不足。查是不是动态生成类太多常见于反射代理、热部署场景。解决办法是加大MaxMetaspaceSize但更要紧的是找到谁在无限生成类。第三类unable to create new native thread创建线程失败。这通常不是堆的问题而是操作系统的线程数或进程内存映射数达到上限。要么排查代码里是不是无脑new Thread要么调小-Xss并限制线程池大小要么在操作系统层面调ulimit。3. 垃圾收集器不是玄学从Serial到G1、ZGC的选择逻辑3.1 早期收集器的思路Serial和ParallelSerial收集器是最老实的单线程收集器。它GC时只会用一个处理器核心且会暂停所有业务线程Stop The WorldSTW。虽然听起来很笨但在单核机器上、堆只有几百MB的客户端程序里Serial反而简单可靠。Parallel收集器是JDK8默认的组合Parallel Scavenge Parallel Old。它的核心目标是提升吞吐量也就是“花在GC上的时间尽量少花在业务上的时间尽量多”。它跟Serial最大的区别在于可以用多线程并行回收所以能充分利用多核CPU。如果你有一个批处理任务对停顿时间不敏感只要总吞吐量大Parallel是很好的选择。JDK8默认就是它也解释了为什么很多老项目跑在JDK8上啥也不配也能稳定运行。3.2 CMS的并发清理革命和它的谢幕CMSConcurrent Mark Sweep并发标记清除是第一代真正把“低停顿”作为目标的收集器。它追求的是业务线程尽量少被暂停所以搞出了并发标记、并发清理。但CMS有两个绕不开的硬伤。第一个是内存碎片化。标记清除算法不会整理内存用久了老年代里会出现大量碎片导致你要分配一个大对象时找不到连续内存被迫触发Full GC。Full GC时CMS退化成Serial Old做标记整理停顿时间反而爆炸。第二个是浮动垃圾。CMS并发清理时业务线程还在产生垃圾这些垃圾只能等下一次GC处理如果清理速度跟不上垃圾产生速度就会出现“并发模式失败”同样退化成Full GC。所以CMS在JDK9被标记废弃JDK14正式移除。但它“并发标记、尽量不暂停业务线程”的思路被G1完整继承了下来算是垃圾收集史上的里程碑。3.3 G1凭什么成为JDK9以后的默认选择G1Garbage First从JDK9起成为HotSpot的默认垃圾收集器。它的设计目标非常明确在保证大内存的前提下提供可预测的停顿时间。G1的核心创新是把整个堆划分成一个个大小相等的Region。新生代不再是连续的一大块老年代也不是每个Region在逻辑上可以属于新生代或老年代物理上全部打散。GC发生前G1会先根据每个Region里垃圾的多少做排序回收时优先处理垃圾最多的Region这就是“Garbage First”名字的由来。通过控制每次回收的Region数量G1可以把一次GC的停顿时间控制在-XX:MaxGCPauseMillis设定的目标之内默认200毫秒。G1的另一项关键数据是RSetRemembered Set记忆集。老年代Region中的对象可能被新生代Region中的对象引用如果每次Minor GC都把整个老年代扫一遍代价太高。RSet记录的是“谁引用了当前Region中的对象”这样GC扫描时只需要查RSet就能找到跨代引用省掉全堆扫描。G1的收集过程分三块年轻代回收、并发标记周期、混合回收。年轻代回收时把Eden区存活对象复制到Survivor或晋升到老年代并发标记周期用来挑出那些垃圾特别多的老年代Region混合回收阶段既处理年轻代又回收一部分高价值的老年代Region。这个机制让G1既不像CMS那样容易碎片化又能把停顿控制在预期范围内所以默认选它是合理的。3.4 ZGC和Shenandoah超低延迟路线上的两匹黑马ZGC在JDK11里以实验特性登场JDK15转为正式特性。它的目标是做到10毫秒以内的停顿而且堆越大优势越明显支持TB级别的堆。ZGC用了一个叫染色指针Colored Pointer的黑科技把GC标记信息直接写进指针里再配合读屏障让大部分回收工作都能和业务线程并发完成。Shenandoah是Red Hat主导开发的另一款低延迟收集器思路和ZGC类似也被一些大厂用作低延迟场景的备选方案。那普通项目要不要上ZGC我的看法是别盲目追新。如果业务对延迟极其敏感比如证券交易、实时推荐且堆内存超过16GBZGC值得试但如果你的服务就是常规的CRUD接口G1已经把停顿控制在几十毫秒内完全够用。GC选型永远是在吞吐量和延迟之间做平衡没有银弹。3.5 面试常问的GC Roots到底有哪些聊GC必然要聊“哪些对象算存活”。JVM用可达性分析算法从一组称为GC Roots的根对象出发沿着引用链走能被访问到的对象就是存活的访问不到的就是可回收的。GC Roots包括这几类虚拟机栈中栈帧里的局部变量表引用的对象也就是正在执行的方法里的局部变量指向的对象本地方法栈中Native方法引用的对象方法区中类的静态属性引用的对象比如private static的对象字符串常量池、类加载器等JVM内部的引用被synchronized锁持有的对象存活线程对象本身。理解GC Roots之后你就能解释一个现象一个对象即使局部变量已经不再用了但只要有静态变量引用着它GC就不会回收它。这也是很多内存泄漏的根源——某些容器类对象被静态集合长期持有堆越来越大。4. 那些真正值得背的JVM参数不只是-Xmx和-XX:UseG1GC4.1 参数分类标准参数、-X参数、-XX参数JVM参数大体分三类很多人只知道-Xmx其实掌握分类才能少踩坑。标准参数是所有JVM实现都必须支持的以-开头比如-version、-classpath、-Dnamevalue。这些参数最稳定跨JVM厂商都有效。非标准参数以-X开头是HotSpot特有的但常见的通常比较稳定比如-Xmx、-Xms、-Xss。这类参数可能变动但一般不会突然删掉。以-XX开头的参数才是真正的“高级玩法”不稳定是HotSpot的内部参数随时可能调整。查看当前JVM所有-XX参数的默认值用这个命令java -XX:PrintFlagsFinal -version | grep -E CompileThreshold|MaxHeapSize|UseG1GC|MaxRAMPercentage线上排查时这个命令配合-XX:UnlockDiagnosticVMOptions -XX:PrintFlagsFinal可以看到很多诊断级参数非常有用。4.2 -XX:CompileThreshold背后的JIT编译机制-XX:CompileThreshold在很多人的参数清单里被归为“冷门参数”但它是理解JIT编译的一把钥匙。这个参数的含义是一个方法被调用多少次之后JVM就判定它为“热点方法”触发JIT编译。在纯解释模式下HotSpot默认值是Client模式C1编译器下1500次Server模式C2编译器下10000次。但现代的JVM默认开启了分层编译-XX:TieredCompilation情况就复杂了。分层编译把编译分成多个层级C1编译器先做低优化度的快速编译让热点代码先运行起来等热度继续升高后C2编译器再做高优化度的深度编译。实际生效的阈值会根据C1和C2的配合动态变化不只是CompileThreshold这一个数还包括Tier2CompileThreshold、Tier3CompileThreshold等参数。那调低这个值有没有用理论上如果某段非常关键但又长期被解释执行的代码把阈值调低可以让它更早被编译成本地代码。但在实践中我不建议普通开发者去改它。因为JIT编译本身也要消耗CPU把阈值调太低会导致大量不太热的代码也被编译占用CPU和CodeCache反而得不偿失。如果你怀疑某个方法没有被编译导致性能差正确做法是先加-XX:PrintCompilation看JIT到底编译了哪些方法确认问题后再决定要不要微调。4.3 一张表搞定生产环境常用JVM参数下面这些参数是我在实际项目中至少用过一遍的整理成表方便查。参数默认值/建议值作用-Xms/-Xmx建议设为相同值初始堆/最大堆避免堆动态伸缩带来的停顿-Xss512k~1m线程栈大小影响线程数和栈深度-XX:MaxMetaspaceSize无上限控制元空间上限防止类元数据撑爆本地内存-XX:UseG1GCJDK9默认启用G1收集器-XX:MaxGCPauseMillis200200G1期望的最大GC停顿时间-XX:SurvivorRatio88Eden区与Survivor区的比例-XX:NewRatio22新生代与老年代的比例-XX:MaxTenuringThreshold1515对象晋升老年代前经历Minor GC的次数-XX:HeapDumpOnOutOfMemoryErrorfalseOOM时自动导出堆转储-XX:HeapDumpPath/data/logs无堆转储文件输出路径-XX:ErrorFile/data/logs/hs_err_%p.log无JVM崩溃日志输出路径-XX:MaxRAMPercentage75.025.0JVM根据总内存自动计算的堆上限百分比容器必配-XX:UseContainerSupportJDK8u191默认开让JVM识别容器内存限制-XX:CompileThresholdserver 10000JIT编译触发阈值-XX:TieredCompilation默认开启用分层编译GC日志输出方面JDK8和JDK9有巨大差异。JDK8用老式参数-Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStampsJDK9改用统一日志体系参数格式变成了-Xlog更灵活比如-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags4.4 调参的核心原则与常见误区我见过不少同学一到线上就先把-Xmx加到16G觉得堆越大越好结果GC停顿从几十毫秒涨到几百毫秒。堆不是越大越好堆越大单次GC要扫描和复制的对象就越多。如果你追求低延迟正确的方法是合理控制堆大小配合G1或ZGC把停顿压到目标范围内。第二个原则是“先监控后调参”。不要凭感觉把-XX:MaxTenuringThreshold从15改到5觉得“对象早点晋升老年代能减轻年轻代压力”结果老年代涨得快反而频繁触发Mixed GC。任何参数调整之前都要先通过GC日志、监控大盘确认当前的瓶颈在哪是年轻代频繁回收、老年代增长过快还是Full GC太长然后再对症下药。第三个原则是容器环境下不要再写死-Xmx。以前把-Xmx4g写死在启动脚本里还能用现在一个服务可能要部署在内存配额不同的容器里写死就很容易出事。容器环境推荐用-XX:MaxRAMPercentage和-XX:InitialRAMPercentage让JVM根据容器配额自动算堆大小。注意一旦显式设置了-XmxMaxRAMPercentage就失效了两者不要混用。5. 容器化时代JVM的新坑Docker里内存被杀的排查路径5.1 为什么容器里的Java程序总被“离奇”杀死容器和JVM这对组合最经典的坑就是JVM不认容器的内存限制。JDK8u191之前的版本JVM在读内存总量时读的是宿主机/proc/meminfo而不是cgroup给容器设的配额。假设宿主机有64GB内存容器限制512MBJVM启动时按64GB的1/4算出默认最大堆16GB一跑起来就直接超过容器的cgroup限制内核的OOM Killer干脆利落地把进程杀掉。表现就是服务启动后没一会儿就死docker inspect看容器OOMKilled字段是true退出状态码是137也就是128加上SIGKILL的9。到这一步很多人还傻傻地在代码里找内存泄漏其实根因就是JVM和容器的认知不一致。JDK8u191之后-XX:UseContainerSupport默认打开JVM能读到容器配额了。但如果你用的是老旧的JDK8版本或者启动脚本里还写死了-Xmx问题依旧存在。所以容器化项目的第一条检查项永远是你的JVM能不能感知容器限制。5.2 容器里JVM日志去哪找GC日志、崩溃日志、应用日志容器里找日志比宿主机上麻烦因为容器一重建里面的文件就没了。很多人问“docker容器部署的Java程序异常重启JVM日志在哪儿”这里得分三类看。应用日志是logback或log4j输出的那些正常会写到容器内的文件路径。如果你没有把日志目录挂载到宿主机容器一删日志就没了所以生产环境一定要用-v /data/applogs:/data/applogs这类卷挂载把日志目录持久化。GC日志需要你在JVM参数里显式配置不配置的话JVM本身不会输出GC日志。你还需要把GC日志的输出路径也挂载到宿主机不然GC日志跟着容器一起消失事后想分析根本无从下手。JVM崩溃日志是hs_err_pidpid.log这样的文件JVM遇到致命错误比如原生内存分配失败、JIT编译异常时会写到当前工作目录默认名字是hs_err_pid进程号.log。容器里工作目录往往就是/或应用目录同样需要设置-XX:ErrorFile/data/logs/hs_err_%p.log并挂载目录。还有一层数据来源容易被忽略docker logs 容器ID看到的只是标准输出和标准错误。如果你用System.out.println打日志或者logback配置了输出到控制台那么这些内容会出现在docker logs里。崩溃前的最后几行往往有线索比如OOM、堆栈异常等。5.3 一个真实排查案例容器里定时服务每隔两小时被重启我去年帮人排查过一个案例一个Java定时任务服务跑在Docker里容器限制1GB内存每两个小时左右就被重启一次。看起来毫无规律日志里也没有业务异常。接到这个case我第一时间看了容器的退出码是137马上怀疑OOM Killer。然后我执行了三条命令定位# 看容器退出状态 docker inspect container --format{{.State.OOMKilled}} {{.State.ExitCode}} # 看内核日志确认是不是OOM Killer干的 dmesg | grep -i killed processdmesg输出里赫然写着Killed process 1234 (java), total-vm:... anon-rss:...坐实了是进程内存超限被杀。接着我再查启动参数发现应用里写死了-Xmx768m想着容器1GB留了200多兆够用了。但算总账不能只算堆——768M堆、64M元空间、还有几十个线程的栈、JIT的CodeCache、NIO的直接内存杂七杂八加起来早就飙到1GB以上了内核只能杀进程。修复方案很直接把-Xmx768m拿掉换成-XX:InitialRAMPercentage60.0 -XX:MaxRAMPercentage60.0这样JVM只按容器内存的60%分配堆留了40%给元空间、线程栈、直接内存。同时在参数里加了堆转储、崩溃日志和GC日志的输出路径并挂载到宿主机。从那之后这个服务再也没被莫名其妙杀过。5.4 容器环境下JVM参数的推荐姿势结合前面的经验我给出一个适合绝大多数容器化Java服务的启动参数模板。java -jar app.jar \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -XX:InitialRAMPercentage60.0 \ -XX:MaxRAMPercentage60.0 \ -XX:MaxMetaspaceSize256m \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/heap.hprof \ -XX:ErrorFile/data/logs/hs_err_%p.log \ -Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags如果你的服务跑在JDK8上把-Xlog参数换成前面说的JDK8老式参数。然后在Docker启动时挂载日志目录docker run -m 1g \ -v /data/applogs:/data/logs \ your-image:tag这里-m 1g是容器的硬限制JVM自己通过MaxRAMPercentage读到的也是这个1G的值不会再去按宿主机算。留出来的60%到80%之间怎么选如果应用里大量使用NIO、Netty直接内存占用大比例就往下调比如50%如果是纯计算型服务线程栈和直接内存不多70%左右也没问题。这个比例没有标准答案需要结合你的监控监控数据去微调。另外还有一点Kubernetes环境里如果Pod设置了requests和limitsJVM会优先读取limits。你千别在Pod里一边设limits.memory512Mi一边又在启动脚本里写-Xmx512m这种配置一定会炸——因为512M堆没有给元空间、线程栈留任何余地。正确做法是让JVM的百分比参数自己去适配limits你只管设置容器的内存上限。说实话JVM入门最大的障碍不是资料少而是碎片化的知识太多。今天从三兄弟关系讲到运行时数据区从G1收到器讲到CompileThreshold又从容器OOM排查讲到参数模板这条线基本覆盖了后端开发日常会碰到的全部高频问题。我自己这几年带团队发现能把上面这些讲清楚的人线上出问题时基本都是团队里最快定位的。所以不用贪多求全先把这块地基打牢后面再啃类加载、字节码、调优工具都不迟。