
写这篇东西的起因是前两天又有同事跑过来问我“我写了个注解结果反射的时候拿不到Class文件里也没看到会不会是JVM把它吃了”这个问题看着小但牵出来的链路特别长Java源码怎么变成Class文件、注解在字节码里怎么存、类加载的时候怎么读。我索性把Class文件这条线完整地梳理了一遍这也是我这么多年排查JVM问题的重要锚点。如果你刚开始学JVM或者写了一段时间Java但没细看.class里到底是什么再或者你在做Agent、字节码增强、线上问题排查这类偏底层的工作这篇内容都值得过一遍。我不会只讲概念会直接掰开一个Class文件逐字节看也会把注解丢失、版本不兼容、Metaspace膨胀这类实战坑讲明白。1. 先搞清楚Class文件在JVM技术栈里的真正位置1.1 它是JVM的“通用语言”不是Java的专属产物很多人有个错觉觉得Class文件是Java编译出来的所以是Java的东西。这个理解不完整。Class文件是JVM规范里定义的一种二进制格式JVM只认得这个格式谁编译出来的它不管。Java、Kotlin、Groovy、Scala、Clojure都能编译成Class文件。你甚至可以用文本编辑器手写字节码只要格式合法JVM一样认。打个不全但贴切的比方Class文件像是JVM世界的“普通话”不同语言就是各地的方言。你写Java、写Kotlin本质都是把自己的源码“翻译”成方言再统一整成普通话让JVM这个“听众”听懂。这也是为什么Kotlin能和Java无缝互调因为它们最终都落在同一个字节码世界里。也正因如此想真正理解JVM绕开Class文件是不现实的。类加载、动态代理、AOP、热部署、Agent这些看起来高级的能力底层全是在和Class文件打交道。1.2 JVM、JRE、JDK的关系几句话讲透网上天天有面试题问JVM和JRE的区别我发现很多人背了定义但没串起来。前面已经提了几个热词这里就得把关系理顺。JDK是Java Development Kit是给开发者用的完整工具包里面包含了编译工具javac、打包工具jar、诊断工具jstack/jmap/jstat这些还包含一个JRE。JRE是Java Runtime Environment是给运行Java程序用的环境它里面包含JVM和Java核心类库。JVM是Java Virtual Machine是真正执行程序的那部分人话说是“跑字节码的虚拟机”。关键点在于Class文件不是给操作系统直接执行的而是给JVM执行的。跨平台靠的是JVM对Class文件的统一解释不同操作系统装不同的JVMClass文件不用变。这里有个经常被误解的点Java跨平台不是“源码跨平台”源码在哪都得先编译成Class真正跨平台的是这个统一的字节码产物。1.3 JVM编译器有几种别再说“编译器只有javac”热词里有“java jvm编译器有几种”这问题看着基础其实问得很细。如果只答“javac”面试官容易觉得你停留在表面。实际上一套完整的Java技术栈里和编译相关的行为至少有三类。第一类是前端编译器最常见的就是javac也把Eclipse JDT里的ECJ算进去。它的职责很明确把.java源码翻译成.class文件做语法检查、注解处理、生成字节码。这一阶段产出的是Class文件。第二类是即时编译器也就是JIT运行期在JVM内部工作的C1、C2还有Graal JIT。它们负责把热点方法的字节码进一步编译成机器码提升运行速度。第三类是提前编译器AOT比如jaotc可以把Class文件或源码直接编译成原生二进制跳过运行期解释或JIT编译。理解这个分类Class文件的位置就清晰了。javac生产它JIT消费它AOT甚至能绕开它。你问“JVM编译器有几种”时如果能把这三类以及它们和Class文件的关系讲清楚比死记答案有说服力得多。1.4 用十六进制认识一下CAFEBABE任何Class文件的头4个字节一定是十六进制的CAFEBABE也就是咖啡宝贝。这是Class文件的魔数JVM用它来快速判断文件是不是一个合格的Class文件。你随便找一个.class文件用xxd或者Hex Fiend打开都能看到这个标志。紧接着是次版本号和主版本号各占2个字节。主版本号决定了这个Class文件是给哪个JDK版本用的52对应Java 855对应Java 1161对应Java 1765对应Java 21。所以“UnsupportedClassVersionError”本质上就是版本号不匹配你用一个老JVM去加载一个高版本编译出来的Class文件JVM一看版本号直接拒绝了。看十六进制只是第一步我得先说明一件事Class文件里存的所有字符串、引用、方法描述符都不是直接写明文而是通过常量池来管理的。这个结构是整个Class文件的灵魂我们下一步把它剥开。2. 逐字节看Class文件常量池才是真正的主角2.1 Class文件总体结构一张表说清楚Class文件整体是一张线性的二进制表没有分隔符靠固定的顺序和每一个数据项的长度来确定边界。作为参考它的顶层结构大概是这样的。数据项说明magic魔数固定CAFEBABEminor_version / major_version次版本号、主版本号决定JDK兼容版本constant_pool_count / constant_pool常量池数量与内容access_flags类的访问标志public/final/abstract等this_class / super_class当前类名索引、父类名索引interfaces_count / interfaces接口数量和索引列表fields_count / fields字段数量和字段表methods_count / methods方法数量和方法表attributes_count / attributes属性数量和属性表字段表里每一项都包含访问标志、简单名称索引、描述符索引和属性表方法表同理但方法表里的属性表里通常藏着Code属性这才是真正干活的字节码指令。2.2 常量池是“数据仓库”所有引用都靠下标常量池是Class文件里体量最大、也最容易被忽略的部分。Java源码里出现的类名、方法名、字段名、字符串字面量、访问修饰符全都被收进常量池统一编号。比如方法里要调用System.out.printlnClass文件不会直接把“System.out.println”这串字符写进Code里而是存一个对常量池项的引用运行期再解析。先后我一起看一个简化过的例子。比如一个被调用方法是print(String)V在常量池里你会看到类似这种结构Utf8类型的“print”表示方法名Utf8类型的“(Ljava/lang/String;)V”表示方法描述符NameAndType指向这两个Utf8Methodref再由类名和NameAndType组合起来。任何一个Class文件里这个链条无处不在。理解它你再看JVM规范里关于Methodref、Fieldref、InterfaceMethodref这些项就不会晕了。这也是为什么javap反编译时满屏的“#2 #5 #12”那些数字不是随机的每个数字都对应常量池里的一个槽位。我在实际排查中发现很多人看常量池觉得枯燥其实是没把它当“仓库”。你只要知道一句口诀就够了在Class文件里凡是需要引用的名字最终都要落到常量池的一个下标上。常量池看懂Class文件的结构就懂了一大半。2.3 访问标志、字段表、方法表与Code属性类的访问标志由2字节的位掩码构成ACC_PUBLIC对应的值是0x0001ACC_FINAL是0x0010ACC_ABSTRACT是0x0400ACC_SYNTHETIC是0x1000。位掩码的意思就是多个修饰符叠加时直接把各数值按位或。比如一个public final类访问标志就是0x0001 | 0x0010 0x0011。方法表里的Code属性值得特别注意。它里面存字节码指令、异常表、行号表、局部变量表。行号表表示哪一行Java源码对应哪些字节码偏移这是异常堆栈能显示“某某类第多少行”的原因。局部变量表则是存方法参数和局部变量的槽位描述。JIT做各种优化时大量依赖这些附加信息。我见过不少人在分析性能问题时盯着系统调用的耗时看半天忘了先看方法的字节码长什么样。用javap -c就能把方法展开成一条条字节码指令。字节码是JVM执行的中间形态它不像汇编那么底层但也不像Java那么抽象。看懂它你就能回答很多“这段代码编译后到底执行了什么”的疑问。2.4 一条命令把Class文件“扒光”最常用也最推荐的工具是javap它是JDK自带的Class文件反编译工具。想快速看一个Class文件整体信息我第一次用时会执行javap -v -p -c -s YourClass.class-v表示verbose打印堆栈、本地变量和常量池的详细信息-p表示显示私有成员-c表示对方法进行反汇编输出字节码-s表示打印方法签名和字段签名。如果想要更原始的信息可以配合xxd先看二进制再用javap对照。实际经验是先跑javap -v看版本号和常量池确认这个文件是不是你预期那个版本编译的再跑javap -c看方法体确认某个逻辑有没有被编译器做掉或做成了什么样子。对一些“明明写了代码但运行行为不对”的状况这一步经常能救命。3. 从加载到内存模型Class文件进入JVM之后发生了什么3.1 类加载的五个阶段与双亲委派Class文件在磁盘上只是文件真正跑起来前JVM要把它加载进来。类加载的生命周期大致分成加载、验证、准备、解析、初始化五个阶段。加载阶段JVM按全限定名读入Class文件二进制流把它转换成方法区里的运行时数据结构并在堆里生成一个Class对象作为访问入口。验证阶段检查格式、语义、字节码合法性这是CAFEBABE魔数的意义所在防止非法文件混进来。准备阶段给静态变量分配内存并设置零值。解析阶段把常量池里的符号引用替换成直接引用。初始化阶段才真正执行静态初始化器和静态字段赋值。这里绕不开双亲委派。JVM的类加载器有层级关系启动类加载器Bootstrap负责JDK内部核心类平台类加载器Platform负责模块化类应用类加载器Application负责classpath下的类。一个类要被加载时会先让父加载器尝试父亲加载不了才轮到儿子。这样做的核心价值有两个一是避免核心类被应用层的同名类替换二是保证同一个类在JVM里只有一个加载结果。如果你在做框架开发想打破双亲委派那就得自己继承ClassLoader并重写loadClass但这是反模式一般不建议在业务代码里瞎搞。3.2 Class对象在JVM内存模型里的落点热词“jvm内存模型”经常被讨论但很多人把Java内存模型JMM和JVM运行时内存区混在一起。JMM是抽象并发语义讨论的是线程与主内存间的可见性规则JVM运行时内存区是具体存储区域比如堆、虚拟机栈、方法区。这里要讲的是后者。Class文件被加载之后它的元数据——方法信息、字段信息、常量池解析结果、注解——是放在方法区里的。JDK 8之后方法区的落地实现是Metaspace也就是元空间它使用本地内存不再像以前PermGen那样有固定的默认上限。每一个被加载的类都会在堆里对应一个java.lang.Class对象这个对象是访问该类的元信息的“门把手”。这里有一个很微妙的地方你在代码里写“User.class”拿到的那个Class对象本身是在堆里的一个普通对象但它背后代表的类元数据却在Metaspace里。对象头里还有一个指向Klass的引用用来在运行时快速找到这个对象的类型信息。这也是为什么你能对任意对象调用getClass()JVM得靠对象头里那个Klass指针去定位到Class文件解析出来的元数据。3.3 Kotlin写Java Agent从Class文件到字节码增强热词里有一条“Kotlin 快速上手在 JVM 上跑通一个 agent”这个场景特别适合用来理解Class文件的“活学活用”。所谓Java Agent本质上就是在JVM启动之后、正式类加载前后插一脚通过Instrumentation接口对Class文件做转换。这个转换不是改源码而是直接改字节码。写Agent的思路大概是这样的打包时在MANIFEST.MF里声明Premain-Class指向一个包含premain方法的主类premain方法里拿到Instrumentation实例调用addTransformer注册一个转换器ClassFileTransformer.transform方法会在每个类被加载时收到原始字节数组你在这里面修改后返回新的字节数组。Kotlin写这个为什么方便因为Kotlin编译出来的同样是标准Class文件方法、注解、属性映射逻辑都还能保留。你可以用data class去定义Agent配置用协程去处理耗时逻辑最后打包成jar挂-javaagent启动。由于最终产物是字节码JVM根本不在乎你是用Java还是Kotlin写的。真正的技术点在transform里怎么做字节码增强。最常用的是ASM库它是直接操作节点树和指令的性能好但API难用。另外一个选择是ByteBuddy它在ASM之上做了封装写起来舒服很多。举个例子追踪某个方法耗时用ByteBuddy的Advice机制在进入和退出两个切点上织入System.nanoTime差值的逻辑几行就能搞定。我踩过的一个坑是要修改的类正在被当前ClassLoader加载过程中transform里如果再去触发其他类的加载可能死锁。经验做法是做好类名过滤无关紧要的类直接返回null或用ClassReader快速跳过。还要注意ClassWriter的flags如果用COMPUTE_FRAMES能自动计算栈帧但性能会慢不少如果你明确知道字节码不会改变栈帧结构可以用COMPUTE_MAXS能省很多开销。所以Agent技术并不神秘本质就是“类加载前把Class文件改写一下”。理解这一点你再看Arthas、SkyWalking这类工具的底层原理会突然通透很多。它们做的就是字节码注入只是做进了更完善的产品里。3.4 版本窗口UnsupportedClassVersionError是怎么来的“编译的时候好好跑起来就报UnsupportedClassVersionError”这种问题新人基本都会遇到一次。本质上就是Class文件的主版本号高于当前JVM支持的版本。Java官方保持向后兼容高版本JVM能读低版本Class文件但反过来不行老JVM没法理解新版Class文件里的新字节码指令。比如你用JDK 17的javac编译默认主版本号就是61。把它扔到JDK 8的服务器上跑JVM版本号只支持52一看61大于52直接拒绝加载。解决办法有几种。第一降低编译版本但注意-source/-target只控制语法和字节码版本不能阻止你用到高版本JDK的新API后者照样会在运行期报NoSuchMethodError。第二用--release参数比如javac --release 8它会同时限制API使用和版本输出是更安全的选择。第三升级线上JDK版本一劳永逸。排查时最快的方式是跑一句命令javap -verbose YourClass.class | grep major看到主版本号再对照当前JVM版本问题定位就完成了。4. 实战踩坑注解丢失、字节码修改与Class文件异常排查4.1 Override注解“丢”了先分清三种Retention热词里有一条“class文件overrider注解为什么会丢失”我非常确定提问者说的就是Override。先说结论Override注解的Retention是SOURCE它本来就不会进Class文件所以不是“丢了”是人家压根不打算留。Java注解的保留策略有三种。SOURCE的注解只存在于源码里编译时就被丢弃典型代表就是Override和SuppressWarnings它们只是给编译器看的提示。CLASS的注解会被写进Class文件的注解属性里但JVM运行时不加载它们默认的Retention就是CLASS这也是很多自定义注解“编译在但运行时反射拿不到”的原因。RUNTIME的注解不仅进Class文件还会在运行期被JVM读取反射API能拿到比如Transactional、GetMapping这些。所以排查自定义注解反射拿不到的时候第一件事就是查注解定义里的Retention。如果你写的是Retention(RetentionPolicy.CLASS)那在运行时getAnnotation是拿不到的要改成RUNTIME。4.2 另一个常见元凶注解Target/Inherited与Kotlin use-site target除了Retention还有两个很容易忽略的点。一个是Inherited。这个注解只对“类上的注解”的继承起作用方法、字段、参数上的注解即使加了Inherited也不随继承传递。很多人以为父类方法上的注解子类重写后还会在反射里出现其实不会。Spring的Transactional之所以能在子类方法上生效是因为Spring走了代理和AOP它读的是目标方法注解并自己做了传播处理跟JVM原生注解继承是两回事。另一个是Kotlin的use-site target。Kotlin代码里的annotation写在属性上编译成Class文件时这个注解到底落在哪个目标上取决于你有没有指定use-site target。想让它成为字段注解要写field:NotNull若写get:NotNull就成了getter方法上的注解。如果你在Kotlin和Java混编的项目里做反射很容易因为use-site没指定而对不上目标表现就是“Java那边反射明明该看到却没有”。这个坑我在跨语言工具里踩过不止一次排查方式永远是反编译看字节码里的RuntimeVisibleAnnotations到底挂在哪个位置。4.3 字节码修改工具链导致的“隐性丢注解”还有一种注解丢失既不怪Retention也不怪继承而是字节码处理工具在改写Class文件时把注解洗掉了。混淆工具、打包工具、字节码AOP框架都可能干这种事。ProGuard/R8在做代码压缩和混淆时默认会丢弃注解信息。Spring框架大量使用注解驱动如果没保注解启动时一堆Autowired失效这是非常经典的线上事故。解决方案是在混淆规则里写上类似-keepattributesAnnotation之类的指令。ASM做类转换时如果你只想改方法体但使用了自定义的ClassVisitor重写visitMethod时忘了调用visitAnnotation把注解Visitor转发出去注解信息也会悄悄消失。一个实际建议在改造Class文件的管道里最后一步加上“注解完整性校验”。你可以先跑一个不改动的控制组用JDK自带的能力或ASM的CheckClassAdapter做一次扫描对比改造前后的RuntimeVisibleAnnotations数量。我在做一个跨JVM语言的Agent时就是这么抓到丢注解问题的不是源码层丢了是ASM的ClassNode处理路径上有一段没有遍历AnnotationNode。4.4 常见Class文件相关异常速查表做应用排障多了会发现Class文件相关的异常就那么几个特征是重复且好认。我总结成了一张速查表。异常/错误典型原因排查方向ClassNotFoundException运行时找不到目标类检查classpath是否完整、依赖是否缺失NoClassDefFoundError类存在但初始化失败或依赖类缺失往前翻日志找Caused by多数是静态初始化异常UnsupportedClassVersionErrorClass版本高于JVM版本javap看major版本调整编译版本或JVMClassFormatErrorClass文件结构非法确认文件有没有被截断、有没有被错误处理VerifyError字节码验证失败栈帧或类型不兼容多发生在字节码增强代理类时检查ASM转换逻辑LinkageError类的重复加载或签名不一致检查依赖版本冲突是否多个ClassLoader加载同名类这里要特别提一下NoClassDefFoundError和ClassNotFoundException的区别。后者是找不到类前者是类其实存在但加载过程中因为某个依赖缺失或静态初始化抛了异常导致JVM认为这个类不可用。排查NoClassDefFoundError时不能只看这一行一定要往前翻真正的Root Cause往往在第一条异常链里。5. JVM调优与Class数量治理Metaspace才是隐藏焦点5.1 Class数量膨胀为什么危险很多人聊JVM调优开口就是堆大小、GC策略但很少有人主动提Metaspace。实际上Class文件的加载结果就是Metaspace里的类元数据。如果一个应用在运行期不断创建新的类Metaspace就会持续增长。旧时代的PermGen空间溢出是著名的OOM来源到了JDK 8之后这个坑变成Metaspace OOM。哪些操作会“造类”JDK动态代理、CGLIB子类代理、热部署框架、Groovy等动态脚本引擎、大量lambda捕获外部变量导致动态类生成还有Spring这类依赖CGLIB做增强的框架。脚本引擎按次执行可能每次都生成新类如果调用频率高且不清理Metaspace涨得飞快。判断是否是因为Class数量过多导致Metaspace紧张一个快捷方法是用jstat看一眼Metaspace的增长趋势同时通过GC日志对比类加载数。如果加载数量飙升、卸载数量几乎为零基本就是“只进不出”离OOM不远了。5.2 动态类生成CGLIB、JDK代理与Kotlin编译常见生成点先说JDK动态代理。它要求目标类实现接口运行时在内存里生成一个实现该接口的Proxy类这个生成类是一个全新Class继承自java.lang.reflect.Proxy。CGLIB则是生成目标类的子类靠继承和覆写方法实现增强所以目标类不能被final修饰。这些代理类本身也是标准Class文件你可以通过在启动参数里加一个JVM开关把生成的类落盘JDK代理类可以用-Dsun.misc.ProxyGenerator.saveGeneratedFilestrue保存到磁盘CGLIB生成类则可以通过-Dcglib.debugLocation目录来输出再用javap或IDEA反编译。做框架或插件开发时看这些生成类和看业务Class一样重要。Kotlin这边也有生成点。顶层函数会被编译成以文件名加Kt后缀命名的类data class会生成componentN、copy、equals/hashCode/toStringlambda也会被编译成Function接口的实现类。你要是发现某个应用里Class数量多到一个诡异的值先别急着慌很可能是Kotlin的语法糖在生成辅助类这本身不算问题但反过来也说明Class数量会随着这类语言特性明显上升。5.3 调参方向与验证手段针对MetaspaceJVM提供几个常用参数。第一个是-XX:MetaspaceSize它表示Metaspace首次触发GC的阈值不是上限。第二个是-XX:MaxMetaspaceSize这才是硬上限默认情况下受本地内存限制你可以显式设置一个值保护进程。第三个是-XX:MaxMetaspaceFreeRatio控制GC后Metaspace空闲容量百分比。排查时我会配合这几个命令jstat -gc pid看M列和MC列的数值变化。MC表示当前Metaspace容量MU表示已使用量。再配合jcmd pid VM.native_memory加上JVM参数-XX:NativeMemoryTrackingsummary能看到Metaspace的本地内存占用明细。如果怀疑是动态类生成直接加-XX:TraceClassLoading和-XX:TraceClassUnloading日志会刷出每个被加载或卸载的类重定向到文件里再按类名前缀统计就能揪出谁在疯狂造类。我实际碰过一个案例一个平台不停创建CGLIB代理类给远程RPC做拦截运行两天后Metaspace OOM。当时就是开着-XX:TraceClassLoading把日志存下来发现同一个业务接口的代理类被重复创建了数万个原因是上层做缓存的时候key没写全每次调用都认为“代理不存在”然后重新生成。这只是治理Class数量膨胀的一个常见例子。5.4 面试官常问的JVM/Class文件进阶题与我的回答思路热词里有“jvm面试题”这块正好把这篇文章的知识点串起来。以下这几个问题我经常在面工程师时问也建议自查。先看“Class文件里有注解吗”“注解存哪个段”。这两个是结构题考的是常量池、属性表以及RuntimeVisibleAnnotations这类属性。答出来说明看过Class文件答不出来大概率是背了书没实操过。再看“类加载过程哪个阶段会出错”“一个类被两个ClassLoader加载算两个类吗”。后者尤其刁钻答案是类由类全限定名和定义加载器共同标识同一个Class文件被两个不同加载器加载生成的两个Class对象不相等instanceof校验也可能过不去。这是OSGi、插件化框架的底层难题。再看“为什么高版本JDK不能运行低版本编译的Class文件反而没问题”“如果JDK 17编译的Class在JDK 8上跑会怎样”。这是版本兼容题考察Class版本号以及向后兼容的设计。最后是“Metaspace存什么”“为什么JDK 8要移除PermGen”。答到Metaspace存类元数据、运行时常量池用本地内存替代固定容量避免因为固定上限导致OOM基本就能过关。最好再补一句实际调优经验比如设置MaxMetaspaceSize不要过小否则正常应用一天到晚在GC。我在实际筛人时最怕听到“Class文件就是个字节码文件”这种一句到底的答案。真正动手拆过Class、做过字节码增强、处理过Metaspace问题的人描述同一个东西时是会有细节的。这也是这篇东西存在的意义把JVM这条线上的一个重要零件拆到能落地为止。后面如果再有人问我“注解为什么丢了”我不会再直接给答案而是让他先跑一句javap -v看清楚Class文件里到底有什么再反推结论。这个习惯比记住任何一张表都有用。