代码、内容参考来自于张秀宏大佬的自己动手写Java虚拟机 (Java核心技术系列)。
1.Class文件格式
官方文档位置:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html
Class的结构不像XML等描述语言,由于它没有任何分隔符号。所以在其中的数据项,无论是字节顺序还是数量,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。
Class文件格式采用一种类似于c语言结构体的方式进行数据存储,这种结构中只有两种数据类型:无符号数和表。
- 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值。
- 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以”_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表, 由于表没有固定长度,所以通常会在其前面加上个数说明。
public class Demo {
private int num = 1;
public int add(){
num = num + 2;
return num;
}
}看看在idea编译后的文件
对应字节码:

换句话说,充分理解了每一个字节码文件的细节,自己也可以反编译出Java源文件来。
Class文件的总体结构如下:
- 魔数
- Class文件版本
- 常量池
- 访问标志
- 类索引,父类索引,接口索引集合
- 字段表集合
- 方法表集合
- 属性表集合
Java虚拟机规范使用一种类似C语言的结构体语法来描述class文件格式。整个class文件被描述为一个ClassFile结构,代码如下:
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}对应:

JDK提供了一个功能强大的命令行工具javap,可以用它反编译class文件。
图形化的工具:
《自己动手写Java虚拟机》的作者推荐classpy,https://github.com/zxh0/classpy
不过我个人推荐jclasslib和Binary Viewer。

以ClassFileTest类为例,分析class文件格式。ClassFileTest的代码如下:
package jvmgo.book.ch03;
public class ClassFileTest {
public static final boolean FLAG = true;
public static final byte BYTE = 123;
public static final char X = 'X';
public static final short SHORT = 12345;
public static final int INT = 123456789;
public static final long LONG = 12345678901L;
public static final float PI = 3.14f;
public static final double E = 2.71828;
public static void main(String[] args) throws RuntimeException {
System.out.println("Hello, World!");
}
}

编译后,用Binary Viewer打开:

Go语言内置了丰富的数据类型,非常适合处理class文件表
Go和Java语言基本数据类型对照关系。

2.读取数据
解析class文件的第一步是从里面读取数据。虽然可以把class文件当成字节流来处理,但是直接操作字节很不方便,所以先定义一个结构体来帮助读取数据。在ch03\classfile目录下创建class_reader.go文件,在其中定义ClassReader结构体和数据读取方法,代码如下:
package classfile
import (
"encoding/binary"
)
type ClassReader struct {
data []byte
}
func (self *ClassReader) readUint8() uint8 {
val := self.data[0]
self.data = self.data[1:]
return val
}
func (self *ClassReader) readUint16() uint16 {
val := binary.BigEndian.Uint16(self.data)
self.data = self.data[2:]
return val
}
func (self *ClassReader) readUint32() uint32 {
val := binary.BigEndian.Uint32(self.data)
self.data = self.data[4:]
return val
}
func (self *ClassReader) readUint64() uint64 {
val := binary.BigEndian.Uint64(self.data)
self.data = self.data[8:]
return val
}
func (self *ClassReader) readUint16s() []uint16 {
n := self.readUint16()
s := make([]uint16, n)
for i := range s {
s[i] = self.readUint16()
}
return s
}
func (self *ClassReader) readBytes(n uint32) []byte {
bytes := self.data[:n]
self.data = self.data[n:]
return bytes
}
- ClassReader只是[]byte类型的包装而已。readUint8()读取u1类型数据.
- 注意,ClassReader并没有使用索引记录数据位置,而是使用Go语言的reslice语法跳过已经读取的数据。readUint16()读取u2类型数据。
- Go标准库encoding/binary包中定义了一个变量BigEndian,正好可以从[]byte中解码多字节数据。readUint32()读取u4类型数据,代码如下:
- readUint64()读取uint64(Java虚拟机规范并没有定义u8)类型数据。
- readUint16s()读取uint16表,表的大小由开头的uint16数据指出。
- 最后一个方法是readBytes(),用于读取指定数量的字节。
代码扩展1:
什么是reslice语法?简单
func reslice(refirst []int) []int {
res := refirst[0:3]
return res
}在此函数中,切片res截取了切片refirst中的部分数据并返回。
不过,要注意内存泄漏问题,比如,上述代码res与reslice共用一个底层数组

