Java程序运行机制这个话题,说实话是每个Java开发绕不开的核心。不管是刚入门准备面试的新人,还是工作了几年想回头补基础的老手,只要想把这门语言吃透,就必须把这些机制弄明白。网上关于这块的文章不少,但大多是零散知识点,要么只讲类加载,要么只讲JVM内存,缺少一条完整链路的串联。我结合自己这么多年写Java、调优、排查线上问题的实际经验,把整个运行机制从头到尾梳理一遍,不讲虚的,直接把那些面试八股、实战排查中最关键的东西拎出来说清楚,新手能看懂,有经验的也能从中对一遍自己的知识体系。
Java程序运行的核心,简单说就是两部分:编译阶段和运行阶段。编译阶段把.java文件变成.class字节码,运行阶段由JVM加载这些字节码、分配内存、执行指令。听起来简单,但每个环节展开后都藏着大量值得深挖的细节。你平时写的每一行代码,最终都要走完这条路,哪一环出了问题,程序就会以各种诡异的姿势挂掉。
1. 一个Java程序从源码到运行,它的完整路线到底是什么
1.1 三个核心环节而不是两个:从源码到字节码再到机器码
很多人学Java的第一课就知道“一次编写,到处运行”,但能把这个承诺讲清楚的人不多。实际上Java程序的运行路径是一条三阶段流水线:源码阶段、字节码阶段、机器码执行阶段。
源码阶段就是你平时写的.java文件,这个阶段唯一的产物是给人看的代码,约束非常宽松,怎么组织、怎么命名都可以,只要符合语法规范。用javac命令执行编译后,得到的是.class文件,里面装的是JVM能识别的字节码指令。字节码不是某个具体CPU的机器码,它是一种针对JVM虚拟机设计的中间格式。JVM拿到.class文件后,一边通过类加载器把这些字节码载入运行时数据区,一边用解释器或JIT编译器(Just-In-Time,即时编译器)把字节码翻译成当前操作系统和CPU架构能执行的机器码。
这三者的关系我习惯用做饭来类比。源码是菜谱,字节码是切好配好的净菜半成品,机器码才是最后下锅炒出来的成品菜。菜谱可以被人看懂,净菜半成品可以标准化运输到任何厨房,真正下锅翻炒必须使用特定炉灶。Java的“到处运行”靠的就是把“净菜半成品”标准化——不同平台只需要各自准备适配的炉灶(JVM),半成品本身无需改动。
1.2 为什么选择“中间格式”而不是直接编译成机器码
既然最终都要变成机器码,为什么不学C/C++那样直接在编译阶段一次性编译成可执行文件呢?这里藏着Java设计者的核心取舍,JVM引入字节码作为中间层,本质上是用一部分性能换来了极大的跨平台性和安全性。
如果直接编译成机器码,编译结果就和操作系统、CPU架构强绑定。你在一台装有x86 Linux的机器上编译出的程序,拿到ARM Windows上绝对跑不了。想支持所有平台,就要针对每个平台单独编译一份,维护成本直接失控。而字节码是独立于具体平台的中立格式,它只在JVM内被消费,JVM充当了字节码和底层硬件之间的适配层。Sun公司当年推Java时,靠的正是“一处编译,处处运行”这张王牌,让Windows、Linux、Unix上的开发者在同一份字节码上协作。
安全性也是重要考量。JVM对字节码有一套校验机制,类文件被加载时会经过字节码验证器检查,比如类型是否正确、操作数栈是否越界、符号引用是否能正常解析等。恶意构造的.class文件如果没有经过校验就进入系统,可以直接操纵内存地址,那后果不堪设想。在Java早期以Applet运行在浏览器中的时代,这个安全设计尤为关键,防止了从互联网下载的不受信代码搞破坏。C/C++的程序一旦被编译为机器码,内部的内存访问几乎完全不受控制,而JVM在字节码层面设了关卡,这是一个非常强的安全边界。
用大白话说,JVM给Java程序套了一层“沙箱”,同时带了一组适配不同平台的“翻译官”。这也是为什么Java能覆盖服务器后端、移动端Android、大数据框架等众多领域,底层运行机制的统一性功不可没。
2. 类加载机制:Java程序启动时到底发生了什么
2.1 类加载的五个阶段:加载、验证、准备、解析、初始化
平时写一个main方法后直接运行java命令,JVM在背地里做了大量工作。类加载不是简单地把.class文件读进内存就完事,它由五个阶段组成:加载、验证、准备、解析、初始化。面试里最常问的类加载机制,说得就是这条链路。
加载阶段由类加载器(ClassLoader)完成,它根据类的全限定名去读取对应的字节码二进制流,把它转换成方法区中的运行时数据结构,并在堆中生成一个java.lang.Class对象作为访问入口。这里有个容易忽略的点:类的加载不一定要等到使用它的那一刻。JVM规范允许类加载器按需加载,但加载时机受“主动引用”触发,比如new对象、访问静态字段、调用静态方法时。而仅仅定义引用变量、通过数组反射类等被动引用不会触发初始化。
验证阶段是对类的字节码做安全检查,其实就是前面提到的字节码验证器在起作用,确保这个类不会破坏JVM的约束。准备阶段是给类的静态变量分配内存并设置零值,比如一个static int value = 100,在准备阶段value先被设为0,真正的100要到初始化阶段才被赋值。解析阶段是把常量池中的符号引用转换为直接引用,也就是从“逻辑上的名字”指向“内存中的真实地址”。
初始化阶段才真正开始执行类构造器 方法,给静态变量赋予初始值,执行静态代码块。这个阶段最容易碰到的一个问题就是“循环依赖初始化”,A类初始化时引用了B类,B类初始化时又引用了A类,处理不好会导致非常奇怪的初始化顺序问题。平时开发中如果发现某个静态变量在程序启动初期出现“不该为空却为空”的情况,多半是对初始化的触发时机理解不够。
2.2 双亲委派模型:为什么父子类加载器的顺序是这样的
类加载机制中必须掌握的概念是双亲委派模型。JVM的类加载器在默认情况下有严格的层次关系:启动类加载器(Bootstrap ClassLoader)在最顶层,负责加载JDK核心类,比如rt.jar中的java.lang、java.util等;扩展类加载器(Extension ClassLoader)在下一层,负责加载扩展目录下的类;应用类加载器(Application ClassLoader)在最底层,负责加载classpath下你写的业务类。
双亲委派的核心逻辑很简单:任何一个类加载器想要加载某个类时,它不会自己先去尝试,而是先把请求委派给父加载器处理,父加载器再往上委派,直到最顶层的启动类加载器。只有当父加载器找不到这个类时,子加载器才会自己去尝试加载。
为什么要坚持这个顺序?最直接的原因是安全。如果没有双亲委派,你可以在自己的代码里写一个java.lang.String类,如果自己的加载器先加载,JVM核心类库就被你的类覆盖了,这会引发灾难。而有了双亲委派,加载java.lang.String的请求会一直委派到启动类加载器,最终加载的是JDK自带的String,防止核心类被篡改。不同加载器加载同一个类会形成不同的Class对象,如果用两个不同的加载器各加载一遍同一个类,哪怕字节码一模一样,它们在Java里也不被认为是同一个类的。
热词里提到的“java容器”也与此相关。Tomcat这类Web容器实现每个Web应用一个独立的类加载器,就是为了实现应用之间类隔离。你在一个应用里部署了使用Spring 4的代码,另一个应用用Spring 5,它们互不干扰,这就是类的隔离性。但隔离也会带来问题,最常见的就是ClassCastException,因为同一个类在两个不同的类加载器下被当成两个不同的类型,强转就失败了。排查这类问题的核心思路就是要看对象的实际类加载器是谁。
2.3 为什么NoClassDefFoundError和ClassNotFoundException这么难排查
这两个异常是Java开发者职业生涯中碰得最多的运行期问题,很多人混淆它们的本质。ClassNotFoundException发生在类加载的“查找”阶段,一般是使用Class.forName()或ClassLoader.loadClass()时,在类路径中找不到对应类。而NoClassDefFoundError更隐蔽,它发生在“某个类在编译期存在,运行期加载时却失败”的情况下,比如类初始化时抛了异常,或者之前加载失败的类又被另一个类引用。
很多线上事故的根源都是NoClassDefFoundError。举个例子,某个工具类的静态初始化块抛了RuntimeException,JVM在初始化它时失败,这个类就被标记为“不可用”。后续其他类再引用它时,JVM不再尝试重新初始化,直接抛NoClassDefFoundError。这种问题排查时,光盯着报错信息往往找不到真凶,需要去翻日志里第一次出现的异常堆栈,找到那最初的根源。我自己的经验是,遇到NoClassDefFoundError,第一反应就是去看这次报错之前还有没有别的异常被吞掉,不要被表面的错误信息误导。
依赖冲突也会导致这类问题。热词里提到的“java 源码混淆工具”也在这里面掺一脚,混淆工具会改变类名和方法名,如果混淆后的包与其他依赖包发生类名冲突,运行期就可能出现找不到类或者类不匹配的情况。使用混淆工具时,务必保留足够的映射文件,否则线上排查会变成一场噩梦——你完全不知道Error里那个乱码类名到底是哪个业务类。
3. JVM内存模型:对象从创建到回收的完整旅程
3.1 运行时数据区:堆、栈、方法区各管什么
类加载完成后,接下来就是为对象分配内存和线程执行的问题。JVM把运行时内存划分为若干个区域,理解这些区域是掌握运行机制的关键。我按照HotSpot虚拟机的标准划分来梳理。
线程私有的区域有两个:程序计数器(Program Counter Register)和虚拟机栈(Java Virtual Machine Stack)。程序计数器是当前线程执行的字节码行号指示器,线程切换时用来恢复现场。它还有一个独特之处:唯一一个在JVM规范中没有规定OutOfMemoryError的内存区域,因为它的空间很小,线程数再多也就是一套索引记录。
虚拟机栈就是平时常说的“栈内存”,每个方法在执行时都会创建一个栈帧,栈帧里有局部变量表、操作数栈、动态链接、方法返回地址。局部变量表存放的是基本数据类型和对象引用——注意,只放引用,对象本体还是在堆里。递归调用过深时,每个方法调用都会往栈里压入一个栈帧,而栈的深度是有上限的,超了就会抛StackOverflowError。
线程共享的区域主要是堆(Heap)和方法区(Method Area)。堆是内存管理的重头戏,几乎所有对象实例和数组都在这里分配。方法区在JDK 8以后由元空间(Metaspace)替代了永久代,存储类元信息、常量、静态变量等数据。静态变量和类信息放在元空间,而不是堆里,这个变化影响了很多调优参数的写法。
3.2 一个对象从创建到回收,内存中到底经历了什么
一个new语句触发的过程,是理解运行机制很好的切入点。以Object obj = new Object()为例,JVM做了这些事:首先检查类是否已经加载、解析、初始化,没有就触发类加载;然后在堆中为对象分配一块内存;接着把对象的内存空间初始化为零值;再设置对象头信息,包含哈希码、GC分代年龄、锁状态等;最后执行构造方法,完成我们写的初始化逻辑。
分配到堆里的对象不会永远存活。JVM采用分代收集理论,把堆划分为新生代和老年代。新生代又划分为Eden区和两个Survivor区(From和To),比例一般是8:1:1。新对象一般都分配在Eden区,经过一次Minor GC后还被引用的对象会被移到Survivor区,每次GC年龄加1,达到阈值(默认15)后晋升到老年代。
这个“年龄”记录在对象头里,所以前面说对象头里包含GC分代年龄。对象每次在From区和To区之间复制时,年龄都会累加。对象从新生代进入老年代还有一种情况:大对象直接进老年代。一个超过阈值的大数组如果在Eden区分配,Minjor GC时复制起来太贵,所以JVM会让大对象直接进入老年代,避免在新生代反复复制。
3.3 垃圾回收机制:从分代收集到G1再到ZGC
垃圾回收(GC)可能是Java程序员最关心的运行机制话题之一。面试必问,调优必碰。早期HotSpot默认采用Parallel Scavenge配合Parallel Old,追求高吞吐量。后来CMS(Concurrent Mark Sweep)为了缩短停顿时间而生,但CMS有一个众所周知的痛点:它基于标记-清除算法,会产生内存碎片,而且并发阶段对CPU资源敏感。再往后G1成了主流,它不再严格区分新生代和老年代的物理空间划分,而是把堆划分成一个个Region(区域),各代是Region的集合,突破了物理分代的限制。
G1的核心设计是“回收集”概念。它不一次性回收整个年轻代或老年代,而是根据每个Region的垃圾比例,挑选回收收益最大的Region集合来回收,这样可以控制GC停顿时间,实现可预测的停顿目标。用-XX:MaxGCPauseMillis参数可以指定期望的GC停顿时间,G1会尽量朝这个目标努力。但这不意味着你可以把它设得特别小,比如1毫秒,那会让G1更频繁地触发GC,反而降低吞吐量,实际的合理区间通常在几十到几百毫秒之间。
JDK 17以后ZGC被逐步推广,ZGC支持高达16TB的堆,停顿时间与堆大小无关,通常都在几毫秒以内。它的核心是染色指针和读屏障,实现了几乎完全并发的垃圾回收。不过ZGC目前对CPU资源的要求较高,并不是所有场景都能受益。我个人的建议是:如果JDK 17以上、多核CPU、堆内存比较大、业务对延迟敏感,值得尝试ZGC;如果对延迟没有那么苛刻,G1仍然是稳妥的选择。
GC调优前一定要先看懂GC日志。这里给出一个较新的统一日志格式示例:
java -Xlog:gc*:file=gc.log:time,uptime,level:tags -jar your-app.jar运行后会生成gc.log,里面能看到GC类型、堆内存的容量变化、停顿时间、各代的内存使用情况。热词里提到的“java面试八股文”里关于GC的问题,比如Minor GC、Major GC、Full GC的区别,其实不用死记硬背。Minor GC只清理新生代,Major GC清理老年代,Full GC清理整个堆(包括元空间)。“Minor GC频繁”和“Full GC频繁”对应的调优思路完全不同,前者通常是Eden区过小或者短期对象太多,后者往往意味着老年代空间不足,或者晋升阈值设置不合适,又或者是内存泄漏。
4. 从字节码到机器码:解释执行和JIT编译的配合
4.1 字节码是什么,为什么叫“中间语言”
编译出的.class文件,用javap命令就能看到字节码的真面目。我随便写一个简单的类:
public class Demo { public static void main(String[] args) { int a = 1; int b = 2; int c = a + b; } }用javac编译后执行 javap -c Demo,能看到类似这样的指令序列:
iconst_1 istore_1 iconst_2 istore_2 iload_1 iload_2 iadd istore_3 return这就是JVM的指令集——一个面向栈的指令集。注意“面向栈”这个说法,Java字节码的算术运算不是直接在寄存器里操作,而是先把操作数压入操作数栈,弹出后运算再把结果压回栈中。这种设计的优点是简单、平台无关,缺点是相对寄存器机多一些额外的压栈、弹栈指令。但JIT编译器会对字节码做深度优化,所以真正执行的机器码并不会像字节码那样“老实”。
4.2 解释执行与JIT编译器的取舍
JVM执行字节码有两种方式:解释执行和即时编译(JIT)。解释执行很直观,一行一行翻译成机器码执行,启动快但长期性能差,因为每一条字节码指令都要被重复翻译。JIT则把“热点代码”——也就是频繁被调用的方法——在运行期编译成机器码缓存起来,后续再执行这段代码时直接命机器码,速度大幅提升。
JIT编译不是所有代码一上来就编译的,那样启动时间会变得不可接受。HotSpot要统计方法调用次数和循环回边次数,达到阈值(默认10000次)才会触发编译。这个阈值由-XX:CompileThreshold参数控制。这里就能解释一个常见问题:为什么Java程序经常需要“预热”?因为JIT编译是运行期逐渐发生的。程序刚启动时,对业务代码是解释执行,性能偏低。跑一段时间后,热点代码被编译成了机器码,性能才达到峰值。对性能要求高的服务,上线前做一次压测预热,不是为了走形式,而是让JIT在正式流量进来之前把该编译的代码编译完。
除了JIT,还有一个和JIT经常搞混的概念:AOT(Ahead-Of-Time)编译。JDK 9引入了AOT,把字节码在程序运行之前直接编译成机器码,规避了JIT预热带来的性能抖动。但AOT也有明显缺陷,它无法基于运行期统计做激进优化,像是内联、逃逸分析这些依赖动态信息的手段都用不了。所以直到现在,主流Java应用仍然以JIT为主,AOT还不是万能解药。
4.3 从Hello World到线上服务:启动参数和运行环境配置
热词里“java安装”和“java环境变量配置”被大量搜索,说明很多新手刚开始就被基础环境卡住了。环境变量配置本身不算运行机制的范畴,但是它是运行机制的第一道门。配置JAVA_HOME和PATH的核心逻辑是让JVM可执行文件能被全局找到。JAVA_HOME指向JDK的安装目录,PATH里加入%JAVA_HOME%\bin(Windows)或$JAVA_HOME/bin(Linux)后,终端里敲java、javac就会去这个目录找可执行文件。如果引用了错误版本的JDK,运行Java程序时就会出现热词里提到的“java: 警告: 源发行版 17 需要目标发行版 17”这样的警告。这个警告实际上是maven-compiler-plugin或javac在编译时发现source、target和当前JDK版本不一致,最好在pom里明确指定maven.compiler.source和maven.compiler.target,或者使用release参数,保证在不同的JDK版本下编译行为一致。
运行时参数是调整JVM行为的重要手段。这里列举几个最实用的启动参数,它们在面试和实际工作中都频繁出现:
-Xms512m -Xmx2g -Xmn512m -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/app.hprof -verbose:gc -Xlog:gc* -Xss256k -XX:SurvivorRatio=8-Xms和-Xmx设置堆的初始和最大容量。这里提醒一下,生产环境强烈建议把-Xms和-Xmx设成相同的值,避免每次扩容或缩容触发Full GC造成延迟抖动。-Xmn设置新生代大小,-XX:SurvivorRatio控制Eden区和Survivor区的比例。线程栈大小-Xss,如果线程数很多,可以把栈调小一点,节省内存,但不能太小,否则递归稍深就StackOverflowError。-XX:+HeapDumpOnOutOfMemoryError是保命参数,OOM时自动导出堆快照,排查内存泄漏时没有堆dump基本就是盲人摸象。
线上排查时,jstack、jmap、jstat、jcmd这些JDK自带工具往往能救你一命。jstack可以打印线程快照,看线程状态和锁情况,定位死锁和阻塞特别有效。jmap可以生成堆dump,分析对象分布。jstat监控GC实时情况。很多刚入行的同事宁愿自己写日志猜问题,也不肯先跑一下几个jdk自带命令,方向走了不少弯路。
5. 那些“面试八股文”里的高频问题,其实都指向同一个体系
5.1 类加载、内存、GC:脚手架式的三板斧
热词里反复出现“java面试题”、“java面试八股文”、“java基础面试题”,说明这部分是求职者的刚需。很多人背八股文时,把类加载机制、JVM内存、垃圾回收当成三个孤立的知识点来背,其实它们是一套有机体系。面试官问“对象在JVM中如何创建”时,你已经要把类加载、内存分配、并发安全、对象头、GC回收全部连贯起来了。对象创建从类加载开始,加载完的类信息和方法区相关,对象本身在堆里分配,对象头里存储的信息又关联GC分代年龄和锁状态。你回答这个问题时,如果能自然把双亲委派、栈帧结构、GC Roots串起来,给面试官的印象就不是背过,而是真正理解。
GC Roots是什么?它是垃圾回收中“活对象”的起点。线程栈中局部变量引用的对象、静态变量引用的对象、JNI中全局引用对象,这些都是GC Roots。可达性分析从这些根出发,能到达的对象就是活的,到不了的就是垃圾。这个判断方式完美体现了栈和堆协作的关系:栈上是引用,堆里是对象,引用指向对象。如果没有引用指向这个对象了,它就成了垃圾。
5.2 容器、动态代理、策略模式:运行机制在框架层面的体现
热词里的“java容器”、“java动态代理”、“java策略模式多种组合”看起来是独立话题,实际上都依托于运行机制。Spring的核心IoC容器本质上就是一个大的类加载器管理系统加上对象生命周期管理器。Spring在启动时要扫描大量class文件,把它们转成BeanDefinition,这个过程极度依赖类加载机制对classpath的扫描能力。如果你不了解类加载顺序,就很难理解为什么换了一个类加载器,Spring的Bean就报ClassCastException。
动态代理也和运行机制直接相关。JDK动态代理运行时生成一个与目标类实现相同接口的代理类字节码,然后加载它。CGLIB则是生成目标类的子类。这个“生成字节码再加载”的过程,就是对类加载机制最典型的应用。面试里问动态代理的实现原理,如果你能说到代理类是如何在运行期被生成并加载的,回答就立住了。
策略模式多种组合也不是什么内存之外的东西。Java 8的Lambda表达式经过编译后,本质上是通过invokedynamic指令实现的,JVM在运行期动态地链接到真正的函数式接口实现。invokedynamic是Java 7为了支持动态语言而加入的指令,Java 8的Lambda和Stream能够流畅运行,靠的就是它。很多人看Lambda只想到语法糖,没有意识到它在字节码层面带来的变革。
5.3 数据一致性、深度拷贝、SPI机制:这些热词背后的运行期知识点
“java怎么保证数据一致性”这个热词,虽然是并发编程的问题,但底层依然是JVM内存模型(JMM)和锁机制的问题。JMM规定了共享变量的可见性、有序性和原子性规则。一个线程修改了变量,另一个线程未必能立即看到,因为CPU有缓存,JVM有工作内存和主内存的差异。volatile关键字保证可见性和有序性,synchronized和Lock保证原子性和互斥性。理解JMM是理解Java并发运行的基石,如果你只知道加锁但不知道为什么加锁,出了问题很难排。
“java对象深度拷贝”涉及到对象如何被复制。浅拷贝只复制地址,深拷贝要把整个对象图全部复制一遍。要安全深度拷贝对象,一个思路是实现Cloneable接口,另一个思路是通过序列化和反序列化复制对象,还有基于反射的BeanUtils.copyProperties,但这些都依赖运行期类型信息。反射就是JVM在运行期获取和操纵类的元数据的能力,它和动态代理一样,都构建在类加载机制和类元信息之上。
“java api开发与部署”常常配合“jenkins持续集成java项目”、“java开发api接口以供外部调用”出现。API项目部署上线后,运行的同样是.class字节码,在JVM里接受请求,只是多了Servlet容器或Netty这种网络框架。Netty基于NIO,而NIO底层调用了操作系统的epoll等机制,Java程序运行的边界从JVM延伸到了操作系统I/O层。这个层面上的调优,需要你跳出JVM,去看线程模型、看文件描述符、看网络栈,但根基依然是Java本身的并发和运行机制。
6. 从运行机制到日常实践:我踩过的坑和几点实在心得
6.1 环境变量、JDK版本这些“新手题”反而让老手翻过车
热词里出现“java环境变量配置详细教程”、“java官网jdk下载”、“java卸载时提示程序包有问题”,都是些看起来简单到不像技术问题的题,但实操中坑也不少。去年我接过一个内部工具的维护,运行环境要求JDK 8,但机器上装了JDK 17。我们用Maven编译时看到“源发行版 17 需要目标发行版 17”的告警,没有在意,结果部署到测试环境JVM直接拒绝加载某些类。根本原因是编译期用了高版本的字节码,运行期低版本JVM不认这个版本号(class文件版本号比JVM支持的高)。后来统一用Maven的toolchains插件指定编译使用的JDK,彻底解决了团队内IDE和构建工具JDK版本不一致的问题。
还有一次线上排查OOM,发现堆内存设置非常大,但应用启动不久就Full GC不断。看GC日志后发现元空间只有几十兆,却占用了大量CPU去回收。原因是CGLIB生成的动态代理类全都塞入了元空间,导致元空间膨胀,每次Full GC都要扫描这些类。很多人对元空间的理解停留在“装类的元数据”,实际上大量动态生成类的框架(Spring、MyBatis、CGLIB)都是元空间的消耗大户。线上运行时,元空间不够用会导致java.lang.OutOfMemoryError: Metaspace,这种错误和堆OOM的排查方向完全不同,要看是不是生成的类过多,而不是去看堆里有什么大对象。
6.2 用运行机制知识定位线上问题的几个实例
前两年有个服务一到晚间高峰期就变慢,从日志看是数据库调用超时,但团队里查了一圈数据库没有任何问题。后来抓线程栈发现,业务线程大量阻塞在一个第三方SDK的内部锁上,这个SDK内部用了某个静态的线程池,默认核心线程数只有2个,高峰时成千上万的请求在这个池子里排队。问题的本质就是“热点代码”没有做好并发控制,线程池的并发度远小于业务并发度。你不去看线程栈,永远猜不到是第三方SDK的默认配置拖垮了服务。jstack在这里的价值,就是把运行期的瞬时线程状态暴露出来。
另一个例子是排查线上频繁的Full GC。用jmap dump了堆后,发现大量同一个类产生的对象占据了90%的内存。看代码时发现一个缓存组件,每次key不存在就从数据库加载,同时用put写入缓存。在高并发下出现了“缓存击穿”,大量线程同时查询数据库并把结果写入缓存,瞬间堆里有无数份相同的数据副本。修复方式是加锁和互斥,或者用“单线程加载”的策略。这类问题的本质就是没有理解并发下对象创建的速率和GC回收的速率关系。
6.3 摸排运行机制,最好的路径是动手
学习Java程序运行机制,我推荐的路径是:先搭好环境(JDK下载安装、环境变量配置),用javac、java、javap工具链跑通一个最简单的Hello World,并在代码里加入sleep,然后用jps、jstack、jmap去观察它。你只要亲手做一次,很多抽象概念就能落地。比如写个死循环,jstack看一下RUNNABLE状态的线程;写个OOM的代码,配合-XX:+HeapDumpOnOutOfMemoryError和jvisualvm分析堆快照,GC压力、堆增长情况就全部可视化在你的眼前了。
遇到问题先报错,再分析,别猜。这套方法论的根基就是对运行机制有真正的理解。Java程序运行机制并不是考试用的死知识,它决定你排查线上疑难杂症时的上限。程序运行得不正常,运行机制就是你的排查地图。我见过一些简历上写着熟悉JVM的同学,遇到性能问题时就只会把-Xmx往上调,从没看过GC日志,也不分析是对象分配问题还是逃逸问题,最后只能用重启大法解决。理解运行机制的人和不懂的人,在面对同一堆日志时,双方看到的东西几乎是两个世界。