鱼C论坛

 找回密码
 立即注册
查看: 3437|回复: 3

[好文转载] JVM之用 Java 解析 class 文件

[复制链接]
发表于 2017-2-17 22:37:58 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能^_^

您需要 登录 才可以下载或查看,没有账号?立即注册

x
本帖最后由 零度非安全 于 2017-2-18 09:56 编辑

前言:

身为一个 Java 程序员,怎么能不了解 JVM 呢,倘若想学习 JVM,那就又必须要了解 Class 文件,Class 之于虚拟机,就

如鱼之于水,虚拟机因为 Class 而有了生命。《深入理解 java 虚拟机》中花了一整个章节来讲解 Class 文件,可是看完

后,一直都还是迷迷糊糊,似懂非懂。正好前段时间看见一本书很不错:《自己动手写 Java 虚拟机》,作者利用 go 语言

实现了一个简单的 JVM,虽然没有完整实现 JVM 的所有功能,但是对于一些对 JVM 稍感兴趣的人来说,可读性还是很高

的。作者讲解的很详细,每个过程都分为了一章,其中一部分就是讲解如何解析 Class 文件。

这本书不太厚,很快就读完了,读完后,收获颇丰。但是纸上得来终觉浅,绝知此事要躬行,我便尝试着自己解析 Class 文

件。go 语言虽然很优秀,但是终究不熟练,尤其是不太习惯其把类型放在变量之后的语法,还是老老实实用 java 吧。

话不多说,先贴出项目地址:https://github.com/HalfStackDeveloper/ClassReader


Class 文件

什么是 Class 文件?

java 之所以能够实现跨平台,便在于其编译阶段不是将代码直接编译为平台相关的机器语言,而是先编译成二进制形式的

java 字节码,放在 Class 文件之中,虚拟机再加载 Class 文件,解析出程序运行所需的内容。每个类都会被编译成一个单独

的 class 文件,内部类也会作为一个独立的类,生成自己的 class。

基本结构

随便找到一个 class 文件,用 Sublime Text 打开是这样的:

0.jpg

是不是一脸懵逼,不过 java 虚拟机规范中给出了 class 文件的基本格式,只要按照这个格式去解析就可以了:
  1. ClassFile {
  2.        u4 magic;
  3.        u2 minor_version;
  4.        u2 major_version;
  5.        u2 constant_pool_count;
  6.        cp_info constant_pool[constant_pool_count-1];
  7.        u2 access_flags;
  8.        u2 this_class;
  9.        u2 super_class;
  10.        u2 interfaces_count;
  11.        u2 interfaces[interfaces_count];
  12.        u2 fields_count;
  13.        field_info fields[fields_count];
  14.        u2 methods_count;
  15.        method_info methods[methods_count];
  16.        u2 attributes_count;
  17.        attribute_info attributes[attributes_count];
  18. }
复制代码

ClassFile 中的字段类型有 u1、u2、u4,这是什么类型呢?其实很简单,就是分别表示 1 个字节,2 个字节和 4 个字节。

开头四个字节为:magic,是用来唯一标识文件格式的,一般被称作 magic number(魔数),这样虚拟机才能识别出所

加载的文件是否是 class 格式,class 文件的魔数为 cafebabe。不只是 class 文件,基本上大部分文件都有魔数,用来标识

自己的格式。

接下来的部分主要是 class 文件的一些信息,如常量池、类访问标志、父类、接口信息、字段、方法等,具体的信息可参考

《Java虚拟机规范》。


解析

字段类型

上面说到 ClassFile 中的字段类型有 u1、u2、u4,分别表示 1 个字节,2 个字节和 4 个字节的无符号整数。java 中

short、int、long 分别为2、4、8个字节的有符号整数,去掉符号位,刚好可以用来表示 u1、u2、u4。
  1. public class U1 {
  2.     public static short read(InputStream inputStream) {
  3.         byte[] bytes = new byte[1];
  4.         try {
  5.             inputStream.read(bytes);
  6.         } catch (IOException e) {
  7.             e.printStackTrace();
  8.         }
  9.         short value = (short) (bytes[0] & 0xFF);
  10.         return value;
  11.     }
  12. }

  13. public class U2 {
  14.     public static int read(InputStream inputStream) {
  15.         byte[] bytes = new byte[2];
  16.         try {
  17.             inputStream.read(bytes);
  18.         } catch (IOException e) {
  19.             e.printStackTrace();
  20.         }
  21.         int num = 0;
  22.         for (int i= 0; i < bytes.length; i++) {
  23.             num <<= 8;
  24.             num |= (bytes[i] & 0xff);
  25.         }
  26.         return num;
  27.     }
  28. }                                                                                                                                                                                   

  29. public class U4 {
  30.     public static long read(InputStream inputStream) {
  31.         byte[] bytes = new byte[4];
  32.         try {
  33.             inputStream.read(bytes);
  34.         } catch (IOException e) {
  35.             e.printStackTrace();
  36.         }
  37.         long num = 0;
  38.         for (int i= 0; i < bytes.length; i++) {
  39.             num <<= 8;
  40.             num |= (bytes[i] & 0xff);
  41.         }
  42.         return num;
  43.     }
  44. }