那么gc在进行检测时,res还在使用,自然不会将底层数组回收。而如果切片refirst中含有大量的数据,那这会极大的浪费内存。
代码扩展2:
binary.BigEndian.Uint16(self.data):
binary包实现了数字和字节序列之间的简单转换。
比如:
- binary.BigEndian(大端模式):将低位字节存储在高地址空间中、高位字节存储在低地址空间中就是大端字节序
- binary.LittleEndian(小端模式):将低位字节存储在低地址空间中、高位字节存储在高地址空间中就是小端字节序
也就是说给我一个指向0xB4(10110100)这个数的指针,对于big endian方式的CPU来说,它是从左往右依次读取这个数的8个比特;而对于little endian方式的CPU来说,则正好相反,是从右往左依次读取这个数的8个比特。很明显big endian方式存储数据更符合我们人类的思维习惯的。
Go中处理大小端序的代码位于 encoding/binary ,包中的全局变量BigEndian用于操作大端序数据,LittleEndian用于操作小端序数据,这两个变量所对应的数据类型都实行了ByteOrder接口,ByteOrder是一个接口,在binary中有两个实现了该接口的结构体,分别是littleEndian和bigEndian,也就是小端和大端。大端小端指的是数据如何存储在内存中。
源码:

前三个方法用于读取数据(字节–>数字),后三个方法用于写入数据(数字–>字节)。

