参考书籍:
《深入理解Java虚拟机》
使用工具:
-
jclasslib Bytecode viewer
可以在idea插件中搜索下载到,可以查看Java class文件的字节码。使用的时候,需要先将先编译Java文件,生成class文件。然后如下图所示,点击View->Show Bytecode With jclasslib即可看到相关字节码信息。
-
hexdump for VSCode
可以在 VSCode插件中搜索下载到,可以查看十六进制的字节文件。使用的时候选中class文件,右击Show Hexdump。
一、Java文件编译
Javac
Javac可以将Java文件编译成字节码文件。首先我们在 idea中新建项目,输入下面的代码,
public class Main {
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = a + b;
System.out.println(c);
}
}
之后右键 Open in Terminal,就会打开 idea自带的终端。这时候,只要我们配置了环境变量就可以直接在终端输入:
javac Main.java
输入完命令,在 Main.java的同级目录里就会有一个 Main.class文件。
这个就是 Main.java的字节码文件。这个文件在 idea里面打开时显示的下面这个样子:
这个文件已经被 idea反编译了,展示出的信息和我们的Java源码没什么差别,看不到字节码原本的信息。
hexdump
要查看 class文件的原本信息,可以使用 hexdump for VSCode。首先在VsCode里面打开我们刚才创建的项目。然后选中Main.class,右键 Show Hexdump。就可以看到下面的情况:
我们可以看到整个文件有很多数字和字母。以上面这个文件为例:我们可以看到一共有27行。第一行代表的是每一行的行内偏移地址,有16个偏移量。我们再看第一列,是以16进制在逐行递增。我们通过第一行和第一列,就可以像xy坐标一样可以帮助我们确定文件里每一个字节码的位置。
两个16进制的数字组合在一起可以表示最多256种信息,也就是用一个字节(8位)来表示。所以文件里的两个十六进制的编码就是一个字节码。
比如,第一行的 CA
FE
BA
BE
就分别是字节码。
最后:为什么需要编译呢?
二、类文件结构
到这里,我们已经通过 Javac获得了class文件。也使用了 hexdump for VSCode查看了class文件的字节码。那么什么是Class文件呢?
Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符。
从上面使用 hexdump for VSCode工具处理的结果,我们就可以看出Class文件是以字节为单位的二进制流。Class文件中规定的数据项目按顺序紧凑的排列,使得虚拟机在执行Class文件,知道什么时候执行的是哪一部分。
在Class文件中只有两种数据类型:无符号数和表。
类型 | 名称 | 数量 |
---|---|---|
u4 | magic | 1 |
u2 | minor_version | 1 |
u2 | major_version | 1 |
u2 | constant_pool_count | 1 |
cp_info | coustant_pool | constant_pool_count - 1 |
u2 | access_flags | 1 |
u2 | this_class | 1 |
u2 | super_class | 1 |
u2 | interfaces_count | 1 |
u2 | interfaces | interfaces_count |
u2 | fields_count | 1 |
field_info | fields | fields_count |
u2 | methods_count | 1 |
method_info | methods | methods_count |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
Class文件就是按照上表的顺序填充一个编译后的文件。其中我们可以看到,比如一些有多个同种数据结构的,Class文件会先声明这种数据结构的数量,然后接下来就会有多少种该类型的数据结构出现。这就确保了按顺序排列,知道什么文件的那个地方是什么数据类型,当然读取的时候也是按照顺序进行读取的。
在上表中,u2, u4分别代表的是2个字节,4个字节。有info结尾的就是表。
接下来,我们就按照上面那张表,实践一下。
magic
首先是四个字节的名称为magic的数据。它的作用是确定这个文件是否为一个能被虚拟机接受的Class文件。只要它是Class文件那么它的值也是固定的,即0xCAFEBABE
。
version
接下来是两个字节的次版本号和两个字节的主版本号。
次版本号为0,主版本号0x0034换算成十进制是52。
这个版本号指的是编译这个Java文件的JDK的版本。Java的版本号是从45开始的,然后我使用的是JDK8编译的,所以52是正确的。Java是兼容向下兼容的,所以高版本的JDK运行低版本的Class文件。
constant_pool
接下来是有两个字节用于表示常量池数量的。
0x001B换算成十进制数是27。我们的常量池里有27个常量,每一个都是cp_info类型的。然而截至目前cp_info类型的常量池表的类型有超过十种,每一种表的长度都是不一样的。那么我们怎么按顺序继续把整个Class文件读下去呢?
在Class文件中,使用了的标志位来区分不同的表,也就是在每一个表的开始会有1个大小为1个字节的标志位来区分不同的表。我们继续往下分析Class文件。
0x0A换算成十进制数是10,也就是常量池的第一个常量是标志位为10的常量。我们查看这种类型的常量池表是什么样的,如下所示:
tag | u1 | 值为10 |
---|---|---|
index | u2 | 指向声明方法的类描述符CONSTANT_Class_info的索引项 |
index | u2 | 指向名称及类型描述符CONSTANT_NameAndType的索引项 |
然后我们连续读两个2个字节的数据,分别是0x0005 -> 5,0x000E -> 14。对应的5和14是第一个常量的索引项。接下来的一个字节是第二个常量的标志位,标志为9。至此我们分析完了Class文件的第一行,这个过程有点繁琐。不过我们可以借助其它工具来帮助我们分析。
Javap
用终端打开Class文件所在的路径,输入命令:
javap -verbose Main.class
可以看到反汇编的附加信息,如下图所示
上图箭头所标识的是我们分析过的内容。在第一个常量的右边还用双斜线标注这个常量的值。
Class文件的分析就是按照预定的结构分析下来。
Class文件是怎么被编译得到的?编译是一个怎么样的过程?
官网提供了解释->编译概述
另外,查看字节码信息,也可以使用 jclasslib Bytecode viewer。
三、Class类加载
那么截至到目前,我们得到的Class文件还是静态的,我们怎么让它到内存中运行呢?
加载
首先需要加载,通过类加载器会把Class文件从静态转化为 方法区运行时的数据结构。
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的入口。
其中第二点的转化过程是通过类加载器来的。
类加载器
类的唯一性
类文件本身和加载它的类加载器确定了这个类的唯一性。
import java.io.IOException;
import java.io.InputStream;
public class Main {
public static void main(String[] args) throws Exception{
//自定义加载器
ClassLoader loader1 = new ClassLoader() {
@Override
public Class> loadClass(String name) throws ClassNotFoundException {
String fileName = name + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
}
try {
byte[] bytes = new byte[is.available()];
is.read(bytes);
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
e.printStackTrace();
return super.loadClass(fileName);
}
}
};
Object o1 = loader1.loadClass("Main").newInstance();
System.out.println(o1.getClass());
System.out.println(o1 instanceof Main);
//系统加载器
ClassLoader loader2 = ClassLoader.getSystemClassLoader();
Object o2 = loader2.loadClass("Main").newInstance();
System.out.println(o2.getClass());
System.out.println(o2 instanceof Main);
}
}
双亲委派模型
系统提供了三种类加载器:1.启动类加载器;2.扩展类加载器;3.应用程序类加载器。它们分别加载特定路径下的Class文件。
加载策略是这样的:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器,如果父类加载器在自己的加载路径下没有这个类,那么子类才会尝试加载这个类。
验证
验证的最主要的就要保证字节码文件是符合当前虚拟机的要求,不会危害虚拟机的安全。
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
准备
为类变量分配内存并设置类变量的初始值,变量使用的内存在方法区分配。
数据类型 | 零值 | 数据类型 | 零值 |
---|---|---|---|
int | 0 | boolean | false |
long | 0L | float | 0.0f |
short | (short) 0 | double | 0.0d |
char | '\u0000' | reference | null |
byte | (byte) 0 |
解析
将符号引用转换为直接引用。
public class Main {
public static void main(String[] args) {
Main main = new Main();
}
}
这个Java文件反编译后(截取部分)是:
new 指令携带者一个参数,这个参数是常量池里的字符串,这个字符串的字面值就是“Main”,不过它是 CONSTANT_Class_info类型的常量。这时候就会去看看这个类是否已经存在,如果没有存在就会去加载。这个过程就是解析,解析就是把一个特殊含义的常量实现它本身的含义。
初始化
初始化阶段是执行类构造器
方法的过程。 是有编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。
public class Main {
private static int a = 1;
private int b;
static {
a = 2;
}
static {
a = 3;
}
}
反编译后可见:
原本分开的静态语句块按照在源码中的顺序执行了,其它非静态变量并没有赋值。
四、字节码的执行
栈帧
在Jvm中一个方法的调用执行是在 虚拟机栈中执行的。一个方法会使用一个栈帧,一个栈帧包括局部变量表、操作数栈、返回地址等。一个栈帧需要的多大的内存已经在代码编译的时候已经确定了。在运行时,只有位于栈顶的栈帧才是有效的。
局部变量表
局部变量表是一组变量存储空间,用于存放方法参数和方法内定义的局部变量。
局部变量表有8中数据类型:boolean、byte、char、short、int、float、reference、returnAddress。
在局部变量表中的最小内存单位是一个Slot。每个Slot都可以存放上面的8中数据类型。
在面试中,面试官会希望你知道自己写下的每一行代码是怎么样执行的。例如,下面这个程序,它的每一条语句的内存分布会是怎么样的呢?
public class Main {
public static void main(String[] args) {
int a = 1;
Main main = new Main();
}
}
通过反编译,我们获得上面代码的字节码指令:
- stack=2,表示这个方法的操作数栈深度为2
- locals=3,表示这个方法的局部变量表的大小为3
- args_size=1,表示这个方法的形参个数
- iconst_1,将int型1推送至栈顶
- istore_1,将栈顶int型存入第二个本地变量
- new,创建一个对象,并将其引用值压入栈顶
- dup,复制栈顶数值并将其压入栈顶
- invokespecial,调用实例化方法
- astore_2,将栈顶引用型数值存入第三个本地变量
- return,从当前方法返回void
上面标黑的是那两条赋值语句反编译后的指令操作。
inconst_1 这条指令直接就将int型送到操作数栈,然后 istore_1 存到本地变量。所以第一条语句,我们可以知道1是存在这个栈帧的局部变量表里的。
new 这条指令会先检查要实例化的这个类在方法区里是否已经存在了。如果存在了,就会直接在 Java堆中分配内存;如果不存在,就是执行 类加载过程。分配完内存的后的,对象的字段都会初始化为零值。
invokespecial 这条指令会把对象,按照程序员自己的意愿进行初始化对象。
astore_2 所以通过字节码指令,我们可以知道栈帧的局部变量表里面存储的是引用,而对象本身是存储在 Java堆中的。
那么对象在Java堆中是如何存在的呢?
操作数栈
Jvm的指令集是基于栈的,在执行指令的时候伴随着入栈、出栈操作。操作数栈的深度,在字节码文件生成的时候就已经确定了
五、运行时内存区域
在上文中,我们分别介绍了
- 方法区,用于存储已被虚拟机加载的类信息、常量、静态变量等
- 虚拟机栈,Java方法执行的内存模型
- Java堆,对象实例及数组都在堆上分配
除了这三种以外,在Java虚拟机运行时的数据区还有
- 程序计数器,用来指示下一条要执行的指令
- 本地方法栈,用来执行Native方法的内存模型
关于上面这五种运行时数据区,除了程序计数器不会发生 OutOfMemoryError之外,其它的四个都会发生相对应的 OOM。其中 Java堆是垃圾回收的主要地方。
六、内存分配策略
什么是GC回收对象
在判断一个对象是不是可回收对象,我们有两种算法:
引用计数算法
可达性分析算法
从GCRoots 向下搜索,搜索的路径叫做引用链,不在引用链上的对象是可回收对象。
可作为GCRoots的对象:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象,
- 方法区中类静态属性引用的对象,
- 方法区中常量引用的对象,
- 本地方法栈中引用的对象,
垃圾搜集算法
-
标记-清除算法
首先标记出所有需要回收的对象,在标记完成后同一回收所有被标记的对象。
不足:1.效率;2.内存碎片
-
复制算法
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当一块用完,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存一次清理掉。
采用此算法来回收新生代。
-
标记-整理算法
标记后,让存活的对象向一端移动,然后直接清理掉边界以外的内存。
采用此算法回收老年代。
分代搜集算法
内存分配与回收策略
对象优先在Eden分配
-
大对象直接进入老年代
大对象指需要大量连续内存空间的Java对象
-
长期存活的对象将进入老年代
如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加一岁,当它的年龄增加到一定程度,就将会被晋升到老年代。
-
动态对象年龄判定
如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于改年龄的对象就可以直接进入老年代,无需等到要求的年龄。
空间分配担保
七、最后
最后,本文从一个Java源文件到被送入虚拟机执行连起来说了,中间省略了一些其它的知识点。比如,Class文件的编译过程是怎么样的?字节码在执行方法调用的时候是如何找到方法的入口的?对象在Java堆中的数据结构是怎么样的?等等还有其它一些细节。可以查看周志明老师的《深入理解Java虚拟机》进行补充。
本文是基于《深入理解Java虚拟机》,JDK7版本,按照个人理解总结归纳写的,有错误的地方还望指出。
其它资料
Java语言和虚拟机规范