复制代码

常量池

定义好字段类型后,我们就可以读取 class 文件了,首先是读取魔数之类的基本信息,这部分很简单:
  1. FileInputStream inputStream = new FileInputStream(file);
  2. ClassFile classFile = new ClassFile();
  3. classFile.magic = U4.read(inputStream);
  4. classFile.minorVersion = U2.read(inputStream);
  5. classFile.majorVersion = U2.read(inputStream);
复制代码

这部分只是热热身,接下来的大头在于常量池。解析常量池之前,我们先来解释一下常量池是什么。

常量池,顾名思义,存放常量的资源池,这里的常量指的是字面量和符号引用。字面量指的是一些字符串资源,而符号引用

分为三类:类符号引用、方法符号引用和字段符号引用。通过将资源放在常量池中,其他项就可以直接定义成常量池中的索

引了,避免了空间的浪费,不只是 class 文件,Android 可执行文件dex也是同样如此,将字符串资源等放在 DexData

中,其他项通过索引定位资源。java 虚拟机规范给出了常量池中每一项的格式:
  1. cp_info {
  2.     u1 tag;
  3.     u1 info[];
  4. }
复制代码

上面的这个格式只是一个通用格式,常量池中真正包含的数据有 14 种格式,每种格式的 tag 值不同,具体如下所示:

1.jpg

由于格式太多,文章中只挑选一部分讲解:

这里首先读取常量池的大小,初始化常量池:
  1. //解析常量池
  2. int constant_pool_count = U2.read(inputStream);
  3. ConstantPool constantPool = new ConstantPool(constant_pool_count);
  4. constantPool.read(inputStream);
复制代码

接下来再逐个读取每项内容,并存储到数组 cpInfo 中,这里需要注意的是,cpInfo[] 下标从 1 开始,0 无效,且真正的常

量池大小为 constant_pool_count-1。
  1. public class ConstantPool {
  2.     public int constant_pool_count;
  3.     public ConstantInfo[] cpInfo;

  4.     public ConstantPool(int count) {
  5.         constant_pool_count = count;
  6.         cpInfo = new ConstantInfo[constant_pool_count];
  7.     }

  8.     public void read(InputStream inputStream) {
  9.         for (int i = 1; i < constant_pool_count; i++) {
  10.             short tag = U1.read(inputStream);
  11.             ConstantInfo constantInfo = ConstantInfo.getConstantInfo(tag);
  12.             constantInfo.read(inputStream);
  13.             cpInfo[i] = constantInfo;
  14.             if (tag == ConstantInfo.CONSTANT_Double || tag == ConstantInfo.CONSTANT_Long) {
  15.                 i++;
  16.             }
  17.         }
  18.     }
  19. }
复制代码

我们先来看看 CONSTANT_Utf8 格式,这一项里面存放的是 MUTF-8 编码的字符串:
  1. CONSTANT_Utf8_info {
  2.     u1 tag;
  3.     u2 length;
  4.     u1 bytes[length];
  5. }
复制代码

那么如何读取这一项呢?
  1. public class ConstantUtf8 extends ConstantInfo {
  2.     public String value;

  3.     @Override
  4.     public void read(InputStream inputStream) {
  5.         int length = U2.read(inputStream);
  6.         byte[] bytes = new byte[length];
  7.         try {
  8.             inputStream.read(bytes);
  9.         } catch (IOException e) {
  10.             e.printStackTrace();
  11.         }
  12.         try {
  13.             value = readUtf8(bytes);
  14.         } catch (UTFDataFormatException e) {
  15.             e.printStackTrace();
  16.         }
  17.     }

  18.     private String readUtf8(byte[] bytearr) throws UTFDataFormatException {
  19.         //copy from java.io.DataInputStream.readUTF()
  20.     }
  21. }
复制代码

很简单,首先读取这一项的字节数组长度,接着调用 readUtf8(),将字节数组转化为 String 字符串。

再来看看 CONSTANT_Class 这一项,这一项存储的是类或者接口的符号引用:
  1. CONSTANT_Class_info {
  2.     u1 tag;
  3.     u2 name_index;
  4. }
复制代码

注意这里的 name_index 并不是直接的字符串,而是指向常量池中 cpInfo 数组的 name_index 项,且 cpInfo[name_in

dex] 一定是 CONSTANT_Utf8 格式。
  1. public class ConstantClass extends ConstantInfo {
  2.     public int nameIndex;

  3.     @Override
  4.     public void read(InputStream inputStream) {
  5.         nameIndex = U2.read(inputStream);
  6.     }
  7. }
复制代码

常量池解析完毕后,就可以供后面的数据使用了,比方说 ClassFile中的this_class 指向的就是常量池中格式为 CONSTANT_

Class 的某一项,那么我们就可以读取出类名:
  1. int classIndex = U2.read(inputStream);
  2. ConstantClass clazz = (ConstantClass) constantPool.cpInfo[classIndex];
  3. ConstantUtf8 className = (ConstantUtf8) constantPool.cpInfo[clazz.nameIndex];
  4. classFile.className = className.value;
  5. System.out.print("classname:" + classFile.className + "\n");