测试代码:
package main
import (
"encoding/binary"
"fmt"
)
func main() {
//定义一个数组
var buf [4]byte
//转换123456为uint32类型
pkglen := uint32(123456)
//输出原值
fmt.Println("输出原值:" ,pkglen)
//将pkglen转换为字节放到buf中索引为0到4的位置
binary.BigEndian.PutUint32(buf[0:4], pkglen)
//输出
for i := 0; i <= 3; i++ {
fmt.Printf("buf[%d]=%d\n", i, buf[i])
}
//字节-->数字
pkgLen := binary.BigEndian.Uint32(buf[0:4])
//输出还是原值
fmt.Println("输出数字-->字节-->数字后结果:",pkgLen)
}
3.整体结构
整体代码
有了ClassReader,可以开始解析class文件了。在ch03\classfile目录下创建class_file.go文件,在其中定义ClassFile结构体,代码如下:
package classfile
import "fmt"
type ClassFile struct {
//magic uint32
minorVersion uint16
majorVersion uint16
constantPool ConstantPool
accessFlags uint16
thisClass uint16
superClass uint16
interfaces []uint16
fields []*MemberInfo
methods []*MemberInfo
attributes []AttributeInfo
}
func Parse(classData []byte) (cf *ClassFile, err error) {
defer func() {
if r := recover(); r != nil {
var ok bool
err, ok = r.(error)
if !ok {
err = fmt.Errorf("%v", r)
}
}
}()
cr := &ClassReader{classData}
cf = &ClassFile{}
cf.read(cr)
return
}
func (self *ClassFile) read(reader *ClassReader) {
self.readAndCheckMagic(reader) //代码在3.2.3
self.readAndCheckVersion(reader) //代码在3.2.4
self.constantPool = readConstantPool(reader) //代码在3.3
self.accessFlags = reader.readUint16()
self.thisClass = reader.readUint16()
self.superClass = reader.readUint16()
self.interfaces = reader.readUint16s()
self.fields = readMembers(reader, self.constantPool) // 代码在3.2.8
self.methods = readMembers(reader, self.constantPool)
self.attributes = readAttributes(reader, self.constantPool) //3.4
}
func (self *ClassFile) MinorVersion() uint16 {
return self.minorVersion
}
func (self *ClassFile) MajorVersion() uint16 {
return self.majorVersion
}
func (self *ClassFile) ConstantPool() ConstantPool {
return self.constantPool
}
func (self *ClassFile) AccessFlags() uint16 {
return self.accessFlags
}
func (self *ClassFile) Fields() []*MemberInfo {
return self.fields
}
func (self *ClassFile) Methods() []*MemberInfo {
return self.methods
}
func (self *ClassFile) ClassName() string {
return self.constantPool.getClassName(self.thisClass)
}
func (self *ClassFile) SuperClassName() string {
if self.superClass > 0 {
return self.constantPool.getClassName(self.superClass)
}
return "" //只有java.lang.Object没有超类
}
func (self *ClassFile) InterfaceNames() []string {
interfaceNames := make([]string, len(self.interfaces))
for i, cpIndex := range self.interfaces {
interfaceNames[i] = self.constantPool.getClassName(cpIndex)
}
return interfaceNames
}
func (self *ClassFile) readAndCheckMagic(reader *ClassReader) {
magic := reader.readUint32()
if magic != 0xCAFEBABE {
panic("java.lang.ClassFormatError: magic!")
}
}
func (self *ClassFile) readAndCheckVersion(reader *ClassReader) {
self.minorVersion = reader.readUint16()
self.majorVersion = reader.readUint16()
switch self.majorVersion {
case 45:
return
case 46, 47, 48, 49, 50, 51, 52:
if self.minorVersion == 0 {
return
}
}
panic("java.lang.UnsupportedClassVersionError!")
}
代码解释:
相比Java语言,Go的访问控制非常简单:只有公开和私有两种。所有首字母大写的类型、结构体、字段、变量、函数、方法等都是公开的,可供其他包使用。首字母小写则是私有的,只能在包内部使用。
ClassFile结构体如实反映了Java虚拟机规范定义的class文件格式。还会在class_file.go文件中实现一系列函数和方法,将下列方法加入class_file.go文件即可,列举如下:
Parse()函数
Parse()函数把[]byte解析成ClassFile结构体,代码如下:

Go语言没有异常处理机制,只有一个panic-recover机制。程序报panic时,会使整个程序挂掉。glong引用recover()函数来捕获异常,使得即使报panic,也能继续运行下去。
defer func() {
if err := recover(); err !=nil {
fmt.Println(err)
}
}()
需要注意的是:
recover() 只是针对当前函数和以及直接调用的函数可能产生的panic,它无法处理其调用产生的其它协程的panic
err, ok = r.(error)
类型断言被用于检查接口类型变量所持有的值是否实现了期望的接口或者具体的类型。如果断言对象是指定的类型,则返回指定类型接口;如果不是指定的类型,断言的第二个参数将返回false。

设置错误的格式:
标准库中的 fm 还提供方法 Errorf
package main
import (
"fmt"
)
func main() {
to, xr := "今天", "勤劳的"
//err := errors.New("error来了")
err := fmt.Errorf("在%v ,%v的error来了", to, xr)
if err != nil {
fmt.Print(err)
return
}
}
read()方法
read()方法依次调用其他方法解析class文件,也就是依次调用其他方法来对字节码文件处理并赋值给结构体:

Getter方法
MinorVersion()等6个方法是Getter方法,把结构体的字段暴露给其他包使用。

ClassName()方法
ClassName()从常量池查找类名

SuperClassName()方法
SuperClassName()从常量池查找超类名。

InterfaceNames
InterfaceNames()从常量池查找接口名。

下面详细介绍class文件的各个部分
4.魔数
整体代码
在整体结构中的整体代码已全部给出
魔数解释

