前言
java语言在其刚诞生之际喊出的口号–“Write Once,Run Anywhere”,正是基于字节码(byte code)而存在的,java能够做到平台无关性,得力于这样一款优秀的中间语言,字节码的描述能力比java更强,所以它当然还不止为java服务,它同样为运行于JVM的其他语言服务,以作为一款通用的,与平台无关的,交付给JVM执行的媒介
广义的class文件就是字节码,但字节码不仅仅是class文件,它作为一段二进制流,还可以以其他各种形式存在,如压缩包(jar)、网络流等等
Tips:理论上任何编程语言,只要能被编译成字节码,就可以交付给JVM去跨平台的运行
class文件的前世今生
java发展已经有了二十多个年头了,但class文件的格式几乎没有任何改变,为了代码的向后兼容性,少数的几次class文件格式更新也是在原本结构上进行增加功能,并不更改已经定义的功能
class文件的特点
二进制流
由于class文件不需要可读性,为了节省空间,内部的数据按照既定的顺序紧凑的排列在一起,没有任何无用数据(空格、分隔符),全部都是重要数据。
正是因为不像XML\JSON等含分隔符,所以为了准确性,每个字节的含义、长度、顺序都是严格规定,不能改变
仅表示一个类
一个class文件表示一个类,如果一个java文件里写了多个类(包括内部类),会被编译成多个class文件
仅含两种数据类型
- 无符号数。以u1、u2、u4、u8来分别代表1字节、2字节、4字节、8字节的无符号数,并且是高位在前,即高位存储在地址最低位,和x86等处理器恰恰相反。
- 表。其实就是由多个无符号数组合而成的复合类型,也可以含有表,即嵌套结构,整个Class文件也可以视作一个表
魔数与版本号
如同计算机里的其他文件一样,class文件也被授予了一个标准文件头部,用于判断是否是一个class文件
class文件头被称为“魔数”,是个u4的数据,值为0xCAFEBABE(咖啡宝贝),呼应了java的logo图案。
Tips:来自于010Editor软件,可以很方便的查看class文件,并且会提示你每个位置二进制串的含义,如下:提示我括号里的4位是u4 magic
后面的四位分别是次版本号和主版本号,其中主版本号用的比较多,大家可以看书或上网查阅它与java版本的对应关系,如我是Java8,对应的大版本号就是十进制52,也就是0x34。高版本jdk能向下兼容老版本class文件,但会拒绝运行超过jdk版本的class文件
编译的时候可以通过javac参数自定义编译的版本,不过要符合版本的java特性,比如我写了个符合java6的代码,可以用jdk8来编译出java6的class文件
javac -source 1.6 -target 1.6 .\Test.java
查看生成的class文件,可以看到主版本号变成了0x32,对应着java6
常量池
魔数和版本号之后,也就是8位开始,就是常量池的入口了,通常是Class文件中最大的一部分。
存放内容
- 字面量
- 文本字符串
- final的常量值
- 符号引用
- 类和接口的全限定名
- 字段的名称和描述符(后面讲解名称和描述符有什么区别)
- 方法的名称和描述符
- 方法句柄和方法类型
- 被模块导出或开放的包
开头存放u2类型的容量计数器,用于判断常量池的结束
值得一提的是它是从1开始计常量池的数据索引,0索引被用来表示不引用任何常量池数据,所以0x0F表示常量池有14条数据(1-14)
常量池中有十几种数据结构类型,这里就不给出所有的了,需要请查阅相关资料书籍
常量池中数据可以抽象成如下形式反复拼接
类型标志tag/u1 | 此种类型的特定结构/表 |
---|
常量池中这十几种数据类型无缝拼接,所有的类型都是以tag来开头,通过它可以判断当前类型,进而得知类型的大小,进而读取全部的数据
手工分析实例
0x0A0003000C是第一个常量,因为tag是0x0A,对应的数据结构长度是5,从这之后就是下划线处第二个常量0x07000D,tag是0x07,数据结构如下
查表可知它表示的是Class_info
0x07是tag,0x000D是全限定名的索引,也就是常量池里的索引0x000D,十进制13,索引从1开始,我们可以数一数到第十三个常量,也就是下划线部分
tag是0x01
package org.b1ackc4t.jvm;
public class Test {
public static void main(String[] args) {
int a = 1;
}
}
对照可知这个常量的bytes部分确实是类的全限定名,一模一样。同理可以将常量表所有数据都推出来。
工具分析实例
幸运的是,我们不需要去每次手工的分析常量池数据,JDK已经内置了专门分析Class字节码的工具./bin/javap,正确配置了Java环境变量就可以直接使用了
javap -v Test.class
可以清晰的看到常量池的内容,比如刚刚分析的类全限定名,是第二个常量,指向第十三个常量的utf-8字符串
访问标志
紧随常量池之后的是访问标志
存放内容
各种各样的类修饰符,包括但不限于
- 是否是Public
- 是否是Final
- 是否是个接口
- 是否是Abstract
- 是否是个注解
类、父类、接口索引
紧随访问标志之后
存放内容
存储的是索引,指向常量池中的内容
- 自身全限定名
- 直接父类全限定名
- 所有接口全限定名集合
字段表集合
存放所有的字段,包括类级变量(static)、实例级变量。(一个字段表表示一个字段)
字段表结构内容
一个字段表结构包含以下内容:
- 访问修饰符
- 简单名称
- 描述符
- 其他额外属性(不像常量池的tag,额外属性没有tag,直接用属性名标识,属性名存在常量池中)
- 比如会为final(包括非静态的final)的字段记录初始值
真实的详细结构请查阅相关资料,这里只描述大致存储了哪些内容
简单名称和描述符
相信如果初始简单名称和描述符,都会对这两个词迷惑,这两个词不仅描述字段也描述方法,意思似乎差不多,但其实大不一样,接下来开始详谈
- 简单名称。就是平时给变量、方法定义的名字,嗯,就这么简单
- 描述符。是一种特殊的约定俗成的结构字符串。用来表达字段的数据类型、方法的参数列表、返回值等等,具体如何书写这种描述字符串可查阅相关资料
其他额外属性用的较少,在此不过多介绍。只了解这些相信也能理解字节码是如何用这些简短的数据结构来记录各种复杂的字段了
方法表集合
存放所有的方法包括:
- 静态方法、普通方法
- 父类里被子类重写的方法
- <init>方法,由编译器自动根据构造方法添加的实例构造器方法
- <clinit>方法,如果有静态代码块或者给静态字段赋值操作,编译器自动根据这些代码生成<clinit>方法(类构造器)
方法表结构内容
方法表结构与字段表十分类似,仅仅在访问修饰和额外属性有区别
一个方法表结构包含以下内容:
- 访问修饰符(比字段表少几种修饰方法比如:volatile)
- 简单名称
- 描述符
- 其他额外属性(不像常量池的tag,额外属性没有tag,直接用属性名标识,属性名存在常量池中)
- Code (方法代码,最重要的额外属性)
只要两个方法简单名称和描述符不完全相同,那么就可以在类中共存。Java方法重载就是简单名称相同,描述符不同。但是描述符还包括了返回值类型,所以字节码的描述其实是比Java范围更大的,理论上只有返回值不同的方法也能够进行重载,但这在Java上是不成立的,也印证了字节码并非只为Java而创造,未来可能有其他语言编译成字节码,能够用上字节码的其他特性,运行在JVM上
额外属性
这里只列举两个最常见的,详细信息请查阅相关资料
Code属性
作为方法表中最重要的额外属性,能够描述诸多信息,并非单纯存放代码
存放内容:
- 操作栈深度最大值
- 编译期可知的,编译时由编译器计算出方法执行时,任意时刻都不会超过的最大深度
- 局部变量表长度(单位:变量槽Slot)
- 编译期可知的,编译时由编译器计算出方法任意时刻所使用的局部变量(包括形参)最大长度,可能会根据变量生命周期进行相应的优化。
- 方法字节码内容(我们的主角!)
- 《Java虚拟机规范》规定字节码长度不能超过65535(也就是一个方法不能超过65535个字节码,不然编译不通过),但长度实际上是用u4来存储的,有四个字节长,可能是为了未来发展设定的。
- 异常表(用于方法体中异常处理(try-catch)的代码跳转)
Exceptions属性
方法throws抛出的异常信息,比如下面的Exception
public static void main(String[] args) throws Exception {
System.out.println("hello world");
}
实例-分析一个简单程序的字节码
目标class文件的源码如下:
package org.b1ackc4t.jvm;
public class Test extends SuperClass {
public static int staticVal = 1;
public static final byte finalStaticVal = 100;
public int val1 = 2;
public final int finalVal = 3;
public int val3;
public int[] val4;
public int val5;
static {
System.out.println("static code block!");
}
public Test() {
this.val3 = 10;
System.out.println("constructor 1");
}
public Test(int arg) {
this.val3 = 20;
System.out.println("constructor 2");
}
public boolean fun1(int a, float b) {
int c = 10;
int d = 20;
System.out.println("fun1");
return true;
}
public static void main(String[] args) throws Exception {
try {
System.out.println("main!");
Test t1 = new Test();
System.out.println(t1.val5);
} catch (Exception e) {
e.printStackTrace();
}
}
}
我们还是用javap来分析
javap -v Test.class
版本号
主版本号52,对应java8版本
常量池
访问标志以及父类、接口
访问权限为public
直接父类是SuperTest
没有接口
字段表集合
分析一个比较特别的字段
方法表集合
这里只分析两个方法
惊讶的是结尾还记录了编译的文件名Test.java
“Test.java”也确实出现在了常量池中,这并不是javap加的,而是字节码中确实存在的。
总结
字节码(class文件)的描述能力真的比Java语言要更强一些,默认情况下可以把Java语言表述的所有内容全部装进简短的字节码中,这也是为何class文件被反编译后还原成java代码的还原度如此之高,甚至文件名、局部变量名都能够一一还原。但真实环境中,无论是为了运行效率还是代码安全性,我们都应当适当更改编译选项,不要将过多信息编译进字节码之中,以免隐患产生。