
1. 为什么一个后端开发者绕不开JVM这块硬骨头刚入行那会儿我对JVM的态度就是能跑就行。项目里堆内存溢出第一反应是加机器GC日志打出来一堆看不懂的缩写直接关掉日志眼不见为净。直到有一次线上服务每隔几小时就卡顿十几秒排查了整整两天才发现是Full GC在作祟那一刻我才意识到——JVM不是底层黑盒它是你线上服务的命门。这份笔记的源头是我早年跟着一套体系化课程入门JVM时整理的内容后来在工作中反复踩坑、反复补充慢慢形成了现在这个版本。它适合谁看如果你写过Java代码知道main方法怎么跑起来但对对象到底住在哪为什么我的内存一直涨GC日志里那些数字什么意思这些问题还模棱两可那这份笔记就是给你准备的。它不追求把JVM规范逐条翻译一遍而是把内存结构、垃圾回收、类加载、调优工具这几块最影响日常开发的核心内容讲透让你看完能上手排查问题而不是只记住几个名词。我个人的经验是JVM这东西光看不动手等于白看。所以下面每一块我都会尽量配上为什么这么设计实际怎么用我踩过什么坑你跟着走一遍比啃三天文档管用。2. JVM内存结构对象到底住在哪块地皮上2.1 运行时数据区全景拆解JVM在运行Java程序时会把内存划分成几个区域每个区域职责不同、生命周期不同。很多人背过堆、栈、方法区、程序计数器、本地方法栈这五个名字但背完就忘因为没理解它们各自解决什么问题。我用一个类比帮你建立直觉把JVM想象成一栋写字楼。堆Heap整栋楼里最大的公共办公区所有线程共享。你new出来的对象、数组绝大部分都住在这里。它是GC的主战场也是内存调优的核心。虚拟机栈VM Stack每个线程进楼时领到的私人工作台。线程每调用一个方法就往工作台上压一个栈帧方法执行完就弹出去。栈帧里放着局部变量、操作数栈、方法出口等信息。线程私有生命周期跟线程走。本地方法栈Native Method Stack跟虚拟机栈类似但服务的是native方法比如底层用C/C实现的方法。日常开发感知不强。程序计数器PC Register每个线程的当前执行到哪一行的小本本。线程切换后能回到正确位置全靠它。它是唯一不会抛OutOfMemoryError的区域。方法区Method Area存放类元信息、常量、静态变量、即时编译后的代码等。JDK 8之前用永久代实现JDK 8之后换成了元空间Metaspace挪到了本地内存里。这里有个高频面试点也是实操点JDK 8为什么把永久代换成元空间简单说永久代大小在启动时就固定了很难调容易抛OutOfMemoryError: PermGen space元空间用本地内存默认只受物理内存限制类加载多了也不容易炸。我实测过一个加载大量动态代理类的服务JDK 7下永久代调到256M还是频繁溢出换JDK 8后基本没再管过这块。2.2 堆内存的细分新生代与老年代堆是重头戏它内部又分了两大块新生代Young Generation和老年代Old Generation。默认比例大概是1:2新生代占1/3老年代占2/3这个比例可以通过-XX:NewRatio调整。新生代再细分就是大名鼎鼎的Eden和两个Survivor区通常叫S0、S1默认比例是8:1:1。为什么要这么分因为绝大多数对象都是朝生夕死的——方法里临时创建的字符串、包装类、局部对象用完就没用了。把新生代切出一块大Eden和两块小Survivor就是为了用复制算法高效回收这些短命对象。对象的一生大致是这样走的新对象优先在Eden分配。Eden满了触发Minor GC存活对象被复制到一块Survivor比如S0同时年龄1。下次Minor GCEden和S0的存活对象一起复制到S1年龄再1S0清空。如此来回倒腾对象年龄达到阈值默认15-XX:MaxTenuringThreshold后晋升到老年代。老年代满了触发Full GC这个通常比较慢也是线上卡顿的常见元凶。注意大对象比如很长的数组可能直接进老年代由-XX:PretenureSizeThreshold控制。这个参数只对Serial和ParNew收集器有效别乱设。2.3 一个容易被忽略的细节栈上分配与逃逸分析很多人以为对象一定在堆上其实不一定。JIT编译器会做逃逸分析如果一个对象只在方法内部使用、没被外部引用它可能被优化成栈上分配方法结束就随栈帧销毁连GC都不用参与。还有标量替换把一个对象拆成几个基本类型变量直接放在栈上。这块平时不用手动配但理解它能帮你明白为什么有些代码看起来创建了大量对象实际GC压力却不大。我做过一个对比测试一个循环里创建百万级临时对象开启逃逸分析JDK 8默认开启后GC次数明显少于关闭时。所以别轻易用-XX:-DoEscapeAnalysis去关它。3. 垃圾回收谁该被回收怎么回收3.1 判断对象死活的两种思路GC要做的第一件事是判断哪些对象还活着。主流思路有两种引用计数法每个对象记一个被引用次数为0就回收。实现简单但解决不了循环引用问题A引用BB引用A两者都不为0但实际已死。所以主流JVM不用它。可达性分析GC Roots从一组根对象出发能走到的对象算存活走不到的可回收。这是HotSpot实际采用的方法。哪些能当GC Roots常见的有虚拟机栈中局部变量引用的对象、方法区静态变量引用的对象、常量引用的对象、本地方法栈中引用的对象等。理解GC Roots的意义在于当你怀疑内存泄漏时就是去找谁还牢牢抓着这个对象不放顺着引用链往上找根。3.2 三种经典垃圾回收算法算法这块不用背定义理解它们各自的取舍就行。标记-清除Mark-Sweep先标记存活对象再清除未标记的。缺点是产生大量内存碎片大对象来了可能找不到连续空间提前触发GC。复制Copying把内存分两半存活对象复制到另一半然后整块清空。效率高、无碎片但浪费一半空间。新生代用的就是它只不过不是1:1而是EdenSurvivor的巧妙设计浪费控制在10%左右。标记-整理Mark-Compact标记后把存活对象往一端挪再清理边界外的内存。无碎片但移动对象成本高。老年代常用这种思路。实际JVM里没有哪种算法一统天下而是分代收集新生代用复制老年代用标记-清除或标记-整理各取所长。3.3 主流垃圾收集器怎么选收集器是GC算法的落地实现选错了调优方向就全歪了。下面这张表是我自己整理的核心对比建议收藏收集器作用区域特点适用场景Serial新生代单线程STW明显客户端、小内存ParNew新生代Serial多线程版配合CMS使用Parallel Scavenge新生代吞吐量优先后台计算型任务Serial Old老年代单线程标记-整理客户端、CMS后备Parallel Old老年代多线程标记-整理吞吐量优先CMS老年代并发收集低停顿响应优先已废弃G1全堆分区域可预测停顿大堆、均衡场景ZGC全堆超低停顿超大堆、低延迟选型逻辑其实不复杂吞吐量优先比如跑批处理选Parallel系列响应时间优先比如Web服务选CMS或G1JDK 9以后默认G1大多数场景直接用默认就行别瞎折腾。我见过不少团队在JDK 11上还硬配CMS结果各种问题其实G1开箱即用效果更好。提示CMS在JDK 9被标记废弃JDK 14正式移除。新项目别再选它了。3.4 G1的核心设计把堆切成一块块G1Garbage First是现在的主流它的思路跟传统分代很不一样。它把整个堆切成很多个大小相等的Region默认约2048块每个Region可以动态扮演Eden、Survivor或Old的角色。G1会优先回收垃圾最多的Region这也是它名字的由来。G1的停顿是可以预测的通过-XX:MaxGCPauseMillis设定目标停顿时间默认200msG1会尽量在这个时间内完成回收。但要注意这个值是目标不是保证设得太激进比如10ms反而会导致GC更频繁、吞吐量下降。我一般建议从默认值开始观察日志再微调。还有个关键参数-XX:InitiatingHeapOccupancyPercentIHOP默认45%表示老年代占用达到堆的45%就触发并发标记周期。如果发现并发标记启动太晚导致Full GC可以适当调低这个值。4. 类加载机制代码是怎么活起来的4.1 类加载的五个阶段一个.class文件从磁盘到能被使用要经历加载、验证、准备、解析、初始化五个阶段。我重点说几个容易踩坑的准备阶段会给静态变量分配内存并设零值比如static int a 10此时a是0不是10。真正的赋值在初始化阶段才做。这个细节在面试和排查诡异bug时很有用。初始化阶段是执行类构造器clinit()方法的过程也就是静态变量赋值和静态代码块。JVM规定了有且仅有五种情况必须立即初始化一个类俗称主动引用遇到new、getstatic、putstatic、invokestatic这四条字节码指令时。反射调用时。初始化子类时父类先初始化。虚拟机启动时的主类。动态语言支持相关。除此之外都是被动引用不会触发初始化。比如通过子类引用父类的静态字段只会初始化父类定义数组不会初始化元素类。4.2 双亲委派模型为什么你的类不会被随便替换类加载器有三层启动类加载器Bootstrap、扩展类加载器Extension、应用程序类加载器Application。双亲委派的意思是一个类加载请求先往上抛父加载器能加载就加载加载不了才自己上。这么设计的好处是安全。比如你自己写一个java.lang.String类双亲委派会让它一路抛到启动类加载器最终加载的是JDK自带的String你的假货根本没机会上场。这防止了核心类库被篡改。但双亲委派不是铁律有些场景要打破它。最典型的是SPI机制比如JDBC驱动加载核心接口在启动类加载器里但实现类在应用类加载器里父加载器加载不到子加载器的类于是用线程上下文类加载器来反向委派。还有Tomcat为了实现应用隔离每个webapp有自己的类加载器也是打破双亲委派的经典案例。理解这块的实际价值在于当你遇到ClassNotFoundException或NoClassDefFoundError尤其是多模块、热部署场景下往往就是类加载器的问题而不是代码写错了。5. 调优工具与实战排查别等线上炸了才动手5.1 命令行工具速查JDK自带一堆工具不用装任何东西就能用。我列几个最常用的工具作用常用命令示例jps查看Java进程jps -ljstat查看GC统计jstat -gcutil pid 1000jmap堆内存快照jmap -heap pidjstack线程快照jstack pid thread.txtjinfo查看/修改参数jinfo -flags pidjstat -gcutil是我用得最多的每秒打印一次各区域使用率和GC次数能快速判断是新生代问题还是老年代问题。jstack在排查死锁、线程卡死时是神器直接搜deadlock关键字就能定位。5.2 可视化工具JConsole与VisualVM命令行够快但看趋势还是图形化直观。JConsole和VisualVM都是JDK自带的VisualVM在JDK 9后需要单独下载。VisualVM能看内存曲线、GC活动、线程状态还能装插件做堆dump分析。我排查内存泄漏的标准流程是先用VisualVM观察老年代是否持续上涨、Full GC后是否回落。如果Full GC后老年代还是居高不下基本就是泄漏了。然后jmap -dump导出堆快照用**MATMemory Analyzer Tool**打开看Dominator Tree找出占用最大的对象再顺着引用链找到是谁在持有它。5.3 一次真实的内存泄漏排查记录说个我印象最深的案例。某服务运行几天后老年代持续上涨Full GC越来越频繁。用MAT分析堆dump发现一个HashMap占了近1G内存里面存的是用户会话数据但key是不断新增的旧key从不清理。问题代码大致是这样一个静态的ConcurrentHashMap当本地缓存用写入时只put不remove也没有过期策略。用户量一上来这个map就无限膨胀。修复方案很简单换成带过期策略的缓存组件或者定期清理。但排查过程花了小半天因为一开始都怀疑是数据库连接或线程池的问题。这个坑给我的教训是任何静态集合都要警惕无限增长。本地缓存不是不能用但一定要有容量上限和淘汰策略否则它就是个定时炸弹。5.4 常见问题速查表现象可能原因排查方向频繁Full GC老年代对象增长快、内存泄漏jstat看老年代曲线MAT分析dumpOOM: Java heap space堆太小或对象太多调大-Xmx排查大对象OOM: Metaspace类加载过多动态代理、热部署调大-XX:MaxMetaspaceSize服务周期性卡顿Full GC STW看GC日志时间戳换G1或调参CPU飙高GC线程忙、死循环jstack看线程栈jstat看GC频率线程死锁锁顺序不一致jstack搜deadlock注意调优不是参数堆得越多越好。我见过有人一口气配了二十多个GC参数结果互相冲突性能反而更差。原则是先观测、再定位、后调整每次只改一个参数改完观察效果。6. 我踩过的坑和几条实在建议入门JVM最容易犯的错就是把它当成一门背诵学科。背完内存结构、背完GC算法一到线上还是懵。我的建议是找个测试环境故意制造OOM然后亲手用工具把它排查出来。这个过程走一遍比看十篇文章都管用。另外几个实操心得GC日志一定要开。生产环境加上-Xlog:gc*:filegc.log:timeJDK 9或-XX:PrintGCDetailsJDK 8出问题时这就是第一手证据。别等出事了才想起来没开日志。别迷信最佳参数。网上流传的各种万能配置都是特定场景下的产物你的业务特点、机器配置、JDK版本都不一样照搬大概率翻车。JDK版本升级带来的收益往往大于调参。从JDK 8升到JDK 11或17G1的成熟度和默认配置优化能解决很多老问题比你在JDK 8上抠参数划算得多。理解比记忆重要。你不需要记住-XX:SurvivorRatio的默认值但你要知道调大Survivor能减少对象提前晋升需要时查一下就行。最后分享一个我常用的排查起手式服务出问题先jps找到进程jstat -gcutil pid 1000看十秒GC情况同时jstack抓一份线程栈。这三步下来八成的问题方向就清楚了。剩下的就是顺着线索深挖工具只是辅助思路才是核心。