Magic Number(魔数)
- 每个Class文件开头的4个字节的无符号整数称为魔数(Magic Number)
- 它的唯一作用是确定这个文件是否为一个能被虚拟机接受的有效合法的Class文件。即:魔数是Class文件的标识符。
- 魔数值固定为0xCAFEBABE。不会改变。
- 如果一个Class文件不以0xCAFEBABE开头,虚拟机在进行文件校验的时候就会直接抛出以下错误
Error: A JNI error has occurred, please check your installation and try again Exception in thread "main" java.lang.ClassFormatError: Incompatible magic value 1885430635 in classl file StringTest
- 使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。
很多文件格式都会规定满足该格式的文件必须以某几个固定字节开头,这几个字节主要起标识作用,叫作魔数(magic number)。例如PDF文件以4字节“%PDF”(0x25、0x50、0x44、0x46)开头,ZIP文件以2字节“PK”(0x50、0x4B)开头。
代码解释:
readAndCheckMagic()方法,检查文件是否以0xCAFEBABE开头

Java虚拟机规范规定,如果加载的class文件不符合要求的格式,Java虚拟机实现就抛出java.lang.ClassFormatError异常。但是现在的代码,还没有实现抛出异常,所以暂时先调用panic()方法终止程序执行。
5.版本号
整体代码:
在整体结构中的整体代码已全部给出
版本号解释

- 紧接着魔数的4个字节存储的是Class文件的版本号。同样也是4个字节。分别为次版本号和主版本号,都是u2类型。第5个和第6个字节所代表的含义就是编译的副版本号minor_version。而第7个和第8个字节就是编译的主版本号major_version。
- 它们共同构成了class文件的格式版本号,譬如某个Class文件的主版本号为M,副版本号为m,那么这个Class文件的格式版本号就确定为 M.m。
- 版本号和Java编译器的对应关系如下表:

注意,是十进制,字节码里是十六进制。
- Java的版本号是从45开始的, JDK 1.1之后的每个JDK大版本发布主版本号向上加1。
- 不同版本的Java编译器编译的Class文件对应的版本是不一样的。目前,高版本的Java虚拟机可以执行由低版本编译器生成的Class文件,但是低版本的Java虚拟机不能执行由高版本编译器生成的Class文件。否则JVM会抛出java.lang.UnsupportedClassVersionError异常。(向下兼容)
java.lang.UnsupportedClassVersionError
- 52.0就是M.mClass文件版本号(JDK8)。
- 在实际应用中,由于开发环境和生产环境的不同,可能会导致该问题的发生,因此,需要我们在开发时,特别注意开发编译的JDK版本和生产环境中的JDK版本是否一致。
- 虚拟机JDK版本为1.k (k >= 2)时,对应的class文件格式版本号的范围为45.0-44+k.0(含两端)
代码解释
Oracle的实现是完全向后兼容的,比如Java SE 8支持版本号为45.0~52.0的class文件。如果版本号不在支持的范围内,Java虚拟机实现就抛出java.lang.UnsupportedClassVersionError异常。我们参考Java 8,支持版本号为45.0~52.0的class文件。如果遇到其他版本号,暂时先调用panic()方法终止程序执行。

6.类访问标志
版本号之后是常量池,但是由于常量池比较复杂,所以放到下一节介绍。
整体代码
在整体结构中的整体代码已全部给出,本章只对class文件进行初步解析,并不做完整验证,所以只是读取类访问标志以备后用。第6章会详细讨论访问标志。
在3.2.2整体结构中的代码:

类访问标志解释
- 在常量池后,紧跟着访问标记。该标记使用两个字节表示,用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口:是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等。各种访问标记如下所示:

- 类的访问权限通常为ACC_开头的常量。
- 每一种类型的表示都是通过设置访问标记的32位中的特定位来实现的。比如,若是public final的类,则该标记为ACC_PUBLIC | ACC_FINAL。
- 使用ACC_SUPER可以让类更准确地定位到父类的方法super.method(),现代编译器都会设置并且使用这个标记。
补充说明:
- 1.带有ACC_INTERFACE标志的class文件表示的是接口而不是类,反之则表示的是类而不是接口。如果一个class文件被设置了ACC_INTERFACE标志,那么同时也得设置ACC_ABSTRACT标志,同时它不能再设置ACC_FINAL,ACC_SUPER 或 ACC_ENUM 标志。如果没有设置ACC_INTERFACE标志,那么这个class文件可以具有上表中除ACC_ANNOTATION外的其他所有标志,当然, ACC_FINAL和ACC_ABSTRACT这类互斥的标志除外,这两个标志不得同时设置。
- 2. ACC_SUPER标志用于确定类或接口里面的invokespecial指令使用的是哪一种执行语义。针对Java虚拟机指令集的编译器都应当设置这个标志。对于Java SE 8及后续版本来说,无论class文件中这个标志的实际值是什么,也不管class文件的版本号是多少,Java虚拟机都认为每个class文件均设置了ACC_SUPER标志。ACC_SUPER标志是为了向后兼容由旧Java编译器所编译的代码而设计的。目前的ACC_SUPER标志在由JDK 1.0.2之前的编译器所生成的access_flags中是没有确定含义的,如果设置了该标志,那么Oracle的Java虚拟机实现会将其忽略。
- 3. ACC_SYNTHETIC标志意味着该类或接口是由编译器生成的,而不是由源代码生成的。
- 4.注解类型必须设置ACC_ANNOTATION标志。如果设置了ACC_ANNOTATION标志,那么也必须设置ACC_INTERFACE标志.
- 5. ACC_ENUM标志表明该类或其父类为枚举类型。
- 6.表中没有使用的access-flags标志是为未来扩充而预留的,这些预留的标志在编译器中应该设置为0, Java虚拟机现也应该忽略它们。

而其中21是由下图相或而得。

7.类索引,父类索引,接口索引集合
整体代码
在整体结构中的整体代码已全部给出,目前只对class文件进行初步解析,并不做完整验证,所以只是读取。

类索引,父类索引,接口索引集合解释:
在访问标记后,会指定该类的类别、父类类别以及实现的接口,
格式如下:

这三项数据来确定这个类的继承关系:
- class文件存储的类名类似完全限定名,但是把点换成了斜线,Java语言规范把这种名字叫作二进制名(binary names)。
- 类索引用于确定这个类的全限定名
- 父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为 0。
- 接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是 extends 语句)后的接口顺序从左到右排列在接口索引集合中。
1.this_class (类索引):
- 字节无符号整数,指向常量池的索引。它提供了类的全限定名,如<jvmgo/book/ch03/ClassFileTest>。this-class的值必须是对常量池表中某项的一个有效索引值。常量池在这个索引处的成员必须为CONSTANT_Class_info类型结构体,该结构体表示这个class文件所定义的类或接口。

通过之前所说引用方法,最后指向:

下同,不再阐述。
2.super_class (父类索引)
- 字节无符号整数,指向常量池的索引。它提供了当前类的父类的全限定名。如果我们没有继承任何类,其默认继承的是java/lang/Object类。同时,由于Java不支持多继承,所以其父类只有一个。
- 除java.lang.Object之外,其他类都有超类,所以superClass只在Object.class中是0,在其他class文件中必须是有效的常量池索引。
- superclass指向的父类不能是final.

3. interfaces
- 指向常量池索引集合,它提供了一个符号引用到所有已实现的接口
- 由于一个类可以实现多个接口,因此需要以数组形式保存多个接口的索引,表示接口的每个索引也是一个指向常量池的CONSTANT_Class (当然这里就必须是接口,而不是类)。
- interfaces_count (接口计数器):interfaces_count项的值表示当前类或接口的直接超接口数量。
- interfaces [](接口索引集合):interfaces []中每个成员的值必须是对常量池表中某项的有效索引值,它的长度为interfaces_count, 每个成员interfaces[i]必须为CONSTANT_Class_info结构,其中0<= i< interfaces_count。在interfaces[]中,各成员所表示的接口顺序和对应的源代码中给定的接口顺序(从左至右)一样,即interfaces[0]对应的是源代码中最左边的接口。