复制代码

字节码指令

解析常量池之后还需要接着解析一些类信息,如父类、接口类、字段等,但是相信大家最好奇的还是 java 指令的存储,大

家都知道,我们平时写的java代码会被编译成 java 字节码,那么这些字节码到底存储在哪呢?别急,讲解指令之前,我们

先来了解下 ClassFile中的method_info,其格式如下:
  1. method_info {
  2.     u2 access_flags;
  3.     u2 name_index;
  4.     u2 descriptor_index;
  5.     u2 attributes_count;
  6.     attribute_info attributes[attributes_count];
  7. }
复制代码

method_info 里主要是一些方法信息:如访问标志、方法名索引、方法描述符索引及属性数组。这里要强调的是属性数

组,因为字节码指令就存储在这个属性数组里。属性有很多种,比如说异常表就是一个属性,而存储字节码指令的属性

为 CODE 属性,看这名字也知道是用来存储代码的了。属性的通用格式为:
  1. attribute_info {
  2.     u2 attribute_name_index;
  3.     u4 attribute_length;
  4.     u1 info[attribute_length];
  5. }
复制代码

根据 attribute_name_index 可以从常量池中拿到属性名,再根据属性名就可以判断属性种类了。

Code属性的具体格式为:
  1. Code_attribute {
  2.     u2 attribute_name_index; u4 attribute_length;
  3.     u2 max_stack;
  4.     u2 max_locals;
  5.     u4 code_length;
  6.     u1 code[code_length];
  7.     u2 exception_table_length;
  8.     {
  9.         u2 start_pc;
  10.         u2 end_pc;
  11.         u2 handler_pc;
  12.         u2 catch_type;
  13.     } exception_table[exception_table_length];
  14.     u2 attributes_count;
  15.     attribute_info attributes[attributes_count];
  16. }
复制代码

其中 code 数组里存储就是字节码指令,那么如何解析呢?每条指令在 code[] 中都是一个字节,我们平时 javap 命令反编

译看到的指令其实是助记符,只是方便阅读字节码使用的,jvm有一张字节码与助记符的对照表,根据对照表,就可以将指

令翻译为可读的助记符了。这里我也是在网上随便找了一个对照表,保存到本地 txt 文件中,并在使用时解析成 HashMap

。代码很简单,就不贴了,可以参考我代码中 InstructionTable.java。

接下来我们就可以解析字节码了:
  1. for (int j = 0; j < methodInfo.attributesCount; j++) {
  2.     if (methodInfo.attributes[j] instanceof CodeAttribute) {
  3.         CodeAttribute codeAttribute = (CodeAttribute) methodInfo.attributes[j];
  4.         for (int m = 0; m < codeAttribute.codeLength; m++) {
  5.             short code = codeAttribute.code[m];
  6.             System.out.print(InstructionTable.getInstruction(code) + "\n");
  7.         }
  8.     }
  9. }
复制代码


运行

整个项目终于写完了,接下来就来看看效果如何,随便找一个 class 文件解析运行:

2.jpg

哈哈,是不是很赞!

由于篇幅限制,本文中只选取了一部分解析过程讲解,感兴趣的同学可参考我的github项目:

https://github.com/HalfStackDeveloper/ClassReader,欢迎 Fork And Star!


总结

Class 文件看起来很复杂,其实真正解析起来,也没有那么难,关键是要自己动手试试,才能彻底理解,希望各位看完后也

能觉知此事要躬行!

参考:

1. 周志明《java虚拟机规范(JavaSE7)》

2. 张秀宏《自己动手写Java虚拟机》

3. 周志明《深入理解Java虚拟机(第2版)》

3.jpg



本帖被以下淘专辑推荐:

想知道小甲鱼最近在做啥?请访问 -> ilovefishc.com
回复

使用道具 举报

 楼主| 发表于 2017-2-17 22:42:32 | 显示全部楼层
关于这篇文章提到的魔数,可以参看楼主这篇帖子 ----> 关于 0xCAFEBABE 的秘密

http://bbs.fishc.com/thread-81603-1-1.html
想知道小甲鱼最近在做啥?请访问 -> ilovefishc.com
回复 支持 反对

使用道具 举报

发表于 2017-2-18 08:50:15 | 显示全部楼层
真复杂啊
想知道小甲鱼最近在做啥?请访问 -> ilovefishc.com
回复 支持 反对

使用道具 举报

 楼主| 发表于 2017-2-18 10:22:01 | 显示全部楼层

我看了几遍才发了出来,嘻嘻
想知道小甲鱼最近在做啥?请访问 -> ilovefishc.com
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Archiver|鱼C工作室 ( 粤ICP备18085999号-1 | 粤公网安备 44051102000585号)

GMT+8, 2024-4-18 14:46

Powered by Discuz! X3.4

© 2001-2023 Discuz! Team.

快速回复 返回顶部 返回列表