本文主要通过以下几个方面来系统的介绍 JVM

【参考资料】

  1. 视频课程:BiliBili - 黑马程序员 JVM 完整教程
  2. The Java® Virtual Machine Specification - Java SE 8 Edition 官方文档

零、什么是 JVM?

JVM (Java Virtual Machine) 是 Java 程序的运行环境(Java 二进制字节码的运行环境)

好处:

  • 可以提供一个跨平台的一致的运行环境, 达到平台无关性;
  • 提供内存管理, 垃圾回收功能;

JRE = JVM + 基础类库

JDK = JVM + 基础类库 + 编译工具

image-20220719142025243

一、JVM 结构

image-20220719142921758

总体分为三大部分:

  • ClassLoader 类加载器:Java 代码编译成二进制后,会经过类加载器,这样才能加载到 JVM 中运行。
  • JVM 内存结构
  • 执行引擎

二、JVM 内存结构

  1. 程序计数器 (Program Counter Register)
  2. 虚拟机栈 (JVM Stacks)
  3. 本地方法栈 (Native Method Stacks)
  4. 堆 (Heap)
  5. 方法区 (Method Area)

1. 程序计数器

Java中 JVM 指令的实行流程

image-20220719144541816

作用: 在指令的执行中, 记住下一条 JVM 指令的执行地址. 在物理上可使用寄存器实现.

特点:

  • 线程私有。在多线程下, 线程间切换时需要保存当前环境, 需要用到程序计数器记住下一条 JVM 指令的执行地址
  • 不存在内存溢出。

2. 虚拟机栈

2.1 定义

回忆数据结构中“”的结构: 先进后出

虚拟机栈是**线程运行需要的内存空间**,一个栈由多个栈帧组成。一个栈帧对应一次方法的调用,栈帧(Frame)每个方法调用时需要的内存(参数、局部变量、返回地址等)

  • 每个线程只能有一个**活动栈帧,对应着当前正在执行的那个方法**,栈顶的栈帧。

注意⚠️:可以在 IDEA 中用 “debug” 模式下的“Debugger”视图中看到栈和栈帧.

思考:

  • 在函数的调用中,

    1. 先把主调函数入栈,调用被调函数,紧接着被调函数入栈,活动栈帧为被调函数;
    2. 等被调函数返回返回值时,被调函数出栈,活动栈帧为主调函数。
  • 垃圾回收不涉及栈内存, 因为每次执行后栈内存都会被清空(出栈)

  • 栈内存越大, 线程数越小 (默认 1024KB)

    1
    -Xss 1m or 1024k or 1048576

2.2 栈内存溢出

1
java.lang.StackOverflowError
  1. 栈帧过多导致内存溢出

    • 想象一下,在不断的调用方法时,一直入栈没有出栈,直到某一次调用时无法分配新的栈帧内存。

      e.g. 无递归终止条件的递归调用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class StackOverflowTest {
    public static int count;
    public static void main(String[] args){
    try {
    method1();
    } catch (Throwable a) {
    e.printStackTrace();
    System.out.print(count);
    }
    }

    private static void method1() {
    count++;
    method1();
    }
    }
  2. 栈帧过大导致内存溢出,栈帧 > 栈内存

2.3 线程运行诊断

如何查看某个进程中 CPU 的占用情况

1
ps H -eo pid,tid,%cpu | grep 进程id

JDK 自带一个工具 JStack 命令, 用于定位 CPU占用过多的 Java线程(TID). 根据线程 id(TID) 找到有问题的线程,即可能有问题的代码行数. 也可以发现有死锁的进程.

1
jstack 进程id(PID)

3. 本地方法栈

本地方法是由于 Java 语言限制, 不能直接和操作系统底层“打交道”,所以需要 c/c++ 语言编写的方法直接与底层操作系统“打交道”, 而java代码可以使用本地方法调用来调用这些方法。

本地方法使用的内存就是本地方法栈.

  • 例如 hashCode(), wait(), notify(), notifyAll()
  • native 修饰

4. 堆

线程共享的区域,都要考虑线程安全问题

4.1 定义

  • 通过 new 关键字 \to 创建一个堆,都会使用堆的内存

特点:

  • 线程共享,堆中对象都要考虑线程安全问题
  • 有垃圾回收机制,当对象不再被引用时,其占用的内存会被回收

4.2 堆内存溢出

1
java.lang.OutOfMemoryError
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main (String[] args){
int count = 0;
try {
List<String> list = new ArrayList<>(); // 创建堆
String a = "Hello";
while (true) {
list.add(a);
a = a + a; // Hello, HelloHello, HelloHelloHelloHello, ....
count++;
}
} catch (Throwable e) {
e.printStackTrace();
System.out.println(count);
}
}

4.3 堆内存诊断

Java 常用工具:

  1. jps 工具
    • 查看当前系统中有哪些java进程
  2. jmap 工具
    • 查看某一时刻下,堆内存的占用情况
1
2
$ jps
$ jmap -heap 进程id(PID)
  1. jconsole 工具
    • 图形界面的, 多功能的检查工具, 可以连续监测
  2. jvisualvm 工具 (需要自行下载)
1
2
3
4
5
6
7
8
9
10
11
public static void main (String[] args){
System.out.println("1....");
Thread.sleep(30000);
byte[] array = new byte[1024 * 1024 * 10]; // 堆中内存占用新增10MB
System.out.println("2....");
Thread.sleep(30000);
array = null;
System.gc(); // 垃圾回收
System.out.println("3....");
Thread.sleep(30000);
}

5. 方法区

5.1 定义

方法区是 JVM所有线程共享的区域.

存储了与结构相关的信息:

  • 成员变量 (field)
  • 方法的数据 (method data)
  • 方法的代码 (code of method)
  • 构造器的代码 (code of constructor)
  • 运行时常量池 (run-time constant pool)

方法区在 JVM **启动时**创建,逻辑上是的一部分。

5.2 组成

JDK 1.6 与 JDK 1.8。

  • JDK 1.6 中,方法区这种概念的实现方式(永久代)属于 JVM 的内存结构;
  • JDK 1.8 中,方法区这种概念的实现方式(元空间)从 JVM 的内存结构中提取出来,属于操作系统内存结构的一部分

methodArea

5.3 方法区的内存溢出

  • 永久代内存溢出(JDK 1.8以前)
1
2
ERROR INFO: java.lang.OutOfMemoryError: PermGen space
-XX:MaxPermSize=8m
  • 元空间内存溢出(JDK 1.8以后)
1
2
ERROR INFO: java.lang.OutOfMemoryError: Metaspace
-XX:MaxMetaspaceSize=8m
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Demo extends ClassLoader { // 类加载器: 可以用来加载类的二进制字节码, 动态加载
public static void main (String[] args){
int j = 0;
try {
Demo test = new Demo();
for (int i = 0; i < 10000; i++, j++) {
ClassWriter cw = new ClassWriter(0); // ClassWriter作用是生成类的二进制字节码
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
// 版本号, public, 类名:1~10000, 包名:null, 父类: 继承自"java/lang/Object", 接口名:null
//返回 byte[]
byte[] code = cw.toByteArray();
// 只执行类的加载, 而不链接
test.defineClass("Class" + i, code, 0, code.length); // class 对象
}
} finally {
System.out.println(j);
}
}
}

有可能的溢出场景:实际生产中,动态产生并加载类时容易产生这种内存溢出

  1. Spring 框架中的 cglib 字节码技术,AOP 的核心 - 生成动态代理类
  2. Mybatis 框架中的 cglib 字节码技术

5.4 运行时常量池

编译后的二进制字节码包含: 类基本信息常量池类方法定义虚拟机指令

1
javap -v <xxx.class> // -v 显示反编译后的详细信息

例如:

1
2
3
4
5
public class test {
public static void main(String[] args) {
System.out.println("Hello World");
}
}

反编译后的详细信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
Classfile /test.class											// 类基本信息
Last modified 2022年3月6日; size 413 bytes
SHA-256 checksum 7ab757ee2d78f0e76a52ba8b03b43fee2fe9d7994d74bc7d133b2e309ceed8f3
Compiled from "test.java"
public class test
minor version: 0
major version: 59
flags: (0x0021) ACC_PUBLIC, ACC_SUPER // 访问修饰符
this_class: #21 // test
super_class: #2 // 父类:java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1

Constant pool: // 常量池
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = String #14 // Hello World
#14 = Utf8 Hello World
#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V
#16 = Class #18 // java/io/PrintStream
#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V
#18 = Utf8 java/io/PrintStream
#19 = Utf8 println
#20 = Utf8 (Ljava/lang/String;)V
#21 = Class #22 // test
#22 = Utf8 test
#23 = Utf8 Code
#24 = Utf8 LineNumberTable
#25 = Utf8 main
#26 = Utf8 ([Ljava/lang/String;)V
#27 = Utf8 SourceFile
#28 = Utf8 test.java

{ // 方法定义的区域
public test(); // 当程序没有构造方法时, 编译器会默认生成一个无参的构造方法
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0

public static void main(java.lang.String[]); // main方法
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
// 虚拟机指令 #n: 对应着常量池中的变量
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World 加载引用地址
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String)V
8: return
LineNumberTable:
line 3: 0
line 4: 8
}
SourceFile: "test.java"
  • 常量池就是一张常量查找表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量(如字符串、整型、bool类型等)等信息;
  • 运行时常量池,就是当该类被加载时,它的常量池信息会放入运行常量池,地址会替换为真正的内存地址。

5.5 StringTable串池

特征:

  • 常量池中的信息,都会被加载到运行时常量池中。这时 "a", "b", "ab" 都是常量池中的符号,还不是 字符串对象

  • 常量池中的字符串仅是符号,只有在被第一次引用到时才会转化为对象 ldc

  • StringTable在内存结构上是哈希表,不能扩容

  • 利用串池的机制,来避免重复创建字符串对象

  • 字符串变量拼接的原理是StringBuilder

  • 字符串常量拼接的原理是编译器优化

  • 可以使用 intern() 方法,主动将串池中还没有的字符串对象放入串池中

    • JDK 1.8 中,尝试将串池中还没有的字符串对象放入串池时,如果串池中有该对象则不会放入;若没有,则放入串池,且将串池中的对象返回
    • JDK 1.6 中,尝试将串池中还没有的字符串对象放入串池时,如果串池中有该对象则不会放入;若没有,则会先将该对象复制一份,然后放入串池,最后将串池中的对象返回

    注意:无论是串池还是堆里面的字符串,都是对象

5.5.1 串池
1
2
3
4
5
6
7
public class StringTableStudy {
public static void main(String[] args) {
String a = "a";
String b = "b";
String ab = "ab";
}
}

常量池中的信息:

