
1. 准备阶段环境与工具链的底层心思1.1 简单谈谈一个常被忽略的问题JDK 到底怎么选很多Java程序跑着跑着出了内存问题第一反应是“堆不够了加大内存”但加到多大、为什么加心里并没底。我以前带过一个学生线上服务频繁GC停顿他连续加了三次堆内存都没好转最后排查下来是程序里有个死循环一直在往一个List里塞数据内存给再多也白搭。我讲这个例子是想说Java程序的编译与运行机制不是面试八股是排查线上问题躲不开的基本功。回到选JDK这件事。很多刚开始学Java的人下载JDK时根本分不清哪个版本该配哪个。我就直接说结论如果你是在做企业级项目选LTS版本长期支持版最稳妥目前主流是JDK 8、JDK 11、JDK 17再新一点的JDK 21也开始普及。JDK 8在存量系统里占比还很高因为它太成熟了各种框架中间件都兼容得很好。JDK 11引入了不少新特性比如ZGC的初步版本、HTTP Client标准化迁移成本也不算太大。JDK 17则在性能和GC上又做了不少改进新一代项目用这个比较多。其实这里面有个更底层的问题值得想清楚JDK和JRE到底什么关系。JRE只是Java运行时环境只有JVM和核心类库能运行class文件但不能编译。JDK是Java开发工具包包含完整的JRE、编译器javac、打包工具jar、监控工具jcmd等一大堆东西。一把梭装JDK就行因为开发、编译、运行都覆盖了。但如果是纯生产服务器只想跑jar包理论上装JRE就够只是现在JDK和JRE的下载包也都在慢慢整合装JDK倒也省事。1.2 环境变量配置不能省但别被旧教程带偏环境变量这块网上教程鱼龙混杂动不动就让配JAVA_HOME、PATH、CLASSPATH三件套。新版JDK其实没这么复杂。PATH变量的作用是让系统在任意目录下都能直接执行java、javac这些命令。它的本质就是把JDK的bin目录路径添加到系统可执行文件的搜索列表里。这个必须配不配的话你每次都要写一长串绝对路径来调用命令。JAVA_HOME的作用主要是给其他依赖Java的工具用的比如Maven、Tomcat、Idea等。它们启动时通常通过JAVA_HOME去定位JVM如果你没配或者配错经常出现“一个工具能跑另一个工具起不来”的奇怪现象。所以我建议无论如何都配一下成本极低收益很稳定。CLASSPATH就更有意思了。很多老教程还是煞费苦心地配置它但如果你用的是JDK 9以上的版本官方已经大大弱化了CLASSPATH的作用。因为JDK 9引入了模块系统类路径传统的那套全局搜索机制逐渐被边缘化。我记得很多初学者在JDK 1.6时代配置CLASSPATH如果忘了加当前目录点编译出来的class文件运行时经常报找不到主类烦躁得不行。现在的新版环境基本不用为这个折腾了。说白了旧知识里可能有一大半已经过时你得先分清楚哪些是本质、哪些是历史遗留。我个人的建议是如果你刚接触Java优先装最新的LTS版本配好JAVA_HOME和PATH用官方文档确认一下CLASSPATH是否需要手动设置然后就开始写代码不用在环境上过度内耗。1.3 必备的辅助工具做Java开发光有JDK还不够。我强烈建议装一两个顺手的小工具不用多但要精。比如反编译查看字节码用的javapJDK自带、字节码可视化工具、性能分析工具还有代码热部署插件之类的。尤其是javap它是JDK自带的class文件反汇编工具能让你直接看到自己的代码编译后长什么样这对于理解编译机制非常非常有帮助。后面我在讲字节码的时候会实际操作一遍给大家看。提前说一句真正理解Java的编译和运行机制一半的功劳得归给“亲眼看看class文件里到底装了什么”。2. 编译阶段从源代码到字节码javac到底做了什么2.1 javac不是简单“翻译”它是一条流水线拿C/C说话编译器是把高级语言直接编译成特定CPU架构的机器码也就是说你在自己的电脑上编出的可执行文件拿到另一台不同架构电脑上可能跑不了。Java的javac完全不同它并不生成机器码而是生成一种中间表示——字节码bytecode存放在.class文件里。字节码不是给CPU读的而是给JVM读的。JVM在自己的虚拟世界里负责把字节码翻译成当前CPU能理解的机器码来执行。这一层间接设计直接造就了Java“一次编译处处运行”的特性。javac的编译流程一般分四步走。第一步词法分析。把.java源代码的字符流拆成一个一个的Token词法单元就像把一句话拆成一个个单词。比如把 int a 10 拆成 int、a、、10 这几个Token。第二步语法分析。根据Java语法规则把这些Token组装成一棵抽象语法树AST如果语法有错误在这里就会被抓出来。第三步语义分析。这一步是做类型检查、变量定义和引用检查等更高级层面的验证比如你把一个String赋值给int变量编译器在这里就会报错。最后一步字节码生成。遍历语法树生成符合Java虚拟机规范要求的class文件。很多人有个误解觉得编译阶段是“把Java翻译成CPU指令”其实不是。编译阶段的终点是class文件而真正“翻译成机器码”的动作发生在运行阶段由JVM的即时编译器来完成。这个区别是整个Java编译与运行机制认知里最核心的分水岭。2.2 用javap亲眼看一次字节码讲原理讲得再多不如实际打开一个class文件看看。我随手写一个极其简单的类public class Demo { public static void main(String[] args) { int a 3; int b 5; int c a b; System.out.println(c); } }编译后用javap -c Demo.class一跑你会看到类似这样的输出public static void main(java.lang.String[]); Code: 0: iconst_3 1: istore_1 2: iconst_5 3: istore_2 4: iload_1 5: iload_2 6: iadd 7: istore_3 8: getstatic 11: iload_3 12: invokevirtual 15: return你别被这一堆吓住实际上逻辑很清楚iconst_3就是把整数3推入操作数栈istore_1是把栈顶值存到局部变量表第1个位置。iadd是把两个数加起来交给System.out.println调用。所谓的“虚拟机指令集”就是这么一条一条像汇编语言一样的指令排布。想分清JVM的“栈式指令架构”和x86的“寄存器架构”看到这一段输出你就会立刻明白JVM是通过操作数栈来完成计算的这跟CPU直接操作寄存器的工作方式是不一样的。我建议每个Java开发者在学习类加载和JVM内存模型之前都去亲手javap一个自己写的类。不用多看一个就够瞬间就会对“Java源代码到底经历了什么”有一个直观印象。字节码这个东西看多了之后很多高级框架的黑魔法在你眼里就会慢慢透明。2.3 编译期能做的优化比你想象中少很多人以为编译器会做大量性能优化这点Java和C区别挺大。javac做的优化非常有限它更偏向于“忠实翻译”。比如常量折叠如果你写 int a 2 3编译后可能直接就存了5而不是在运行期再算一遍。再比如字符串字面量的一些简单合并处理。但真正复杂的方法内联、循环优化、逃逸分析这些基本都不是javac在管而是运行期JIT编译器干的活儿。JIT编译器有运行时的统计信息知道哪段代码是热点从而决定优化到什么程度。这是编译期静态死等永远比不了的。还有一个特别重要的点语法糖。像泛型、自动拆装箱、增强for循环、lambda表达式这些是程序员友好的“糖”编译时会被javac“还原”成更基础的写法。泛型就一个大坑Java的泛型是“伪泛型”编译前你把List 写得好好的编译后字节码里泛型信息就被擦除了变成List这个在运行期根本拿不到泛型参数。所以经常有人说“运行时想拿一个泛型的真实类型非常难”根子就在编译期的类型擦除机制。再补充一点Java语言通过字节码标准向其他JVM语言敞开了大门。Kotlin、Scala、Groovy这些语言最终都能编译成JVM字节码共用JVM的运行时能力。也就是说只要你懂Java的编译与运行机制你学其他JVM语言时很多底层的堆、栈、GC、类加载机制是可以直接迁移过去的这是非常划算的一笔知识投资。提示想深入理解编译期行为除了javap还能用-verbose参数让javac输出更详细的加载和编译日志。多个编译细节对照看很多以前想不通的问题很快就通了。3. 类加载机制字节码进入JVM的第一道关卡3.1 类加载的五步走class文件并不是一启动就全部被塞进内存的JVM采用“懒加载”策略用到哪个类才加载哪个类。这背后就是类加载的五阶段流程加载、验证、准备、解析、初始化。先说加载。这个阶段由类加载器完成它负责把class文件的二进制字节流读进内存在方法区里生成对应的类元信息同时在堆里创建一个Class对象作为外界访问这个类的入口。类加载器真正读取字节流时既可以从本地文件系统读也可以从jar包、网络甚至动态生成的字节流中间接获取这就为很多框架的骚操作埋下了伏笔。然后是验证和准备。验证阶段会做大量安全性检查比如字节码能不能按规范正确执行、符号引用是否有效等防止恶意字节码搞坏虚拟机。准备阶段则是在方法区中为类的static变量分配内存并给出默认值。注意是默认值不是程序员写的初始值。比如static int count 10在准备阶段count先变成0真正的10要等初始化阶段才被设置进去。接下来是解析。解析阶段是把常量池里的符号引用替换成直接引用的过程。举个例子代码里写的类名、方法名在编译期只是一个符号引用解析阶段要从JVM里找到对应的实际内存地址真正建立“引用连接”。最后是初始化执行类的构造器也就是static变量赋值和静态代码块。整个过程听着不难但有个细节很关键类的初始化时机不是随意的。JVM只在遇到new对象、调用静态方法、访问静态变量、使用反射触碰类、初始化某个类的子类等几个场景时才会触发初始化。我自己就遇到过“奇怪现象”写了一个工具类里面有静态代码块但程序怎么运行那个静态代码块都没执行。原因很简单当时我只拿它做了类名引用没有触发任何主动使用场景初始化当然不会发生。3.2 双亲委派模型一句话解释清楚类加载器一共有四种主要的引导类加载器Bootstrap ClassLoader负责加载JDK核心类库比如rt.jarJDK 8时代扩展类加载器Extension ClassLoader或者叫平台类加载器负责加载一些扩展目录下的类应用类加载器Application ClassLoader负责加载classpath和项目依赖下的类还有用户自定义类加载器由开发者自己写。这四层之间有一种“委托”关系叫双亲委派模型。过程一句话一个类加载器收到加载请求时先不自己去加载而是把请求往上抛给父加载器父加载器再往上抛一直到顶层引导类加载器。只有父加载器加载不到子加载器才试着自己动手。为什么非要做这种看似绕路的操作核心原因就六个字保证核心类不被篡改。比如你自己写一个包名和java.lang.String一模一样的类如果应用类加载器直接加载你自己的类那JVM核心类库就被完全污染了。但类加载请求经过双亲委派一路传到引导类加载器引导类加载器发现“这不就是JDK自带的String吗”直接用真正的核心类返回你写的那个假String根本没有机会被加载。这个机制对整个Java生态的稳定性和安全性意义非常重大比JVM能跑多快重要得多。3.3 打破双亲委派的地方往往藏着重型武器虽然双亲委派是默认规则但很多框架恰恰会在这个机制上动刀。最典型的打破场景就是Tomcat这类Web容器和热部署场景。Tomcat为了部署多个应用、又让各个应用的类互不干扰每个应用都用自己的类加载器来加载WEB-INF/classes下的类。如果严格遵守双亲委派父加载器先加载那多应用之间很容易互相污染。所以Tomcat故意让Web应用类加载器先尝试加载自己的类加载不到才交给父加载器这其实是“优先级翻转”的破坏。热部署原理也类似每次改完代码重新编译替换掉类文件然后干掉旧类加载器创建新类加载器去加载新的字节码这样同样的类名在不同加载器里就能共存达到“不重启JVM”的效果。这些场景告诉我们双亲委派不是死规矩它是个高度可插拔的机制。理解了类加载器的构造和委派顺序你才真正有机会看懂Spring、Dubbo、Tomcat这些框架的很多“神秘行为”。我在学习阶段其实踩过不少坑比如排查一个NoClassDefFoundError查来查去最后发现是自己写了个自定义类加载器加载范围搞错了导致同一个类在不同类加载器里加载了两份签名对不上运行期才爆出来。这类问题日志里往往不直接你只能顺着类加载器的委派路径一层层查。4. 运行阶段解释执行与JIT编译的理性博弈4.1 为什么既有解释器又有JIT编译器前面说JVM执行字节码不是直接给CPU执行的它需要把字节码变成机器码。这个转变JVM提供了两种模式解释执行和即时编译执行JIT编译。解释执行就像你读一本外文书看到一句话翻一句边翻边读。好处是启动快、不用等待编译坏处是每句话都要现场翻译运行速度上不去。JIT编译就像你先把整本外文书翻译成中文装订好再一次性读中文版。好处是后面读得非常快坏处是开头那一段翻译时间你得等一等。所以JVM默认不傻选一条路走到黑程序启动初期大量代码还没跑热用解释执行让程序先跑起来随着程序运行JVM不断统计哪些方法被反复执行、哪些是热点代码热点方法就会被交给JIT编译器编译成本地机器码后续执行直接跑机器码速度飞快。一条方法从“被解释执行几千次”到“被JIT编译成机器码”这中间就是性能暴涨的关键一跃。这也是为什么Java程序启动后要“预热”一段时间性能才会达到巅峰状态。4.2 分层编译和热点代码你需要知道的实操常识现代JVM基本都会开启分层编译Tiered Compilation。JIT编译器分C1客户端编译器和C2服务端编译器两层。C1编译速度快优化程度中等适合快速启动场景C2编译慢优化非常激进适用于长时间跑的重型服务。分层编译的策略是一个方法先被解释执行被判定为热点后先用C1编译一遍拿个中等的性能提升如果热度还在继续上升再用C2做激进优化。这也解释了为什么同一个方法在不同时期可能“变了个人”快慢差别巨大。哪些方法算热点JVM用的是“基于计数器的热点探测”它给每个方法维护调用计数器给循环体维护回边计数器。方法在一个时间段内被调用超过阈值默认可能上千次或者循环次数超出阈值就触发JIT编译。你写一个每秒跑几万次的调用方法几乎必然会被JIT察觉得到然后被编译成高度优化的本地代码。热点编译还能顺手做一堆优化其中我特别想说两个。第一个是方法内联。把被频繁调用的小方法体的代码“搬”到调用处内嵌起来省掉方法调用和返回的开销。很多人在网上搜性能优化技巧搜来搜去结论就是“别写多余的方法”其实JIT早就可能帮你内联了。过度拆分方法反而可能增加编译压力性能并不一定更好。第二个是逃逸分析。JVM会分析一个对象会不会“逃逸”出方法作用域。如果对象只在一个方法内部使用没有外部引用触及JVM就可以尝试把它分配在栈上而不是堆上方法结束直接弹栈释放根本不用进GC回收流程。JVM还能因此做锁消除、标量替换等激进优化。不过说实话这些优化细节平时写业务代码不太会直接体会到但遇到阶段性的性能压测理解这些背景能帮你少走很多弯路。注意任何时候都不要把业务逻辑正确性押注在JIT优化上比如你在循环里写了个多余的对象创建JIT可能会帮你优化掉但不同JDK版本、不同CPU架构下的优化决策不完全一致。正确做法是先把代码写干净再让JVM去优化而不是反过来指望JVM替你遮丑。4.3 JIT相关的JVM参数可调的也就那么几个JVM的JIT相关参数其实不需要背太多常用的也就几个-XX:TieredCompilation开启分层编译新版本默认开启-XX:CompileThreshold10000调整热点阈值的调用计数-XX:PrintCompilation打印方法编译日志-XX:-UseCompiler关闭JIT编译跑纯解释模式通常不推荐纯粹为了对比性能。排查问题的时候输出一下Compilation日志看看哪些方法被编译了、编译耗时多少对定位性能瓶颈有直接帮助。另外提一下JVM有“解释器模式”和“编译器模式”启动参数的差别。如果你执行java -Xint就是强制纯解释模式启动快但后面性能拉胯java -Xcomp则是强制所有方法都走编译执行启动特别慢。这两者都不是常态选项生产环境用默认混合模式即可别闲得没事去乱改启动参数。5. 运行时区域与内存管理代码逃不掉的地基5.1 运行时数据区哪些是线程私有哪些是共享JVM启动后在内存里划分了若干区域Java程序的每个线程都在这片区域里活动。按是否线程私有的标准分开就可以理得很清楚。程序计数器Program Counter Register是线程私有的一个小区域存储当前线程正在执行的字节码指令地址。如果一个线程要发生上下文切换恢复现场时就需要靠它找回位置。Java虚拟机栈也是线程私有的每执行一个方法JVM就创建一个栈帧栈帧里存储局部变量表、操作数栈、动态链接和方法出口等。方法调用和返回对应着栈的压栈和出栈。如果方法递归过深栈空间不够就会抛StackOverflowError。本地方法栈和虚拟机栈很像区别在于它为native方法服务。这个区域经常被忽视但老技术栈里会有大量native调用排查问题绕不开。堆和元空间JDK 8之后取代了永久代是线程共享的。堆是绝大多数对象实例存放的地方也是GC的主要战场元空间装类的元信息、静态变量等和堆的回收逻辑不同。JVM内存模型中最关键的那个思想——几乎所有对象都存在堆里由GC统一管理这从一开始就决定了Java程序员不用也没法手动管理内存。5.2 GC机制建议掌握的底层逻辑对Java来说GC不是“性能好坏的附属品”而是内存管理的基石。但垃圾回收本身也是有代价的尤其是回收时经常需要“停顿应用线程”也就是Stop The World这个停顿时间如果太长线上服务就会出现明显的卡顿。现代JVM的GC基本都采用分代收集思想。“分代”的依据是对象的存活特征绝大部分对象很快变成垃圾所以堆被分成新生代和老年代。新生代里又细分Eden区和两个Survivor区大部分对象先在Eden区分配经过若干次Minor GC仍存活的对象慢慢晋升到老年代。新生代GC经常用复制算法速度快但浪费空间老年代对象存活率高就不适合复制更倾向于标记-清除或标记-整理这类算法。具体到垃圾收集器不同版本场景选择很多JDK 8旧的组合里ParNewCMS很常见到了JDK 11及以后G1成了默认选择JDK 17以及更新的LTS里ZGC开始在高吞吐、低延迟场景里表现出色。ZGC有一点特别神奇它能把STW时间压到个位数毫秒级别这个特性对大内存服务非常友好。但GC选型要结合服务特点来定不是无脑用最新的就好。你一个吞吐型批处理任务用G1或者Parallel Scavenge反而简单可靠。5.3 JVM启动参数写模板不如懂语义网上一搜JVM调优参数能搜出无数“JVM调优模板”但很多模板都是老掉牙的。我的原则是参数宁缺毋滥每一个参数你都要知道它到底在干什么。堆大小配置是最基础的三个-Xms2048m设置初始堆大小-Xmx2048m设置最大堆大小-Xmn512m设置新生代大小。实际部署时一般把-Xms和-Xmx设为相同数值避免堆扩容时产生性能抖动。如果堆设置太小对象频繁触发GC系统吞吐量拉胯堆设置太大GC单次回收时间变长反而可能导致超时。要在内存和停顿之间做权衡。此外元空间用-XX:MaxMetaspaceSize来限制防止框架动态生成大量类元信息把内存挤爆线程栈大小用-Xss控制一个进程能开的线程数也受它影响GC日志开启参数建议生产环境一直打开比如-Xlog:gc*配合文件输出以后排查问题没日志你根本无从下手。我强烈建议任何部署到服务器的服务至少把堆大小、GC日志两个配置写清楚其他参数等遇到具体问题再动手。6. 一次典型的线上问题排查压测峰值时接口大面积超时6.1 场景还原我以前处理过一个案例模拟项目背景大致是这样某业务的API服务平时响应几十毫秒某天大促压测接口突然大面积超时CPU飙高部分请求直接报错。当时团队的直觉是“服务器性能不行”马上想加机器。但加了机器之后过了几分钟新节点竟然也出现同样的情况这就不对劲了。我接手后先看三件事看GC日志、看线程栈、看堆内存。命令组合无非就是jstat、jmap、jstack、jcmd这一套。先跑jstat -gcutil看GC情况发现老年代GC特别频繁而且每次Full GC耗时高达好几秒。再看堆内存JVM可用堆分配了4G但老年代占用已经飙到非常危险的水位说明大量对象长期存活。最后jstack看线程状态发现有一批线程大量阻塞在等待同一个锁上面。6.2 顺着运行机制一步步拆解问题最终定位到某段核心方法里有个对象被保存在了一个静态的Map里并发环境下大家都在往这个Map里写数据读写还套了一把重量级锁。堆内存被高频创建的大量中间对象撑爆GC线程一直在拼命回收却挡不住对象产生的速度而大量线程排队抢锁导致请求堆积响应越来越慢。把JVM的堆设到8G问题依然存在因为根本原因是代码逻辑不是内存不足。解决方案是重构那段方法的锁粒度去掉全局锁改成线程局部变量和合适的并发容器同时优化对象创建频率减轻GC压力。改完之后接口响应时间和GC停顿直接掉到正常水平。这个案例给我最大的启发是调优不是先调参数而是先确认问题出在JVM的哪一层。是堆分配问题、GC算法问题、类加载问题、JIT编译问题还是纯粹代码问题方向错了加多少内存都是白搭。6.3 线上调优的实战建议有一句忠告我要放在前面任何生产环境改动JVM参数都要做小流量灰度验证。我见过有人只调了一个-Xmx值上线后发现频繁Full GC因为程序原本依赖大堆缓存数据堆缩了反而导致缓存不断失效重新构建业务数据要回源查询性能反而更差。调优的一般步骤可以归纳为先建立监控基线搞清楚正常情况下的GC频率、堆占用、CPU和响应时间再根据突变的场景高并发、大数据量、内存泄漏做定向分析最后小步修改参数每次只改一个变量对比前后效果。一次改一堆参数出了问题你根本不知道是谁惹的祸。工具链上面jstat查看类加载和GC统计jmap导出堆快照jstack打印线程堆栈jcmd做各类诊断分析这些都是JDK自带的部署环境里基本都可用。复杂一点的场景配合内存分析工具打开dump文件定位问题熟练掌握这套流程对Java开发者是必修课。7. 常见错误与排查思路速查表我在实际的Java开发与问题排查中遇到过很多很典型的“编译运行机制相关错误”这里整理成一张速查表方便你对号入座。现象可能原因处理方向ClassNotFoundException类加载器在classpath里找不到指定类检查依赖是否缺失、打包是否完整、类加载器范围是否对NoClassDefFoundError类在编译期存在运行期加载失败初始化异常或依赖缺失重点排查静态初始化异常、依赖包版本冲突、类加载器重复加载StackOverflowError方法递归层数过深导致虚拟机栈溢出检查递归终止条件和递归深度必要时调大-Xss但根本方法是改算法OutOfMemoryError: Java heap space堆内对象过多或内存泄漏用jmap导出堆dump分析对象引用链路OutOfMemoryError: Metaspace元空间放不下类元信息增加-XX:MaxMetaspaceSize排查动态生成类反射、代理、热部署是否过多频繁Full GC老年代空间持续不足或存在大对象持续存活检查GC日志、堆占用确认是否存在对象过快晋升和内存泄漏这些错误看似五花八门骨子里都能追溯到“编译产生的class文件、类加载器的加载路径、JVM运行时内存划分”这三个层面。所以我还是那句话编译与运行机制是根本问题表象再多根子大多在这。排查的时候我会给团队定一个简单的铁律不要一上来就改参数先把日志和现场捞干净再说。GC日志、异常堆栈、堆dump、线程快照这四样东西是定位问题的起点。没有现场数据参数改来改去只是在碰运气尤其是在Java这种运行期高度动态的平台上盲猜的效率极低。8. 结尾我的一点个人体会写了这么多我想分享一个我个人特别深的体会Java程序的编译与运行机制最大的价值不体现在你背下了多少条JVM参数或者能画出类加载流程图而在于它能帮你建立一套“定位问题时的直觉”。线上出了问题你大概能猜到是堆不够、GC停顿、类加载失败、还是JIT编译异常这个方向的判断力靠刷题刷不出来只能在一次次和代码、日志、字节码打交道的过程里慢慢磨出来。如果今天这篇内容只让你记住三件事我希望是这三条第一javac编译的终点是字节码真正的机器码翻译发生在运行期JIT手里第二类加载不是一启动就全部完成的它的懒加载和双亲委派决定了Java生态里很多诡异行为的底层原因第三堆、GC、参数这些属于运行时资源管理出了问题先看日志再动参数方向比速度重要。最后再补一句实操经验日常开发中养成随手看字节码和GC日志的习惯不需要多频繁一个月认真看一两次就行。看得多了你对Java程序的运行情况会建立一种像老司机对车子引擎一样的敏感度——一听声音就知道哪里不对劲。这比记住任何一份优化模板都管用得多。