JVM核心原理与调优实战:内存模型、类加载机制与GC日志分析
很多人第一次接触JVM是在面试题里看到“JVM内存模型”“垃圾回收算法”这些名词。可我更愿意从一个更真实的场景说起你写好的Java代码部署上线运行了一段时间突然监控报警——老年代内存占用接近满Full GC频繁到每秒一次接口响应从几十毫秒涨到几秒。这时候你登录服务器敲下jstat -gcutil 进程号看着屏幕上刷出来的一串GC数据如果不懂JVM就只能干瞪眼。这篇文章想做的就是帮你把JVM的底子打牢。它不是面向那些准备深入研究HotSpot源码的专家而是给真正写业务代码、又想知道自己代码跑起来之后内存里发生了什么的朋友。不管你面试被问过多少次“类加载机制了解一下”还是线上遇到OutOfMemoryError想搞明白怎么排查这篇文章都会给你一套用得上的框架。读懂JVM不会让你一夜变成性能调优高手但它能让你在面对任何一个Java进程时不再是靠猜测和重启。1. 一次编译到处运行JVM在Java生态里到底是个什么角色1.1 JDK、JRE和JVM三者的关系先掰扯清楚很多初学者被这三个缩写绕晕过。我用最直白的方式解释JDK是开发工具包你写Java代码要用的javac编译器、jar打包工具都在里面JRE是运行时环境负责让你编译好的程序跑起来而JVM是Java虚拟机是整个运行时环境的内核。它们的包含关系是JDK包含JREJRE包含JVM。你装一个JDK写代码、编译、运行一把全搞定如果只是部署线上程序理论上装JRE就够了但现在大家为了省事基本都是直接上JDK。这个关系要记牢因为后面聊的类加载、内存分配、垃圾回收全部发生在JVM这一层和你的源码本身没有直接关系。Java程序从源码到运行要经过两步先用javac把.java文件编译成.class字节码文件再由JVM把.class文件加载进来解释执行。写C的朋友都知道换一个操作系统就要重新编译一次。Java不是这样.class字节码是跨平台的你在Windows上编译出来的产物拷贝到Linux服务器上一样能跑前提是那台机器装了对应平台的JVM。1.2 跨平台这件事靠的是字节码这个“中间语言”JVM能实现“一次编译到处运行”其实还是“翻译”的功劳。每个操作系统认识的机器指令不一样Windows认识PE格式Linux认识ELF格式而JVM把差异全部挡在虚拟机内部。字节码是一种中间语言它不针对任何具体CPU而是针对JVM本身定义的一套指令集。你可以这么理解字节码是“剧本”JVM是“导演”操作系统是“舞台”。同一个剧本在不同地方演出导演会按照当地舞台的条件调度演员。开发者只需要写剧本Java代码导演的事情由JVM负责舞台的问题由操作系统解决。这里头还有一个重要机制叫JIT即时编译Just-In-Time Compilation。HotSpot虚拟机默认不是纯解释执行字节码它会一边解释执行一遍统计哪些代码是热点代码。方法被调用次数达到一定阈值之后JIT编译器就会把这些字节码编译成本地机器码下次执行直接跑机器码速度能提升一个量级。所以别以为Java一定比C慢很多跑久了热度上来了性能差距会明显缩小。这一节想让你记住的就一句话JVM是Java跨平台能力的具体实现者和兜底者了解它的角色定位后面学习内存和类加载才会有代入感。2. 内存这块地怎么分运行时数据区拆开看2.1 线程私有的三块区域程序计数器、虚拟机栈和本地方法栈JVM在运行Java程序的时候会把自己管理的内存划分成几个区域这些区域统称为运行时数据区。记住一个总原则有些区域是每个线程单独一份有些区域是全进程共享一份。程序计数器是线程私有区域里最小的一块。它的作用只有一个记录当前线程正在执行的字节码指令地址。因为CPU调度是线程级的线程切换之后需要恢复现场程序计数器保证了切换回来之后能接着往下执行。如果你执行的是一个Native方法程序计数器里是undefined因为原生方法不在字节码引擎的控制范围内。这块区域是唯一一个不会抛出OOMOutOfMemoryError的区域因为它需要的空间极小JVM规范没有对它做内存大小限制。虚拟机栈是线程私有区域里最值得你关注的一块。它描述的是Java方法执行时的内存模型每个方法从调用到执行完毕的整个过程对应一个栈帧入栈和出栈。一个栈帧包含四样东西局部变量表、操作数栈、动态链接、方法返回地址。局部变量表存放基本数据类型变量、对象引用和returnAddress类型操作数栈是方法内真正进行运算的“工作台”比如执行加法指令时会先把两个操作数压栈再出栈执行相加。每个方法调用就压入一个栈帧方法返回就弹出。栈的深度不是无限的你写递归不带终止条件就会抛出StackOverflowError。这个错误很多人遇到过但没想过根本原因其实是线程请求的栈深度超过了JVM允许的最大深度。栈大小可以用-Xss参数控制但别盲目调大后面讲参数时再细说。本地方法栈跟虚拟机栈非常相似区别在于它是给Native方法服务的。比如你用JNI调了一个C写的底层库执行那个C方法时用的就是本地方法栈。HotSpot虚拟机的实现中本地方法栈和虚拟机栈是合并在一起的所以你平时用默认JDK跑程序感知不到它们之间的边界。2.2 线程共享的两块区域堆和方法区元空间说完线程私有的再看线程共享的。堆是JVM管理内存中最大的一块几乎所有的对象实例和数组都在这里分配。它是垃圾回收器的主战场也是你在调优时最常折腾的地方。堆在物理上可以不连续逻辑上分成新生代和老年代新生代里面又细分为Eden区和两个Survivor区。这种分代设计是为了配合垃圾回收策略后面专门讲。方法区在Java 8之前有个大家都听过的名字——永久代PermGen。Java 8以后永久代被移除了方法区彻底改为元空间Metaspace并且使用本地内存来实现不再占用JVM堆内存。方法区存放的是已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等“类级别”的数据。元空间的一个核心变化是默认情况下它可以使用本地内存的几乎全部剩余空间不会再像永久代那样容易因为对象数量膨胀而触发OutOfMemoryError: PermGen space。为什么官方要换掉永久代最直接的原因是字符串常量池也在永久代里而字符串创建特别频繁很容易把永久代塞满触发Full GC。改成语元空间后内存上限默认不再受JVM参数限制类元数据放不下时会在操作系统层面分配更多内存当然实际生产环境你还是要用-XX:MaxMetaspaceSize给它一个上限约束防止失控占满服务器物理内存。2.3 对象创建时到底发生了什么很多人以为new一个对象就是“开一块内存”过程远没那么简单。完整流程大致是类加载检查虚拟机遇到new指令先检查常量池里能不能找到对应类的符号引用再检查这个类有没有被加载、解析和初始化过。分配内存对象所需内存大小在类加载完成后就确定了。分配方式有两种如果堆内存规整用“指针碰撞”方式只需要移动一个指针如果堆内存不规整用“空闲列表”方式。为了保证并发安全可以走CAS加失败重试或者直接用TLABThread Local Allocation Buffer——每个线程在Eden区划一小块私有区域去分配这也是为什么以前说“多线程往堆里new对象性能也不会太差”。初始化零值把分配到的内存空间全部初始化为零这一步保证了对象的字段在没赋值之前就有默认值。设置对象头记录这个对象属于哪个类、哈希码、GC分代年龄等信息。执行构造方法调用init方法把对象按照代码里的意图真正组装出来。构造过程繁琐好处是你不需要像C语言那样手动管理内存坏处是很多人由于不了解这一步以为“对象在堆里分配就完事了”。实际排障时如果发现大量对象大小异常从这五步去反推就很有方向感。3. 一份.class文件是怎么变成活对象的类加载机制3.1 五个阶段分别做什么加载、验证、准备、解析、初始化我们从类加载机制去回答“.class文件从磁盘进入内存之后究竟经历了什么”这个问题。整个生命周期是加载、验证、准备、解析、初始化然后才开始使用最后卸载。前面四个步骤加起来也可以叫“连接”阶段。加载是第一步。通过类的全限定名获取该类的二进制字节流然后把这个字节流所代表的静态存储结构转化为方法区的运行时数据结构再在内存中生成一个Class对象作为这个类的方法区数据的访问入口。通俗说就是“把文件读进来在内存里生成一个类档案”。验证是安全兜底。它会检查字节流是否符合Class文件的格式规范、是否满足Java语言规范里的各种约束、字节码指令有没有安全问题。这一步对程序运行不是必需的但默认开着。极端追求启动速度的场景可以用-Xverify:none跳过不过我不建议生产环境这样干安全性和稳定性比那几毫秒重要得多。准备阶段是给静态变量分配内存并设置默认零值的时期。注意此刻还没执行任何代码所以public static int value 100这条语句执行后value在准备阶段的取值是0而不是100。真正赋值成100要等到初始化阶段。但如果静态变量被final修饰编译时会直接生成ConstantValue属性准备阶段就已经赋好值了。解析阶段把常量池里的符号引用替换为直接引用。符号引用是描述目标的字面量比如类的全限定名直接引用是能直接定位到目标的指针或偏移量。初始化阶段才真正执行类定义的Java代码。静态变量赋值和静态代码块会被编译器收集进一个clinit()方法中执行。什么时候会触发初始化最常见的时机是遇到new、getstatic、putstatic、invokestatic字节码指令也就是我们代码里“创建对象、读写静态字段、调用静态方法”的时候以及反射调用、初始化一个类时先初始化其父类。这些时机在《Java虚拟机规范》里被总结为“主动引用”其余情况都属于“被动引用”不会触发初始化。3.2 双亲委派模型为什么JDK核心类不会被“盗版”类加载器也有层级。Java默认有三个层次的类加载器启动类加载器Bootstrap ClassLoader、扩展类加载器Extension ClassLoaderJava 9之后被平台类加载器替代、应用程序类加载器Application ClassLoader。双亲委派模型的工作机制是当一个类加载器收到类加载请求时它不会自己先去加载而是委派给父加载器处理父加载器处理不了自己才动手。整个委派链从应用程序类加载器一路向上到启动类加载器。这就像公司里一个需求不是越级直接飞到顶头上司那儿而是按层级逐级上报。这个机制解决了两大问题。第一避免类被重复加载父加载器已经加载过的类子加载器不会重复加载。第二保证核心类不被篡改如果你自己在代码里写了一个java.lang.String双亲委派模型会保证核心API还是从启动类加载器那边加载你的“盗版String”根本没机会上场。不过双亲委派不是绝对不可打破的。历史上有名的破坏场景是JDBC的SPI机制。JDK核心代码需要调用数据库驱动的实现类但驱动包是第三方jar启动类加载器看不到。这时候就需要通过线程上下文类加载器Thread Context ClassLoader从“父加载器无法加载的目录”里把驱动拉进来。Tomcat等Web容器也有自己的类加载逻辑用来实现多个应用之间的类隔离。了解这些不是为了让你去打破双亲委派而是面试到这类经典问题时你能说出“它是对的框架但也有适用边界”。4. 垃圾回收到底在回收什么JVM内存回收的底层逻辑4.1 对象怎么算“死”从引用计数到可达性分析JVM的自动内存管理主要就是解决“哪些对象该回收”和“怎么回收”两个问题。先说说判断对象存活。老方案叫引用计数法给对象加一个计数器被引用一次加1引用失效减1计数器为0就判定为可回收。听起来很直观但它有一个致命缺陷——循环引用。两个对象互相引用外部已经没有入口访问它们了但计数器永远不为0垃圾回收器就永远收不掉它们造成泄漏。所以主流JVM都没有用这个方法。现在用的方案是可达性分析Reachability Analysis。通过一系列称为GC Roots的根对象作为起点向下追踪引用链凡是能被根对象直接或间接引用的对象就是“活着的”不能到达的就被标记为可回收。GC Roots包括哪些东西虚拟机栈中局部变量表里引用的对象也就是正在执行的方法里new出来的对象、方法区中静态属性引用的对象、常量引用的对象、JNI本地方法栈中引用的对象以及所有正在运行的Java线程对象。另外还得提一下Java的四种引用类型强引用Object obj new Object()内存不足也不回收、软引用SoftReference内存不足时回收、弱引用WeakReference只要GC发现就回收、虚引用PhantomReference完全不影响生命周期只用来跟踪回收时机。高频缓存用软引用、防止内存泄漏的临时对象场景用弱引用这些都是实际开发里用得上的技巧。4.2 三种基础回收算法标记-清除、标记-复制、标记-整理判断完哪些对象要回收接下来就是怎么回收。算法经典的有三种。标记-清除Mark-Sweep是最基础的。先标记出所有需要回收的对象再统一回收。缺点是会产生大量不连续的内存碎片。碎片多了分配一个稍大的连续空间就找不到地方会提前触发下一次GC。标记-复制Mark-Copy把内存按容量划分为两块每次只用一块这块用完就把存活对象复制到另一块然后一次性清掉当前整块内存。实现简单、没有碎片但代价是浪费了一半空间。HotSpot新生代对这个算法做了优化不是按1:1划分而是把Eden区和一个Survivor区的比例调成8:1:1。某次回收把Eden和其中一块Survivor里的存活对象复制到另一块Survivor如果空间不够依赖老年代做分配担保。标记-整理Mark-Compact针对老年代设计标记存活对象之后让所有存活对象向一端移动再清理掉边界以外的内存。这样做没有碎片但对象移动要暂停业务线程形成“Stop The World”停顿。从表格看它们的取舍更直观算法是否产生碎片内存利用率是否移动对象优缺点标记-清除是高否实现简单但碎片多标记-复制否低需额外空间是高效但空间浪费明显标记-整理否高是无碎片但停顿时间更长4.3 分代假说与主流收集器JVM的设计者观察到一个规律绝大多数对象都是“朝生夕灭”的活过第一轮GC的对象占比很小。于是他们引入分代收集理论把堆分为新生代和老年代让不同代的对象使用不同算法和收集器。新生代死亡对象多用标记-复制最划算老年代存活率高用标记-清除或标记-整理更合适。对象新生代里经历足够多次Minor GC后如果还活着就会被逐步晋升到老年代默认阈值是15次可用-XX:MaxTenuringThreshold调整。另外有个容易被忽略的规则大对象比如超长字符串、超大数组会直接进入老年代避免在Eden区和Survivor区之间反复复制所以如果你代码里频繁创建大数组老年代内存压力会非常大。[资料空缺]主流垃圾收集器里值得记住几条主线。Serial是单线程回收适合客户端小应用或者有几十MB堆的小场景Parallel Scavenge注重吞吐量是JDK 8默认的服务器模式收集器组合Parallel Scavenge加Parallel OldCMS以低停顿为目标适合交互类应用但会产生碎片而且并发阶段会占用CPU资源。到了JDK 9以后G1逐渐变成默认选择它把堆划分成一个个Region维护一个优先列表每次根据用户设定的停顿时间目标选取回收价值最大的Region集合进行回收。G1既能同时管理新生代和老年代又能做到可预测的停顿时间算是把“分代”和“分区”融合得比较好的一款收集器。5. 面试和排障都绕不开的JVM参数与工具5.1 一组生产级JVM参数模板与逐条解释理论讲完来说点能直接落地的东西。下面这组参数是一个生产环境Java服务常用的起步模板-Xms512m -Xmx512m -Xmn256m -Xss256k -XX:NewRatio2 -XX:SurvivorRatio8 -XX:MaxTenuringThreshold15 -XX:MaxMetaspaceSize512m -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/jvm/ -Xlog:gc*:file/data/logs/jvm/gc.log:time,uptime,level,pid,tid逐条拆开讲-Xms和-Xmx控制堆的初始大小和最大大小。生产环境我强烈建议两者设成一样避免运行期堆扩容和收缩带来的性能抖动。-Xmn控制新生代大小一般设为堆大小的1/3到1/4。新生代太小会导致Minor GC频繁太大又会挤压老年代空间。-Xss控制线程栈大小。Java 8默认可能达到1MB左右如果服务启动大量线程线程栈累加起来很吓人。调到256k一般够用但如果你代码里有很深的方法调用链调的太小会直接StackOverflowError这个要根据业务实际测试。-XX:NewRatio2表示老年代和新生代的比例是2:1堆整体划分成三份老年代占2份、新生代占1份。-XX:SurvivorRatio8表示Eden区占新生代的8/10两个Survivor区各占1/10。-XX:MaxMetaspaceSize给元空间设上限防止类加载太多把服务器物理内存吃光。-XX:HeapDumpOnOutOfMemoryError加上-XX:HeapDumpPath这是线上救命稻草——一旦OOM自动导出一份堆快照文件之后能用MAT分析。GC日志需要设置好在JDK 8时代通常用-XX:PrintGCDetails -XX:PrintGCDateStamps配合-Xloggc:/path/gc.logJDK 9之后统一用-Xlog:gc*这套统一日志语法。建议从上线第一天就打开GC日志等到出问题才想起来加就晚了。5.2 排障工具链jps、jstat、jmap、jstack怎么配合有了一组合理的参数还得知道怎么观察运行状态。JDK自带的一组命令行工具是我的第一选择因为所有JDK环境都有不用额外装。jpsJVM Process Status Tool列出当前机器上的Java进程类似Linux的ps命令你能拿到每个Java进程的PID和启动的Main类信息。首先找出目标进程。jstatJVM Statistics Monitoring Tool是看GC和内存的主力工具。jstat -gcutil 进程号 1000可以每1000毫秒刷新一次各内存区域的使用比例、GC次数和GC耗时。用法和输出字段一定要掌握线上你会经常跟它打交道。jstat -gcutil 12345 1000输出里E表示Eden区使用占比O表示老年代占比M表示元空间占比YGC/YGCT表示新生代GC次数和总耗时FGC/FGCT表示Full GC次数和总耗时。如果YGC特别频繁但每次时间不长通常是新生代设置太小如果FGC持续走高就要重点看老年代和堆整体。jmap用来导出堆快照和查看堆统计。jmap -heap 进程号看各区域参数配置和当前使用率jmap -dump:formatb,file/data/logs/jvm/heap.hprof 进程号生成堆转储文件。导出完可以用MAT或VisualVM打开重点看哪些对象占据的堆空间最大、它们的引用链从哪里来。jstack查看线程快照。当服务CPU飙高或者卡死时jstack 进程号 thread.txt打开文件后找线程状态是BLOCKED、WAITING或者和业务方法名对应的线程栈。如果很多线程卡在同一个锁对象上通常是锁竞争问题如果GC线程占用很高的CPU那问题多半又回到堆配置和GC选择上。一个常见排查思路是CPU飙升 -top找到Java进程PID -top -Hp找疯狂占用CPU的线程ID - 转成十六进制 -jstack里搜索这个线程ID - 定位到具体代码行。这套链路用熟之后线上问题很少会让你手足无措。工具不在于多在于能读懂输出。很多人装了VisualVM却只会看曲线图反而忽视了jstat和jmap输出里那些一眼就能定位问题的字段。我建议先从命令行工具练起理解了每一个字段的含义再看图形化界面会觉得豁然开朗。我自己实际调优时的体会是JVM参数根本不是越多越好也不是网上抄一个模板就完事。每个服务的对象生命周期、并发规模、响应时间要求都不同调优的核心是先看懂业务对象的存活模式再决定堆分区大小和收集器选择。所以别指望有个万能公式真正可靠的路径是把GC日志打开——压测或者观察线上——从日志里算出当前GC频率和耗时——再反推是哪块内存设置不合理。从这个角度说JVM调优与其说是玄学不如说是建立在日志和数据之上的决策过程。如果你现在刚入门最实际的下一步是找一台测试环境把你平时跑的项目用上面那一组参数启动然后通过压测制造一些并发再用jstat和jmap去观察内存曲线。踩过几次坑之后再回头看这篇文章讲的内存分区和类加载机制你会发现自己突然就串起来了。