跳到主要内容
AZ Tools

Java 类文件分析器

Java 的 .class 文件不是压缩包,而是 JVM 自己的格式:一个文件一个类,从第一个字节起全部是大端序,所有名字、类型和常量都写成指向文件开头那个常量池的索引。本工具在浏览器里读出这套结构,给出与 javap -v 相同的信息,既不需要安装 JDK,文件也不会离开你的设备。文件头给出类文件版本,其中 major 号对应一个 Java 版本:45 是 Java 1.1,49 是 Java 5,52 是 Java 8,从 8 之后每个版本正好加一,所以 55 是 11,61 是 17,65 是 21,69 是 25。UnsupportedClassVersionError 抱怨的正是这个数字,它表示能加载该文件的最低 JVM 版本,而不是编译它的 JDK 版本——在 JDK 21 上用 javac --release 8 编译,产出的仍然是 major 52 的文件。访问标志会被展开,让你一眼看出这是类、接口、枚举、注解还是记录。记录没有自己的访问标志,靠 Record 属性识别;枚举则靠 ACC_ENUM 识别。常量池按标签统计,这是弄清一个生成的类为什么这么大的最快办法:几千个 Utf8 条目,或者由 lambda 和字符串拼接产生的一大片 InvokeDynamic 与 MethodHandle 条目,都会立刻暴露出来。字段和方法会把描述符还原成 Java 类型列出,每个方法还带上 Code 属性里的 max_stack、max_locals 和代码长度;抽象方法与本地方法没有 Code,显示为短横线。引用类列表是除自身以外的全部 CONSTANT_Class 条目,这是单个类文件里最接近依赖清单的东西。它说不出的部分同样重要:描述符是擦除后的类型,所以没有 Signature 属性时 List<String> 字段只会显示成 java.util.List;用 -g:none 编译的类没有 SourceFile、没有行号、也没有局部变量名,它的堆栈跟踪会是空的;运行时按名字反射加载的类,这里也看不到。

使用方法

  1. 把 .class 文件拖到方框里,或点击选择。必须是单个编译好的类:.jar 本质是 zip,请先解压再挑出需要的类。
  2. 看概览行:类文件版本、对应的 Java 版本、类型种类,以及常量池最终有多大。
  3. 在声明区块查看父类、接口、类级属性和源文件名。既没有 SourceFile 也没有调试信息,说明这个类是用 -g:none 编译的。
  4. 浏览方法表。最大栈、最大局部变量和代码字节数都来自各方法的 Code 属性,因此抽象方法、本地方法和接口方法显示为短横线。
  5. 当一个类看起来远大于它包含的代码时,展开常量池分布;按标签的计数通常一行就能指出元凶。

常见问题

“class file version 65.0”是什么意思?为什么会出现 UnsupportedClassVersionError?
这两个数字是文件第 5 到第 8 字节里的 major 和 minor 版本。major 45 是 Java 1.1,46、47、48 分别是 1.2、1.3、1.4;从 major 49(Java 5)起,算法就是 major 减 44,所以 52 是 Java 8,55 是 11,61 是 17,65 是 21,69 是 25。UnsupportedClassVersionError 表示正在运行的 JVM 比文件的 major 版本旧,错误信息里会同时给出两个数字;解决办法要么换更新的运行时,要么用 --release 指定实际部署的版本重新编译。minor 等于 65535 是特殊值,表示该文件使用了预览特性,只能在那个确切的版本上加上 --enable-preview 才能加载。
常量池为什么在 long 或 double 之后跳过一个索引?
因为规范就是这么规定的,而这正是手写类文件读取器最经典的出错点。CONSTANT_Long 和 CONSTANT_Double 条目占用连续两个槽位,第二个槽位不可使用:没有任何东西可以指向它,下一个真实条目从再往后一个索引开始。Java 的设计者后来也承认这是个糟糕的选择,但它已经烙进了迄今写出的每一个类文件。如果读取器在 long 之后只把索引加一,后面整个常量池就会静默错位,从那一刻起每一个字段名、方法名和类引用都会从错误的条目里读出来——结果不是报错,而是一份看着合理却完全错误的清单。本工具按二递增,所以它显示的常量池计数会大于它列出的条目数。
类文件里的字符串是 UTF-8 吗?
几乎是,但差别会咬人。CONSTANT_Utf8 用的是修改版 UTF-8:NUL 字符被编码成 C0 80 两个字节而不是一个零字节,这样任何字符串里都不会出现内嵌的零,C 代码可以把名字当作以空字符结尾的串处理。基本多文种平面之外的字符不会写成真正 UTF-8 的四字节形式,而是先拆成 UTF-16 代理对,再把每个代理项写成三字节形式,一共六个字节。把这些字节交给标准 UTF-8 解码器,得到的要么是替换字符,要么是异常。本工具把每一组解码成一个 UTF-16 码元,让代理对重新组合,这与 JVM 的做法一致。
我的 List<String> 字段为什么显示成 java.util.List?
因为描述符是擦除后的类型。field_info 结构里保存的是描述符,而描述符没有位置写类型参数,Ljava/util/List; 就是全部内容。泛型信息单独保存在可选的 Signature 属性里,编译器会为泛型的类、字段和方法在描述符旁边同时写出它,反射和 javap 打印声明时用的都是它。这也是为什么一个看上去平平无奇的类或成员,属性列表里经常出现 Signature。如果你要确认某个字段是 List<String> 而不是原始的 List,类文件里唯一说明这一点的就是 Signature 属性。
怎样区分类、枚举、记录和注解?
靠访问标志加一个属性。ACC_INTERFACE 表示接口,注解类型就是额外带上 ACC_ANNOTATION 的接口,所以 javap 会把注解打印成继承 java.lang.annotation.Annotation 的接口。ACC_ENUM 表示枚举,它的父类是 java.lang.Enum,各个常量表现为同样带 ACC_ENUM 的 static final 字段,旁边还有一个合成的 $VALUES 数组。记录则完全没有专属标志:它是继承 java.lang.Record 的 final 类,并带有列出各组件的 Record 属性,因此这个属性是唯一可靠的判据。ACC_MODULE 表示 module-info 文件,它没有字段也没有方法,根本就不是一个类。
引用类列表就等于这个类的依赖吗?
很接近,但值得了解它的缺口。这份列表是常量池里全部的 CONSTANT_Class 条目,涵盖父类、接口、被创建、被强制转换、被捕获或作为字段与方法所有者的类型,以及字节码中出现的每一种数组类型。它不涵盖只出现在描述符里的类型:一个接收 String 且无返回值的方法,只会把 (Ljava/lang/String;)V 作为 Utf8 条目放进常量池,java/lang/String 完全可能不以 CONSTANT_Class 的形式出现。它同样看不到仅以字符串写出名字、再用 Class.forName 加载的类,或者运行时才解析的服务。请把它当作编译期的引用集合,而不是运行时的完整闭包。

相关工具