ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java编译到运行全程拆解:从javac字节码到类加载与JIT

Java编译到运行全程拆解:从javac字节码到类加载与JIT 我见过太多人背熟了“Java是跨平台语言一次编译到处运行”这句话可一问他“一次编译到底编译成了什么跑到另一台机器上为什么不用重新编译Java程序的运行过程分几步”就支支吾吾了。还有面试菜鸟被问到“JDK、JRE、JVM三者啥关系”当场就成绕口令JDK包含JREJRE包含JVM……然后呢没有了。这篇文章我想把Java从你指尖的.java源文件变成进程里活蹦乱跳的那个对象整个过程掰开揉碎讲清楚。既适合刚入门想搞懂底层逻辑的Java新手也适合准备面试想把这个经典问题答出深度的人。我会结合自己看过的字节码、排查过的NoClassDefFoundError、调过的启动参数把那些网上教程很少讲的细节一并补上。1. 从按下编译到“跑起来”一次完整的Java旅程都在干什么先把整个流程挂在脑子里后面每个环节我再单独展开。你写了一个Hello.java然后执行javac Hello.java再执行java Hello这前后几秒钟里系统其实经历了编译期和运行期两个大阶段。1.1 编译期javac把源码变成class文件编译期的主角是javac也就是Java编译器。它做的事情是把.java文本文件翻译成.class字节码文件。很多人以为编译器就是把高级语言翻译成机器语言Java不一样它翻译出来的不是机器码而是一种JVM能识别的中间指令——字节码bytecode。字节码文件的后缀是.class里面存着魔数、版本号、常量池、字段描述、方法描述、字节码指令等等这些我后面专门用一节来讲。这里有个关键点javac做的是静态编译它在编译期就会做类型检查、语法检查、常量折叠等。如果你写了int x hello;编译直接报错根本走不到运行那一步。所以大家总说Java是静态类型语言安全就安全在这里——很多错误在编译期就被拦住了。但字节码不等于机器码。字节码是给JVM看的“汇编语言”它是平台无关的因为JVM才是真正跟操作系统打交道的那个人。这就是“一次编译到处运行”的本质程序只要编译出一份字节码到任何装了JVM的平台上都能跑JVM负责把字节码翻译成当前平台能懂的机器指令。1.2 运行期类加载、字节码执行、垃圾回收运行期的主角是java命令它做的事情是启动JVM把.class文件加载进来然后执行里面的字节码。运行期又细分为几个阶段类加载JVM通过类加载器ClassLoader把class文件读进内存进行校验、准备、解析最后初始化成JVM里可用的Class对象。字节码执行JVM的解释器一条一条读字节码指令并执行或者交给JIT编译器把热点代码编译成机器码直接执行。运行时服务执行过程中JVM还负责内存分配、垃圾回收、异常处理、线程调度、同步锁等一堆事。你会发现运行期远比编译期复杂。编译期错了会在屏幕上直接打出错误运行期错了就变成Exception、Error线上半夜给你报警那种。1.3 JDK、JRE、JVM三兄弟到底怎么分工用一句话说清楚JVMJava Virtual MachineJava虚拟机负责执行字节码是运行Java程序的“心脏”。它不是一个单独的安装包而是JRE或JDK里面的一个组成部分。JREJava Runtime EnvironmentJava运行时环境包含JVM和你运行程序所需的核心类库比如rt.jar、java.lang、java.util这些基础库。想跑Java程序有JRE就够了。JDKJava Development KitJava开发工具包包含JRE的全部内容再加上开发工具——javac、jar、javadoc、jdb调试器等。想写Java程序、编译Java程序必须装JDK。JDK 9之前JRE是独立可下载的方便普通用户只装JRE跑程序。JDK 9模块化之后Oracle把独立JRE的安装包去掉了现在你装完JDK它就自带一个完整的运行时环境。我常用的确认方式是命令行敲java -version、javac -version两个都能出版本号说明JDK装好了只出java说明只装了JRE现在基本不存在了。提示面试里这个知识点高频出现最好再补一句——JDK JRE 开发工具JRE JVM 核心类库。2. javac到底做了哪些事从词法分析到字节码生成的完整编译链路很多初级程序员觉得javac就是个“翻译器”把Java代码变成“另一种代码”。其实javac是一个成熟的编译器前端它的处理过程可以拆成好几步跟很多教科书里讲的编译原理完全对得上。2.1 词法分析与语法分析把代码变成“句子”再变成“语法树”第一步是词法分析。javac把你写的源代码从头到尾读一遍把连续的字符流切分成一个个token记号。比如public class Hello会被切成public、class、Hello这三个token外加它们各自的类型。这一步还会过滤掉注释和空白符。第二步是语法分析。javac根据Java语言的语法规则把token序列组装成一棵抽象语法树AST。这棵树描述了代码的结构类声明在哪、方法声明在哪、方法体里有哪些语句、表达式又是什么结构。如果这里发现语法错误比如花括号不匹配、少了分号javac直接报错错误信息一般是“语法错误”或“非法的表达式开始”。2.2 语义分析与字节码生成Java编译器比你想的聪明语法树建好之后javac还要做语义分析。这一步会检查类型是否正确、变量是否定义过、方法参数是否匹配等等。比如你写String s 123;编译器会检查出类型不兼容报错你写int i abc.length();这种它会检查方法引用是否正确。语义分析阶段还会做很多常量折叠比如int a 1 2;在编译期就直接算成int a 3;了运行期不会再算一遍。最后一步才是生成字节码。javac遍历语法树为每个类生成一个.class文件。这里有个细节你一个.java文件里如果定义了多个类编译后会生成多个.class文件。比如你写了Hello.java里面含Hello和Helper两个类运行javac Hello.java后你会看到Hello.class和Helper.class同时出现。2.3 编译期能查出的错误 vs 只能到运行期才爆的错误这是面试里一个很爱考的区分点。编译期能发现的错误包括语法错误少了分号、括号不匹配类型错误把String赋值给int调用不存在的方法部分常量错误不可能到达的代码某些版本的编译器会告警或报错检查型异常未捕获checked exception就是编译期必须处理或抛出的异常运行期才会暴露的错误包括除零异常ArithmeticException空指针NullPointerException数组越界IndexOutOfBoundsException类找不到ClassNotFoundException/NoClassDefFoundError类型转换错误ClassCastException我记得刚工作那会儿有个很经典的困惑Integer.parseInt(abc)编译完全没问题一跑就炸NumberFormatException因为编译期根本无法预知你传入的字符串到底是不是数字。这就是编译期和运行期的天然分界线。3. class文件里到底装了什么扒开字节码看看里面的结构.class文件是个二进制文件用文本编辑器打开是一堆乱码但用javap或者十六进制工具看里面别有洞天。我建议每个学Java的人都至少用javap -c反编译一次自己的代码看完你对“字节码”三个字的理解会完全不同。3.1 class文件的头几个字节魔数和版本号的故事任何一个.class文件开头四个字节一定是CA FE BA BE十六进制显示就是cafebabe。这是Java设计的魔数用来快速识别文件类型。JVM在加载class文件时会先检查这个魔数不对就直接拒绝加载抛出ClassFormatError。紧跟着魔数后面的是次版本号和主版本号。比如你编译用的JDK 8主版本号是52JDK 11对应55JDK 17对应61。不同大版本的JVM能加载的class文件版本上限不同新版JVM一般能向下兼容加载旧版本编译出的class文件但旧版JVM加载新版class文件会报UnsupportedClassVersionError。这就是为什么你本地用JDK 17编译的class丢到只有JDK 8的服务器上跑会看到那个很经典的“unsupported class file major version 61”错误。3.2 常量池class文件里的“字符串仓库”和符号引用常量池是class文件里最核心、最庞大的区域。它存放了类名、方法名、字段名、字符串字面量、接口名等等各种常量信息。你把class文件里所有的常量池条目理解成一个“仓库”后续所有的字节码指令都用索引去引用这个仓库里的数据而不是直接把长的字符串写死在指令里。举个例子代码里写了Hello World这个字符串它会被放进常量池运行时通过ldc指令加载到栈上。代码里调用了System.out.println方法名和描述符也会在常量池里以符号引用的形式存在——注意编译期只记“符号引用”不直接记“内存地址”这个特点直接决定了Java类加载机制的灵活性也解释了为什么类的解析发生在运行期而不是编译期。3.3 字段表、方法表和字节码指令区常量池后面是访问标志public/final之类的修饰符、本类名、父类名、接口列表、字段表和方法表。每个方法在方法表里除了方法名和描述符还会带一个属性表里面存着Code属性——这就是方法体对应的真正字节码指令序列。我用一个非常简单的例子让你直观感受。新建一个Add.javapublic class Add { public int add(int a, int b) { return a b; } }然后执行javac Add.java再执行javap -c Add你会看到public int add(int, int); Code: 0: iload_1 1: iload_2 2: iadd 3: ireturn看不懂我用白话解释一下iload_1是把局部变量表的第1个位置的int值也就是入参a压入操作数栈iload_2同理把b压入栈iadd让JVM把栈顶两个整数取出来相加结果压回栈顶ireturn把栈顶返回值作为方法返回值返回。你看Java的一次a b在字节码层面就是这么朴素。我的建议是初学者不需要背每条字节码指令的含义但最好亲自动手javap -c扒一次自己写过的小项目里的几个类感受一下源码到指令的映射关系这对后续理解JIT、栈帧、方法调用都有帮助。4. 类加载class文件是怎么一步步变成可以直接用的对象java Hello执行时JVM并不会把所有类一口气全部加载进来而是用到了才加载。这种懒加载机制的好处是启动快、内存省。但如果你第一次用到某个类时才去加载而它又加载失败那就只能运行期报错了。4.1 加载、验证、准备、解析、初始化这五步一个都不能少类加载的完整过程分为五个阶段网上很多教程把这个过程概括成“加载-连接-初始化”三阶段连接再拆成验证、准备、解析。我建议你记五阶段的版本因为面试官一旦深挖就会问细节。加载类加载器根据类的全限定名比如com.example.Hello去文件系统、jar包、网络等位置找到对应的.class文件读入内存生成一个java.lang.Class对象。这一步JVM并没有规定必须从哪里读所以才有各种花式类加载器比如从数据库加载class、从网络加载class的玩法。验证JVM当保安检查这个class文件格式是否合法、字节码是否合规、符号引用是否有效。前面说的cafebabe魔数检查就发生在这一步之前或之中。准备为类的静态变量分配内存并设置默认零值。比如static int count 5;在准备阶段先把count设为0真正赋值为5要到初始化阶段。解析把常量池里的符号引用替换为直接引用。比如常量池里有个“System.out”的符号引用解析阶段就要让JVM真正定位到这个字段的具体内存地址或引用之后才能直接访问它。初始化执行类的构造器clinit执行静态代码块和静态变量的显式赋值。这个时候类才真正“活”了。我举个例子你就知道这些阶段的意义了。面试常问“static final int CONSTANT 100;这个常量什么时候被赋值”答案是编译期就被折叠了在准备阶段直接放入常量池初始化阶段都不需要处理它。而static int value compute();这种必须到初始化阶段才能执行compute()。4.2 双亲委派模型为什么自己写了个java.lang.String也顶不上去类加载器是整个类加载机制里最有故事的部分。JVM内置了三个主要的类加载器Bootstrap ClassLoader启动类加载器用C实现JDK 9之前负责加载JAVA_HOME/lib目录下的核心类库比如rt.jar里的java.lang.*、java.util.*。这个加载器在Java代码里并没有对应的类你打印String.class.getClassLoader()得到null就是因为它是Bootstrap加载的。Platform ClassLoader平台类加载器JDK 9模块化之后出现替代了原来的Extension ClassLoader负责加载一些平台模块。Application ClassLoader应用类加载器负责加载你自己写的classpath下的类也就是项目代码和第三方jar包里的类。这三者之间的关系是双亲委派模型当一个类加载器接到加载某类的请求时它首先不会自己去加载而是把请求委托给父类加载器层层往上抛直到Bootstrap ClassLoader。Bootstrap发现加载不了比如这不是核心类再往下返回到子加载器尝试加载。为什么要这么设计核心是为了安全和避免重复加载。假设你写了一个java.lang.MyString如果让你自己的类加载器先去加载就能替换核心类库了那简直灾难。有了双亲委派任何java.*开头的类都会被强制交给Bootstrap加载你写的那个同名类永远不会被系统优先采用。当然双亲委派模型也有被突破的情况比如Tomcat这种Web容器为了隔离不同应用会使用自有的WebAppClassLoader优先加载自己WEB-INF/classes下的类这就是“不守规矩”的典型案例。4.3 实战中的类加载排查工具和技巧线上遇到NoClassDefFoundError或ClassNotFoundException很多人第一反应是“jar包没带全”。但很多时候是运行时类加载链路出了问题。这里分享几个我真实用过的排查手段用java -verbose:class Hello启动程序JVM会把每个加载类的加载过程打印到控制台你会看到类似[Loaded com.example.Hello from file:/...]的输出。定位“类有没有被加载到、从哪个jar加载的”非常好使。用-XX:TraceClassLoadingJDK 8或者-Xlog:classloadinfoJDK 9可以在生产环境临时打开类加载追踪日志看具体哪个类加载失败、为什么失败。如果看到NoClassDefFoundError别第一时间只怀疑缺jar它和ClassNotFoundException有本质区别。ClassNotFoundException是主动用Class.forName之类的API去找类找不到NoClassDefFoundError是类的加载曾经成功过但初始化失败或者它在编译期存在于classpath但运行期不在。比如编译时依赖了commons-lang运行时没带这个jarJVM在解析时找不到就会抛NoClassDefFoundError。我在之前一个项目里排查过一次诡异问题开发环境一切正常测试环境一启动就报NoClassDefFoundError: com/fasterxml/jackson/annotation/JsonIgnore。当时我们第一反应是jackson-annotations没打进去检查依赖树发现明明在。后来用-verbose:class看了才发现真正问题是我们项目的某个jar包用exclude把jackson-annotations排除了但编译时它还存在所以编译没问题运行时解析却找不到——这种“编译期在、运行期丢”的坑在Maven/Gradle版本冲突里非常常见。5. 运行时执行机制解释执行、JIT即时编译和Java“慢”的真相类加载完毕JVM终于可以开始执行字节码了。但这里有个让很多初学者颠覆认知的事实JVM执行字节码并不是像CPU执行机器码那样简单粗暴。它有两种执行方式解释执行和即时编译。5.1 解释执行一行一行翻译慢但启动快JVM刚启动时字节码是由解释器逐条执行的。解释器的工作方式就是读一条字节码指令翻译成机器码对应的操作执行掉再读下一条。你可以把它想象成一个同声传译句子来一句翻一句。好处是省去了编译等待时间类加载完就能立刻跑起来坏处是每条指令都要经过“翻译”这层整体执行速度远低于直接把机器码在CPU上跑。5.2 JIT即时编译把热点代码直接变成机器码HotSpot虚拟机真正的杀招是JITJust-In-Time即时编译器。JVM运行时会统计每个方法被调用的次数、循环执行的次数一旦发现某段代码是“热点代码”就把这段字节码整个编译成当前平台的机器码缓存起来下次直接执行机器码不再一条条解释。这就像同声传译翻着翻着发现某个发言人老说同样的话干脆把他的演讲稿全文背下来后续直接背诵效率翻倍。HotSpot里主要有两个层次的JIT编译器C1Client Compiler编译速度快优化程度略低适合桌面应用这种追求启动速度的场景。C2Server Compiler编译速度慢但优化深度更高允许更多的逃逸分析、方法内联、循环展开等适合服务端长时间运行追求吞吐量的场景。JDK 8以后还有分层编译简单说就是先C1快速编译让程序先热起来后续再让C2深度优化。JIT的威力非常巨大。网上经常有人说“Java慢”可实际上一个运行了很久的Java服务端程序热点方法经过C2优化后性能跟C/C编译出的原生代码差距并不大有些场景甚至能做到反超。真正让Java显得“慢”的往往是启动时的类加载和JIT预热所以线上JVM都有“预热”一说——刚重启完先别急着全量压测等JIT把热点代码编完。5.3 我建议你用这些命令看看JIT是否在工作学习JIT最直观的方法是用JVM参数观察编译情况java -XX:PrintCompilation Hello这个参数会输出JIT编译的记录你会看到类似下面这种结果50 1 0 java.lang.String::hashCode (55 bytes) 73 2 0 java.lang.String::equals (50 bytes) 108 3 n 0 java.lang.System::arraycopy (native method) 124 4 1 com.example.Hello::add (5 bytes)看到com.example.Hello::add出现在打印里说明你的add方法已经被JIT编译成机器码了。你还可以用-XX:PrintCompilation -XX:UnlockDiagnosticVMOptions -XX:PrintInlining看方法内联的详细情况不过这些诊断参数在不同JDK版本上位置有变化自己本地试的时候注意别用在高版本JDK上直接无脑加参数部分参数JDK 9以后需要在-XX:UnlockDiagnosticVMOptions基础上才开放。还有一个跟运行性能强相关的概念逃逸分析。JIT分析某个对象是否只在方法内部使用不会逃逸到外部那么它可以把这个对象直接分配到栈上而不是堆上或者直接标量替换成几个变量甚至消除锁。这就是为什么有时候你写一堆局部小对象GC压力并不大JIT帮你优化掉了。6. 从编译到运行之间最常踩的坑不在classpath、版本错乱、构件冲突讲完原理分享几个我在实际项目里反复踩过、身边同事也经常踩的坑。这些问题本质上都是“编译期和运行期的不一致”造成的。6.1 javac能编译java运行却找不到类先查classpath常见场景你用javac -d classes src/com/example/Hello.java把类文件编译到了classes目录然后执行java com.example.Hello结果报错“找不到主类”。原因很简单JVM默认只在当前目录下找类但你的class文件在classes子目录里而且它希望类名和目录层级匹配。正确做法是javac -d classes src/com/example/Hello.java java -cp classes com.example.Hello-cp就是指定类路径classpath告诉JVM去哪里找.class文件。新手最容易踩的坑就是把-cp漏了或者类路径里的分隔符写错——Windows分号Linux冒号。6.2 编译版本比运行版本高直接UnsupportedClassVersionError这个坑的典型症状是本地开发用的JDK 17服务器部署环境用的JDK 8一启动就报Exception in thread main java.lang.UnsupportedClassVersionError: com/example/Hello has been compiled by a more recent version of the Java Runtime根本原因是class文件的版本号前文说的主版本号超过了运行JVM能接受的最大版本。解决办法有几个让编译和运行使用相同版本JDK最省事。用-source和-target参数降级编译版本注意JDK 9之后还要用--release替代这两个参数否则只降字节码版本但引用的JDK API还是高版本的运行时会报NoSuchMethodError之类的怪错。升级服务器运行时版本到不低于编译版本。这里再补一个容易忽略的细节很多人以为-source 1.8 -target 1.8就万事大吉其实如果用了--release之前的高版本API还是会在运行期炸开。现在推荐统一用javac --release 8它同时限制语言特性和API引用层面都按Java 8来编译。6.3 Maven/Gradle依赖冲突导致的编译期、运行期行为不一致Maven项目里A.jar依赖B-1.0C.jar依赖B-2.0Maven仲裁结果选了B-1.0编译期可能没问题运行期如果C.jar用到了B-2.0才有的类或方法就会抛NoSuchMethodError、NoClassDefFoundError。这种“编译期正常、运行期爆炸”的问题我在实际项目里见过不下十次。推荐的排查方法是mvn dependency:tree看依赖树里同一个groupId/artifactId是否存在多版本。如果要强制指定版本在pom里用dependencyManagement统一管理版本比到处加exclude要干净得多。6.4 一个冷门但有用的操作自己编译完用javap验证Java版本我有个习惯每次发版前都会用javap -verbose看一眼关键类的class文件版本javap -verbose com.example.Hello | grep major看到major version: 52就是Java 861就是Java 17。这个命令在排查“为什么本地能跑、测试环境跑不了”这类问题的时候特别快。7. 从“会用”到“懂它”我建议你亲手做的一次完整实验文章最后给你留一个我当年做过的实验做完你对整个编译运行过程的理解会彻底具象化。7.1 实验步骤亲手看一次类加载全过程准备一个最简单的类public class LifeCycleDemo { static { System.out.println(静态代码块执行了); } public static void main(String[] args) { System.out.println(main方法开始执行); Other other new Other(); } } class Other { static { System.out.println(Other 被初始化了); } }然后按顺序执行以下命令javac -d classes LifeCycleDemo.java java -verbose:class -cp classes LifeCycleDemo你会看到一大串类加载日志重点关注两点LifeCycleDemo在什么时候被加载日志里搜索它的名字会出现一次加载记录。Other并不是在启动时加载的而是在main方法里第一次new Other()之前才被加载紧接着你会看到“静态代码块执行了”和“Other 被初始化了”的输出顺序。这个实验直接证明了两件事JVM按需加载类静态初始化一定是类第一次“主动使用”时才触发。7.2 加一个参数把字节码执行过程也“看见”再执行一次java -XX:UnlockDiagnosticVMOptions -XX:PrintBytecodeVersion -cp classes LifeCycleDemo这个参数组合不一定每个JDK版本都支持不支持就换用java -XX:PrintCompilation -cp classes LifeCycleDemo观察热点方法被JIT编译的记录。你写的main方法可能是第一批被编译的日志里会冒出LifeCycleDemo::main这个条目。说到底Java从编译到运行这条路跟那些“直接编译成机器码”的语言最大的区别就在于中间夹了一个字节码层。这个中间层带来了跨平台能力、安全校验、动态加载这些好处也引入了类加载、JIT预热这些你需要额外理解的概念。在你的学习路上知道javac干了什么、ClassLoader什么时候出现、JIT怎么插一脚你才算真正把Java这门语言“看透”了一半。我个人的体会是很多高级问题——内存泄漏、GC调优、线上故障排查——追到最后都会遇到类加载和字节码。早点把这块地基打牢后面你能省出大把踩坑时间。
返回列表