1
2
3
4
5
6
7
0: ldc           #2                  	// String a
2: astore_1 // 把 a符号 变成 “a”字符串对象
3: ldc #3 // String b
5: astore_2 // 把 b符号 变成 “b”字符串对象
6: ldc #4 // String ab
8: astore_3 // 把 ab符号 变成 “ab”字符串对象
9: return
  1. 当执行到 ldc #2 时,会把符号 a 变为 “a” 字符串对象,并放入串池中(hashtable结构 不可扩容)

  2. 当执行到 ldc #3 时,会把符号 b 变为 “b” 字符串对象,并放入串池中

  3. 当执行到 ldc #4 时,会把符号 ab 变为 “ab” 字符串对象,并放入串池中

  4. 最终 StringTable ["a", "b", "ab"]

    注意:字符串对象的创建都是懒惰的,只有当运行到那一行字符串且在串池中不存在的时候(如 ldc #2)时,该字符串才会被创建并放入串池中。

5.5.2 串池:拼接变量字符串对象创建字符串

使用拼接字符串变量对象创建字符串的过程:

1
2
3
4
5
6
7
8
9
10
11
public class StringTableStudy {
public static void main(String[] args) {
String a = "a";
String b = "b";
String ab = "ab";
String ab2 = a+b; // new StringBuilder().append("a").append("b").toSrting()
// 相当于创建了一个新的 String对象

System.out.println(ab == ab2); // 结果为false
}
}

反编译后的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Code:
stack=2, locals=5, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
16: aload_1
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
20: aload_2
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: astore 4
29: return

通过拼接的方式来创建字符串的过程是:new StringBuilder().append("a").append("b").toString(),地址应该在

最后的toString方法的返回值是一个新的字符串,但字符串的和拼接的字符串一致,但是两个不同的字符串,一个存在于串池之中,一个存在于堆内存之中

5.5.3 串池:拼接常量字符串对象的方法创建字符串

使用拼接字符串常量对象的方法创建字符串

1
2
3
4
5
6
7
8
9
10
11
public class StringTableStudy {
public static void main(String[] args) {
String a = "a";
String b = "b";
String ab = "ab";
String ab2 = a+b;
String ab3 = "a" + "b"; // 使用拼接字符串的方法创建字符串,由于编译期间的优化

System.out.println(ab == ab3); // 结果为true
}
}

反编译后的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Code:
stack=2, locals=6, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
16: aload_1
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
20: aload_2
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: astore 4 // ab3初始化时直接从串池中获取字符串
29: ldc #4 // String ab
31: astore 5
33: return
  • 当虚拟机执行到第0、3、5行时,会将“a” “b“ ”ab“放入串池。当执行到29行时我们可以看到,虚拟机不会先找“a” 再找“b”然后再将它们拼接起来,而是之间找到拼接后的“ab”。

    1
    StringTable["a", "b", "ab"]

⚠️ 需要注意的是:

  • 使用拼接字符串常量 的方法来创建新的字符串时,因为内容是常量javac 在编译期会进行优化,结果已在编译期确定为 ab,而创建 ab 的时候已经在串池中放入了 "ab",所以 ab3 直接从串池中获取值,所以进行的操作和 ab = "ab" 一致。
  • 使用拼接字符串变量 的方法来创建新的字符串时,因为内容是变量,只能在运行期确定它的值,所以需要使用StringBuilder来创建
5.5.4 串池:intern() 方法(JDK1.8)

调用字符串对象的 intern() 方法,会将该字符串对象尝试放入到串池中

  • 如果串池中没有该字符串对象,则放入成功
  • 如果有该字符串对象,则放入失败
  • 无论放入是否成功,都会返回串池中的字符串对象
例1

JDK 1.8 环境下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class StringTableInternMethod1 {
public static void main(String[] args) {
String str = new String("a") + new String("b");
// "a""b" 被放入串池中,str 则存在于堆中
// StringTable["a", "b"]
// new String("a") 和 new String("b") 两个字符串对象
// 字符串对象 str = new StringBuilder().append("a").append("b").toSrting()

String str2 = str.intern();
// 调用str的intern()方法,这时串池中没有"ab",则会将该字符串对象放入到串池中,此时堆内存与串池中的"ab"是同一个对象

String str3 = "ab";
// 给str3赋值,因为此时串池中已有"ab",则直接将串池中的内容返回

// 因为堆内存与串池中的"ab"是同一个对象,所以以下两条语句打印的都为true
System.out.println(str2 == str);
System.out.println(str3 == str);
}
}
例2

JDK 1.8 与 JDK 1.6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SringTableInternMethod2 {
public static void main(String[] args) {
/* 此处创建字符串对象"ab",因为串池中还没有"ab",所以将其放入串池中 */
String str3 = "ab";
/* "a" "b" 被放入串池中,str则存在于堆内存之中 */
String str = new String("a") + new String("b");
/* 此时因为在创建str3时,"ab"已存在与串池中,所以放入失败,但是会返回串池中的"ab" */
String str2 = str.intern();

System.out.println(str == str2); //false
System.out.println(str == str3); //false
System.out.println(str2 == str3); //true
}
}
例3

JDK 1.6环境下,与例1进行比较。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class StringTableInternMethod3 {
public static void main(String[] args) {
String str = new String("a") + new String("b");
// "a""b" 被放入串池中,str 则存在于堆中
// StringTable["a", "b"]
// new String("a") 和 new String("b") 两个字符串对象
// 字符串对象 str = new StringBuilder().append("a").append("b").toSrting()

String str2 = str.intern();
// 调用str的intern()方法,这时串池中没有"ab",则会将该字符串对象放入到串池中,此时堆内存与串池中的"ab"是同一个对象
// 将 str 复制一份,将复制后的对象放入串池
// 此时 str2 与串池中的对象相同;str 则不同,是其的复制

String str3 = "ab";
// 给str3赋值,因为此时串池中已有"ab",则直接将串池中的内容返回,即 str3 = str2

System.out.println(str3 == str2); // true
System.out.println(str3 == str); // false
// JDK1.8 环境下都为true
}
}
5.5.5 串池的位置

在 JDK 1.6 中:

1.6

我们可以看到,由于串池逻辑上处于方法区中,而方法区是由永久代实现的,在垃圾回收时需要 FullGC 才能清理永久代,这样就会造成串池迟迟得不到清理,从而导致内存溢出。

1
2
3
JDK1.6 环境下:
-XX:MaxPermSize=10m
-Xmx10m -XX:-UseGCOverheadLimit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class StringTableJDK1_6Demo{
public static void main(String[] args){
List<String> list = new ArrayList<~>();
int i = 0;
try {
for (int j = 0; j < 1000000; j++) {
list.add(String.valueOf(j).intern());
i++;
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
}
1
2
ERROR INFO:
java.lang.OutOfMemoryError: PermGen space

为了解决以上问题,在 JDK 1.8 中改进了串池的位置。

在 JDK 1.8 中:

StringTable1.8

串池位于堆中,在垃圾回收时需要 MinorGC 进行垃圾回收,从而减轻内存占用。

1
2
JDK1.6 环境下:
-Xmx10m -XX:-UseGCOverheadLimit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class StringTableJDK1_8Demo{
public static void main(String[] args){
List<String> list = new ArrayList<~>();
int i = 0;
try {
for (int j = 0; j < 1000000; j++) {
list.add(String.valueOf(j).intern());
i++;
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
}
1
2
ERROR INFO:
java.lang.OutOfMemoryError: Java heap space
5.5.6 串池的垃圾回收

StringTable 在内存紧张时,会发生垃圾回收

5.5.7 串池的性能调优
  • 因为 StringTable 是用 HashTable 实现的,所以我们可以适当增加 HashTable 的桶的个数,来减少字符串放入串池所需要的时间

    1
    -XX:StringTableSize=xxxx
  • 考虑是否需要将字符串对象入池,可以通过 intern() 方法减少重复入池

6. 直接内存

6.1 定义

直接内存不属于 JVM 内存结构,而是操作系统的内存

  • 属于操作系统,常见于 NIO 操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受 JVM 内存回收管理

6.2 基本使用

image-20220816162626624

使用了 DirectBuffer 后,

【直接内存】是操作系统和 Java 代码都可以访问的一块区域,无需将代码从系统内存复制到 Java 堆内存,从而提高了效率

image-20220816163058539

6.3 分配和回收原理

  • 使用了 Unsafe 类来完成直接内存的分配回收,而且回收需要主动调用**unsafe.freeMemory()**方法
  • ByteBuffer 的实现内部使用了 Cleaner虚引用)来检测 ByteBuffer。一旦 ByteBuffer 被垃圾回收,那么会由 ReferenceHandler 来调用 Cleaner 的 clean() 方法调用 freeMemory 来释放内存

直接内存的回收不是通过JVM的垃圾回收来释放的,而是通过**unsafe.freeMemory()**来手动释放

1
2
//通过ByteBuffer申请1M的直接内存
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1M);

申请直接内存,但JVM并不能回收直接内存中的内容,它是如何实现回收的呢?

allocateDirect() 的实现底层源码分析
1
2
3
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}

DirectByteBuffer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
DirectByteBuffer(int cap) {   // package-private

super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);

long base = 0;
try {
base = unsafe.allocateMemory(size); //申请内存
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); //通过虚引用,来实现直接内存的释放,this为虚引用的实际对象
att = null;
}

这里调用了一个Cleaner的 create() 方法,且后台线程还会对虚引用的对象监测,如果虚引用的实际对象(这里是 DirectByteBuffer)被回收以后,就会调用Cleaner的 clean() 方法,来清除直接内存中占用的内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void clean() {
if (remove(this)) {
try {
this.thunk.run(); //调用run方法
} catch (final Throwable var2) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null) {
(new Error("Cleaner terminated abnormally", var2)).printStackTrace();
}

System.exit(1);
return null;
}
});
}

对应对象的 run() 方法

1
2
3
4
5
6
7
8
9
public void run() {
if (address == 0) {
// Paranoia
return;
}
unsafe.freeMemory(address); // 释放直接内存中占用的内存
address = 0;
Bits.unreserveMemory(size, capacity);
}

三、JVM 垃圾回收

0 主要内容大纲

【概述】

之前我们讲解了 JVM 的内存结构,其中我们了解到存在着垃圾回收机制。这一章我们将重点介绍这一部分内容。

  1. 如何判断对象可以回收
  2. 垃圾回收算法
  3. 分代垃圾回收
  4. 垃圾回收器
  5. 垃圾回收调优

1 如何判断对象可以被回收

1.1 引用计数法

  • 只要一个对象被其他变量所引用,那我们就让这个对象的计数 +1+1,如果被引用两次,该计数就为2。
  • 如果某个变量不再引用这个对象,该对象的引用计数 1-1
  • 当计数为 0 时,表示没有变量引用这个对象了,则可作为垃圾回收掉。
20200608150750 (1)

弊端:在例如上图的循环引用时,两个对象的计数都为 1,导致两个对象都无法被释放

1.2 可达性分析算法

首先先要确定【根对象】。那么什么是根对象呢?就是那些肯定不能被当成垃圾回收的对象

在垃圾回收之前,我们先扫描堆内存中的所有对象,检查对象是否被根对象直接或者间接的引用。若是,则不能被回收;反之则可以被回收。

  • JVM中的垃圾回收器通过可达性分析来探索所有存活的对象
  • 扫描堆中的对象,看能否沿着 GC Root 对象(根对象)为起点的引用链找到该对象,如果找不到,则表示可以回收

可以作为 GC Root的对象:

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象;
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中JNI(即一般说的 Native方法)引用的对象

1.3 五种引用

20200608150800

【总结】引用应用垃圾回收 GC 的时机:

  1. 强引用:只有 GC Root 都不引用该对象时,才会回收强引用对象
  2. 软引用:
    • 仅有软引用引用该对象时,在垃圾回收之后,内存仍不足时会再次触发垃圾回收,回收软引用对象
    • 可以配合引用队列来释放软引用自身
  3. 弱引用:
    • 只有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用所引用的对象。
    • 可以配合引用队列来释放弱引用自身
  4. 虚引用:
    • 必须配合引用队列使用,主要配合 ByteBuffer 使用。
    • 被引用对象回收时,会讲虚引用入队列,由 Reference Handler 线程调用虚引用的相关方法释放内存。
  5. 终结器引用:
    • 无需手动编码,但其内部配合引用队列使用。
    • 在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize() 方法,第二次 GC 时才能回收被引用对象
1.3.1 强引用

如上图,实线箭头表示强引用。日常使用中的引用都属于强引用。例如,new 一个对象,使用 "=" 将该对象赋值给一个变量,那么这个变量就强引用该对象。

垃圾回收的条件:

只有 GC Root 都不引用该对象时,才会回收强引用对象

  • 如上图 B、C 对象都不引用 A1 对象时,A1 对象才会被回收

案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class DemoStrongReference {
private static final int _4M = 4*1024*1024;

public static void main(String[] args) throws IOException {

List<byte[]> list = new ArrayList<>(); // 强引用

for (int i = 0; i < 5; i++) {
list.add(new byte[_4M]);
}

System.in.read();
}
}
1
java.lang.OutOfMemoryError: Java heap space
1.3.2 软引用 (Soft Reference)

使用场景:当内存空间有限时,一些不重要的资源可以用软引用。

只要 A2、A3 两个对象没有被直接的强引用所引用,当垃圾回收发生时,都有可以被回收。

垃圾回收的条件:

当 GC Root 指向软引用对象(垃圾回收)时,在内存不足时,会回收软引用所引用的对象。(先回收一次,如果内存还不够,回收软引用所引用的对象)

  • 如上图如果 B 对象不再引用 A2 对象且内存不足时,软引用所引用的 A2 对象就会被回收