因为接口计数器为零,所以没有接口索引集合。
8.字段和方法表
整体代码
在classfile目录中创建member_info.go文件,在其中定义MemberInfo结构体,代码如下:
package classfile
type MemberInfo struct {
cp ConstantPool
accessFlags uint16
nameIndex uint16
descriptorIndex uint16
attributes []AttributeInfo
}
func readMember(reader *ClassReader, cp ConstantPool) *MemberInfo {
return &MemberInfo{
cp: cp,
accessFlags: reader.readUint16(),
nameIndex: reader.readUint16(),
descriptorIndex: reader.readUint16(),
attributes: readAttributes(reader, cp), //见3.4
}
}
func readMembers(reader *ClassReader, cp ConstantPool) []*MemberInfo {
memberCount := reader.readUint16()
members := make([]*MemberInfo, memberCount)
for i := range members {
members[i] = readMember(reader, cp)
}
return members
}
func (self *MemberInfo) AccessFlags() uint16 {
return self.accessFlags
}
func (self *MemberInfo) Name() string {
return self.cp.getUtf8(self.nameIndex)
}
func (self *MemberInfo) Descriptor() string {
return self.cp.getUtf8(self.descriptorIndex)
}
字段表和方法表解释:
接口索引表之后是字段表和方法表,分别存储字段和方法信息。字段和方法的基本结构大致相同,差别仅在于属性表。下面是Java虚拟机规范给出的字段方法结构定义。



和类一样,字段和方法也有自己的访问标志。访问标志之后是一个常量池索引,给出字段名或方法名,然后又是一个常量池索引,给出字段或方法的描述符,最后是属性表。为了避免重复代
码,用一个结构体统一表示字段和方法。
字段表访问标识:
我们知道,一个字段可以被各种关键字去修饰,比如:作用域修饰符(public、 private、protected)、static修饰符、final修饰符、volatile修饰符等等。因此,其可像类的访问标志那样,使用一些标志来标记字段。字段的访问标志有如下这些:

字段名索引:
根据字段名索引的值,查询常量池中的指定索引项即可。
描述符索引:
描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte,char,double,float,int,long,short,boolean)及代表无返回值的void类型都用一个大写字符来表示,而对象则用字符L加对象的全限定名来表示,如下所示:

方法表访问标志:
跟字段表一样,方法表也有访问标志,而且他们的标志有部分相同,部分则不同,方法表的具体访问标志如下:

完全版:

方法表其他的与字段一样。
代码解释
cp字段保存常量池指针,后面会用到它。
1.readMember()方法
readMember()函数读取字段或方法数据,代码如下:

在这个函数中,return返回了一个MemberInfo结构体,这是结构体的一种常用写法,初始化时在以成员名字和相应的值来初始化,可以包含部分或全部的成员,如果成员被忽略的话将默认用零值。因为提供了成员的名字,所以成员出现的顺序并不重要。&表示获取这个结构体的地址,在这个方法中以一个指向这个地址的MemberInfo指针返回。
2.readMembers()方法
readMembers()读取字段表或方法表,代码如下:

3.Name()
Name()从常量池查找字段或方法名

4.Descriptor()
Descriptor()从常量池查找字段或方法描述符,代码如下:

第6章会进一步讨论字段和方法。ClassFileTest有8个字段和两个方法(其中<init>是编译器生成的默认构造函数)


9.参考
尚硅谷宋红康:JVM全套教程:https://www.bilibili.com/video/BV1PJ411n7xZ
周志明:深入理解java虚拟机
张秀宏:自己动手写Java虚拟机 (Java核心技术系列)




