想了解更多,欢迎关注我的微信公众号:Renda_Zhang
javac
编译 Java 程序成为 .class
文件后,还需要 Java 虚拟机识别 .class
后缀的文件,并且解析它的指令,然后才会被操作系统识别从而能调用操作系统上的函数。Clojure
、JRuby
、Groovy
等,编译到最后都是 .class
文件,Java 语言的维护者,只需要控制好 JVM 这个解析器,就可以将这些扩展语言无缝的运行在 JVM 之上了。The Java Virtual Machine Specification
– JVM 规范:定义了 .class 文件的结构、加载机制、数据存储、运行时栈等诸多内容,最常用的 JVM 规范实现就是 Hotspot VM
。The Java Language Specification
– Java 语言规范:定义 Java 语法规范,比如 switch、for、泛型、lambda 等。HelloWorld.java
,它遵循的就是 Java 语言规范。其中,它调用了 System.out
等模块,也就是 JRE 里提供的类库。public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
使用 JDK 的工具 javac HelloWorld.java
进行编译后,会产生 HelloWorld
的字节码。
可以使用 javap -v -p HelloWorld
来查看字节码文件内容。下面是System.out.println("Hello World");
的字节码内容:
...
0 getstatic #2 <java/lang/System.out>
3 ldc #3 <Hello World>
5 invokevirtual #4 <java/io/PrintStream.println>
8 return
...
Java 虚拟机采用基于栈的架构,其指令由操作码和操作数组成。这些字节码指令,就叫作 opcode。其中,getstatic
、ldc
、invokevirtual
、return
等,就是 opcode。
可以继续使用 Linux 的 hexdump
看一下字节码的二进制内容,与以上字节码对应的二进制,就是下面这几个数字(十六进制展示):
b2 00 02 12 03 b6 00 04 b1
对应关系:
0xb2 getstatic // 获取静态字段的值
0x12 ldc // 常量池中的常量值入栈
0xb6 invokevirtual // 运行时方法绑定调用方法
0xb1 return // void 函数返回
opcode 有一个字节的长度 (0~255),意味着指令集的操作码个数不能超过 256 条。而紧跟在 opcode 后面的是被操作数。比如 b2 00 02
,就代表了 getstatic #2
。
JVM 就是靠解析这些 opcode 和操作数来完成程序的执行的。当我们使用 Java 命令运行 .class
文件的时候,实际上就相当于启动了一个 JVM 进程。这些 .class 文件会被加载、存放到 元数据(metaspace)
中,执行引擎将会通过 混合模式
执行这些字节码。然后 JVM 会翻译这些字节码为操作系统相关的函数,它有两种执行方式:
opcode + 操作数
翻译成机器代码;JVM 作为 .class
文件的黑盒存在,输入字节码,调用操作系统函数。
JVM 的生命周期是和 Java 程序的运行是一样的。当程序运行结束,JVM 实例也跟着消失了。
C++ 需要手动管理内存,使用了指针的概念;而 Java 是自动内存管理机制,使用了引用的概念。Java 为了管理内存的申请和释放操作,就必须引入一个池子来延迟这些内存区域的回收操作。这个池子,叫作堆。
随着 Java 的发展,内存布局一直在调整之中。比如,Java 8 及之后的版本,彻底移除了持久代,而使用 Metaspace(元数据) 来进行替代。这也表示着 -XX:PermSize
和 -XX:MaxPermSize
等参数调优,已经没有了意义。但大体上,比较重要的内存区域是固定的。
JVM 堆中的数据是共享的,是占用内存最大的一块区域。可以执行字节码的模块叫作执行引擎。执行引擎在线程切换时,依靠程序计数器来恢复。JVM 的内存划分与多线程是息息相关的。像程序中运行时用到的栈(Java 虚拟机栈),以及本地方法栈,它们的维度都是线程。本地内存包含元数据区和一些直接内存。
JVM 中存在多个常量池。
constant_pool
,是每个类每个接口所拥有的,(如字节码中的 getstatic #2
)。这部分数据在方法区,也就是元数据区。JVM 的运行时区域是栈,而存储区域是堆。很多变量在编译期就已经固定了。
returnAddress
类型的值就是指向特定指令内存地址的指针。String.intern
相关的运行时常量池也放在这里。这个区域有大小限制,很容易造成 JVM 内存溢出,从而造成 JVM 崩溃。Perm 区在 Java 8 中已经被彻底废除,取而代之的是 Metaspace。原来的 Perm 区是在堆上的,现在的元空间是在非堆上的。元空间在非堆可以使用操作系统的内存,JVM 不会再出现方法区的内存溢出;但是,无限制的使用会造成操作系统的死亡。所以,一般也会使用参数 -XX:MaxMetaspaceSize
来控制大小。
方法区,是一个概念,它的物理存储的容器,就是元空间。方法区存储的内容,包括:类的信息、常量池、方法数据、方法代码。
-XX:MaxDirectMemorySize
来控制。一个 .class
文件,需要经历 ”加载、验证、准备、解析、初始化“ 的过程,然后才会被 JVM 的执行引擎执行。
.class
文件,加载到 Java 的方法区内。java.lang.VerifyError
错误。比如,一些低版本的 JVM,是无法加载一些高版本的类库的。// 如果没有为类变量赋值,它会有一个默认的初始值。
public class A {
static int a ;
public static void main(String[] args) {
// 输出 0
System.out.println(a);
}
}
// 如果没有给局部变量赋初始值,是不能使用的。
public class B {
public static void main(String[] args) {
int b ;
// 编译报错
System.out.println(b);
}
}
java.lang.NoSuchFieldError
根据继承关系从下往上,找不到相关字段时的报错。java.lang.IllegalAccessError
字段或者方法,访问权限不具备时的错误。java.lang.NoSuchMethodError
找不到相关方法时的错误。初始化阶段初始化成员变量。到了这一步,才真正开始执行一些字节码。
初始化规则:
static 语句块,只能访问到定义在 static 语句块之前的变量。
public class A {
static int a = 0 ;
static {
a = 1;
b = 1;
}
static int b = 0;
public static void main(String[] args) {
// 输出 1
System.out.println(a);
// 输出 0
System.out.println(b);
}
}
static {
b = b + 1;
}
static int b = 0;
会保证在子类的初始化方法执行之前,父类的初始化方法已经执行完毕。
所以,JVM 第一个被执行的类初始化方法一定是 java.lang.Object。另外,也意味着父类中定义的 static 语句块要优先于子类的。
与
:类的初始化。static 字段和 static 代码块,是属于类的,在类的加载的初始化阶段就已经被执行。类信息会被存放在方法区,在同一个类加载器下,这些信息有一份就够了,所以 static 代码块只会执行一次。
:对象的初始化。在对象初始化,即 new 一个新对象的时候,都会调用它的构造方法,用来初始化对象的属性;每一次新建对象的时候,都会执行。public class A {
static {
System.out.println("1");
}
public A(){
System.out.println("2");
}
}
public class B extends A {
static{
System.out.println("a");
}
public B(){
System.out.println("b");
}
public static void main(String[] args){
// static 代码块只会执行一次,
// 对象的构造方法执行两次。
A ab = new B();
ab = new B();
}
}
/**
输出:
1
a
2
b
2
b
*/
类加载器实现了 ”Loading(加载),Linking(链接),Initializing(初始化)“ 的过程。
类加载器保证了这个过程的安全性,比如 JRE 的类不能轻易被覆盖,防止被利用。
几个不同等级的类加载器:
rt.jar
、resources.jar
、charsets.jar
等。-Xbootclasspath
参数可以完成指定操作。lib/ext
目录下的 jar 包和 .class
文件。同样的,通过系统变量 java.ext.dirs
可以指定这个目录。URLClassLoader
。.class
文件,自定义的 Java 类会首先尝试使用这个类加载器进行加载。ClassLoader#loadClass
方法,可以知道,首先使用 parent 尝试进行类加载,parent 失败后才轮到自己。同时,应当注意到,这个方法是可以被覆盖的,也就是双亲委派机制并不一定生效:如果出现一些业务需求比如 “加载一个远程的 .class
文件” 或 “加密 .class
文件”,那么这时候就需要自定义一个新的类加载器。
所以,为了支持一些自定义加载类多功能的需求,Java 设计者作出了一些妥协,即可以打破双亲委派机制。
Tomcat 通过 war 包进行应用的发布,它其实是违反了双亲委派机制原则的。
对于一些需要加载的非基础类,会由一个叫作 WebAppClassLoader 的类加载器优先加载。等它加载不到的时候,再交给上层的 ClassLoader 进行加载。这个加载器用来隔绝不同应用的 .class
文件,而两个应用可能会依赖同一个第三方的不同版本,它们是相互没有影响的。
Tomcat 通过自定义类加载器,实现同一个 JVM 里运行着不兼容的两个版本。
Tomcat 自定义的 WebAppClassLoader 加载自己目录下的 .class 文件,并不会传递给父类的加载器。但是,它却可以使用 SharedClassLoader 所加载的类,实现了共享和分离的功能。
Class.forName("com.mysql.jdbc.Driver")
,用于加载所需要的驱动类。MySQL 通过在 META-INF/services
目录下,创建一个以接口全限定名为命名的文件(内容为实现类的全限定名),即可自动加载这一种实现,这就是 SPI。所以,即使删除了 Class.forName
这一行代码,也能加载到正确的驱动类。rt.jar
的。它们的类加载器是 Bootstrap ClassLoader,也就是最上层的那个。而具体的数据库驱动,却属于业务代码,这个启动类加载器是无法加载的。在数据库驱动加载的源码中,应用启动的时候,把当前的类加载器设置成了线程的上下文类加载器;而对于一个刚刚启动的应用程序来说,当前的加载器就是启动 main 方法的 Application ClassLoader。使用它来加载第三方驱动,是没有什么问题的。java.util.ServiceLoader
类进行动态装载。这种方式,同样打破了双亲委派的机制。HashMap
为例。当 Java 的原生 API 不能满足需求时,比如要修改 HashMap
类,就必须要使用到 Java 的 endorsed
技术。这时候就需要将自己写的 HashMap 类,打包成一个 jar 包,然后放到 -Djava.endorsed.dirs
指定的目录中。注意类名和包名,应该和 JDK 自带的是一样的。但是,java.lang
包下面的类除外,因为这些都是特殊保护的。-Djava.endorsed.dirs
指定的目录下的 jar 包,会比核心类库 rt.jar
中的文件,优先级更高,可以被最先加载到。分析字节码的小工具
javap
javap
是 JDK 自带的反解析工具。它的作用是将 .class 字节码文件解析成可读的文件格式。在使用 javap 时一般会添加 -v 参数,尽量多打印一些信息。同时也会使用 -p 参数,打印一些私有的字段和方法。
javap -p -v HelloWorld
在 javac 中可以指定一些额外的内容输出到字节码。经常用的有:
javac -g:lines
强制生成 LineNumberTable
。javac -g:vars
强制生成 LocalVariableTable
。javac -g
生成所有的 debug
信息。jclasslib
jclasslib
是一个图形化的工具,能够更加直观的查看字节码中的内容。它还分门别类的对类中的各个部分进行了整理,非常的人性化。对象创建方式除了常用的 new,还有下面四种方式(后面两种方式没有调用到构造函数):
一个简单的 Java 程序,它有一个公共方法 test,还有一个静态成员变量和动态成员变量:
// A.java
class B {
private int a = 1234;
static long C = 1111;
public long test(long num) {
long ret = this.a + num + C;
return ret;
}
}
public class A {
private B b = new B();
public static void main(String[] args) {
A a = new A();
long num = 4321 ;
long ret = a.b.test(num);
System.out.println(ret);
}
}
当虚拟机遇到一条 new 指令时,首先会检查这个指令的参数能否在常量池中定位一个符号引用。然后检查这个符号引用的类字节码是否加载、解析和初始化。如果没有,将执行对应的类加载过程。
拿上面的代码来说,执行 class A 代码,在调用 private B b = new B()
时,就会触发 B 类的加载。A 和 B 会被加载到元空间的方法区,进入 main 方法后,将会交给执行引擎执行。这个执行过程是在栈上完成的,其中有几个重要的区域,包括虚拟机栈、程序计数器等。
使用下面的命令编译源代码 A.java。这将强制生成 LineNumberTable 和 LocalVariableTable。如果用的是 Idea,可以直接将参数追加在 VM options 里面。
javac -g:lines -g:vars A.java
然后使用 javap 命令查看 A 和 B 的字节码。这个命令,不仅会输出行号、本地变量表信息、反编译汇编代码,还会输出当前类用到的常量池等信息。由于内容很长,这里就不全部展示了,可以使用下面的命令实际操作一下就可以了:
javap -p -v A
javap -p -v B
可以看到对象的初始化,首先是调用了 Object 类的初始化方法。注意这里是
而不是
。
1: invokespecial #1 // Method java/lang/Object."":()V
直接拼接内容。
#2 = Fieldref #6.#27 // B.a:I
...
#6 = Class #29 // B
#27 = NameAndType #8:#9 // a:I
...
#8 = Utf8 a
#9 = Utf8 I
注意到 :I
这样特殊的字符。它们也是有意义的,如果经常使用 jmap 这种命令,应该不会陌生。大体包括:
B
基本类型 byte
C
基本类型 char
D
基本类型 double
F
基本类型 float
I
基本类型 int
J
基本类型 long
S
基本类型 short
Z
基本类型 boolean
V
特殊类型 void
L
对象类型,以分号结尾,如 Ljava/lang/Object;
[Ljava/lang/String;
数组类型,每一位使用一个前置的 [
字符来描述注意到 code 区域,有非常多的二进制指令。如果你接触过汇编语言,会发现它们之间其实有一定的相似性。但这些二进制指令,并不是操作系统能够认识的,它们是提供给 JVM 运行的源材料。
上面的代码 Class B 的 test 函数同时使用了成员变量 a、静态变量 C,以及输入参数 num。此时说的函数执行,内存其实就是在虚拟机栈上分配的。下面这些内容,就是 test 方法的字节码。
public long test(long);
descriptor: (J)J
flags: ACC_PUBLIC
Code:
stack=4, locals=5, args_size=2
0: aload_0
1: getfield #2 // Field a:I
4: i2l
5: lload_1
6: ladd
7: getstatic #3 // Field C:J
10: ladd
11: lstore_3
12: lload_3
13: lreturn
LineNumberTable:
line 13: 0
line 14: 12
LocalVariableTable:
Start Length Slot Name Signature
0 14 0 this LB;
0 14 1 num J
12 2 3 ret J
首先,注意 stack 字样,它此时的数值为 4,表明了 test 方法的最大操作数栈深度为 4。JVM 运行时,会根据这个数值,来分配栈帧中操作栈的深度。
相对应的,locals 变量存储了局部变量的存储空间。它的单位是 Slot(槽),可以被重用。其中存放的内容,包括:
args_size 指的是方法的参数个数,因为每个方法都有一个隐藏参数 this,所以这里的数字是 2。
main
线程会拥有两个主要的运行时区域:Java 虚拟机栈和程序计数器。其中,虚拟机栈中的每一项内容叫作栈帧,栈帧中包含四项内容:局部变量报表、操作数栈、动态链接和完成出口。字节码指令,就是靠操作这些数据结构运行的。下面我们看一下具体的字节码指令。
0: aload_0
1: getfield #2
将栈顶的指定的对象的第 2 个实例域(Field)的值,压入栈顶。#2 就是指成员变量 a。
#2 = Fieldref #6.#27 // B.a:I
...
#6 = Class #29 // B
#27 = NameAndType #8:#9 // a:I
4: i2l
5: lload_1
6: ladd
7: getstatic #3
10: ladd
11: lstore_3
12: lload_3
13: lreturn
注意上面的 11: lstore_3
,它首先把变量存放到了变量报表,然后又拿出这个值,把它入栈。会有这种多此一举的操作的原因就是:函数定义了 ret 变量。JVM 不知道后面还会不会用到这个变量,所以只好傻瓜式的顺序执行。
如果把程序稍微改动一下,直接返回这个值:
public long test(long num) {
return this.a + num + C;
}
再次查看字节码指令,会发现简单很多:
0: aload_0
1: getfield #2 // Field a:I
4: i2l
5: lload_1
6: ladd
7: getstatic #3 // Field C:J
10: ladd
11: lreturn
但是栈的操作复杂度是 O(1),对程序性能几乎没有影响。平常的代码编写,还是以可读性作为首要任务。
JVM 为我们提供了非常丰富的字节码指令。详细的字节码指令列表,可以参考以下网址:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html
13: lreturn
注意上面的 11: lstore_3
,它首先把变量存放到了变量报表,然后又拿出这个值,把它入栈。会有这种多此一举的操作的原因就是:函数定义了 ret 变量。JVM 不知道后面还会不会用到这个变量,所以只好傻瓜式的顺序执行。
如果把程序稍微改动一下,直接返回这个值:
public long test(long num) {
return this.a + num + C;
}
再次查看字节码指令,会发现简单很多:
0: aload_0
1: getfield #2 // Field a:I
4: i2l
5: lload_1
6: ladd
7: getstatic #3 // Field C:J
10: ladd
11: lreturn
但是栈的操作复杂度是 O(1),对程序性能几乎没有影响。平常的代码编写,还是以可读性作为首要任务。
JVM 为我们提供了非常丰富的字节码指令。详细的字节码指令列表,可以参考以下网址:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html