案例 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 软引用演示
* -Xmx20m -XX:+PrintGCDetails -verbose:gc
*/
public class DemoSoftReference1 {
public static void main(String[] args) {
final int _4M = 4*1024*1024;

// 使用软引用对象 list和SoftReference是强引用,而SoftReference和byte数组则是软引用
List<SoftReference<byte[]>> list = new ArrayList<>(); // 强引用

for(int i = 0; i < 5; i++){
SoftReference<byte[]> ref= new SoftReference<>(new byte[_4M]); // 软引用
// List list --强引用--> SoftReference ref --软引用--> byte[_4M]
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}

System.out.println("循环结束:" + list.size());
for (SoftReference<byte[]> ref : list) {
System.out.println(ref.get());
}

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[B@75b84c92
1
[B@6bc7c054
2
[B@232204a1
3
[B@4aa298b7
4
[B@7d4991ad
5
循环结束:5
null
null
null
null
[B@7d4991ad // 只有最后一个数组被保留,上面的都被垃圾回收了

如果在垃圾回收时发现内存不足,在回收软引用所指向的对象时,软引用本身不会被清理

如果想要清理软引用,需要使用引用队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class DemoSoftReference2 {
public static void main(String[] args) {
final int _4M = 4*1024*1024;

//使用引用队列,用于移除引用为空的软引用对象
ReferenceQueue<byte[]> queue = new ReferenceQueue<>(); // 引用队列

//使用软引用对象 list和SoftReference是强引用,而SoftReference和byte数组则是软引用
List<SoftReference<byte[]>> list = new ArrayList<>(); // 强引用

for(int i = 0; i < 5; i++){

// 关联了引用队列,当软引用所关联的 byte[]被回收时,软引用自自己会加入到 queue 中去
SoftReference<byte[]> ref= new SoftReference<>(new byte[_4M], queue); // 软引用
// List list --强引用--> SoftReference ref --软引用--> byte[_4M]

System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}

//遍历引用队列,如果有元素,则取出
Reference<? extends byte[]> poll = queue.poll(); // poll(), 取出最先放入队列的元素,返回类型Reference

while(poll != null) {
// 引用队列不为空,则从集合中移除该元素
list.remove(poll);
// 移动到引用队列中的下一个元素
poll = queue.poll();
}

System.out.println("========================");
for (SoftReference<byte[]> ref : list) {
System.out.println(ref.get());
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
[B@75b84c92
1
[B@6bc7c054
2
[B@232204a1
3
[B@4aa298b7
4
[B@7d4991ad
5
========================
[B@7d4991ad

**大概思路为:**查看引用队列中有无软引用,如果有,则将该软引用从存放它的集合中移除(这里为一个list集合)

1.3.3 弱引用 (Weak Referrnce)

垃圾回收的条件:

只有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用所引用的对象。

  • 如上图如果B对象不再引用A3对象,则A3对象会被回收

弱引用的使用和软引用类似,只是将 SoftReference 换为了 WeakReference

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class DemoWeakReference1 {
public static void main(String[] args) {
final int _4M = 4*1024*1024;

List<WeakReference<byte[]>> list = new ArrayList<>(); // 强引用

for(int i = 0; i < 6; i++){
WeakReference<byte[]> ref= new WeakReference<>(new byte[_4M]); // 软引用
// List list --强引用--> WeakReference ref --弱引用--> byte[_4M]
list.add(ref);
for (WeakReference<byte[]> weakReference : list) {
System.out.print(weakReference.get() + "\t");
}
System.out.println();
}

System.out.println("循环结束:" + list.size());
}
}
1
2
3
4
5
6
7
[B@75b84c92	
[B@75b84c92 [B@6bc7c054
[B@75b84c92 [B@6bc7c054 [B@232204a1
[B@75b84c92 [B@6bc7c054 [B@232204a1 [B@4aa298b7
null [B@6bc7c054 [B@232204a1 [B@4aa298b7 [B@7d4991ad
null [B@6bc7c054 [B@232204a1 [B@4aa298b7 null [B@28d93b30
循环结束:6
1.3.4 虚引用 (Phantom Reference)

必须配合引用队列一同使用。当虚(终结器)引用被创建时,会关联一个引用队列

  • 当虚引用对象所引用的对象被回收以后,虚引用对象就会被放入引用队列中,调用虚引用的方法
  • 虚引用的一个体现是释放直接内存所分配的内存,当引用的对象 ByteBuffer 被垃圾回收以后,虚引用对象 Cleaner 就会被放入引用队列中,然后调用 Cleanerclean() 方法来释放直接内存
  • 如上图,B 对象不再引用 ByteBuffer 对象,ByteBuffer 就会被回收。但是直接内存中的内存还未被回收。这时需要将虚引用对象 Cleaner 放入引用队列中,然后调用它的 clean() 方法来释放直接内存
1.3.5 终结器引用 (Finalize Reference)

所有的对象都继承自 Object 类,Object 类有一个 finalize() 方法。

当某个对象不再被其他的对象所引用时,会先将终结器引用对象放入引用队列中(处理这个引用队列的FinalizeHandler 线程 优先级很低),然后根据终结器引用对象找到它所引用的对象,然后调用该对象的 finalize() 方法。调用以后,该对象就可以被垃圾回收了。

  • 如上图,B对象不再引用A4对象。这是终结器对象就会被放入引用队列中,引用队列会根据它,找到它所引用的对象。然后调用被引用对象的finalize()方法。调用以后,该对象就可以被垃圾回收了

2 垃圾回收算法

2.1 标记 - 清除 算法 (Mark - Sweep)

20200608150813

定义:标记清除算法顾名思义,是指在虚拟机执行垃圾回收的过程中,先采用标记算法确定可回收对象(图中为没有GC Root引用的块),然后垃圾收集器根据标识清除相应的内容,给堆内存腾出相应的空间。

  • 这里的腾出内存空间并不是将内存空间的字节清零,而是记录下这段内存的起始结束地址,下次分配内存的时候,会直接覆盖这段内存。同理于操作系统中的内存管理

优点:垃圾回收速度快

缺点容易产生大量的内存碎片,可能无法满足大对象的内存分配,一旦导致无法分配对象,那就会导致 JVM 启动 GC,一旦启动 GC,我们的应用程序就会暂停,这就导致应用的响应速度变慢。同理于操作系统中的内存碎片。

2.2 标记 - 整理 算法 (Mark - Compact)

20200608150827

标记-整理 会将不被 GC Root 引用的对象回收,清理其占用的内存空间。然后整理剩余的对象(将其地址向前移动,使内存更为紧凑,连续空间更多).

优点:可以有效避免因内存碎片而导致的问题

缺点:但是因为整体需要消耗一定的时间,所以效率较低

2.3 复制 算法 (Copy)

将内存分为等大小的两个区域,FROMTO(其中 TO 中是空闲的)。

先将被 GC Root 引用的对象从 FROM 复制到 TO 中,再回收不被 GC Root 引用的对象。然后交换 FROMTO

  1. 如下图,先采用标记算法确定可回收对象(图中为没有 GC Root 引用的块)
20200608150842
  1. FROM 区域中存活的对象复制到 TO 区域
20200608150856
  1. 此时由于 FROM 区域中全是垃圾,全部清空
20200608150907
  1. 交换 FROM 区域 和 TO 区域 的位置
20200608150919

优点:可以避免内存碎片的问题

缺点:但是会占用双倍的内存空间

2.4 总结

  1. 标记 - 清除 算法 (Mark - Sweep)
    • 优点:垃圾回收速度快
    • 缺点容易产生大量的内存碎片
  2. 标记 - 整理 算法 (Mark - Compact)
    • 优点:可以有效避免因内存碎片而导致的问题
    • 缺点:但是因为整体需要消耗一定的时间,所以效率较低
  3. 复制 算法 (Copy)
    • 优点:可以避免内存碎片的问题
    • 缺点:但是会占用双倍的内存空间

3 分代垃圾回收机制

20200608150931

如上图,我们将堆内存划分成两个部分,一个是左边的 YoungGeneration 新生代 (新生代又分为【伊甸园 Eden】、【幸存区 FROM】和【幸存区 TO】三个部分),另一个是老年代 OldGeneration

Java 中,长时间使用的对象放在老年代中用完就可以丢弃的对象放在新生代中。这样就可以根据对象的存活时间的不同特点进行不用的回收策略。老年代中的垃圾回收很久发生一次,而新生代中回收更频繁

3.1 分代回收流程

简要流程:

  1. 对象首先分配在伊甸园区域;
  2. 新生代空间不足时,触发 Minor GC,伊甸园和 FROM 存活的对象使用 copy 复制到 TO 中,存活的对象年龄加 1,并且交换 FROM 和 TO;
  3. 当幸存区中的对象的寿命超过阈值(最大为15,4bit),就会晋升到老年代中;
  4. 如果新生代中的内存空间不足时,先触发 Minor GC;垃圾回收后发现新生代中的内存空间仍然不足,且老年代中的内存空间也不足,再触发 Full GC (整体清理);
  5. 内存分配失败,触发 java.lang.OutOfMemoryError

【流程图解】

1、新创建的对象都被放在了新生代的伊甸园中,伊甸园逐渐就会被占满。

20200608150939

20200608150946

2、当伊甸园中的内存不足时,就会进行一次垃圾回收,这时新生代的垃圾回收叫做 Minor GC

​ (1) Minor GC 触发后,采用“可达性分析算法”,沿着以 GC Root 对象(根对象)为起点的引用链,采用“标记算法”确定可回收对象;

​ (2) 标记完成后,采用“复制算法”将**伊甸园幸存区 FROM** 存活的对象复制到**幸存区 TO** 中, 并让其寿命+1+1

20200608150955

​ (3) 根据复制算法,我们将交换幸存区 FROM幸存区 TO 的位置

20200608151002

3、再次创建对象,若新生代的伊甸园又满了,则会再次触发 Minor GC(会触发 “Stop the world”, 暂停其他用户线程,只让垃圾回收线程工作);

这时不仅会回收伊甸园中的垃圾,还会回收幸存区中的垃圾,再将活跃对象复制到幸存区 TO 中;

回收以后会交换两个幸存区,并让幸存区中的对象寿命加1

20200608151010

4、如此反复。如果幸存区中的对象的寿命超过某个阈值(最大为15,4bit),就会晋升到老年代

20200608151018

5、如果新生代中的内存空间不足时,先触发 Minor GC;垃圾回收后发现新生代中的内存空间仍然不足,且老年代中的内存空间也不足,再触发 Full GC (整体清理),也会触发“Stop the world”,时间更长,以扫描新生代和老年代中所有不再使用的对象并回收

6、如果老年代的内存也不够,内存分配失败,触发 java.lang.OutOfMemoryError

IMG_FB5339E468EB-1

3.2 相关虚拟机参数

含义 参数
堆初始大小 -Xms
堆最大大小 -Xmx-XX:MaxHeapSize=size
新生代大小 -Xmn-XX:NewSize=size + XX:MaxNewSize=size
幸存区比例(动态) -XX:InitialSurvivorRatio=ratio-XX:+UseAdaptiveSizePolicy
幸存区比例 -XX:SurvivorRatio=ratio
晋升阈值 -XX:MaxTenuringThreshold=threshold
晋升详情 -XX:+PrintTenuringDistribution
GC 详情 -XX:+PrintGCDetails -verbose:gc
在 FullGC 前执行 MinorGC -XX:+ScavengeBeforeFullGC

3.3 GC分析

1
-Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
1
2
3
4
5
6
7
8
9
Heap
def new generation total 9216K, used 1505K [0x00000007be800000, 0x00000007bf200000, 0x00000007bf200000)
eden space 8192K, 18% used [0x00000007be800000, 0x00000007be9786f8, 0x00000007bf000000)
from space 1024K, 0% used [0x00000007bf000000, 0x00000007bf000000, 0x00000007bf100000)
to space 1024K, 0% used [0x00000007bf100000, 0x00000007bf100000, 0x00000007bf200000)
tenured generation total 14336K, used 0K [0x00000007bf200000, 0x00000007c0000000, 0x00000007c0000000)
the space 14336K, 0% used [0x00000007bf200000, 0x00000007bf200000, 0x00000007bf200200, 0x00000007c0000000)
Metaspace used 3132K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 341K, capacity 388K, committed 512K, reserved 1048576K

垃圾回收信息:

1
[GC (Allocation Failure) [DefNew: 1333K->331K(9216K), 0.0003968 secs] 1333K->331K(23552K), 0.0004083 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
  • GC 表示是新生代的 MinorGCFullGC 表示是老年代的垃圾回收
  • DefNew 表示垃圾回收发生在新生代,xxx -> xxx 表示回收之前的占用和回收之后的占用

案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Demo {
public static final int _512K = 512 * 1024;
public static final int _1M = 1 * 1024 * 1024;
public static final int _5M = 5 * 1024 * 1024;
public static final int _6M = 6 * 1024 * 1024;
public static final int _7M = 7 * 1024 * 1024;

public static void main(String[] args) {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_7M]);
list.add(new byte[_512K]);
list.add(new byte[_512K]);
}
}
1
2
3
4
5
6
7
8
9
10
11
[GC (Allocation Failure) [DefNew: 1508K->366K(9216K), 0.0006691 secs] 1508K->366K(23552K), 0.0006841 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [DefNew: 8210K->876K(9216K), 0.0012508 secs] 8210K->8044K(23552K), 0.0012588 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 1716K [0x00000007be800000, 0x00000007bf200000, 0x00000007bf200000)
eden space 8192K, 10% used [0x00000007be800000, 0x00000007be8d1eb8, 0x00000007bf000000)
from space 1024K, 85% used [0x00000007bf000000, 0x00000007bf0db1e8, 0x00000007bf100000)
to space 1024K, 0% used [0x00000007bf100000, 0x00000007bf100000, 0x00000007bf200000)
tenured generation total 14336K, used 7168K [0x00000007bf200000, 0x00000007c0000000, 0x00000007c0000000)
the space 14336K, 50% used [0x00000007bf200000, 0x00000007bf900010, 0x00000007bf900200, 0x00000007c0000000)
Metaspace used 3199K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 352K, capacity 388K, committed 512K, reserved 1048576K
3.3.1 大对象处理策略

当遇到一个较大的对象时,就算新生代的伊甸园为空,也无法容纳该对象时,会将该对象直接晋升为老年代

1
2
3
4
5
6
7
8
public class Demo {
public static final int _8M = 8 * 1024 * 1024;

public static void main(String[] args) {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_8M]);
}
}
1
2
3
4
5
6
7
8
9
Heap
def new generation total 9216K, used 1497K [0x00000007be800000, 0x00000007bf200000, 0x00000007bf200000)
eden space 8192K, 18% used [0x00000007be800000, 0x00000007be9766e8, 0x00000007bf000000)
from space 1024K, 0% used [0x00000007bf000000, 0x00000007bf000000, 0x00000007bf100000)
to space 1024K, 0% used [0x00000007bf100000, 0x00000007bf100000, 0x00000007bf200000)
tenured generation total 14336K, used 8192K [0x00000007bf200000, 0x00000007c0000000, 0x00000007c0000000)
the space 14336K, 57% used [0x00000007bf200000, 0x00000007bfa00010, 0x00000007bfa00200, 0x00000007c0000000)
Metaspace used 3106K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 333K, capacity 388K, committed 512K, reserved 1048576K
3.3.2 线程内存溢出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Demo {
public static final int _8M = 8 * 1024 * 1024;

public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_8M]);
list.add(new byte[_8M]);
}).start();

System.out.println("sleep...");
Thread.sleep(1000);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sleep...
[GC (Allocation Failure) [DefNew: 3683K->572K(9216K), 0.0010077 secs][Tenured: 8192K->8762K(14336K), 0.0015132 secs] 11875K->8762K(23552K), [Metaspace: 4106K->4106K(1056768K)], 0.0025433 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [Tenured: 8762K->8707K(14336K), 0.0012168 secs] 8762K->8707K(23552K), [Metaspace: 4106K->4106K(1056768K)], 0.0012369 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Exception in thread "Thread-0" java.lang.OutOfMemoryError: Java heap space
at GC_Analyse.Demo.lambda$main$0(Demo.java:17)
at GC_Analyse.Demo$$Lambda$1/455659002.run(Unknown Source)
at java.lang.Thread.run(Thread.java:750)
Heap
def new generation total 9216K, used 383K [0x00000007be800000, 0x00000007bf200000, 0x00000007bf200000)
eden space 8192K, 4% used [0x00000007be800000, 0x00000007be85fc28, 0x00000007bf000000)
from space 1024K, 0% used [0x00000007bf100000, 0x00000007bf100000, 0x00000007bf200000)
to space 1024K, 0% used [0x00000007bf000000, 0x00000007bf000000, 0x00000007bf100000)
tenured generation total 14336K, used 8707K [0x00000007bf200000, 0x00000007c0000000, 0x00000007c0000000)
the space 14336K, 60% used [0x00000007bf200000, 0x00000007bfa80c48, 0x00000007bfa80e00, 0x00000007c0000000)
Metaspace used 4133K, capacity 4676K, committed 4864K, reserved 1056768K
class space used 461K, capacity 496K, committed 512K, reserved 1048576K

某个线程的内存溢出了而抛异常 (java.lang.OutOfMemoryError),不会让其他的线程结束运行,原因如下:

  • 当一个线程抛出 OutOfMemoryError 异常后它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行,其他进程依然正常

4 垃圾回收器

4.0 概述

  1. 串行垃圾回收器
    • 实质是一个单线程的垃圾回收器
    • 使用场景:堆内存较小,适合个人电脑
  2. 吞吐量优先垃圾回收器
    • 多线程
    • 使用场景:堆内存较大,多核 CPU 支持
    • 在单位时间内,STW 的时间最短。例如,在一小时内,垃圾回收了 2 次,总时长是 0.2 + 0.1 秒
  3. 响应时间优先垃圾回收器
    • 多线程
    • 使用场景:堆内存较大,多核 CPU 支持
    • 垃圾清理(STW)的单次时间尽可能最短

4.1 串行垃圾回收器

1
-XX:+UseSerialGC=Serial+SerialOld // 新生代copy算法,老年代 标记整理
image-20220818231033690

4.2 吞吐量优先垃圾回收器

1
2
3
4
5
-XX:+UseParallelGC -XX:+UseParallelOldGC
-XX:ParallelGCThreads=n // 设置垃圾回收线程数
-XX:+UseAdaptiveSizePolicy // 自适应调整
-XX:GCTimeRatio=ratio // 调整垃圾回收时间与总时间的占比: 1/(1+ratio)
-XX:MaxGCPauseMillis=ms // 最大暂停时间(毫秒)
image-20220818231641184

4.3 响应时间优先垃圾回收器

1
2
3
4
-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads
-XX:CMSInitiatingOccupancyFraction=percent
-XX:+CMSScavengeBeforeRemark
image-20220818235606862

4.4 G1 (Garbage First) 垃圾回收器

JDK 1.9 之后代替 CMS 回收器,成为默认的垃圾回收器。

【适用场景】

1
-XX:+UseG1GC
  • 同时注重吞吐量 (Throughput) 和低延迟 (Low latency),默认的暂停目标是 200 ms

    1
    -XX:MaxGCPauseMillis=time
  • 超大堆内存,会将堆划分为多个大小相等的 Region

    1
    -XX:G1HeapRegionSize=size // size = 2^n
  • 整体上是标记整理算法,两个区域之间是复制

4.4.1 G1 垃圾回收阶段
image-20220819112113472
4.4.2 Young Collection

首先,G1 垃圾回收器会将堆内存 (Heap) 分成若干个大小相等的区域 (Region),也就是说,Region 是 G1 操作时的单位

image-20220819113509917

当一个为伊甸园 E 的 Region 被占满时,使用拷贝算法将非垃圾对象放入幸存区 S,如下图

image-20220819114148727

继续运行一段时间,当:

  • 幸存区 S 中的对象年龄到达可以晋升老年代 O 的阈值时;
  • 幸存区 S 的空间不足时
image-20220819114932406
4.4.3 Young Collection + Concurrent Mark
  • Young GC 时会进行 GC Root初始标记

  • 老年代占用堆空间比例达到阈值时,进行并发标记 (不会 STW),由下面的 JVM 参数决定

    1
    -XX:InitiatingHeapOccupancyPercent=percent (默认45%)
image-20220819120000627
4.4.4 Mixed Collection

会对伊甸园 E幸存区 S老年代 O 进行全面垃圾回收:

  • 最终标记 (重新标记 Remark) 会 STW

  • 拷贝存活 (Evacuation) 会 STW

    1
    -XX: MaxGCPauseMillis=ms
image-20220819122114517

如上图所示,对于伊甸园 E幸存区 S 的垃圾回收与之前一样:

  • 伊甸园 E 被占满时,使用拷贝算法将非垃圾对象放入幸存区 S
  • 幸存区 S 中的对象年龄到达可以晋升老年代 O 的阈值时,将其放入老年代 O
  • 幸存区 S 的空间不足时,复制存活的对象到另一个幸存区 S 中。

对于老年代 O,我是使用一系列的算法,在最大暂停时间 MaxGCPauseMillis 内,来筛选“有价值”的存活对象,复制到另一个老年代 O 中。

那么,什么是“有价值”的存活对象呢?

个人理解是在空间不足的老年代 O 中,挑选出那些可以在最大暂停时间 MaxGCPauseMillis 内完成复制的存活对象后,并且该老年代 O 可以最大程度上的被回收,从而尽可能多的释放空间。我们将那些存活对象复制到另一个老年代 O 中。从 Region 的角度考虑,就是优先回收垃圾最多的 Region。

4.4.5 Full GC
  • 串行垃圾回收器
    • 新生代内存不足时发生的垃圾收集:Minor GC
    • 老年代内存不足时发生的垃圾收集:Full GC
  • 并行垃圾回收器
    • 新生代内存不足时发生的垃圾收集:Minor GC
    • 老年代内存不足时发生的垃圾收集:Full GC
  • 并发垃圾回收器 CMS
    • 新生代内存不足时发生的垃圾收集:Minor GC
    • 老年代内存不足时发生的垃圾收集:触发并发垃圾回收
  • G1 垃圾回收器
    • 新生代内存不足时发生的垃圾收集:Minor GC
    • 老年代内存不足时发生的垃圾收集:老年代占用堆空间比例达到阈值时,进行并发标记 (不会 STW),并进入到 Mixed Collection 阶段。当产生垃圾的速度大于垃圾收集的速度,触发 Full GC。
4.4.6 Young Collection 的跨代引用问题

持续更新中。。。

四、类加载与字节码技术

0 主要内容大纲

提示:本章节可与 [" Sémantique et Traduction des Langages "](https://github.com/Dave0126/S8_2A_SN_ENSEEIHT/tree/master/UE - Sémantique et Traduction des Langages/Sémantique et Traduction des Langages) 结合理解

  1. 类文件结构
  2. 字节码指令
  3. 编译期处理
  4. 类加载阶段
  5. 类加载器
  6. 运行期优化
image-20220819161019323

1 类文件结构

参考文献:Oracle JDK 1.8 官方文档

我们以 HelloWorld.java 文件为例

1
2
3
4
5
6
7
package ClassFile;

public class DemoHelloWorld {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
1
javac -parameters -d src/main/java/ClassFile/DemoHelloWorld.java

编译后的 DemoHelloWorld.class 是这样的:

1
od -t xC ClassFile/DemoHelloWorld.class 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 标号		内容
0000000 ca fe ba be 00 00 00 3b 00 1f 0a 00 02 00 03 07
0000020 00 04 0c 00 05 00 06 01 00 10 6a 61 76 61 2f 6c
0000040 61 6e 67 2f 4f 62 6a 65 63 74 01 00 06 3c 69 6e
0000060 69 74 3e 01 00 03 28 29 56 09 00 08 00 09 07 00
0000100 0a 0c 00 0b 00 0c 01 00 10 6a 61 76 61 2f 6c 61
0000120 6e 67 2f 53 79 73 74 65 6d 01 00 03 6f 75 74 01
0000140 00 15 4c 6a 61 76 61 2f 69 6f 2f 50 72 69 6e 74
0000160 53 74 72 65 61 6d 3b 08 00 0e 01 00 0c 48 65 6c
0000200 6c 6f 20 57 6f 72 6c 64 21 0a 00 10 00 11 07 00
0000220 12 0c 00 13 00 14 01 00 13 6a 61 76 61 2f 69 6f
0000240 2f 50 72 69 6e 74 53 74 72 65 61 6d 01 00 07 70
0000260 72 69 6e 74 6c 6e 01 00 15 28 4c 6a 61 76 61 2f
0000300 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 56 07 00
0000320 16 01 00 18 43 6c 61 73 73 46 69 6c 65 2f 44 65
0000340 6d 6f 48 65 6c 6c 6f 57 6f 72 6c 64 01 00 04 43
0000360 6f 64 65 01 00 0f 4c 69 6e 65 4e 75 6d 62 65 72
0000400 54 61 62 6c 65 01 00 04 6d 61 69 6e 01 00 16 28
0000420 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69
0000440 6e 67 3b 29 56 01 00 10 4d 65 74 68 6f 64 50 61
0000460 72 61 6d 65 74 65 72 73 01 00 04 61 72 67 73 01
0000500 00 0a 53 6f 75 72 63 65 46 69 6c 65 01 00 13 44
0000520 65 6d 6f 48 65 6c 6c 6f 57 6f 72 6c 64 2e 6a 61
0000540 76 61 00 21 00 15 00 02 00 00 00 00 00 02 00 01
0000560 00 05 00 06 00 01 00 17 00 00 00 1d 00 01 00 01
0000600 00 00 00 05 2a b7 00 01 b1 00 00 00 01 00 18 00
0000620 00 00 06 00 01 00 00 00 03 00 09 00 19 00 1a 00
0000640 02 00 17 00 00 00 25 00 02 00 01 00 00 00 09 b2
0000660 00 07 12 0d b6 00 0f b1 00 00 00 01 00 18 00 00
0000700 00 0a 00 02 00 00 00 05 00 08 00 06 00 1b 00 00
0000720 00 05 01 00 1c 00 00 00 01 00 1d 00 00 00 02 00
0000740 1e
0000741

根据 JVM 规范,类文件的结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 field[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}

示意图如下:

1

1.1 魔数 Magic

  • 前四个字节 (0~3),表示该文件是否是 .class 类型的文件

0000000 ca fe ba be 00 00 00 3b 00 1f 0a 00 02 00 03 07

1.2 版本 Version

  • 4~7 个字节,表示类的版本 (minor_version + major_version)。 00 3b 表示 JDK 15。

0000000 ca fe ba be 00 00 00 3b 00 1f 0a 00 02 00 03 07

1.3 常量池 Constant Pool

Constant Type (Bytes) Value (hex)
CONSTANT_Class(1B): 引用索引(2B) 07
CONSTANT_Filedref(1B): CONSTANT_Class(2B) + CONSTANT_NameAndType(2B) 09
CONSTANT_Methodref(1B): CONSTANT_Class(2B) + CONSTANT_NameAndType(2B) 0a
CONSTANT_InterfaceMethodref(1B): CONSTANT_Class(2B) + CONSTANT_NameAndType(2B) 0b
CONSTANT_String(1B): CONSTANT_Utf8(2B) 08
CONSTANT_Integer(1B): value(4B) 03
CONSTANT_Float(1B): value(4B) 04
CONSTANT_Long(1B): value(8B) 05
CONSTANT_Double(1B): value(8B) 06
CONSTANT_NameAndType(1B): Name_引用索引(2B) + Type_引用索引(2B) 0c
CONSTANT_Utf8(1B): 长度(2B) + 数据(nB) 01
CONSTANT_MethodHandle(1B) 0f
CONSTANT_MethodType(1B) 10
CONSTANT_InvokeDynamic(1B) 12
  • 8~9 字节,表示常量池的长度。00 1f 表示常量池的长度是 31,有 #1~#30 项。注意 #0 项不计入,也没有值

0000000 ca fe ba be 00 00 00 3b 00  1f 0a 00 02 00 03 07

  • (第 #1 项)10~14 字节中,10 字节表示常量的类型(见上表),0a 表示一个 Methodref 信息;

    11~12 字节13~14 字节00 0200 03 表示它引用了常量池中 #2 和 #3 项来获得这个方法的【所属类】和【方法名

0000000| ca fe ba be 00 00 00 3b 00 1f 0a   00  02   00   03 07

  • (第 #2 项)15~17 字节中,15 字节表示常量的类型(见上表),07 表示一个 Class 信息;

    16~17 字节00 04 表示它 表示它引用了常量池中 #4 项。

0000000| ca fe ba be 00 00 00 3b 00 1f 0a 00 02 00 03 07

0000020| 00   04 0c 00 05 00 06 01 00 10 6a 61 76 61 2f 6c

  • (第 #3 项)18~22 字节中,18 字节表示常量的类型(见上表),0c 表示一个 “名称 + 类型” 信息;

    19~20 字节21~22 字节00 0500 06 表示它 表示它引用了常量池中 #5 项和第 #6 项。

0000020| 00 04 0c   00  05  00   06 01 00 10 6a 61 76 61 2f 6c

  • (第 #4 项)23~41 字节中,23 字节:01 表示一个 utf8 串,后两位 00 10 表示这个串的长度是 16。所以后面紧跟的 16 个字节就是这个串的内容:6a 61 76 61 2f 6c 61 6e 67 2f 4f 62 6a 65 63 74,转义后是:java/lang/Object

0000020| 00 04 0c 00 05 00 06 01   00  10  6a   61  76   61   2f  6c
0000040| 61   6e   67   2f  4f   62  6a   65   63   74 01 00 06 3c 69 6e

  • (第 #5 项)42~50 字节中,42 字节:01 表示一个 utf8 串,后两位 00 06 表示这个串的长度是 6。所以后面紧跟的 6 个字节就是这个串的内容:3c 69 6e 69 74 3e,转义后是:<init>

0000040| 61 6e 67 2f 4f 62 6a 65 63 74 01  00  06   3c   69   6e
0000060| 69   74   3e 01 00 03 28 29 56 09 00 08 00 09 07 00

  • (第 #6 项)51~56 字节中,51 字节:01 表示一个 utf8 串,后两位 00 03表示这个串的长度是 3。所以后面紧跟的 3 个字节就是这个串的内容:28 29 56,转义后是:()V,表示无参、无返回值

0000060| 69  74 3e 01   00  03   28   29  56 09 00 08 00 09 07 00

  • (第 #7 项)57~61 字节中,57 字节:09 表示一个 Filedref 信息,后四位 00 0800 09 表示它引用了常量池中 #8 和 #9 项来获得这个成员变量的【所属类】和【成员变量名

0000060| 69  74 3e 01  00  03 28  29  56 09   00  08  00  09 07 00

以此类推……

1.4 访问标识与继承信息

常量池 Constant Pool 结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是接口还是类;是否被定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被声明为 final 等。

Flag Name Value (hex) Description
ACC_PUBLIC 00 01 是否为 public 类型
ACC_FINAL 00 10 是否被声明为 final,只有类可以设置
ACC_SUPER 00 20 是否允许使用 invokespecial 字节码指令的新语义
ACC_INTERFACE 02 00 标志这是一个接口
ACC_ABSTRACT 04 00 是否为 abstract 类型,对于接口或者抽象类来说,次标志值为真,其他类型为假
ACC_SYNTHETIC 10 00 标志这个类并非由用户代码产生
ACC_ANNOTATION 20 00 标志这是一个注解
ACC_ENUM 40 00 标志这是一个枚举
  • 00 21 可以推断是由 ACC_PUBLICACC_SUPER 通过 OR 组合而成。

0000540| 76 61 00  21 00 15 00 02 00 00 00 00 00 02 00 01

  • 00 15 表示根据常量池中的第 #15 项找到本类的全限定名

0000540| 76 61 00  21 00   15 00 02 00 00 00 00 00 02 00 01

  • 00 02 表示根据常量池中的第 #2 项找到父类的全限定名

0000540| 76 61 00  21 00  15 00  02 00 00 00 00 00 02 00 01

  • 00 00 表示本类中接口的数量,为 0

0000540| 76 61 00  21 00  15 00  02 00  00 00 00 00 02 00 01

1.5 成员变量 Field

字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。

field info 的结构:

1
2
3
4
5
6
7
field_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
  • access_flags: 字段的作用域(public, private, protected 修饰符),是实例变量还是类变量(static 修饰符),可否被序列化(transient 修饰符),可变性(final),可见性(volatile 修饰符,是否强制从主内存读写)。
  • name_index: 对常量池的引用,表示的字段的名称;
  • descriptor_index: 对常量池的引用,表示字段和方法的描述符;
  • attributes_count: 一个字段还会拥有一些额外的属性,attributes_count 存放属性的个数;
  • attributes[attributes_count]: 存放具体属性具体内容。

紧接接口信息后的两个字节00 00 表示成员变量的数量,为 0。

0000540| 76 61 00 21 00 15 00 02. 00 00 00 00 00 02 00 01

.class 文件中,成员变量的类型由更简洁的字符表示,其与源代码 .java中的类型对应表如下:

Field Type(.class) Field Type(.java) Description
B byte byte 类型
C char char 类型
D double double 类型
F float float 类型
I int int 类型
J long long 类型
L ClassName reference ClassName 的引用类型
S short short 类型
Z boolean boolean 类型
[ reference 一维数组类型

1.6 方法 Method

1
2
u2             methods_count;
method_info methods[methods_count];

methods_count 表示方法的数量,而 method_info 表示的方法表。

.class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。

method_info (方法表的) 结构:

1
2
3
4
5
6
7
method_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}

方法表的 access_flags 取值:

access_flags Value (hex) Description
ACC_PUBLIC 00 01 是否为 public 类型
ACC_PRIVATE 00 02 是否为 private 类型
ACC_PROTECTED 00 04 是否为 protected 类型
ACC_STATIC 00 08 是否为 static 类型
ACC_FINAL 00 10 是否被声明为 final,只有类可以设置
ACC_VOLATILE 00 40 是否为 volatile 类型,不可和 ACC_FIANL 一起使用
ACC_TRANSIENT 00 80 在序列化中被忽略的字段
ACC_SYNTHETIC 10 00 标志这个类并非由用户代码产生
ACC_ENUM 40 00 标志这是一个枚举

1.7 属性 Attribute

1
2
u2			attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合

.class 文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与 .class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。

2 字节码指令

2.1 入门

接着上一节,研究一下两组字节码指令,一个是

public ClassFile.DemoHelloWorld(); 构造方法的字节码指令

1
2a b7 00 01 b1
  1. 2a \Rightarrow aload_0 加载 slot 0 的局部变量,即 this,做为下面的 imvokespecial 构造方法调用的参数
  2. b7 \Rightarrow invokespecial 预备调用构造方法,哪个方法呢?
  3. 0001 \Rightarrow 引用常量池中第 #1 项,即 Method java/lang/Object."<init>":()V
  4. b1 \Rightarrow 表示 return

另一个是主方法的字节码指令 public static void main(java.lang.String[]);

1
b2 00 02 12 03 b6 00 04 b1
  1. b2 \Rightarrow getstatic 用来加载静态变量,哪个静态变量呢?
  2. 00 02 \Rightarrow 引用常量池中 #2 项,即 Field javallang System.out:Ljava/io/Printstream;
  3. 12 \Rightarrow ldc 加载参数,哪个参数呢?
  4. 03 \Rightarrow 引用常量池中 #3 项,即 String hello world!
  5. b6 \Rightarrow invokevirtual 预备调用成员方法,哪个方法呢?
  6. 00 04 \Rightarrow 引用常量池中 #4 项,即Method java/io/Printstream.println:(Ljava/lang/String;)V
  7. b1 \Rightarrow 表示 return

2.2 javap 工具

由以上步骤我们可以深刻体会到,手动分析 .class 文件太繁琐了。我们可以使用 Oracle 提供的 javap 工具来反编译 .class 文件。

1
javap -v file_name.class

javap 反编译后的 DemoHelloWorld.class 输出信息为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
Classfile /Users/JavaVM/target/classes/ClassFile/DemoHelloWorld.class
Last modified 2022年8月19日; size 481 bytes
SHA-256 checksum d5b3da891b099837a6618847d9ccee77453fb9a3cafca7bdcda19487f6020290
Compiled from "DemoHelloWorld.java"
public class ClassFile.DemoHelloWorld
minor version: 0
major version: 59
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #21 // ClassFile/DemoHelloWorld
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = String #14 // Hello World!
#14 = Utf8 Hello World!
#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V
#16 = Class #18 // java/io/PrintStream
#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V
#18 = Utf8 java/io/PrintStream
#19 = Utf8 println
#20 = Utf8 (Ljava/lang/String;)V
#21 = Class #22 // ClassFile/DemoHelloWorld
#22 = Utf8 ClassFile/DemoHelloWorld
#23 = Utf8 Code
#24 = Utf8 LineNumberTable
#25 = Utf8 main
#26 = Utf8 ([Ljava/lang/String;)V
#27 = Utf8 MethodParameters
#28 = Utf8 args
#29 = Utf8 SourceFile
#30 = Utf8 DemoHelloWorld.java
{
public ClassFile.DemoHelloWorld(); // 构造方法
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1 // 操作的栈的最大深度、本地变量的个数、参数的个数
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0

public static void main(java.lang.String[]); // 主方法
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1 // 操作的栈的最大深度、本地变量的个数、参数的个数
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World!
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 5: 0
line 6: 8
MethodParameters:
Name Flags
args
}
SourceFile: "DemoHelloWorld.java"

2.3 图解方法执行流程

2.3.1 原始 Java 代码
1
2
3
4
5
6
7
8
9
10
package ClassFile;

public class Demo2 {
public static void main(String[] args) {
int a = 10;
int b = Short.MAX_VALUE + 1;
int c = a + b;
System.out.println(c);
}
}
2.3.2 编译后的字节码文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
Classfile /Users/JavaVM/target/classes/ClassFile/Demo2.class
Last modified 2022年8月20日; size 597 bytes
SHA-256 checksum 564a035722627c2a2e6345f6fe7c7ae07e64c76a49d6666827fdcff254d7b5d9
Compiled from "Demo2.java"
public class ClassFile.Demo2
minor version: 0
major version: 52
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #6 // ClassFile/Demo2
super_class: #7 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #7.#25 // java/lang/Object."<init>":()V
#2 = Class #26 // java/lang/Short
#3 = Integer 32768
#4 = Fieldref #27.#28 // java/lang/System.out:Ljava/io/PrintStream;
#5 = Methodref #29.#30 // java/io/PrintStream.println:(I)V
#6 = Class #31 // ClassFile/Demo2
#7 = Class #32 // java/lang/Object
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 LocalVariableTable
#13 = Utf8 this
#14 = Utf8 LClassFile/Demo2;
#15 = Utf8 main
#16 = Utf8 ([Ljava/lang/String;)V
#17 = Utf8 args
#18 = Utf8 [Ljava/lang/String;
#19 = Utf8 a
#20 = Utf8 I
#21 = Utf8 b
#22 = Utf8 c
#23 = Utf8 SourceFile
#24 = Utf8 Demo2.java
#25 = NameAndType #8:#9 // "<init>":()V
#26 = Utf8 java/lang/Short
#27 = Class #33 // java/lang/System
#28 = NameAndType #34:#35 // out:Ljava/io/PrintStream;
#29 = Class #36 // java/io/PrintStream
#30 = NameAndType #37:#38 // println:(I)V
#31 = Utf8 ClassFile/Demo2
#32 = Utf8 java/lang/Object
#33 = Utf8 java/lang/System
#34 = Utf8 out
#35 = Utf8 Ljava/io/PrintStream;
#36 = Utf8 java/io/PrintStream
#37 = Utf8 println
#38 = Utf8 (I)V
{
public ClassFile.Demo2();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LClassFile/Demo2;

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: bipush 10
2: istore_1
3: ldc #3 // int 32768
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
13: iload_3
14: invokevirtual #5 // Method java/io/PrintStream.println:(I)V
17: return
LineNumberTable:
line 5: 0
line 6: 3
line 7: 6
line 8: 10
line 9: 17
LocalVariableTable:
Start Length Slot Name Signature
0 18 0 args [Ljava/lang/String;
3 15 1 a I
6 12 2 b I
10 8 3 c I
}
SourceFile: "Demo2.java"
2.3.3 常量池载入 “运行时常量池”

2

2.3.4 方法字节码载入方法区

3

2.3.5 主线程开始运行,分配栈帧内存
1
stack=2, locals=4 // 操作的栈的最大深度=2、本地变量的个数=4

4

bipush 10

将一个 byte 压入操作数栈(其长度会补齐4个字节),类似的指令还有

  • sipush 将一个 short 压入操作数栈(其长度会补齐4个字节)
  • ldc 将一个 int 压入操作数栈
  • lde2_w 将一个 long 压入操作数栈(分两次压入,因为 long 是 8 个字节)
  • 这里小的数字(4 个字节)都是和字节码指令存在一起,超过 short 范围的数字存入了常量池

5

istore 1

将操作数栈栈顶的元素弹出,存入本地变量表的 slot 1

6

7

ldc #3
  • 从常量池加载 #3 数据到操作数栈
  • 注意:Short.MAX_ VAIUE32767,所以 32768 = Short. MAX_ VAIUE + 1实际是在编译期间计算

8

istore 2

9

10

iload 1

11

iload 2

12

iadd

13

14

istore 3

15

16

getstatic #4

17

18

iload 3

19

invokevirtual #5
  • 找到常量池 #5 项
  • 定位到方法区 java/io/PrintStream.println:(I)V方法
  • 生成新的栈帧(分配 localsstack等)
  • 传递参数,执行新栈帧中的字节码

20

  • 执行操作,弹出栈帧
  • 清除 main 栈帧中操作数栈内容

21

return
  • 完成 main 方法调用,弹出 main 栈帧
  • 程序结束

2.4 从字节码角度分析 i++++i

  • i++ \Rightarrow 先执行 iload,再执行 iinc
  • ++i \Rightarrow 先执行 iinc,再执行 iload
  • iinc 1 -1 自增运算操作再本地变量表中进行操作,而非操作数栈。意为在 slot 1 的本地变量自增 -1

2.5 条件判断指令

指令 (hex) 助记符 含义
99 ifeq 判断是否 =0=0
9a ifne 判断是否 0\ne 0
9b iflt 判断是否 <0< 0
9c ifge 判断是否 $ \ge 0$
9d ifgt 判断是否 >0>0
9e ifle 判断是否 0\le 0
9f if_icmpeq 判断两个 int 是否 ==
a0 if_icmpne 判断两个 int 是否 \ne
a1 if_icmplt 判断两个 int 是否 <<
a2 if_icmpge 判断两个 int 是否 \ge
a3 if_icmpgt 判断两个 int 是否 >>
a4 if_icmple 判断两个 int 是否 \le
a5 if_acmpeq 判断两个 引用 是否 ==
a6 if_acmpne 判断两个 引用 是否 \ne
c6 ifnull 判断是否为 null
c7 ifnonnull 判断是否为 非null
  • byte, short, char 都会按照 int 来比较,因为操作数栈都是 4 个字节
  • 条件判断指令后,使用 goto 语句来跳转到指定位置

2.6 构造方法

2.6.1 <clinit>()V

<clinit>()V类构造器方法,也就是在 JVM 进行"类加载 - 链接 - 初始化"中的初始化阶段,JVM 会调用 <clinit>()V 方法。<clinit>()V类构造器静态变量,静态代码块进行初始化

从字节码角度分析如下代码:

1
2
3
4
5
6
7
8
9
10
11
public class Demo {
static int i = 10;

static {
i = 20;
}

static {
i = 30;
}
}

编译器会按照从上至下的顺序,收集所有的 static 静态代码块的静态成员赋值的代码,合并成一个特殊的方法 <clinit>()V

1
2
3
4
5
6
7
 0: bipush        10
2: putstatic #3 // Field i:I
5: bipush 20
7: putstatic #3 // Field i:I
10: bipush 30
12: putstatic #3 // Field i:I
15: return
2.6.2 <init>()V

<init>()V对象构造器方法,也就是说在程序执行 new 一个对象,调用该对象类的构造器方法时才会执行 <init>()V 方法。<init>()V实例构造器,对非静态变量解析初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package ClassFile;

public class DemoStatic {
private String a = "s1";

{
b = 20;
}

private int b = 10;

{
a = "s2";
}

public DemoStatic(String a, int b) {
this.a = a;
this.b = b;
}

public static void main(String[] args) {
DemoStatic demo = new DemoStatic("s3",30);
System.out.println(demo.a + "\t" + demo.b);
}
}

编译器会按照从上至下的顺序,收集所有的 {} 初始化代码块的静态成员赋值的代码,合并成一个特殊的方法 <init>()V,但原始构造方法内的代码总是在最后。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: ldc #2 // String "s1"
7: putfield #3 // Field a:Ljava/lang/String;
10: aload_0
11: bipush 20
13: putfield #4 // Field b:I
16: aload_0
17: bipush 10
19: putfield #4 // Field b:I
22: aload_0
23: ldc #5 // String "s2"
25: putfield #3 // Field a:Ljava/lang/String;
28: aload_0
29: aload_1
30: putfield #3 // Field a:Ljava/lang/String;
33: aload_0
34: iload_2
35: putfield #4 // Field b:I
38: return

2.7 方法的调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package ClassFile;

public class DemoCallMethod {
private void test1(){}

private final void test2(){}

public void test3(){}

public static void test4(){}

public static void main(String[] args) {
DemoCallMethod demo = new DemoCallMethod();
demo.test1();
demo.test2();
demo.test3();
demo.test4();
DemoCallMethod.test4();
}
}

字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
         0: new           #2                  // class ClassFile/DemoCallMethod
3: dup
- 4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
- 9: invokespecial #4 // Method test1:()V
12: aload_1
- 13: invokespecial #5 // Method test2:()V
16: aload_1
+ 17: invokevirtual #6 // Method test3:()V
20: aload_1
21: pop
& 22: invokestatic #7 // Method test4:()V
25: invokestatic #7 // Method test4:()V
28: return

在以上例子中,出现了三种不同的方法调用:

  • invokespecial:构造方法、私有方法 privatefinal 修饰的方法
  • invokevirtual:公共方法 public
  • invokestatic:静态方法 static

在 JVM 中,invokespecialinvokestatic 属于静态方法绑定,在字节码文件生成时就已知该方法属于哪个类;但 public 方法有可能出现方法重写的情况,编译期间无法确定该方法属于哪个类(子类或父类),所以使用 invokevirtual 动态方法绑定,需要在运行时确定。

细节说明:

  • new 关键字调用构造方法时,先在堆空间分配对象所需的内存,分配成功后,再将对象引用放入操作数栈
  • dup 复制栈顶元素

2.8 多态的原理

在上一小节中,我们提到 invokevirtual 动态方法绑定,在此节中,我们将了解 Java 中多态的原理。

视频讲解:BiliBili - 黑马程序员 JVM - P119 多态原理

当执行 invokevirtual 指令时,

  1. 先通过栈帧中的对象引用找到对象
  2. 分析对象头,找到对象的实际 Class
  3. Class 结构中有 vtable,它在类加载的链接阶段就已经根据方法的重写规则生成好了
  4. 查表得到方法的具体地址
  5. 执行方法的字节码

2.9 处理异常

2.9.1 单 try-catch 代码块
1
2
3
4
5
6
7
8
9
10
11
12
package ClassFile;

public class DemoException {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (Exception e) {
i = 20;
}
}
}

字节码(重要的部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1 // i = 0
2: bipush 10
4: istore_1 // i = 10
5: goto 12 // 没有异常,跳转 12
8: astore_2 // Execption e
9: bipush 20
11: istore_1
12: return
* Exception table: // 异常表,若[from, to)范围内出现异常且匹配type类型,跳转到target
from to target type
2 5 8 Class java/lang/Exception
LocalVariableTable:
Start Length Slot Name Signature
9 3 2 e Ljava/lang/Exception;
0 13 0 args [Ljava/lang/String;
2 11 1 i I
  • 可以看到,字节码文件中多出来一个 ”Exception table“ 的结构,[from,to) 是前闭后开的检测范围,一旦这个范围内的字节码执行出现异常,则通过 type 匹配异常类型,如果一致,进入 target 所指示行号;
  • 8 行的字节码指令 astore 2 是将异常对象引用存入局部变量表的 slot 2 位置
2.9.2 单 trycatch 代码块 (一)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package ClassFile;

public class DemoException {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (NullPointerException e) {
i = 20;
} catch (Exception e) {
i = 30;
}
}
}

字节码(重要的部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1 // i = 0
2: bipush 10
4: istore_1 // i = 10
5: goto 19 // 没有异常,跳转 12
8: astore_2 // NullPointerException e
9: bipush 20
11: istore_1 // i = 20
12: goto 19
15: astore_2 // Execption e
16: bipush 30
18: istore_1 // i = 30
19: return
Exception table:
from to target type
2 5 8 Class java/lang/NullPointerException
2 5 15 Class java/lang/Exception
LocalVariableTable:
Start Length Slot Name Signature
9 3 2 e Ljava/lang/NullPointerException;
16 3 2 e Ljava/lang/Exception; // slot位置的复用
0 20 0 args [Ljava/lang/String;
2 18 1 i I
  • 因为异常出现时,只能进入 Exception table 中一个分支,所以局部变量表 slot 2 位置被共用
2.9.3 单 trycatch 代码块 (二)
1
2
3
4
5
6
7
8
9
10
11
12
package ClassFile;

public class DemoException {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (NullPointerException | IllegalAccessError e) {
e.printStackTrace();
}
}
}

字节码(重要的部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 10
4: istore_1
5: goto 13
8: astore_2
9: aload_2
10: invokevirtual #4 // Method java/lang/Throwable.printStackTrace:()V
13: return
Exception table:
from to target type
2 5 8 Class java/lang/NullPointerException
2 5 8 Class java/lang/IllegalAccessError
LocalVariableTable:
Start Length Slot Name Signature
9 4 2 e Ljava/lang/Throwable;
0 14 0 args [Ljava/lang/String;
2 12 1 i I
  • 本质上与之前是一样的。
2.9.4 finally 代码块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package ClassFile;

public class DemoException {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (Exception e) {
i = 20;
} finally {
i = 30;
}
}
}

字节码(重要的部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=4, args_size=1
0: iconst_0
1: istore_1 // i = 0
2: bipush 10 // try代码块 -----------------------------
4: istore_1 // i = 10 |
5: bipush 30 // finally |
7: istore_1 // i = 30 |
8: goto 27 // 没有异常,跳转 27 ----------------------
11: astore_2 // catch Exception e ---------------------
12: bipush 20 // |
14: istore_1 // i = 20 |
15: bipush 30 // finally |
17: istore_1 // i = 30 |
18: goto 27 // 没有异常,跳转 27 ----------------------
21: astore_3 // catch any(slot 3) e -------------------
22: bipush 30 // finally |
24: istore_1 // i = 30 |
25: aload_3 // |
26: athrow // throw ---------------------------------
27: return
Exception table:
from to target type
2 5 11 Class java/lang/Exception
2 5 21 any
11 15 21 any
LocalVariableTable:
Start Length Slot Name Signature
12 3 2 e Ljava/lang/Exception;
0 28 0 args [Ljava/lang/String;
2 26 1 i I
  • 可以看到 finally 中的代码被复制了 3 份,分别放入 try 流程,catch 流程以及 catch 剩余的异常类型流程
2.9.5 finally 块中返回值的问题

我们考虑如下代码,分析其最后的返回值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class DemoException {
public static void main(String[] args) {
int result = test();
System.out.println(result);
}

public static int test() {
int i = 10;
try {
return i;
} finally {
i = 20;
return i;
}
}
}

​ 返回结果为 20。其字节码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static int test();
descriptor: ()I
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=0
0: bipush 10
2: istore_0
3: iload_0
4: istore_1 // 思考,为何不直接return,而是将栈顶的10再次存入slot1中呢?
5: bipush 20
7: istore_0
8: iload_0
9: ireturn
10: astore_2
11: bipush 20
13: istore_0
14: iload_0
15: ireturn
Exception table:
from to target type
3 5 10 any
LocalVariableTable:
Start Length Slot Name Signature
3 13 0 i I

我们再考虑如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class DemoException {
public static void main(String[] args) {
int result = test();
System.out.println(result);
}

public static int test() {
int i = 10;
try {
return i;
} finally {
i = 20;
// return i;
}
}
}

​ 返回结果为 10。其字节码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static int test();
descriptor: ()I
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=0
0: bipush 10 // 将10压入栈顶,try ------------------------
2: istore_0 // 将栈顶的10存入slot0(i)中,i=10
3: iload_0 // 将slot0中的10加载到栈顶
4: istore_1 // 将栈顶的10存入slot1中
5: bipush 20 // 将20压入栈顶,finally ---------------------
7: istore_0 // 将栈顶的20存入slot0(i)中,i=20
8: iload_1 // 将slot1中的10加载到栈顶,return -------------
9: ireturn // 返回栈顶元素10
10: astore_2
11: bipush 20
13: istore_0
14: aload_2
15: athrow
Exception table:
from to target type
3 5 10 any
LocalVariableTable:
Start Length Slot Name Signature
3 13 0 i I

我们通过分析以上代码,画出以下流程图:

sequenceDiagram
    participant e1 as 例1
    participant e2 as 例2
    
    Note over e1,e2 :将 10 入栈,栈[10]
    Note over e1,e2 :将栈顶元素 10 存入 i,栈[ ],i=10
    
    e1 -> e2 :try 代码块
    Note over e1,e2 :将 i=10 入栈,栈[10]
    Note over e1,e2 :⚠️准备 return,但存在 finally 代码块,不能 return。将要返回的元素暂存
    Note over e1,e2 :将栈顶元素 10 存入本地变量表中,栈[ ]
    
    e1 -> e2 :finally 代码块
    Note over e1,e2 :将 20 入栈,栈[20]
    Note over e1,e2 :将栈顶元素 20 存入 i,栈[ ],i=20
    
    Note over e1 :将 i=20 入栈,栈[20]
    Note over e1 :ireturn 栈顶元素 20
    Note over e1 :程序结束 ❌
    
    e1 -> e2 :finally 代码块结束,回到 try 中的 return
    Note over e2 :恢复 return,将之前暂存的 10 入栈,栈[10, 20]
    Note over e2 :ireturn 栈顶元素 10
    Note over e2 :程序结束 ❌

注意⚠️:

  • 由上我们可知,在 try - catch - finally 代码块中,try 中的 return 会因为 finally 代码块而被“打断”,从而需要暂存,以保护返回的值。

2.10 synchronized 代码块分析

1
2
3
4
5
6
7
8
public class DemoSynchronized {
public static void main(String[] args) {
Object lock = new Object();
synchronized (lock) {
System.out.println("LOCK!");
}
}
}

其字节码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: new #2 // new Object
3: dup
4: invokespecial #1 // 调用构造方法 "<init>":()V
7: astore_1 // lock对象的引用 -存到-> slot1(lock) 中
8: aload_1 // lock引用加载到栈[lock_ref]【synchronized开始】
9: dup // 复制,栈[lock_ref, lock_ref]
10: astore_2 // 栈顶lock对象的引用lock_ref -存到-> slot2 中,栈[lock_ref]
11: monitorenter // 加锁,栈[]
12: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
15: ldc #4 // String LOCK!
17: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
20: aload_2 // 将slot2的lock_ref加载到栈,栈[lock_ref]
21: monitorexit // 解锁,栈[]
22: goto 30
25: astore_3 // 若出现异常,将异常对象的引用存入slot3
26: aload_2 // 将slot2的lock_ref加载到栈,栈[lock_ref]
27: monitorexit // 解锁,栈[]
28: aload_3 // 将slot3中异常对象的引用加载到栈,栈[exception_ref]
29: athrow // 抛出异常
30: return
Exception table:
from to target type
12 22 25 any
25 28 25 any
LocalVariableTable:
Start Length Slot Name Signature
0 31 0 args [Ljava/lang/String;
8 23 1 lock Ljava/lang/Object;

注意 ⚠️

  • 方法级别的 synchronized 不会体现在字节码指令中

3 编译期处理

所谓的语法糖,其实就是指 java编译器把 .java 源码编译为 .class 字节码 的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担,算是 java 编译器给我们的一个额外福利。

注意⚠️

以下代码的分析,借助了 javap 工具,idea 的反编译功能,idea 插件 jclasslib 等工具。另外,编译器转换的结果直接就是 class 字节码,只是为了便于阅读,给出了几乎等价 的 java 源码方式,并不是编译器还会转换出中间的 java 源码,切记。

3.1 默认构造器

1
2
public class Constructor {
}

编译成 .class 后的等效代码为

1
2
3
4
5
6
public class Constructor {
// 这个无参构造是编译器帮助我们加上的
public Constructor() {
super(); // 即调用父类 Object 的无参构造方法,即调用 java/lang/Object."<init>":()V
}
}

3.2 自动拆装箱

这个特性是 JDK5 开始加入的,如下代码举例:

1
2
3
4
5
6
public class BoxingUnboxing {
public static void main(String[] args) {
Integer x = 1;
int y = x;
}
}

这段代码在 JDK5 以前的版本是不能通过编译的,基本类型和包装类型还不能自动转化。必须改写成如下代码:

1
2
3
4
5
6
public class BoxingUnboxing {
public static void main(String[] args) {
Integer x = Integer.valueOf(1);
int y = x.intValue();
}
}

显然代码太麻烦了,需要在基本类型和包装类型之间来回转换(尤其是集合类中操作的都是包装类型),因此这些转换的事情在 JDK 5以后都由编译器在编译阶段完成。即代码片段 1 都会在编译阶段被转换为代码片段 2。

3.3 范型集合取值

泛型 也是在 JDK5 开始加入的特性,但 java 在编译泛型代码后会执行泛型擦除的动作,即泛型信息在编译为字节码之后就丢失了,实际的类型都当做了 object 类型来处理

1
2
3
4
5
6
7
8
public class DemoGeneric {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(10); // 实际调用的是 List.add(Object o);

Integer x = list.get(0); // 实际是 Object o = list.get(int index);
}
}

所以在取值时,编译器真正生成的字节码中,还要额外做一个类型转换的操作:

1
2
// 需要将 object 转为 Integer
Integer x = (Integer)list.get(0);

如果前面的 x 变量类型修改为 int 基本类型那么最终生成的字节码是:

1
2
//需要将 object 转为 Integer,并执行拆箱操作
int x = ((Integer)list.get(0)).intValue();

在 JDK5 以后,以上操作在编译期间自动完成了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class java/util/ArrayList
3: dup
4: invokespecial #3 // Method java/util/ArrayList."<init>":()V
7: astore_1
8: aload_1
9: bipush 10
11: invokestatic #4 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
14: invokeinterface #5, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
19: pop
20: aload_1
21: iconst_0
22: invokeinterface #6, 2 // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
27: checkcast #7 // class java/lang/Integer 强制类型转化,Object -> Integer
30: astore_2
31: return
LocalVariableTable:
Start Length Slot Name Signature
0 32 0 args [Ljava/lang/String;
8 24 1 list Ljava/util/List;
31 1 2 x Ljava/lang/Integer;
LocalVariableTypeTable:
Start Length Slot Name Signature
8 24 1 list Ljava/util/List<Ljava/lang/Integer;>;
}

擦除的是字节码上的泛型信息,可以看到 LocalVariableTypeTable 仍然保留了方法参数泛型的信息。可以供类型转换时使用。

3.4 可变参数

在 Java 5 中提供了变长参数,允许在调用方法时传入不定长度的参数。变长参数是 Java 的一个语法糖,本质上还是基于数组的实现:

1
2
void foo(String... args);
void foo(String[] args);

在定义方法时,在最后一个形参后加上三点 ,就表示该形参可以接受多个参数值,多个参数值被当成数组传入。上述定义有几个要点需要注意:

  • 可变参数只能作为函数的最后一个参数,但其前面可以有也可以没有任何其他参数

  • 由于可变参数必须是最后一个参数,所以一个函数最多只能有一个可变参数

  • Java 的可变参数,会被编译器转型为一个数组

  • 变长参数在编译为字节码后,在方法签名中就是以数组形态出现的。这两个方法的签名是一致的,不能作为方法的重载。如果同时出现,是不能编译通过的。可变参数可以兼容数组,反之则不成立

    1
    2
    3
    4
    5
    public void foo(String...varargs){}
    foo("arg1", "arg2", "arg3");

    //上述过程和下面的调用是等价的
    foo(new String[]{"arg1", "arg2", "arg3"});

3.5 foreach 循环

foreach 语句是 java5 的新特征之一,在遍历数组、集合方面,foreach 为开发人员提供了极大的方便。

foreach 语法格式如下:

1
2
for(type 元素变量x : 遍历对象obj){ 
}

以下实例演示了数组forforeach 循环使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class DemoForeach {
public static void main(String[] args) {
int[] intary = {1,2,3,4}; // int[] intary = new int[]{1,2,3,4};
forDisplay(intary);
foreachDisplay(intary);
}
public static void forDisplay(int[] a){
for (int i = 0; i < a.length; i++) {
System.out.print(a[i] + " ");
}
}
public static void foreachDisplay(int[] data){
for (int a : data) {
System.out.print(a+ " ");
}
}
}

而对于集合

1
2
3
4
5
6
7
8
public class DemoForeach {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
for (Integer i : list) {
System.out.println(i);
}
}
}

实际被编译器转换为对迭代器的调用:

1
2
3
4
5
6
7
8
9
10
public class DemoForeach {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
Iterator iter = list.iterator();
while (iter.hasNext()) {
Integer i = (Integer) iter.next();
System.out.println(i);
}
}
}

3.6 switch 字符串

从 JDK 7开始,switch 可以作用于字符串和枚举类,这个功能其实也是语法糖,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DemoSwitch {
public static void main(String[] args) {
switch(str) {
case "hello": {
System.out.println("Hello ");
break;
}

case "world": {
System.out.println("World!");
break;
}
}
}
}

⚠️注意

switch 配合 stringenum 使用时,变量不能为 null

会被编译器转换为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class DemoSwitch {
public static void main(String[] args) {
byte x = -1;
switch(str.hashCode()) {
case 99162322: { // hello 的hashCode
if (str.equals("Hello ")) {
x = 0;
}
break;
}

case 113318802: { // world 的hashCode
if (str.equals("World!")) {
x = 1;
}
}
}
switch(x) {
case 0:
System.out.println("Hello ");
break;

case 1:
System.out.println("World!");
break;
}
}
}

可以看到,执行了两遍 switch,第一遍是根据字符串的 hashCode()equals() 将字符串的转换为相应 byte 类型,第二遍才是利用 byte 执行进行比较。

为什么第一遍时必须既比较 hashCode,又利用 equals 比较呢?

hashcode 是为了提高效率,减少可能的比较;而 equals 是为了防止 hashCode 冲突,例如 "EM""C." 这两个字符串的 hashCode 值都是 2123

3.7 switch 枚举

switch 枚举的例子如下

1
2
3
enum Gender {
MALE, FEMALE
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public class DemoSwitchEnum {
public static void print (Gender g) {
switch (g) {
case MALE:
System.out.println("MALE");
break;

case FEMALE:
System.out.println("FEMALE");
break;
}
}
}

转换后代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class DemoSwitchEnum {
/**
* 定义一个合成类(仅jvm 使用,对我们不可见)
* 用来映射枚举的 ordinal 与数组元素的关系
* 枚举的 ordinal 表示枚举对象的序号,从0开始
* 即 MALE 的ordinal()=0, FEMALE的ordinal()=1
*/
static class $MAP {
// 数组大小即为枚举元素个数,里面存储case用来对比的数字
static int[] map = new int[2];
static {
map[Gender.MALE.ordinal()] = 1;
map[Gender.FEMALE.ordinal()] = 2;
}
}

public static void print (Gender g) {
int x = $MAP.map[g.ordinal()];
switch (x) {
case 1:
System.out.println("MALE");
break;

case 2:
System.out.println("FEMALE");
break;
}
}
}

4 类加载阶段

4.1 加载

  • 在 Java 类编译成字节码以后,在运行时通过类加载器 将类的字节码加载到方法区 中,内部采用 C++ 的instanceKlass 描述 Java 类,它的重要 field 有:

    • _java_mirror 即 Java 的类镜像,例如对 String 来说,就是 String.class,作用是把 instanceKlass 暴露给 Java 使用。对 String 来说, String.class 就是 instanceKlass 的类镜像,它们两者间相互持有对方的指针。

    • _super 即父类

    • _fields 即成员变量

    • _methods 即方法

    • _constants 即常量池

    • _class_loader 即类加载器

    • _vtable 虚方法表

    • _itable 接口方法表

  • 如果这个类还有父类没有加载,先加载父类

  • 加载和链接可能是交替运行的

注意 ⚠️

  • instanceKlass 这样的“元数据”是存储在方法区(JDK 1.8 后是元空间),但 _java_mirror 是存储在队中的
  • 可以通过 HSDB 工具查看
image-20220823212538798

4.2 链接

(1) 验证

验证 .class 文件是否符合 JVM 的规范,安全性检查。例如如果我们修改 .class 文件的字节码,JVM 会在加载过后验证类的规范性。如果不符合规范,则会报错。

(2) 准备

准备:为 static 静态变量分配空间,设置默认值

  • static 变量在 JDK 1.7 之前存储于 instanceKlass 末尾,从 JDK 1.7 开始,存储于 _java mirror 末尾

  • static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成

  • 如果 static 变量是 final 的基本类型,那么编译阶段值就确定了,赋值在准备阶段完成

  • 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成。

    因为引用一个新对象 new Object() 的时候,新对象引用的创建需要在堆中完成,所以需要在初始化阶段赋值。

(3) 解析

将常量池中的符号引用,解析为直接引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package ClassLoad;
import java.io.IOException;

class C {
D d = new D();
}

class D {}

public class DemoLoad {
public static void main(String[] args) throws ClassNotFoundException, IOException {
ClassLoader classLoader = DemoLoad.class.getClassLoader();
Class<?> c = classLoader.loadClass("ClassLoad.C"); // loadClass() 不会导致类的解析和初始化
// new C();
System.in.read();
}
}

个人理解:在上述例子中,我们在通过 loadClass() 加载类的时候,由于 C 类不会被初始化,所以 C 类中引用的 D 类不会被加载,而是只是作为一个“class ClassLoad.D” 存在常量池中,并不是一个类。此所谓“符号引用”,“直接引用”则是 D 类也被加载,有了地址,此时引用的就是一个真实的类了。

4.3 初始化

初始化就是调用 <clinit>()V,虚拟机会保证这个类的构造方法的线程安全。

那么何时初始化呢?概括得说,类初始化是【懒情的】

  • main 方法所在的类,总会被首先初始化
  • 首次访问这个类的静态变量或静态方法
  • 子类初始化,会引发父类的初始化
  • 子类访问父类的静态变量,只会触发父类的初始化
  • 执行 Class.forName
  • new 一个对象的时候,会导致该对象的初始化

不会导致类初始化的情况

  • 访问类的 static final 静态常量基本类型和字符串) 不会触发初始化
  • xxx.class 不会触发该类的初始化
  • 创建该类的数组不会触发初始化
  • 类加载器的 loadClass 方法
  • Class.forName参数2false

4.4 练习

(1)

从字节码分析,使用 a, b, c 这三个常量是否会导致 E 初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package ClassLoad;

public class DemoLoad2 {
public static void main(String[] args) {
System.out.println(E.a); // 不会
System.out.println(E.b); // 不会
System.out.println(E.c); // 会
}
}

class E {
public static final int a = 10;
public static final String b = "Hello";
public static final Integer c = 20; // Integer.valueOf(20);
static {
System.out.println("init E");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
static {};
descriptor: ()V
flags: (0x0008) ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: bipush 20
2: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
5: putstatic #3 // Field c:Ljava/lang/Integer;
8: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
11: ldc #5 // String init E
13: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
16: return
(2)

完成懒惰初始化单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package ClassLoad;

class Singleton {
private Singleton() {} // 限制构造方法,其他类不能使用该构造方法
private static class LazyHolder {
static final Singleton SINGLETON = new Singleton();
static {
System.out.println("lazy holder init");
}
}
public static Singleton getInstance() {
return LazyHolder.SINGLETON;
}
public static void test() {
System.out.println("testing...");
}
}

public class DemoLoad3 {
public static void main(String[] args) {
// Singleton.test();
Singleton singleton1 = Singleton.getInstance();
Singleton singleton2 = Singleton.getInstance();
System.out.println(singleton1 == singleton2);
}
}

5 类加载器

在 JDK 中,类加载器有一定的层级关系。以 JDK 1.8 为例,从顶至底为:

名称 加载哪里的类 说明
Bootstrap ClassLoader JAVA_HOME/jre/lib 无法直接访问
Extension ClassLoader JAVA_HOME/jre/lib/ext 上级为 Bootstrap,显示为 null
Application ClassLoader classpath 上级为 Extension
自定义 ClassLoader 自定义 上级为 Application
  • 类加载器在加载类时需要分层级加载。例如 Application ClassLoader 加载类时会检查该类是否被上级类加载器加载,若没有,则再由 Extension ClassLoader 向上层检查。若都没有,则由 Application ClassLoader 加载。(双亲委派)

5.1 Bootstrap ClassLoader

Bootstrap ClassLoader 加载类:

1
2
3
4
5
6
7
package ClassLoad;

class Klass {
static {
System.out.println("BootStrap ClassLoader Klass init");
}
}

执行

1
2
3
4
5
6
7
8
package ClassLoad;

public class DemoBootstrapClassLoader {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> klass = Class.forName("ClassLoad.Klass");
System.out.println(klass.getClassLoader());
}
}

输出

1
java -Xbootclasspath/a:. ClassLoad.DemoBootstrapClassLoader
1
2
BootStrap ClassLoader Klass init
null

5.2 Extension ClassLoader

还是上一节的例子,如果我们使用 .jar

1
jar .cvf Test_Klass.jar ClassLoad/Klass.class

将我们打包好的 Test_Klass.class 复制到 Java 安装目录下的 JAVA_HOME/jre/lib/ext 扩展目录下。重新运行程序,我们得到以下输出:

1
2
Extension ClassLoader Klass init
sun.misc.Launcher$ExtClassLoader@18b4aac2

5.3 双亲委派模式

所谓双亲委派,就是指调用类加载器的 loadClass() 方法时,查找上级类加载器是否已经加载该类的行为。

图解如下:

image-20220824100940813
源码分析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 首先检查该类加载器是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 如果有上级
c = parent.loadClass(name, false);
// 递归调用上级的loadClass()
} else {
c = findBootstrapClassOrNull(name);
// 如果没有上级了(ExtClassLoader),则委派BootstrapClassLoader
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) {
// If still not found, then invoke findClass in order to find the class.
// 如果每一层都找不到,调用findClass方法(每个类加载器自己扩展)来加载
long t1 = System.nanoTime();
c = findClass(name);

// this is the defining class loader; record the stats 记录耗时
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

5.4 线程上下文类加载器

5.5 自定义类加载器

需要自定义类加载器的场景:

  1. 想加载非 classpath 随意路径中的类文件
  2. 都是通过接口来使用实现,希望解耦时,常用在框架设计
  3. 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器

【步骤】

  1. 继承 ClassLoader 父类
  2. 要遵从双亲委派机制,重写 findClass 方法
    • 注意不是重写 loadClass 方法,否则不会走双亲委派机制
  3. 读取类文件的字节码
  4. 调用父类的 defineClass 方法来加载类
  5. 使用者调用该类加载器的 loadClass 方法

6 运行期优化

6.1 即时编译

分层编译

我们先考虑一下例子

1
2
3
4
5
6
7
8
9
10
11
12
public class DemoJit1 {
public static void main(String[] args) {
for (int i = 0; i < 200; i++){
long start = System.nanoTime();
for (int j = 0; j<1000 ; j++) {
new Object();
}
long end = System.nanoTime();
System.out.println( i + "\t" + (end-start));
}
}
}

输出如下

1
2
3
4
5
6
7
 0	42542
...
70 11958
71 4750
72 4041
73 4167
...

我们可以看到,JVM 在执行这些相同的代码时所用的时间并不相同。

原因是什么呢?

JVM将执行状态分成了 5个层次:

  • 0层,解释执行 I (Interpreter)
  • 1层,使用 C1 即时编译器编译执行 (不带 profiling)
  • 2层,使用 C1 即时编译器编译执行(带基本的 profiling)
  • 3层,使用 C1 即时编译器编译执行(带完全的 profiling)
  • 4层,使用 C2 即时编译器编译执行

profling 是指在运行过程中收集一些程序执行状态的数据,例如方法的调用次数循环的回边次数

即时编译器(JIT) 与解释器的区别

  • 解释器 是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
  • JIT 是将一些字节码编译为机器码,并存入 Code Cache, 下次遇到相同的代码,直接执行,无需再编译
  • 解释器是将字节码解释为针对所有平台都通用的机器码
  • JIT 会根据平台类型,生成平台特定的机器码

对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另
一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。执行效率上 Interpreter < C1 < C2。目的是为了发现那些热点代码,加以优化。

这一种优化手段我们称之为【逃逸分析】,发现新建的对象是否逃逸。可以使用如下指令

1
-XX:-DoEscapeAnalysis

关闭逃逸分析。

逃逸分析的作用:

经过逃逸分析的对象,可以直接在栈空间进行分配,而非堆空间。因为这样的对象不会在其他的方法中被引用,所以它可以被分配在当前栈上,可以随着栈消亡。从而极大的降低了 GC 次数,提升了程序整体的执行效率。

方法内联 (Inlining)
1
2
3
private static int square(final int i) {
return i * i;
}

如果发现 square()热点方法,并旦长度不太长时,会进行内联,所谓的内联就是把方法内代码拷贝、粘贴到调用者的位置:

1
System.out.println(9 * 9):

还能够进行常量折叠 (constant folding)的优化

1
System.out.println(81);

实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class DemoJit2 {
public static void main(String[] args) {
int x = 0;
for (int i = 0; i < 600; i++){
long start = System.nanoTime();
for (int j = 0; j<1000 ; j++) {
x = square(9);
}
long end = System.nanoTime();
System.out.println( i + "\t" + x + "\t" + (end-start));
}
}

private static int square(final int i) {
return i * i;
}
}
1
2
3
4
5
6
 0	81	26792
...
78 81 6375
...
410 81 42
411 81 0

可以使用如下指令打印内联信息

1
-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
1
-XX:CompileCommand=doninline,包名.类名.方法名	// 关闭方法的内联
JMH

JMH(Java Microbenchmark Harness)是用于代码微基准测试的工具套件,主要是基于方法层面的基准测试,精度可以达到纳秒级。当你定位到热点方法,希望进一步优化方法性能的时候,就可以使用 JMH 对优化的结果进行量化的分析。

JMH 比较典型的应用场景如下:

  1. 想准确地知道某个方法需要执行多长时间,以及执行时间和输入之间的相关性
  2. 对比接口不同实现在给定条件下的吞吐量
  3. 查看多少百分比的请求在多长时间内完成

关于 JMH 工具的具体运用,恕在此不作详解。

五、JMM 内存模型

本章节内容单独记录,点击链接以跳转

链接:JMM - Java 内存模型