什么是 JVM

定义

大的来说:Java Virtual Machine,JAVA 程序的运行环境(JAVA 二进制字节码的运行环境)

细的来说:JVM 是运行在操作系统之上的,它与硬件没有直接的交互。Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS)

说了那么多,究竟 JVM 的核心特性是什么呢?是使用相同的字节码,它们都会给出相同的结果。

FYI,达到的核心目的:实现了跨平台

好处

  • 一次编写,到处运行(java 跨平台的原因)
  • 自动内存管理,垃圾回收机制
  • 数组下标越界检查
  • 多态

比较

JVM JRE JDK的区别(一层封装接一层封装 JVM 是最里面的 与 OS 打交道)

  • JDK

    Java Development Kit 用作开发, 包含了 JRE, 编译器和其他的工具(比如: JavaDoc,Java 调试器), 可以让开发者开发、编译、执行 Java 应用程序.

  • JRE

    Java 运行时环境是将要执行 Java 程序的 Java 虚拟机, (运行 Java 程序的用户使用的软件)可以想象成它是一个容器, JVM 是它的内容.

    JRE = JVM + Java Packages Classes(like util, math, lang, awt,swing etc)+runtime libraries.

  • JVM

    Java virtual machine (Java 虚拟机) 是一个可以执行 Java 编译产生的 Java class 文件 (bytecode) 的虚拟机进程, 是一个纯的运行环境.

img

常见 JVM

image-20210327170325065

内存结构 - 运行时数据区域

image-20210327171507549

程序计数器

  • 程序计数器 - Program Counter Register

作用

  • 程序计数器是一块较小的内存空间,给字节码解释器服务。是当前线程锁执行的字节码的行号指示器

读完上面这段文字,是不是感觉很抽象?我们结合下面的这幅图理解

image-20210327172341180

右侧是 java 的代码,左边是二进制字节码,代表着一些 jvm 指令;

然而左侧指令能交由 CPU 直接执行么?答案是否定的,需要经由:指令 → 【解释器】 → 机器码 → CPU

这个解释器是怎么工作的呢?是通过改变程序计数器的值来选取下一跳需要执行的字节码指令!

还记得程序计数器的作用么?是的,它记住了下一条 jvm 指令的执行地址,因此就可以配合解释器去进行程序流程控制了

在物理上,这个程序计数器是通过寄存器实现的

特点

  • 线程私有

    • CPU 会为每个线程分配时间片,当当前线程的时间片使用完以后,CPU 就会去执行另一个线程中的代码(也就是一个确定的时刻,一个处理器都只会去执行一条线程中的指令)
    • 为了线程 恢复正确的执行位置:每条线程需要有一个独立的程序计数器
    • 各线程之间计数器互不影响,独立存储,称这类内存区域为:线程私有的内存

    可以结合我们的寄存器与 PCB 之间的关系去理解

    1
    2
    3
    4
    5
    CPU中有个东西 叫做寄存器。它们有不同的功能,比如可以存放下一条指令地址,也可以存放正在执行的指令,也可以保存暂时运算得到的结果;

    然而我们知道寄存器并不是很专一的,他有可能会随时被其他的进程使用,那我们之前运算的结果啊,什么保存的指令啊,岂不是都不见了吗?

    这个时候就会用到我们的PCB了,它能够保持关键的一些信息,也就是运行环境,这样等我们的寄存器又空闲下来了的时候,我们就可以继续我们未完成的指令啦。
  • 不会存在内存溢出:是唯一一个在《Java 虚拟机规范》中没有规定任何OutOfMemoryError(内存溢出)情况的区域

虚拟机栈

  • Java 虚拟机栈 - Java Virtual Machine Stack

定义

  • 每个线程运行需要的内存空间,称为虚拟机栈
  • 每个栈由多个栈帧组成(存储局部变量表、操作数栈、动态连接、方法出口等信息),对应着每次调用方法时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的方法
  • 每一个方法被调用直至执行完毕的过程,就对应着一个栈帧虚拟机栈中入栈到出栈的过程

image-20210327175412940

ps:虚拟机栈是线程私有的,没有线程安全问题。

代码流程演示

  • Code

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class Main {
    public static void main(String[] args) {
    method1();
    }

    private static void method1() {
    method2(1, 2);
    }

    private static int method2(int a, int b) {
    int c = a + b;
    return c;
    }
    }
  • 结果

    main 先进栈 然后是 method1 进栈 最后 method2 进栈

    而释放(出栈)则是反过来。

    在顶部活动的栈帧,称之为:活动栈帧

    image-20210327180817345

思考

  • 垃圾回收是否涉及栈内存?

    • 不需要。因为虚拟机栈中是由一个个栈帧(一次次的方法调用)组成的,在方法执行完毕后,对应的栈帧就会被自动的被弹出栈,被自动回收掉;

      因此无需通过垃圾回收机制去回收内存。

  • 栈内存的分配越大越好吗?

    • 首先需要明白栈内存是运行代码时通过虚拟机参数指定

      ![image-20210327181945387](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/image-20210327181945387.png)

    • 线程是存放在物理内存中的,假如栈内存分配过大,线程就会过大,一个物理内存能够承载的线程数量就会减少;

      栈内存的分配大了,导致的是支持更多的递归调用,然而可以执行的线程数量却不会增多,反而减少。

  • 方法内的局部变量是否是线程安全的?

    image-20210327212734640

    image-20210327212704950

    下面分析三段代码,你是否能判断出各段代码是否是线程安全的呢?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public static void m1() {
    StringBuilder sb = new StringBuilder();
    sb.append(1);
    sb.append(2);
    sb.append(3);
    System.out.println(sb.toString());
    }

    public static StringBuilder m2() {
    StringBuilder sb = new StringBuilder();
    sb.append(1);
    sb.append(2);
    sb.append(3);
    return sb;
    }

    public static void m3(StringBuilder sb) {
    sb.append(1);
    sb.append(2);
    sb.append(3);
    }

    答案是:只有 m1 是安全的,m2、m3 都不是安全的;

    为什么呢?m1 中的 sb 为线程中局部变量,是线程私有的,其他线程无法访问;

    m2 中的 sb 虽然作为局部变量,但是最后会return sb; 导致别的线程可以得到这个变量,因此也不是线程私有的;

    m3 中的 sb 作为的是方法的参数,意味着别的可能可以访问到,因此不是线程私有的(想要安全,就应该改成 String

    Buffer)

    • 如果方法内局部变量没有逃离方法的作用范围,则是线程安全
    • 如果如果局部变量引用了对象,并逃离了方法的作用范围,则需要考虑线程安全问题

栈内存异常

StackOverFlow

  • 栈内存溢出 StackOverFlow:假如线程请求的栈深度大于虚拟机所允许的深度,则会抛出该异常;

导致的原因:

  1. 栈帧过多导致栈内存溢出(无限递归)

    image-20210327220740850

  2. 栈帧过大导致栈内存溢出

    image-20210327220755869

OutOfMemoryError

  • 内存溢出 OutOfMemoryError 如果 Java 虚拟机栈容量可以动态扩展,而当栈扩展到W 伏案申请到足够内存的时候抛出

    ps:一个线程 java 栈的大小由-Xss 设置决定

  • HotSpot 虚拟机栈是无法动态扩展的

线程运行诊断

CPU 占用过高

  • Linux 环境下运行某些程序的时候,可能导致 CPU 的占用过高,这时需要定位占用 CPU 过高的线程

    • top命令,查看是哪个进程占用 CPU 过高
    • ps H -eo pid, tid(线程 id), %cpu | grep 去定位刚才通过 top 查到的进程号,再通过 ps 命令进一步查看是哪个线程占用 CPU 过高
    • jstack 进程 id 通过查看进程中的线程的 nid,刚才通过 ps 命令看到的 tid 来对比定位,注意 jstack 查找出的线程 id 是16 进制的进制需要转换

    比如 62665 进程 对应的 7f99 进程

    image-20210329131631397

    第 8 行:一个 while true 循环导致进程占用过高

    image-20210329131624431

程序运行长时间得不到结果

image-20210329132108123

image-20210329132146310

分析一下这段代码:

现在有 a b 两个对象,第一个线程锁住了对象 a,然后休眠 2000ms(2s),醒过来后尝试去锁对象 b;而在第一个线程休眠的过程中,有一个新的线程创建了,它锁住了对象 b,并尝试去锁对象 a,而对象 a 此时已经被第一个线程锁住了,因此就必须等待第一个线程释放 a 对象。接着第一个线程休眠时间到了,醒过来后尝试去获得 b 对象的锁,然而 b 对象早已被第二个线程锁住了,因此不可得。这样的结果就导致第一个线程等待第二个线程去释放 b 的锁,第二个线程等待第一个线程释放 a 的锁 ——死锁了;

本地方法区

  • 本地方法区(本地方法栈):Native Method Stacks

与虚拟机栈作用相似,区别在于:

虚拟机栈为虚拟机执行 Java 方法(字节码)服务,而本地方法栈是为虚拟机使用到的本地(Native)方法服务

简单来说:指的是被 native 修饰的方法,即非 Java 代码。

下图中 getClass()没有方法体,是由 Java 的底层的 C/C++来实现的。

img

ps:本地方法栈也是由线程独享的,没有线程安全问题。

  • 堆 Heap:是存放对象实例的区域,是垃圾收集器管理的内存区域(因此一些资料中称之为 GC【Garbage Collected】堆)

定义

通过 new 关键字创建的对象都会被放在堆内存,java 世界中“几乎”所有的对象实例都在这里分配内存

(为什么说是几乎?由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致了一些微妙的变化悄然发生了,因此说 Java 对象实例都分配在堆上也渐渐变得不是那么绝对了 —— 《深入理解 JVM 虚拟机》)

特点

  • 所有线程共享,堆内存中的对象都需要考虑线程安全问题
  • (重点)垃圾回收机制

堆分配

如果从分配内存的角度这去看,所有线程共享的Java 堆是可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)的,用以提升对象分配时的效率;

然而无论用什么角度,无论怎么划分,Java 堆都只能存储对象的实例,堆划分目的是为了:更好回收内存 or 更快分配内存

同时,根据《Java 虚拟机规范》规定,Java 堆是可以处于物理上不连续的内存空间中的,但是在逻辑上应被视为连续,但对于大对象(如 数组对象),为了存储高效、实现简单,往往要求连续的内存空间(类似于磁盘吧?)

堆内存溢出

  • 虽然说堆中存在垃圾回收机制,但是回收的对象是我们认定为垃圾的数据,也就是不再使用的对象,但假如一直有对象在创建,且一直有被使用的情况,那堆内存肯定不够用,就会导致堆内存溢出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 演示堆内存溢出 java.lang.OutOfMemoryError: Java heap space
* -Xmx8m 使用Xmx进行堆空间大小的控制
*/
public class Test {

public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "hello";
while (true) {
list.add(a); // hello, hellohello, hellohellohellohello ...
a = a + a; // hellohellohellohello
i++;
}
} catch (Throwable e) {
e.printStackTrace();
System.out.println(i);
}
}
}

image-20210329140851007

堆内存诊断

诊断工具:

  • jps 工具

    命令行形式,查看当前系统中有哪些 java 进程

  • jmap 工具

    命令行形式,查看堆内存占用情况

  • jconsole 工具

    图形界面形式,多功能的检测工具,可以进行连续监测

方法区

图中的常量池为【运行时常量池】

image-20210329143315393

定义

  • 方法区 Method Area:与 Java 堆一样,是各线程共享的内存区域。用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。《Java 虚拟机规范》将方法区描述为堆的一个逻辑部分,但方法区还有一个别名叫非堆(Non-Heap),用于与 Java 堆区分开(说人话就是方法区只是一个逻辑上概念,具体怎么实现是可以不同处理的)

永久代

  • 在 JDK 8 之前,许多 Java 程序员习惯在HotSpot虚拟机上开发、部署程序,许多人去把方法区称为永久代,然而这两者并不是等价的;
    • 那为什么要这么称呼呢?是因为当时的HotSpot虚拟机设计团队把收集器的分代设计扩展到了方法区,或者说使用永久代去实现了方法区
    • 那好处是什么呢?使得垃圾收集器能够像管理 Java 堆一样去管理这部分的内存了,省去了专门为方法区编写内存管理代码的工作了
  • 然而对于其他虚拟机,不一定有着永久代这个概念;
  • JDK 6 的时候,HotSpot开发团队就有放弃永久代,逐步改为采用本地内存去实现方法区的计划
  • JDK 7 的时候,就把原本放在永久代字符串常量池、静态变量移出
  • JDK 8 的时候,完全废除了永久代,改用了在本地内存中实现的元空间 Meta-Space去代替,并把 JDK 7 中永久代剩余的内容(主要是类型信息)全部移到了元空间

方法区内存溢出

  • 类创建过多,导致方法区内存溢出,想一想我们什么场景会遇到?
  • 没错,我们的 Spring Mybatis 中有着许许多多的代理对象创建,bean 注入等情况,少不了类的创建,那就十分有可能出现这个问题

永久代 - 内存溢出

  • 使用 JDK 1.6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 演示永久代内存溢出 java.lang.OutOfMemoryError: PermGen space
* -XX:MaxPermSize=8m
*/
public class Demo1_8 extends ClassLoader {
public static void main(String[] args) {
int j = 0;
try {
Demo1_8 test = new Demo1_8();
for (int i = 0; i < 20000; i++, j++) {
ClassWriter cw = new ClassWriter(0);
cw.visit(Opcodes.V1_6, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
byte[] code = cw.toByteArray();
test.defineClass("Class" + i, code, 0, code.length);
}
} finally {
System.out.println(j);
}
}
}

image-20210329145942295

元空间 - 内存溢出

  • 使用 JDK 1.8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
* -XX:MaxMetaspaceSize=8m
*/
public class Demo1_8 extends ClassLoader { // 可以用来加载类的二进制字节码
public static void main(String[] args) {
int j = 0;
try {
Demo1_8 test = new Demo1_8();
for (int i = 0; i < 10000; i++, j++) {
// ClassWriter 作用是生成类的二进制字节码
ClassWriter cw = new ClassWriter(0);
// 版本号, public, 类名, 包名, 父类, 接口
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
// 返回 byte[]
byte[] code = cw.toByteArray();
// 执行了类的加载
test.defineClass("Class" + i, code, 0, code.length); // Class 对象
}
} finally {
System.out.println(j);
}
}
}

image-20210329145612278

常量池

为了学习运行时常量池,必须先明白什么是常量池

1
2
3
4
5
6
// 二进制字节码(类基本信息,常量池,类方法定义 - 包含了虚拟机指令)
public class HelloWorld {
public static void main(String[] args) {
System.out.println("hello world");
}
}

对于上述代码来说,其字节码包含的信息会保存在常量池中;

image-20210329151426081

image-20210329151456843

根据常量池查表,最后可以翻译出指令:

image-20210329151617194

因此简单来说,常量池就是一张表,虚拟机指令根据这张长凉飙去找到要执行的类名、方法名、参数类型和字面量、等信息(或者说用于存放编译期生成的各种字面量与符号引用)

运行时常量池

  • 运行时常量池 Runtime Constant Pool 是方法去的一部分。Class 文件中除了类基本信息:版本号, public, 类名, 包名, 父类, 接口

    还有方法等描述信息外,还有一项就是我们上述学习的常量池表,这部分的内容会在类加载后存放到方法区的运行时常量池

    (说那么多,其实运行时常量池就是常量池在程序运行时的称呼啦)

  • 运行时常量池具有动态性。也就是在方法区中的运行时常量池是可以发生变化的。

    (也就是说,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,在运行其间也可以将新的常量放入池中。String 类的intern()方法便利用了这一个特性)

    而常量池就不行,它是静态的,当编译生成字节码文件直接就不变了。

StringTable

StringTable 一些特性:

  1. 常量池中的字符串是符号,不是对象,只有第一次使用到的时候才是对象
  2. 可以利用串池的机制,避免重复创建字符串对象
  3. 字符串变量拼接的原理是 StringBuilder(1.8)
  4. 字符串常量拼接的原理是编译期优化
  5. 可以使用intern 方法,主动的去将串池中还没有的字符串对象放入串池
StringTable - 常量池与串池关系
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 字符串串池:StringTable [ "a", "b" ,"ab" ]  hashtable 结构,不能扩容
public class Demo1_22 {
// 常量池中的信息,都会被加载到运行时常量池中, 这时 a b ab 都是常量池中的符号,还没有变为 java 字符串对象
// 等到真正执行了指令才会转成对象,如下:
// ldc #2 会把 a 符号变为 "a" 字符串对象
// ldc #3 会把 b 符号变为 "b" 字符串对象
// ldc #4 会把 ab 符号变为 "ab" 字符串对象
public static void main(String[] args) {
String s1 = "a";
// StringTable [ "a"]
String s2 = "b";
// StringTable [ "a", "b"]
String s3 = "ab";
// StringTable [ "a", "b" ,"ab" ]
}
}

image-20210329153609323

下图的意思表示:

到常量池 #2 号位置 加载一个信息(在这里是字符串对象 a),接着把这个对象加载到局部变量表的一号 Slot 中,以此类推后续操作…

  • ps:字符串对象的创建都是懒惰的,只有当运行到那一行字符串且在串池中不存在的时候(如 ldc #2)时,该字符串才会被创建并放入串池中。
StringTable - 字符串变量拼接
1
2
3
4
5
6
7
8
9
public class Demo1_22 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2; // new StringBuilder().append("a").append("b").toString() new String("ab")
//这段代码执行过后,字符串常量池中会有 a b 但不会有ab
}
}

反编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
	 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/Str
ing;
27: astore 4
29: return

根据反编译的指令,我们可以看出来字符串被创建的过程:

StringBuilder().append("a").append("b").toString()

首先初始化了 StringBuilder 然后添加字符串 a,添加字符串 b,最后调用 toString()方法。之后使用 astore 4,也就是放入局部变量表中的 4 号 slot 中

image-20210329210714606

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
String s1 = "a"; // 懒惰的
String s2 = "b";
//ab字符串
String s3 = "ab";
//拼接后的字符串
String s4 = s1 + s2;

System.out.println(s3 == s4);
}

对于这段代码打印结果是什么呢?答案是:false

根据上面的分析,我们知道了 s4 的构建过程,是由字符串 a b 拼接而成,最终通过 toString()方法锁返回的一个对象,既然是对象就存在于堆内存中

而 s3 本身就是一个”ab“完整的字符串,存在于串池之中;

因此字符串的相同,但却是完全不同(地址不同/内存位置不同)的两个字符串,而 == 恰好比较的就是内存位置,故为 false

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
String s1 = "a"; // 懒惰的
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2; // new StringBuilder().append("a").append("b").toString() new String("ab")
String s5 = "a" + "b"; // javac 在编译期间的优化,结果已经在编译期确定为ab

System.out.println(s3 == s4);
System.out.println(s3 == s5);

}

现在我们修改一下,不使用 s1 + s2 将变量拼接在一起的写法了,直接将常量拼接在一起,答案还会是 false 吗?

我们看看反编译后的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 	  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/Str
ing;
27: astore 4
//ab3初始化时直接从串池中获取字符串
29: ldc #4 // String ab
31: astore 5
33: return

image-20210329212803232

我们根据运行流程:在第 6 行指令中,程序进行到这想去串池中寻找ab 对象,发现没有这对象,就根据 ab 符号创建了一个 ab 对象,放入串池,程序继续执行,直到 29 行指令,依旧想去串池寻找ab 对象,而此时这个对象是存在的,因此不会创建一个新的,故这两个对象是同一个对象

因此答案是 true

其本质是 javac 在编译期间的一个优化,由于我们的“a”和“b”是两个常量,因此在编译期间的时候就可以写死,确定结果为”ab“了

然而 s4 不是这样,s4 是变量的拼接,变量的值是有可能修改的,因此其结果只能在运行期间用StringBuilder动态的去确定结果

(这也可以解释为什么答案是 true:因为前面创建 ab 的时候(s3)在串池中放入了“ab”这个对象了,因此我们的 s5 可以直接从串池获取结果,故 s5 = s3,为 true);

intern 方法(JDK 8)

技术参考:

https://www.jianshu.com/p/be66e22f5fc8

https://blog.csdn.net/qiullan/article/details/65936959

https://blog.csdn.net/lcsy000/article/details/82782864

public native String intern();

作用:将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池;

而无论放入是否成功,都会返回串池中的字符串对象

FYI:此时如果调用 intern 方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象

使用场景:

现在我们Sting str1 = new String("ABC");,根据之前学习我们知道这个str1的实例保存于中,假如我们创建一个字符串

String str2 = "ABC",JVM 会在字符串常量池/串池中创建这个String的实例,再将池中的实例的引用返回给str2

那么假如想让第一个 new 出来的 String 也保存在字符串常量池中,该怎么办呢?可以使用String.intern(),将字符串放到常量池中,返回在常量池中的引用;

关于这个 intern 方法有个细节,在 jdk1.7 之后,假如我们的字符串在字符串常量池中没有出现过,那么就会在字符串常量池中保存一个引用,指向谁呢?指向中这个字符串的实例。而假如字符串在常量池中出现过了,就直接返回常量池中的引用。

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {

String s = new String("a") + new String("b");
String s2 = s.intern();

System.out.println(s2 == "ab");//true
System.out.println(s == "ab");//true
System.out.println(s == s2);//true
}

下面来逐行分析一下每一行代码所带来的影响:

  1. String s = new String("a") + new String("b"); 将字符串”a“和”b“放入串池中,现在串池中状态为:[“a”, “b”]s 存在于堆中;
  2. String s2 = s.intern(); 由于串池中没有 ab,因此会把“ab”串放入串池(此时的串池状态为[“a”, “b”, “ab”]),再返回这个“ab”在串池中的引用
  3. System.out.println(s2 == "ab"); s2 是“ab”这个串在池中的引用,因此与池中的“ab”为同一个 ab 答案为 true
  4. System.out.println(s == "ab");s 在堆内存中,与串池中“ab”为同一个 ab,答案为 true
  5. System.out.println(s == s2); s2 是“ab”串在池中引用,与堆内存中的 s(ab)为同一个对象

下面我们改动一些地方,结果却是不一样的;

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {

String x = "ab";
String s = new String("a") + new String("b");
String s2 = s.intern(); // 这里由于串池有了“ab”串,因此不会放入串池,返回的s2是串池中的ab,而s依旧不变,为堆中ab

System.out.println(s2 == "ab");//true
System.out.println( s2 == x);//true
System.out.println(s == "ab");//false
System.out.println( s == x );//false
}
  1. String x = "ab";首先存放一个“ab”串到串池中,现在串池为[“ab”]
  2. String s = new String("a") + new String("b"); 将“a” 和 “b”放入串池。同时创建出“ab”对象,这个对象存在于堆中,不是串池中,s 指向的是”ab“的对象引用,与串池中的不是同一个 ab。这步过后,串池情况: [“ab”, “a”, “b”]
  3. String s2 = s.intern();由于串池有了“ab”串,因此不会放入串池,返回的 s2 是串池中的 ab,而 s 依旧不变,为堆中 ab
  4. System.out.println(s2 == "ab");:s2 是串池中的 ab,故为 true
  5. System.out.println( s2 == x);:同上
  6. System.out.println(s == "ab");:s 是堆中的“ab”,不是串池中的 ab,答案为 false
  7. System.out.println( s == x );:同上
intern 方法(JDK 6)

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

  • 如果串池中没有该字符串对象,会将该字符串对象复制一份,再放入到串池中
  • 如果有该字符串对象,则放入失败

无论放入是否成功,都会返回串池中的字符串对象

FYI:此时无论调用 intern 方法成功与否,串池中的字符串对象和堆内存中的字符串对象都不是同一个对象

1
2
3
4
5
String s = new String("a") + new String("b");
String s2 = s.intern();
String x = "ab";
System.out.println( s2 == x);//true
System.out.println( s == x );//false

分析:

  1. String s = new String("a") + new String("b");将“a” 和 “b”放入串池。同时创建出“ab”对象,这个对象存在于堆中。这步过后,串池情况: [“a”, “b”]
  2. String s2 = s.intern();由于串池没有”ab“,因此尝试将”ab“放入串池的同时,拷贝了一份放入串池,而拷贝的这个”ab“与堆中的”ab“并不是相同的对象。之后返回结果,s2 是串池中的”ab“
  3. String x = "ab";由于串池中有了”ab“了,不会创建新的字符串放入串池,而是直接沿用串池的”ab“
  4. System.out.println( s2 == x); s2 与 x 都是串池中的“ab”,因此结果是 true
  5. System.out.println( s == x );s 是堆中的“ab”,而 x 是串池中”ab“,结果为 false

这段代码放在 JDK 8 运行结果为:true true;原因是放入串池的字符串对象不是拷贝出来的对象,而是自己这个字符串的引用,为相同对象。因此结果为 true;

StringTable 位置
  • 1.6 中 StringTable 位于方法区中
  • 1.8 中 StringTable 位于堆中

image-20210330220051576

1
2
* 在jdk8下设置 -Xmx10m -XX:-UseGCOverheadLimit
* 在jdk6下设置 -XX:MaxPermSize=10m
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class FindOutPositionOfStringTable {

public static void main(String[] args) throws InterruptedException {
List<String> list = new ArrayList<String>();
int i = 0;
try {
for (int j = 0; j < 260000; j++) {
list.add(String.valueOf(j).intern());
i++;
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
}

在 jdk 6 中出现问题:

image-20210330220606611

在 jdk 8 中出现问题:

image-20210330220707797

StringTable 垃圾回收
  • StringTable 的底层实现是使用HashTable

字符串存在于堆内存中,只有当内存紧张的时候,才会触发垃圾回收;

-Xmx10m -XX+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc

参数 说明
-Xmx10m 堆空间大小
-XX+PrintStringTableStatistics 打印串池统计信息
-XX:+PrintGCDetails 打印 GC 日志详情
-verbose:gc 打印 GC 日志

首先进行一段代码:

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
int i=0;
try {
}catch (Throwable e){
e.printStackTrace();
}finally {
System.out.println(i);
}
}

控制台打印:

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
0
Heap
PSYoungGen total 2560K, used 1388K [0x00000007bfd00000, 0x00000007c0000000, 0x00000007c0000000)
eden space 2048K, 67% used [0x00000007bfd00000,0x00000007bfe5b100,0x00000007bff00000)
from space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
to space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000)
ParOldGen total 7168K, used 0K [0x00000007bf600000, 0x00000007bfd00000, 0x00000007bfd00000)
object space 7168K, 0% used [0x00000007bf600000,0x00000007bf600000,0x00000007bfd00000)
Metaspace used 2927K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 319K, capacity 388K, committed 512K, reserved 1048576K
SymbolTable statistics:
Number of buckets : 20011 = 160088 bytes, avg 8.000
Number of entries : 11756 = 282144 bytes, avg 24.000
Number of literals : 11756 = 455312 bytes, avg 38.730
Total footprint : = 897544 bytes
Average bucket size : 0.587
Variance of bucket size : 0.587
Std. dev. of bucket size: 0.766
Maximum bucket size : 6
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 831 = 19944 bytes, avg 24.000
Number of literals : 831 = 56304 bytes, avg 67.755
Total footprint : = 556352 bytes
Average bucket size : 0.014
Variance of bucket size : 0.014
Std. dev. of bucket size: 0.118
Maximum bucket size : 2

我们查看我们关心的StringTable信息:

我们的串池本质结构是 HashTable,它的本质是一个 Map,其中的存储结构使用的是数组的方式,每一个 bucket 为一个数组结构,数字代表其中存储的字符串个数;

1
2
3
4
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 831 = 19944 bytes, avg 24.000
Number of literals : 831 = 56304 bytes, avg 67.755

下面写入 10 万个字符,来强制触发 GC:

1
2
3
4
5
6
7
8
9
10
11
int i=0;
try {
for(int j=0;j<100000;j++){
String.valueOf(j).intern();
i++;
}
}catch (Throwable e){
e.printStackTrace();
}finally {
System.out.println(i);
}

控制台结果:

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
[GC (Allocation Failure) [PSYoungGen: 2048K->496K(2560K)] 2048K->504K(9728K), 0.0015310 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2544K->512K(2560K)] 2552K->528K(9728K), 0.0010023 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2560K->480K(2560K)] 2576K->520K(9728K), 0.0013527 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
100000
Heap
PSYoungGen total 2560K, used 1295K [0x00000007bfd00000, 0x00000007c0000000, 0x00000007c0000000)
eden space 2048K, 39% used [0x00000007bfd00000,0x00000007bfdcbc60,0x00000007bff00000)
from space 512K, 93% used [0x00000007bff00000,0x00000007bff78020,0x00000007bff80000)
to space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
ParOldGen total 7168K, used 40K [0x00000007bf600000, 0x00000007bfd00000, 0x00000007bfd00000)
object space 7168K, 0% used [0x00000007bf600000,0x00000007bf60a000,0x00000007bfd00000)
Metaspace used 3095K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 339K, capacity 388K, committed 512K, reserved 1048576K
SymbolTable statistics:
Number of buckets : 20011 = 160088 bytes, avg 8.000
Number of entries : 12129 = 291096 bytes, avg 24.000
Number of literals : 12129 = 467440 bytes, avg 38.539
Total footprint : = 918624 bytes
Average bucket size : 0.606
Variance of bucket size : 0.606
Std. dev. of bucket size: 0.778
Maximum bucket size : 6
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 15285 = 366840 bytes, avg 24.000
Number of literals : 15285 = 866120 bytes, avg 56.665
Total footprint : = 1713064 bytes
Average bucket size : 0.255
Variance of bucket size : 0.267
Std. dev. of bucket size: 0.516
Maximum bucket size : 4

我们发现日志显示发生了三次 Yong gc(新生代 GC),Number of entries 是 15285,没有达到 10 万,说明了我们的串池是会发生 GC 的。

StringTable 性能调优

由于StringTable的本质是一个HashTable,那我们调优的思路就是:减少 hash 碰撞,加快查找速度;

  • 减少 hash 碰撞的方法,就是通过增加 HashTable 桶的个数去减少字符串放入串池所需时间

    方法参数:-XX:StringTableSize=xxxx

  • 另一种调优思路,由于有许多字符串是重复的,并不是都要放入串池中,因此可以通过intern()方法去减少重复入池

方法区 堆 JV 栈间关系

img

如上图一条 Java 新建对象实例语句:

  1. 第一个 Person 是变量类型,所以存放在方法区(变量的类型,类的信息,等等)中
  2. 第二个 person 是局部变量引用,在一个方法中写的,所以放在 JVM 栈中
  3. 第三个 new Person();是一个实例化对象,所以放在 Java 堆中

img

JVM 栈中为局部变量的引用,实际上是指向 Java 堆中的对象实例,然后对象实例又指向方法区中的对象类型。

直接内存

  • 直接内存(Direct Memory)并不是运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域,属于操作系统直接管理

  • JDK 1.4 中引入了 NIO 类(New Input/Output)类,引入了一种基于通道缓冲区(Buffer)的 IO 方式,可以使用Native 函数库直接的去分配堆外内存,接着通过一个存储在Java 堆中的DirectByteBuffer对象去作为这块内存(也就是所分配的堆外内存)的引用进行操作。这样在一些场景中就能显著的提高性能,为什么呢?因为避免了在Java 堆Native 堆中来回复制数据

    (我们可以结合下面的文件读写部分进行理解)

文件读写的流程

image-20210331222026374

Java 语言本身并没有读写磁盘文件的能力,想要去读写文件需要依靠操作系统为我们提供的函数。因此需要用户态切换到内核态

在这里插入图片描述

  • 在内核态时,需要内存如上两个图所示。也就是先把磁盘文件读到系统内存中的系统缓冲区,再读到 Java 堆内存中的java 缓冲区 byte[]

    使用 IO,这里需要两份内存存储重复数据,效率低;

而是用了DirectByteBuffer后呢?

我们多出来一块:direct memory(直接内存),使得磁盘文件可和 java 内存都可以直接读到这里面,省了一次缓冲区的复制操作,这样子的特性适合大文件的读写操作。

image-20210401155032539

在这里插入图片描述

内存溢出

虽然本机直接内存的分配并不会受到Java 堆的大小限制。However,既然是内存肯定就会受到本机的总内存(物理内存、SWAP 分区 or 分页文件的大小以及处理器寻址空间的限制!

有时候我们管理实际内存去设置-Xmx等参数信息,却往往省略掉了去管理直接内存,这就会导致:各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常

直接内存释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class DirectMemoryTest {
static int _1Gb = 1024 * 1024 * 1024;

/*
* -XX:+DisableExplicitGC 显式的 让显示的垃圾回收无效(小tips)
*/
public static void main(String[] args) throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
System.out.println("分配完毕...");
System.in.read();//控制台随便摁一下 模拟一个空隙
System.out.println("开始释放...");
byteBuffer = null;
System.gc(); // 显式的垃圾回收,Full GC
System.in.read();
}
}

程序执行后,我们能看到进程分配了 1 个 G 内存

image-20210401161651540

接着 byteBuffer 指空,被回收

image-20210401161711251

那我们可不可以理解成是使用了System.gc()导致直接内存被回收了呢?但垃圾回收是 JVM 的内容,JVM 不是不会管理直接内存么?

其实他是使用了unsafe.freeMemory();进行释放资源

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
public class DirectMemoryTest2 {
static int _1Gb = 1024 * 1024 * 1024;

public static void main(String[] args) throws IOException {
Unsafe unsafe = getUnsafe();
// 分配内存
long base = unsafe.allocateMemory(_1Gb);
unsafe.setMemory(base, _1Gb, (byte) 0);
System.in.read();

// 释放内存
unsafe.freeMemory(base);
System.in.read();
}

public static Unsafe getUnsafe() {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);
return unsafe;
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}

释放前:

image-20210401163229697

释放后:

image-20210401163240449

释放原理

我们点进allocateDirect这个方法,它内部是使用了DirectByteBuffer(capacity)这个方法

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
26
27
// Primary constructor
//
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));
att = null;
}

根据代码,我们发现它底层调用的其实就是unsafe

同时需要留意一行代码:cleaner = Cleaner.create(this, new Deallocator(base, size, cap));

解读:Cleaner 是虚引用类型,特点:关联的对象被回收时,会调用 Cleaner 的 clean 方法(在这里的 this → DirectByteBuffer)

下面就来看看 clean 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void clean() {
if (remove(this)) {
try {
this.thunk.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;
}
});
}

}
}

这里的 thunk 指的是任务对象,执行其 run 方法,去真正的进行内存释放。

1
private final Runnable thunk;//任务对象 在这里指的是:new Deallocator(base, size, cap)
1
2
3
4
5
6
7
8
9
10
//本质执行的就是下面这段代码
public void run() {
if (address == 0) {
// Paranoia
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}

总结:

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

※垃圾回收※

引言:Java 与 C++之间有一堵由内存动态分配和垃圾收集技术所围起的高墙,墙外的人想进去,墙里的人却想出来

如何判断对象可以回收

大致思路:由于堆中存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,首先要判断哪些是“垃圾”,怎么定义“垃圾”呢?那就是通过判断对象的“死活”,也就是判断哪些对象还存活着,而哪些已经死去了(死去:即不可能再被任何途径使用的对象)

AKA,我们能够判断哪些对象死去了,就能够判断该对象是否能被回收了,下面介绍几种能达到这效果的算法。

引用计数法

  • 思路:在对象中添加一个引用计数器,每当有一个地方引用它的时候,计数器值+1;而当引用失效的时候,计数器值-1;任何时刻计数器值 = 0的对象就代表着是不可能再被使用的。

这种算法看似简单,但需要大量额外处理才能保证正确地工作,譬如简简单单一个引用计数法就很难解决对象之间的循环引用问题

如下图:对象objA和对象objB间有字段instance,我们下面进行赋值

令:objA.instance = objBobjB.instan = objA

同时,这两个对象没有别的引用了,按照定义来说,这两个对象是不可能被访问的了,但由于他们互相引用对方,导致了他们的引用计数器值不为 0,而我们也就无法按照引用计数算法去回收他们;

image-20210401170801241

可达性算法

  • 思路:通过一系列称为GC Roots的根对象作为起始字节集,从这些节点开始,根据引用关系去向下搜索,搜索过程中我们走过的路径称之为:引用链(Reference Chain),如果某个对象到GC Roots间没有任何的引用链相连,用图论的话就是:从GC Roots到这个对象是不可达的时候,就证明这个对象是不可能再被使用的;

    (其实就是当时学数据结构说的图,然后判断可达性的算法啦)

eg:下图

image-20210401172719840

根对象

通过上面对于可达性算法的学习,确定GC Roots根对象就显得尤为重要了,那么哪些类可以作为根对象呢?

  1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,神神叨叨的,什么意思呢?其实就是各线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等

  2. 在方法区中类静态属性所引用的对象,such as:Java 类的引用类型静态变量

  3. 在方法区中常量引用的对象,such as:字符串常量池中的引用

  4. 在本地方法栈中JNI(也就是,NATIVE 方法)引用的对象

  5. Java 虚拟机内部的引用,such as:基本数据类型对应的 Class 对象,一些常驻的异常对象(NullPointException,OutOfMemoryError)等,以及系统类加载器

  6. 所有被同步锁(synchronized 关键字)持有的对象

  7. 反应 Java 虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

  8. 根据垃圾收集器和当前回收区域的不同,还可能临时加入其它 GC Roots。

    比如:分代收集和局部回收,这个区域的对象完全可能被其他区域对象所引用,例如老年代的对象引用与年轻代的对象实例。

    在新生代建立一个全局数据结构“记忆集”。这个结构把老年代划分为若干小块,标识出哪一块会出现跨代引用。此后发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入 GC Roots 进行扫描。该方法需要在对象改变引用关系时维护记录数据的正确性。

引用

可达性算法的两个要求:根对象 + 引用,上面讲完了根对象,接下来来学习引用

  • Java 中对于引用的概念:如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称该reference数据是代表某块内存、某个对象对象(其实就是指针的概念吧?)
四种引用

然而如果我们对于一个对象只定义:被引用了、未被引用。那我们就无法描述一些留在内存中有点没必要,但丢了又有点浪费(食之无味,弃之可惜?)的对象了不是吗?因此,就对引用的概念进行扩充,将引用分为:强引用、软引用、弱引用和虚引用

(下图中,实线代表强引用,虚线代表软、弱、虚)

image-20210401181412932

概述:

image-20210401211909934

强引用
  • 强引用(Strongly Reference)

其实就是最传统的关于引用的定义,指的是代码中普遍存在的引用赋值,即:Object obj = new Object();

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

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

描述一些还有用,但不是必须的对象

当 GC Root 指向软引用对象时,在内存不足时,会回收软引用所引用的对象

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

  • 使用案例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class Demo1 {
    public static void main(String[] args) {
    final int _4M = 4*1024*1024;
    //list --> SoftReference --> byte[]
    //list对SoftReference为强引用,而SoftReference对byte[]则是弱引用
    //list先引用软引用对象 再间接引用byte数组
    List<SoftReference<byte[]>> list = new ArrayList<>();
    //通过软引用对象创建数组
    SoftReference<byte[]> ref= new SoftReference<>(new byte[_4M]);
    //将该软引用对象加入list中
    list.add(ref);
    }
    }
  • 而软引用本身也要占有一部分内存(虽然很小),如果想把这部分清除掉,需要配合引用队列去清理

    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
    public class Demo {
    private static final int _4MB = 4 * 1024 * 1024;

    public static void main(String[] args) {
    //同样 构建list --> SoftReference --> byte[]三者关系
    List<SoftReference<byte[]>> list = new ArrayList<>();
    // 构建了引用队列
    ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
    //模拟存储5个 4MB的byte[]数组
    for (int i = 0; i < 5; i++) {
    //构建SoftReference软引用对象
    SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
    System.out.println(ref.get());
    //加入list
    list.add(ref);
    System.out.println(list.size());
    }

    // 从队列中获取无用的 软引用对象,并移除
    Reference<? extends byte[]> poll = queue.poll();
    //遍历引用队列 直到不为空
    while( poll != null) {
    //移除
    list.remove(poll);
    //移动到队列中的下个元素
    poll = queue.poll();
    }

    System.out.println("===========================");
    for (SoftReference<byte[]> reference : list) {
    System.out.println(reference.get());
    }

    }
    }

    image-20210401213735454

弱引用
  • 弱引用(Weak Reference)

描述非必须对象,且强度比软引用更弱一些。

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

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

  • 使用示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public class Demo2_5 {
    private static final int _4MB = 4 * 1024 * 1024;

    public static void main(String[] args) {
    // list --> WeakReference --> byte[]
    List<WeakReference<byte[]>> list = new ArrayList<>();
    for (int i = 0; i < 10; i++) {
    WeakReference<byte[]> ref = new WeakReference<>(new byte[_4MB]);
    list.add(ref);
    for (WeakReference<byte[]> w : list) {
    System.out.print(w.get()+" ");
    }
    System.out.println();

    }
    System.out.println("循环结束:" + list.size());
    }
    }

    大体上来说和软引用差不多,只是将SoftReference 换为了 WeakReference

虚引用

image-20210401182820987

上面我们其实在学习直接内存的内存管理部分有接触过虚引用(Cleaner 就是个虚引用)

使用场景:假如有一个 ByteBuffer 被垃圾回收后,留下来的直接内存是不会被回收掉的,因此就需要使用到虚引用,由于虚引用指向直接内存的地址,则可以通过 unsafe.freeMemory 去释放直接内存。

image-20210401183107197

终结器引用
  • 终结器引用(FinalReference)

我们知道,所有的类都继承自Object 类Object 类有一个finalize方法。当某个对象不再被其他的对象所引用时,会先将终结器引用对象放入引用队列中,然后根据终结器引用对象找到它所引用的对象,然后调用该对象的finalize方法。调用以后,下一次 GC 时,该对象的内存就可以被回收掉了

image-20210401211654862

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

垃圾回收算法

上面讲述了如何判断对象是不是垃圾,接下来章节就要学习如何进行垃圾回收了。

垃圾回收算法可划分为两大部分:引用计数式垃圾收集(Reference CountingGC)追踪式垃圾收集(Tracing GC)两大类,这两类也分别被称为:直接垃圾收集间接垃圾收集

下面所介绍的算法均属于:追踪式垃圾收集(间接垃圾收集)范畴

标记 - 清除算法

  • 标记 - 清除(Mark-Sweep):这是最早也是最基础的垃圾回收算法,顾名思义,该算法分为【标记】【清除】两个阶段

    • 首先标记所有需要回收的对象
    • 在标记完成之后,统一回收到所有被标记的对象

    也可以反过来操作

    • 首先标记存活的对象
    • 统一回收没有被标记的对象

    标记的过程即判断对象是否为垃圾的过程;

image-20210401220030117

需要注意的是,清除并不是将内存中的字节清零,而是记录了内存的起始结束地址,在下次分配内存的时候,如果该块内存大小合适,则直接覆盖;

这部分的内容和操作系统中的内存管理很像,只不过这里好像不合并空闲的内存块,进而会产生内部碎片,产生内部碎片了,就无法满足大对象的内存分配啦,那 jvm 就不得不进行垃圾回收,那程序就不得不暂停,应用就不得不变慢

操作系统中关于内存管理部分笔记:https://uesugier11.gitee.io/uesugi-er11/2020/09/08/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/#%E5%8A%A8%E6%80%81%E5%88%86%E5%8C%BA%E5%88%86%E9%85%8D

标记 - 整理算法

  • 标记 - 整理(Mark - Compact)算法:标记过程与标记 - 清除算法是相同的,而后续步骤就有些不一样了。

我们知道,标记 - 清除算法有可能会产生大量的内部碎片,因此,标记 - 整理算法就是在这一点对标记 - 清除算法进行了优化。

它是怎么做的呢?它让所有存活的对象都向内存空间的一端进行移动,然后直接清除掉边界以外的内存。

image-20210401221929519

那难道这个算法就是完美的了吗?显然不是。

移动存活的对象,尤其是在老年代这种每次回收都有大量对象存活的区域,移动存活对象并且更新所有引用这些对象的地方将会是一种极为繁琐的操作。而且,对象在移动的时候,用户应用程序是必须暂停的(ps:这种停顿被最初的虚拟机设计者称为:“Stop The World” )

然而,结合 OS 的学习我们知道,我们完全可以不移动和整理存活对象的内存。可以依靠内存分配器和内存访问器

such as:使用分区空闲分配链表,关于这部分的内容,我的博客有记载:https://uesugier11.gitee.io/uesugi-er11/2020/09/08/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/#%E7%A9%BA%E9%97%B2%E9%93%BE%E8%A1%A8%E6%B3%95

标记 - 复制算法

  • 标记 - 复制算法被简称为复制算法。它的本质是利用了【半区复制】:将可用的内存按照容量划分成了大小相等的两块,每次只去使用其中的一块。当这一块内存用完了,就把还存活着的对象复制到另一块上面,再将已经使用过的内存空间一次清理。
  • 优点:虽然说如果内存中多数对象是存活的时候,需要产生大量的内存间复制的开销;但如果大多数对象都是可以回收的话,算法需要复制的就是这部分少数的存活对象了。且这种算法无需考虑有空间碎片的复杂情况,只需要移动堆顶指针,按照顺序去分配就好了
  • 缺点:这种算法会将可用的内存变为原来的一半(一半真实投入利用,一半等着放入存活对象)

首先标记垃圾,然后将存活的对象复制到另一块上

img

接着清空垃圾

image-20210402164402777

接着交换两块的位置,FROM→TO,TO→FROM

image-20210402164522414

分代垃圾回收

分代垃圾回收理论

当前大多数商业虚拟机的 GC,大都遵循“分代收集”设计。

它建立在了两个分代假说之上

  1. 弱分代假说(Weak Generatjional Hypothesis):绝大多数的对象都是朝生夕死
  2. 强分代假说(Strong Generatjional Hypothesis):熬过越多次垃圾回收过程的对象就越难以消亡

基于这两个假说,垃圾收集器的设计就有了原则:垃圾收集器应该将Java 堆划分出不同的区域,然后将回收对象按照年龄(对象熬过垃圾回收过程的次数)为依据,分配到不同的区域的存储;

这样做有什么好处呢?好处在于,各分区内的对象具有的特点很明显,虚拟机就能依据其特点去回收了,能提高效率;

Java 堆划分出不同区域后,垃圾收集器才能每次只回收其中某一个/某一些区域,因此也才衍生了:Minor GC、Major GC、Full GC这样的回收类型划分,进而,也才能够针对不同的区域安排与里面所存储的对象的特征相匹配的 GC 算法 —— 上面提到的那些算法。

一般来说,会把Java 堆划分为:新生代(Young Generation)老年代(Old Generation).

  • 新生代:特点在于每次垃圾收集时都有大量的对象死去
  • 老年代:从每次回收中存活下来的对象都存储在这

分代垃圾回收过程

首先要明确,新对象会在伊甸园中产生。

img

此时,伊甸园已满,没有办法存储新的对象,则触发了一次 GC

img

会将存活的对象复制到幸存区 TO中,由于捱过了一次垃圾回收,因此寿命+1

img

同时白板 FROM 与幸存区 TO 交换内存

img

此时假如伊甸园满了,而恰巧又创建了个新对象,那此时会再次触发Minor GC,这次会把伊甸园中幸存区中的垃圾都回收了,除此之外,其他操作和上述基本相同;

img

image-20210402215216638

我们注意到,我们的老年代一直是空着的,那难道他是没有用处的嘛?还记得我们说的关于老年代的特点吗?他是存储经历了多次Minor GC但仍存活下来的对象的。那我们怎么判断一个对象存活的次数够不够多呢?通常来说就会设置一个阈值(最大为 15,4bit),达到了,就会放入老年代中。(当然,如果内存实在是紧张有时候不会去管那个阈值,直接将对象移入老年代)

image-20210402220116879

假如,我们的老年代也放满了,那Minor GC就无法达到内存回收了,此时就会触发Full GC

image-20210402220402831

GC 参数

image-20210402220907130

GC 分析

正常来说,当新的对象创建,进行 GC,放入伊甸园,走一个这样完整的流程;

但如果我们创建的对象过于大,导致新生代无法容纳,那么就会把这个对象直接放入老年代;

image-20210402222723556

  • 线程内存溢出问题

假如某个线程的内存溢出了而抛异常(out of memory),不会让其他的线程结束运行

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

HotSpot 算法细节 - 概念辨析

由于我们接下来会引用到一些专用名词:三色标记法、增量更新、记忆集、卡表等,因此我们需要对这几个名词的出现场景以概念进行一定的理解;

分代收集理论扩展

概述

记不记得我们的分代垃圾回收理论部分立下了 2 条假说/法则?

  1. 弱分代假说(Weak Generatjional Hypothesis):绝大多数的对象都是朝生夕死
  2. 强分代假说(Strong Generatjional Hypothesis):熬过越多次垃圾回收过程的对象就越难以消亡

现在我们引入了第三条法则:

  • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数

有什么用呢?别急,下面来进行解释:

首先需要明白在分代收集部分并非只是简单划分一下内存区域那么简单的,它存在一个明显的困难:对象之间并不是孤立的,对象之间会存在跨代引用,什么意思呢?

假如现在进行一次只局限于新生代区域内的收集(即 Minor GC),但是新生代的对象有没有可能被老年代所引用呢?完全有这个可能。而为了找出该区域中的存活对象,就不得不在固定的GC Roots之外,额外去遍历整个老年代中所有对象去确保可达性分析结果的正确性(翻译大白话就是:找出新生代中对象被哪些老年代引用了),这种方案会为内存的回收带来极大的性能负担,因此就需要我们的第三条法则了。

理解

这个理论的本质,或者说我们该怎么去理解呢?

  • 存在相互引用关系的两个对象,是倾向于同时生存或者同时消亡的。

eg:一个新生代对象存在跨代引用(一个老年代对象引用了该对象),由于老年代对象难以消亡,这份引用(羁绊)会使得新生代对象在 GC 的时候得以存活,这样时间久了,新生代会晋升到老年代中,此时就不存在跨代引用

作用

根据这条假说,既然这种跨代引用为少数情况,则我们不该为了这少量的跨代引用去扫描整个老年代,也不必去浪费空间去专门记录每一个对象是否存在哪些跨代引用了,就只需要在新生代上建立一个全局的数据结构(即记忆集),该结构将老年代划分成若干小块,并且标识出了老年代中哪块内存会存在跨代引用

这样之后发生 Minor GC 的时候,只有这部分的小块内存中对象才会被加入到GC Roots进行扫描。Although,这种方法需要在对象改变引用关系(或将自己或某个属性赋值)时,维护记录数据的正确性,增加了一些运行的开销,但比起之前说的扫描整个老年代来说肯定是划算不少了。

记忆集与卡表

记忆集

上面提到了记忆集这个数据结构,下面就来详解一下:

  • 记忆集是一种用于记录从非收集区域指向收集区域指针集合抽象数据结构,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构(下列代码为用对象指针去实现的记忆集)

    1
    Class RememberedSet {Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];}

而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节

那设计者在实现记忆集的时候,便可以选择更为“粗犷”的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范围以外的的记录精度

  1. 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的 32 位或 64 位,这个 精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  2. 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  3. 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

卡表

上述的卡精度的具体实现,是使用了一种叫卡表(Card Table)的方式去实现的记忆集,也是目前最常用的一种记忆集的实现形式(一些资料中甚至直接把它和记忆集混为一谈)

怎么理解卡表呢?记 忆集其实是一种“抽象”的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的 具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。(用 HashMap 和 Map 的关系去理解吧)

实现

卡表最简单的形式可以只是一个字节数组(之所以使用 byte 数组而不是 bit 数组主要是速度上的考量,现代计算机硬件都是最小按字节寻址的, 没有直接存储一个 bit 的指令,所以要用 bit 的话就不得不多消耗几条 shift+mask 指令。)

即:CARD_TABLE [this address >> 9] = 0 表示的是 HotSpot 默认的卡表标记逻辑

字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个 内存块被称作“卡页”(Card Page)

一般来说,卡页大小都是以2 的 N 次幂的字节数,通过上面代码可 以看出 HotSpot 中使用的卡页是 2 的 9 次幂,即 512 字节(地址右移 9 位,相当于用地址除以 512)。那如 果卡表标识内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 0、1、2 号元素,分别对应了 地址范围为 0x0000 ~ 0x01FF、0x0200 ~ 0x03FF、0x0400 ~ 0x05FF 的卡页内存块,如下图所示:

image-20210405180124566

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏(Dirty,脏表),没有则标识为 0。

在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

写屏障

问题引入

我们可以顺便来梳理一下:分代收集理论存在跨代引用问题 → 使用记忆集卡表进行解决 → 卡表何时变脏?谁来把他们变脏?

这留下来了两个疑问。有问题就得解决

  1. 卡表元素何时变脏?有其他分代区域中对象引用了本区域对象时,其对应的 卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻
  2. 如何变脏,即如何在对象赋值的那一刻去更新维护卡表?假如是解释执行的字节码,那相对好处理,虚拟 机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代 码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一 个赋值操作之中。

写屏障

下面就对第二个问题进行更深一步理解:

在 HotSpot 虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。

  • ps:写屏障(Write Barrier)≠ 内存屏障(Memory Barrier),后者的目的是为了指令不因编译优化、CPU 执行优化等原因 而导致乱序执行,它也是可以细分为仅确保读操作顺序正确性和仅确保写操作顺序正确性的内存屏障 的

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP 切面(有没有觉得很熟悉?没错,就是 Spring 中的那个),在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。

在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值 后的则叫作写后屏障(Post-Write Barrier)。

下面是一段写后屏障更新卡表的伪代码:

1
2
3
4
5
6
7
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}

虽然应用这种技术后,只要对引用更新,就会产生额外的开销,但这个开销与 Minor GC 时扫描整个老年代的代价相比还是低得多的。

除了开销外还有一个问题:卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。

伪共享是处 理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line) 为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影 响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。

为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:

1
2
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;

我们可以使用一个新的参数+UseCondCardMark,去决定是否开启卡表更新的条件判断,开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损 耗,是否打开要根据应用实际运行情况来进行测试权衡

并发可达性分析

使用三色标记:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是 白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过

image-20210405201052497

在上图中,最后一种情况会发生“对象消失”的问题,即原本应该是黑色的对象被标注为白色,导致被误认为是垃圾回收掉了

本质上来说,当且仅当以下两个条件同时满足时,这种情况会发生:

  1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  2. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此衍生了

垃圾回收器

总概述

垃圾回收算法是内存回收的方法论,垃圾回收器则为内存回收的实践者。(下图为 HotSpot 虚拟机的垃圾回收器,连线表示可以搭配使用)

image-20210404145418336

那么我们如何评判一个垃圾回收器好不好呢?需要什么指标呢?

一般来说我们从这三个方面入手去评判

  1. 吞吐量:即 CPU 用于运行用户代码的时间与 CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行 100 分钟,垃圾收集器花掉 1 分钟,那么吞吐量就是 99%
  2. 暂停时间:STW 发生的时间
  3. 内存占用:Java 堆区所占的内存大小

注重吞吐量(做的事多),每次 STW 时间长,总时长短

注重低延迟(用户交互性好,不卡),每次 STW 间隔短,总时长长

现在的垃圾回收器标准:在满足最大吞吐量优先的情况下,降低停顿时间。

串行

  • 串行垃圾回收器适用场景
    • 单线程
    • 内存小,多用于个人电脑(CPU 核数较少)

Serial 收集器

  • Serial(串行),从名字看出来这是一个单线程工作收集器,其潜在意义是:在进行 GC 的时候,会强制暂停其他所有的工作进程。
  • 参数:-XX:+UseSerialGC = Serial + SerialOld

在这里插入图片描述

  • 特点
    • 简单而高效:Serial 收集器没有线程交互的开销,专心做 GC 回收,因此可以获得最高的单线程收集效率
    • 额外内存消耗较小

基于以上特点,Serial 收集器适用于运行在客户端模式下的虚拟机

ParNew 收集器

  • ParNew 收集器:本质是Serial 收集器的多线程并行版本

在这里插入图片描述

  • 特点
    • 支持多线程并行收集
    • 只有这个收集器能够和CMS 收集器配合工作(CMS 收集器是 HotSpot 虚拟机中第一款支持并发的垃圾收集器,首次实现了让垃圾收集线程和用户线程(基本上)同时工作。
    • ParNew 收集器较 Serial 在多核 cpu 上效率更高。
  • 并发与并行区别
    • 并发:并发描述的是垃圾收集器线程和用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程序都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量会受到一定影响。
    • 并行:并行描述的是多条垃圾收集器之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程处于等待状态

吞吐量优先

  • 吞吐量优先垃圾回收器适用场景
    • 多线程
    • 堆的内存较大,多核 CPU
  • 特点
    • 在单位的时间内,STW 的时间最短
    • JDK 1.8默认使用的垃圾回收器

image-20210404152959054

Parallel Scavenge 收集器

  • Parallel Scanvenge 收集器:是一款新生代收集器,同样基于标记-复制算法,能够实现并行收集。

image-20210404154734362

  • 特点:MDS 等收集器的关注点是在于尽可能缩短STW时间,而这款收集器注重于达到一个可控制的吞吐量

  • 由于该款收集器注重于控制吞吐量,因此我们需要留意这 2 个参数

    image-20210404154222616

  • GC 自使用调节策略(与 ParNew 收集器最重要的一个区别)

    image-20210404154248333

Parallel Old 收集器

  • Parallel Old 收集器:是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,是基于标记 - 整理算法实现的

  • 特点:在注重吞吐量,或者处理器资源较为稀缺的场合,优先考虑Parallel Scavenge 收集器 + Parallel Old 收集器这个组合

响应时间优先

  • 多线程
  • 堆内存较大,多核 CPU
  • 尽可能让单次 STW 时间变短(尽量不影响其他线程运行)

image-20210404154955660

CMS 收集器

  • CMS 收集器(Concurrent Mark Sweep):一种以获取最短回收停顿时间为目标的老年代收集器

使用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如 web 程序、b/s 服务

  • 特点

    • 基于标记-清除算法实现。
    • 从总体来说,内存回收过程能与用户线程一起并发执行
  • 缺点

    • 处理器资源敏感。特别是核心数少的处理器,因为 GC 占用线程,所以总吞吐量降低。

    • 难以应对“浮动垃圾”(即在标记完成后出现的新垃圾),因为有并发标记的阶段,所以这阶段产生的垃圾不会被标记,这些垃圾只能等到下一次垃圾收集时清理。

      同时,CMS 不能等到老年代几乎填满后再启动,因为他必须留下足够的内存,在垃圾收集的同时给用户线程使用。预留过多会造成内存浪费以及回收频繁;预留过少会造成“并发失败”,并发失败发生后虚拟机会调用 Serial Old 重新收集,会使性能下降。

    • 由于标记-清除算法,而产生的空间碎片化问题,甚至可能会进行一次 Full GC。

在这里插入图片描述

  • 运作过程:
    • 初始标记(initial mark):仅标记一下GC Roots能直接关联到的对象,追求时间效率,避免用户久等。
    • 并发标记(cocurrent mark):从GC Roots 直接关联对象开始遍历整个对象图(所有的可达的对象),这个过程耗时长但不需要停顿用户线程,此过程可以与垃圾回收线程一起并发运行
    • 重新标记(remark):为了修正并发标记期间,用户程序继续运作而导致标记变动的那部分对象的标记记录(这是在并发可达性分析中,增量更新部分的内容),此阶段的停顿时间通常比初始标记阶段时间长一点,但也远比并发标记阶段时间短
    • 并发清除(concurrent sweep):清除删除掉标记阶段中判断出来的已经死亡的对象,此阶段可以与用户线程同时并发

G1(Garabage First)收集器

概述

之前的收集器是以分代为衡量标准进行 GC,而 G1 是面向堆内存任何部分组成回收集,衡量标注是哪块内存中存放的垃圾数量最多,则回收受益最大。

  • Region: G1 在组建回收集时,不再根据新生代老年代衡量。而是把 java 堆划分为多个大小相等的Region,每个 Region 根据需要可以扮演新生代的 Eden、Survivor 存活区域或老年代区域

  • 特点:既兼顾低延迟,也针对大吞吐量。

  • Humongous 区域:收集器再对不同 Region 采用不同策略进行收集。Humongous 区域专门用来存放大对象(即超过 Region 一半大小的对象)。对于超过整个 Region 的对象,用多个连续 Humongous 存储。G1 大多数行为将其视为老年代

  • 注意点:G1 仍保有新生代老年代概念,是一系列区域(不需要为连续的)的动态集合。G1 将Region作为最小回收单元,因此停顿时间可预测(即每次手机的内存空间为Region大小整数倍,可以有计划的避免在这个 Java 堆尽心全区域的垃圾回收)

    • 具体实现:通过跟踪每个 Region 的回收价值大小(即回收所获得空间和回收所需时间的经验值),并维护一个优先级列表。回收时根据用户设定允许的回收停顿时间,优先处理价值高的 Region。(这也是为什么说停顿时间是可预测的原因)
  • 适用场景
    • 同时注重吞吐量和低延迟(响应时间)
    • 超大堆内存(内存大的),会将堆内存划分为多个大小相等的区域
    • 整体上是标记-整理算法,两个区域之间是复制算法

回收阶段

img

总的一个流程:

新生代伊甸园垃圾回收(Young Collection)—–>内存不足,新生代回收+并发标记(Young Collection + Concurrent Mark)—–>回收新生代伊甸园、幸存区、老年代内存(Mixed Collection)——>新生代伊甸园垃圾回收(重新开始)

Young Collection/Minor GC

即新生代收集:目标只是新生代的垃圾收集

  • E:伊甸园
  • S:幸存区
  • O:老年代

这个回收会发生:STW

image-20210405165247574

新生代回收会将幸存对象以拷贝算法放入幸存区

image-20210405165320293

接着,当幸存区中对象过多,或者是年龄达到了阈值,则这部分对象会晋升到老年代,不够年龄的则拷贝到别处幸存区

image-20210405165454440

Young Collection + CM

CM 并发标记:从GC Roots 直接关联对象开始遍历整个对象图(所有的可达的对象)

  • Young GC 时会对 GC Root 进行初始标记

  • 在老年代占用堆内存的比例达到阈值时,对进行并发标记(不会 STW),阈值可以根据参数:-XX:InitiatingHeapOccupancyPercent=percent (默认45%)去决定

  • E:伊甸园

  • S:幸存区

  • O:老年代

如下图,当老年代(O,橙色部分)占用堆 空间比例达到我们设置的阈值的时候,会进行并发标记

image-20210405165830879

Mixed Collection

混合收集:目标是收集整个新生代以及部分老年代的垃圾收集(目前只有 G1 收集器会有该行为)

在该阶段,会对 E S O(具体含义看以下解释)进行全面的垃圾回收

  • E:伊甸园
  • S:幸存区
  • O:老年代

-XX:MaxGCPauseMills:xxx 用于指定最长的停顿时间

  • 回收过程:

    • 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍然遗留下来的最后那少量的 SATB 记录

    • 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个 Region 的回收价值和成本进行排序(其实就是维护一个优先级列表啦),然后结合用户设置的停顿时间去制定一个回收计划,可自由的选择任意多个 Region 去构成一个回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region,然后直接清理整个旧 Region 的全部空间。

      由于这部分的操作设计到了存活对象的移动,因此是必须暂停用户线程的,由多条收集器线程并行完成

image-20210405170011836

我们观察上图,发现并不是所有的老年代区域(O)都被复制到了新的老年代区域,是因为 G1 是根据我们上面的参数 —— 最大暂停时间,有选择的进行回收。也就是一开始我们的注意点部分中的:维护一个优先级列表,从列表中优先回收价值最高的区域。

从这个阶段,也能够体现出Garbage First的含义了。

Full GC

  1. Full GC(整堆收集):收集整个 Java 堆和方法区的垃圾收集

image-20210405170927035

对于 G1 算法来说,它老年代内存不足时需要分类讨论:

  • 如果垃圾产生速度慢于垃圾回收速度,不会触发 Full GC,还是并发地进行清理
  • 如果垃圾产生速度快于垃圾回收速度,便会触发 Full GC

Young Collection 跨代引用

  • 这部分内容在 HotSpot 算法细节 - 概念辨析部分中有具体理解。

跨代引用:老年代引用新生代

img

主要是通过了记忆集卡表去进行问题的解决。

img

  • 在引用变更时通过 post-write barried(写屏障) + dirty card queue
  • concurrent refinement threads 更新 Remembered Set(记忆集)

Remark - 重新标记

这部分内容使用到了三色标记,具体可以查看并发可达性分析部分的内容

img

这是一个 GC Roots 的图

image-20210405201808678

考虑这么个情况:假如 C 被判断成一个无引用的(白色)过后,A 又去引用了 C,那 C 应该是有引用的,那就不应该被回收掉,但是 A 又是黑色的,是已经检查过的元素,那在整个并发标记的过程结束后,我们仍然判断 C 为白色(应该被回收的对象),那这样就不对的了;

因此就需要对引用的对象进行二次检查,即重新标记 Remark

image-20210405202602146

具体实现:

当对象的引用发生改变时,引用关系的插入与删除会通过写屏障去实现。

比如本例中,A 引用 C,则会把 C 加入到一个队列中,并改变其为灰色(没有被处理完),等整个并发标记阶段结束后,进入重新标记阶段,发生 STW,将该队列中的对象拿出来重新进行检查,若是有引用的,则置为黑色,就不会被回收掉了

G1-字符串去重

  • JDK 8U20 字符串去重

提到字符串去重,除了之前提到的 String 方法中的intern方法外,G1 还提供了另外一种方法

  • 将所有新分配的字符串(底层是 char[])放入一个队列
  • 当新生代回收时,G1 并发检查是否有重复的字符串
  • 如果字符串的值一样,就让他们引用同一个字符串对象
  • 优点:节省了大量内存
  • 缺点:新生代回收时间略微增加,导致略微多占用 CPU
  • 与 String.intern()区别
    • intern 关注的是字符串对象
    • 字符串去重关注的是 char[]
    • 在 JVM 内部,使用了不同的字符串表

JDK 8u40 并发标记类卸载

在并发标记阶段结束以后,就能知道哪些类不再被使用。如果一个类加载器的所有类都不在使用,则卸载它所加载的所有类

-XX:+ClassUnloadingWithConcurrentMark 默认开启

JDK 8u60 回收巨型对象

  • 一个对象大于 region 的一半时,就称为巨型对象

  • G1 不会对巨型对象进行拷贝(因为代价太高了)

  • 回收时被优先考虑

  • G1 会跟踪老年代所有incoming引用(卡表),如果老年代incoming引用为 0(卡表为 0)的巨型对象就可以在新生代垃圾回收时处理掉

    image-20210405203350365

JDK 9 并发标记起始时间调整

  • 并发标记必须在空间占满之前完成,否则退化为 FullGC
  • JDK 9 之前需要使用-XX:InitiatingHeapOccupancyPercent
  • JDK 9 之后可动态调整
    • -XX:InitatingHeapOccupancyPercent去设置初始值
    • 进行数据采样并且动态调整
    • 会添加一个安全的空档空间

GC(垃圾回收)调优

关于这部分内容,感觉是项大工程,之后系统的学习一下,不能囫囵吞枣,先把后续部分学了;

类加载与字节码技术

类文件结构

  • 类字节码文件如下

    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
    0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09
    0000020 00 16 00 17 08 00 18 0a 00 19 00 1a 07 00 1b 07
    0000040 00 1c 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29
    0000060 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e
    0000100 75 6d 62 65 72 54 61 62 6c 65 01 00 12 4c 6f 63
    0000120 61 6c 56 61 72 69 61 62 6c 65 54 61 62 6c 65 01
    0000140 00 04 74 68 69 73 01 00 1d 4c 63 6e 2f 69 74 63
    0000160 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c 6f
    0000200 57 6f 72 6c 64 3b 01 00 04 6d 61 69 6e 01 00 16
    0000220 28 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72
    0000240 69 6e 67 3b 29 56 01 00 04 61 72 67 73 01 00 13
    0000260 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69
    0000300 6e 67 3b 01 00 10 4d 65 74 68 6f 64 50 61 72 61
    0000320 6d 65 74 65 72 73 01 00 0a 53 6f 75 72 63 65 46
    0000340 69 6c 65 01 00 0f 48 65 6c 6c 6f 57 6f 72 6c 64
    0000360 2e 6a 61 76 61 0c 00 07 00 08 07 00 1d 0c 00 1e
    0000400 00 1f 01 00 0b 68 65 6c 6c 6f 20 77 6f 72 6c 64
    0000420 07 00 20 0c 00 21 00 22 01 00 1b 63 6e 2f 69 74
    0000440 63 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c
    0000460 6f 57 6f 72 6c 64 01 00 10 6a 61 76 61 2f 6c 61
    0000500 6e 67 2f 4f 62 6a 65 63 74 01 00 10 6a 61 76 61
    0000520 2f 6c 61 6e 67 2f 53 79 73 74 65 6d 01 00 03 6f
    0000540 75 74 01 00 15 4c 6a 61 76 61 2f 69 6f 2f 50 72
    0000560 69 6e 74 53 74 72 65 61 6d 3b 01 00 13 6a 61 76
    0000600 61 2f 69 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d
    0000620 01 00 07 70 72 69 6e 74 6c 6e 01 00 15 28 4c 6a
    0000640 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b
    0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
    0000700 00 07 00 08 00 01 00 09 00 00 00 2f 00 01 00 01
    0000720 00 00 00 05 2a b7 00 01 b1 00 00 00 02 00 0a 00
    0000740 00 00 06 00 01 00 00 00 04 00 0b 00 00 00 0c 00
    0000760 01 00 00 00 05 00 0c 00 0d 00 00 00 09 00 0e 00
    0001000 0f 00 02 00 09 00 00 00 37 00 02 00 01 00 00 00
    0001020 09 b2 00 02 12 03 b6 00 04 b1 00 00 00 02 00 0a
    0001040 00 00 00 0a 00 02 00 00 00 06 00 08 00 07 00 0b
    0001060 00 00 00 0c 00 01 00 00 00 09 00 10 00 11 00 00
    0001100 00 12 00 00 00 05 01 00 10 00 00 00 01 00 13 00
    0001120 00 00 02 00 14
  • 类文件的结构如下

    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 fields[fields_count];//变量信息
    u2 methods_count;//方法信息
    method_info methods[methods_count];//方法信息
    u2 attributes_count;//附加属性信息
    attribute_info attributes[attributes_count];//附加属性信息
    }

魔数

每个 Class 文件的头 4 个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为 一个能被虚拟机接受的 Class 文件。

如:0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09

1
u4 			   magic;//魔数

版本号

紧接着魔数的 4 个字节存储的是Class 文件的版本号:第 5 和第 6 个字节是次版本号(Minor Version)第 7 和第 8 个字节是主版本号(Major Version)

如:0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09

此处的 00 34 中的 34 表示的是十六进制,即十进制的 52:表示的是 java8

1
2
u2             minor_version;//小版本号
u2 major_version;//主版本号

常量池

紧接着主、次版本号之后的是常量池入口,常量池可以比喻为 Class 文件里的资源仓库

1
2
u2             constant_pool_count;//常量池信息
cp_info constant_pool[constant_pool_count-1];//常量池信息

如:0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09

mark 标记的是完整的常量池信息,我们通常拆分成小段去理解:

  • 0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09 :表示常量池的长度,00 23(35),表示常量池有#1~#34 项,容量计数 从 1 而不是从 0 开始;空出来的原因:如果后面某些指向常量池的索引值的数据在特定情况下需要表达:”不引用任何一个常量池项目”的含义,就可以把索引值设置成 0;

  • 0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09:0a(十进制中的 10)表示一个方法引用,00 0600 15(21)表示引用了常量池中#6 和#21 项来获得这个方法的【所属类】和【方法名】

    image-20210406201628119

后续部分以此类推,可阅读《深入理解 JVM 虚拟机》一书进行学习。

访问标志

  • 用于识别一些类或 者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被声明为 final;等等。
1
u2             access_flags;//访问标志:返回一些信息(是类还是接口啊,是公共还是私有等)

具体的标志位以及其对应的含义见下表:

image-20210406202510383

类索引、福类索引与接口索引集合

类索引(this_class)父类索引(super_class)都是一个u2类型的数据,而接口索引集合 (interfaces)是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定该类型的继承关系。类索 引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。

1
2
3
4
u2             this_class;//类包名
u2 super_class;//父类信息
u2 interfaces_count;//接口信息
u2 interfaces[interfaces_count];//接口信息

字段表集合

  • 字段表(field_info)用于描述接口或者类中声明的变量。
1
2
u2             fields_count;//变量信息
field_info fields[fields_count];//变量信息
  • 字段
    • 包括:括类级变量以及实例级变量
    • 不包括:在方法内部声明的局部变量

在 Java 中,我们常见的描述字段的信息:

  1. 字段的作用域(public、private、protected 修饰 符)
  2. 实例变量还是类变量(static 修饰符)
  3. 可变性(final)
  4. 并发可见性(volatile 修饰符,是否 强制从主内存读写)
  5. 可否被序列化(transient 修饰符)
  6. 字段数据类型(基本类型、对象、数组)
  7. 字段名称

上述这些信息,其修饰符是以布尔值形式存在,即:要么有,要么没有,适合使用标志位去表示;

而字段的名字,字段的数据类型都是不可以确定的,或者说不能用 01 去表示,因此就只能引用常量池中的常量去描述

image-20210406204514100 字段修饰符放在 access_flags 项目中,它与类中的 access_flags 项目是非常类似的,都是一个 u2 的数据类型,其中可以设置的标志位和含义如下图所示(可以看到我们熟悉的 public private protected 等)

image-20210406204617979

name_index 和 descriptor_index 是两项索引值,表示对常量池项的引用

  • name_index:字段的简单名称

  • descriptor_index:字段和方法的描述符

  • “简单名称” “描述符”“全限定名”这三种特殊字符串的概念

    1
    2
    3
    4
    5
    6
    7
    package org.fenixsoft.clazz;
    public class TestClass {
    private int m;
    public int inc() {
    return m + 1;
    }
    }
    • 全限定名:org/fenixsoft/clazz/TestClass”是这个类的全限定名,仅仅是把类全名中的“.”替换成了“/”而已,为了使连续的多个全限定名之间不产生混 淆,在使用时最后一般会加入一个“;”号表示全限定名结束。

    • 简单名称:简单名称则就是指没有类型和参数修饰 的方法或者字段名称,这个类中的inc()方法和 m 字段简单名称分别就是“inc”“m”

    • 描述符:是用来描述字段 的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。

      ![image-20210406205720096](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/image-20210406205720096.png)

方法表集合

Class 文件存储 格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样,依次包括

  1. 访问标志(access_flags)

  2. 名称索引(name_index)

  3. 描述符索引(descriptor_index)

  4. 属性表集合(attributes)

    u2             methods_count;//方法信息
    method_info    methods[methods_count];//方法信息

    image-20210406205841685

对于方法表,其标志位与取值可参考下图:

image-20210406205930635

我们需要注意的是,方法可以通过访问标志、名称索引、描述符索引来表达。但方法中的代码,会经过 Javac 编译器编译成字节码指令之 后,存放在方法属性表集合中一个名为Code的属性里面,属性表在下节进行讲解。

属性表集合

  • 属性表(attribute_info):Class 文件、字段表、方法表都可以 携带自己的属性表集合,以描述某些场景专有的信息

    u2             attributes_count;//附加属性信息
    attribute_info attributes[attributes_count];//附加属性信息

    image-20210406210522604

我们下面主要讲解一下 Code 属性:

我们上面说到:Java 程序方法体里面的代码经过 Javac 编译器处理之后,最终变为字节码指令存储在 Code 属性内。

而 Code 属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽象类中的方法就不存在 Code 属性

image-20210406210616293

字节码指令

简介

Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义数字(称为操作码,Opcode) 以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成。

而 Java 虚拟机中大多数指令都不包含操作数,只有一个操作码,指令参数存放在操作数栈中;

图解方法执行流程

1
2
3
4
5
6
7
8
9
//演示 字节码指令 和 操作数栈 常量池的关系
public class Test{
public static void main(String[] args) {
int a = 10;
int b = Short.MAX_VALUE + 1;
int c = a + b;
System.out.println(c);
}
}
  1. 常量池载入运行时常量池

    ps:运行时常量池属于方法区的一部分

    Class 文件常量池中的数据存储在运行时常量池,将来找方法引用等信息等就从这去找。

    32768:是Short.MAX_VALUE + 1的结果,通常来说一些小的数字并不存储在常量池中,而是与方法字节码存储在一起,而较大一些的则放在常量池中。

    image-20210406212905766

  2. 方法字节码载入方法区

    接着会将方法的字节码放入方法区

    image-20210406213536911

  3. main 线程开始运行,分配栈帧内存

    (stack=2,locals=4) 对应操作数栈有 2 个空间(每个空间 4 个字节),局部变量表中有 4 个槽位

    image-20210406213701389

  4. 执行引擎开始执行字节码

    1. bipush 10 :将一个 byte 压入操作数栈(其长度会补齐 4 个字节)

      - 下面是类似的指令:
        - sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节)
        - ldc 将一个 int 压入操作数栈
        - ldc2_w 将一个 long 压入操作数栈(**分两次压入**,因为 long 是 8 个字节)
      - 这里小的数字都是和字节码指令存在一起,**超过 short 范围的数字存入了常量池**
      
      ![image-20210406214624098](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/image-20210406214624098.png)

    2. istore 1(1 代表的是 1 号槽位)

      将操作数栈栈顶元素弹出,放入局部变量表的slot 1中
      
      对应代码中的
      
      
      1
      a = 10
      ![image-20210406214735588](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/image-20210406214735588.png)

      ![image-20210406214755269](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/image-20210406214755269.png)

    3. ldc #3

      从常量池加载#3数据到操作数栈中
      
      32768的计算是在**编译期间**计算好的(不是运行时期间)
      
      ![image-20210406215006674](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/image-20210406215006674.png)

    4. istore 2

      将操作数栈中的元素弹出,放到局部变量表的2号位置
      
      ![img](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/20200608151432.png)

      ![img](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/20200608151441.png)

    5. iload 1

      由于 a + b操作是不能在局部变量表中执行,而是得在操作数栈中进行,因此 **iload**就是进行变量的读取操作
      
      ![image-20210406215628748](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/image-20210406215628748.png)

    6. iload 2

      ![image-20210406215649658](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/image-20210406215649658.png)

    7. iadd

      操作数栈有a 和 b两个数据了,就要进行加法操作:将操作数栈中的两个元素**弹出栈**并相加,结果在压入操作数栈中
      
      ![img](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/20200608151508.png)

      ![img](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/20200608151523.png)

    8. istore 3

      将操作数栈中的32778取出来放入局部变量表三号槽位
      
      ![img](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/20200608151547.png)

      ![image-20210409163256206](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/image-20210409163256206.png)

    9. getstatic #4

      在运行时常量池中找到成员变量的引用,此处的#4指向的是存放在堆中的System.out对象
      
      注意,此处并不会把**该对象**放入操作数栈中,而是将**其引用**放入操作数栈中
      
      ![image-20210409163623604](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/image-20210409163623604.png)

      ![image-20210409163636859](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/image-20210409163636859.png)

    10. iload 3

      将局部变量表中3号槽位的元素压入操作数栈中
      
      ![image-20210409163904424](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/image-20210409163904424.png)

    11. invokevirtual #5

      找到运行时常量池 #5 项,接着定位到方法区 java/io/PrintStream.println:(I)V 方法
      
      虚拟机分配新的栈帧(分配 locals、stack等)
      
      接着执行:传递参数,执行新栈帧中的字节码
      
      ![image-20210409163939850](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/image-20210409163939850.png)

      执行完毕,弹出栈帧
      
      清除main操作数栈的内容
      
      ![image-20210409164137353](

      https://er11.oss-cn-shenzhen.aliyuncs.com/img/image-20210409164137353.png)

    12. return

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

类加载机制

概述

我们首先认识一下什么是类加载机制:

  • Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称作虚拟机的类加载机制

我们可以对这句话进行拆分:

描述类的数据是移动单位,出发点是 Class 文件,目的地是内存。在移动其间对该单位进行了三步:

  1. 检验
  2. 转换解析
  3. 初始化

移动的目的是为了能形成一个能被虚拟机直接使用的 java 类型(意思是一开始不行)

类加载时机

一个类的生命周期从大的来说是从:被加载到虚拟机内存中开始,到卸载出内存为止

细分的话,在这个生命周期内会经历:

加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)七个阶段

验证、准备、解析 这三个部分我们统称为:为连接(Linking)

image-20210410174849844

值得注意的是:

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的(解析的顺序不确定)

因为解析阶段在某些情况下可以在初始化阶段之后再开始, 这是为了支持 Java 语言的运行时绑定特性(也称为动态绑定或晚期绑定)

这个过程表示的是按顺序开始,不是所谓的第一步、第二步、第三步的关系,而往往是交叉混合进行,在一个阶段中可能调用或者激活另一个过程。因此这个图只是一个大致的运行流程图罢了,不是一个真正的时序图。

是对于初始化阶段,《Java 虚拟机规范》 则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始,因为这五个阶段的顺序是确定的)

  1. 遇到new、getstatic、putstatic 或 invokestatic这四条,字节码指令时,如果类型没有进行过初始 化,则需要先触发其初始化阶段,从Java 代码的场景来说的话分为:
    1. 使用new关键字实例化对象的时候。
    2. 读取或设置一个类型的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外) 的时候。
    3. 调用一个类型的静态方法的时候
  2. 使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需 要先触发其初始化
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle实例最后的解 析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了 JDK 8 新加入的默认方法(被default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

综上这六类场景中的行为我们称之为对一个类型进行主动引用(言外之意就是所有引用类型的方式都是不会触发初始化的,我们称之为被动引用

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}

public class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
/**
* 非主动使用类字段演示
**/
public class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}

上述代码只会输出“SuperClass init!123”,而不会输出“SubClass init!

image-20210410181127638

原因是:对于静态字段(static),只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发 父类的初始化而不会触发子类的初始化,因此子类的静态字段是没有被初始化的。

类加载的过程

加载

加载(Loading)阶段是整个类加载(Class Loading)过程中的一个阶段

在此阶段,Java 虚拟机完成了以下三件事

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。(实现这个动作的代码我们称之为:类加载器(Class Loader),这部分内容将在后面着重学习
  2. 将这个字节流所代表的静态存储结构转化为方法区运行时数据结构
  3. 堆内存中生成一个代表这个类的java.lang.Class 对象,作为方法区这个类的各种数据访问入口(外部接口)

第一条这个规则比较宽松,因此衍生了许多 java 技术(这个规则的实践)

  • 从 ZIP 压缩包中读取
  • 从网络中获取,这种场景最典型的应用就是 Web Applet
  • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用 了 ProxyGenerator.generateProxyClass()来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流
  • 由其他文件生成,典型场景是 JSP 应用,由 JSP 文件生成对应的 Class 文件

值得注意的是:

加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部 分,这两个阶段的开始时间仍然保持着固定的先后顺序

(也就是大体上依旧保持着先后顺序,只是连接部分中的几步可能穿插在加载阶段)

验证

  • 验证阶段是连接阶段的第一步

这一步的目的:从字面我们也能看出来,主要是进行 Class 文件的字节流中包含的信息的验证,确认符合《Java 虚 拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证阶段大致上会完成四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。

_文件格式验证_:验证字节流是否符合 Class 文件格式的规范并 验证其版本是否能被当前的 jvm 版本所处理。ok 没问题后,字节流就可以进入内存的方法区进行保存了。后面的 3 个校验都是在方法区进行的。

_元数据验证_:对字节码描述的信息进行语义化分析,保证其描述的内容符合 java 语言的语法规范。

_字节码检验_:最复杂,对方法体的内容进行检验,保证其在运行时不会作出什么出格的事来。

_符号引用验证_:来验证一些引用的真实性与可行性,比如代码里面引了其他类,这里就要去检测一下那些来究竟是否存在;或者说代码中访问了其他类的一些属性,这里就对那些属性的可以访问行进行了检验。(这一步将为后面的解析工作打下基础)

准备

  • 准备阶段是正式为类中定义的变量(即静态变量,被 static 修饰的变量)分配内存设置类变量初始值(0 或者 null)的阶段

注意点:

  1. 在准备阶段进行内存分配的仅包括类变量,而不包括实例变量实例变量将会在对象实例化时随着对象一起分配在Java 堆

    (实例变量就是对象变量,即没加 static 的变量。对应的 类变量,即静态变量,也就是变量前加了 static 的变量)

  2. 初始值“通常情况”下是数据类型的零值

    1
    public static int value = 123;

    静态变量 value 在准备阶段后的初始值是 0 而不是 123.(初始化阶段才会赋值)

    但也有特殊情况,那就是加上了 final 关键字

    1
    public static final int value = 123

    那么准备阶段 value 的值就被赋值为 123

image-20210412150411190

解析

  • 解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程

符号引用,形如:CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等类型

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同, 但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java 虚拟机规范》的 Class 文件格式中。
  • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在

举个例子来说的话:

在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方发表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。

一句话来说:解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。

初始化

  • 类的初始化阶段是类加载过程的最后一个步骤,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,只有在初始化阶段,Java 虚拟机才真正开始执行类中编写的 Java 程序代码(字节码),将主导权移交给应用程序
  • 初始化阶段就是执行类构造器<clinit>()方法的过程,值得注意的是,<clinit>()方法并不是程序员在 Java 代码中直接编写的方法,是Javac 编译器的自动生成物

类初始化条件:要对类进行初始化,代码上可以理解为‘为要初始化的类中的所有静态成员都赋予初始值、对类中所有静态块都执行一次,并且是按代码编写顺序执行’

如下代码:输出的是‘1’。如果①和②顺序调换,则输出的是‘123’。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main {
public static void main(String[] args){
System.out.println(Super.i);
}
}
class Super{
//①
static{
i = 123;
}
//②
protected static int i = 1;
}

静态语句块中只能访问 到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问

eg:

1
2
3
4
5
6
7
public class Test {
static {
i = 0; // 给变量复制可以正常编译通过
System.out.print(i); // 这句编译器会提示“非法向前引用”
}
static int i = 1;
}

类加载原理图解

img

方法代码理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Parent{
public static int A=1;
static{
A=2;
}
}
---相当于---->
class Parent{
<clinit>(){
public static int A=1;
static{
A=2;
}
}
}
  • 接口和类初始化过程的区别:
  1. 对于类,会生成(){……}方法体:去包含静态变量的赋值和静态块代码
  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
public class Main {
public static void main(String[] args){
//第二句
System.out.println("我是main方法,我输出Super的类变量i:"+Sub.i);
Sub sub = new Sub();
}
}
class Super{
{
//第四句
System.out.println("我是Super成员块");
}
public Super(){
//第六句
System.out.println("我是Super构造方法");
}
{
int j = 123;
//第五句
System.out.println("我是Super成员块中的变量j:"+j);
}
static{
//第一句
System.out.println("我是Super静态块");
i = 123;
}
protected static int i = 1;
}
class Sub extends Super{
static{
//第三句
System.out.println("我是Sub静态块");
}
public Sub(){
//第八句
System.out.println("我是Sub构造方法");
}
{
//第七句
System.out.println("我是Sub成员块");
}
}

结果:

img

说明:

  1. 对于同一个类:静态代码块和静态变量的赋值 是先于main 方法的调用执行的。
  2. 对于同一个类:静态代码块和静态变量的赋值是按顺序执行的。
  3. 子类调用父类的类变量成员,是不会触发子类本身的初始化操作的【所以我们调用 Sub.iSub.class并没有被初始化和加载】。
  4. 使用 new 方式创建子类,对于类加载而言,是先加载父类、再加载子类(注意:此时由于父类已经在前面初始化了一次,所以,这一步,就只有子类初始化,父类不会再进行初始化)
  5. 不论成员块放在哪个位置,它都 先于类构造方法执行

类加载器

类与类加载器

类加载器就是根据一个全限定名加载 class 生成二进制流并转换为一个 java.lang.Class 对象实例

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性,每 一个类加载器,都拥有一个独立的类名称空间/命名空间,因此,一旦一个类被加载如 JVM 中,同一个类就不会被再次载入了。

可以这么理解:

  • Java中,一个类用其全限定类名(包括包名和类名)作为标识
  • JVM中,一个类用其全限定类名和其类加载器作为其唯一标识

因此,这两个来源于同一个 Class 文件,被同一个Java 虚拟机加载,但加载它们的类加载器不同,那这两个类就必定不相等

这里所指的“相等”,包括类的 Class 对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof()关键字对做对象所属关系判定等情况

双亲委派模型

引入

-从 jvm 的角度来讲,只存在以下两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader),这个类加载器用 C++实现,是虚拟机自身的一部分;
  • 所有其他类的加载器,这些类由 Java 实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader

-从 Java 开发人员的角度看,类加载器可以划分成三大类:

  • 启动类加载器(Bootstrap ClassLoader): 此类加载器负责将存放在 <JAVA_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。
    • 启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,直接使用 null 代替即可。
  • 扩展类加载器(Extension ClassLoader) : 这个类加载器是由ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将<Java_Home>/lib/ext或者被 java.ext.dir系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader) : 这个类加载器是由 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,因此一般称为系统类加载器。
    • 它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

类加载器的关系:

v2-d4af5df076ca1a8b46a275d62c9d919f_hd.jpg

分层理解的话:

image-20210415212558624

上图展示的类加载器之间的层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器,这里类加载器之间的父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)的关系实现。

  • 组合关系(has-a 关系):把该类当成另一个类的组合成分,从而允许新类直接复用该类的 public 方法
双亲委派机制

当需要使用该类时,才会将它的 class 文件加载到内存生成 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
    protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
    {
    // 首先,检查请求的类是否已经被加载过了,加载了就直接返回
    Class c = findLoadedClass(name);
    if (c == null) {
    try {
    //如果当前类加载器的父加载器不为空,则委托父加载器去加载该类
    if (parent != null) {
    c = parent.loadClass(name, false);
    } else {
    //如果当前加载器父加载器为空则委托引导类加载器加载该类
    c = findBootstrapClassOrNull(name);
    }
    } catch (ClassNotFoundException e) {
    // 如果父类加载器抛出ClassNotFoundException
    // 说明父类加载器无法完成加载请求
    }
    if (c == null) {
    // 在父类加载器无法加载时
    // 再调用本身的findClass方法来进行类加载
    c = findClass(name);
    }
    }
    if (resolve) {
    resolveClass(c);
    }return c;
    }

    从代码也看得来双亲委派的逻辑:

    1. 先检查请求加载的类型是否已经被加载过
      1. 若没有则调用父加载器的 loadClass()方法
      2. 若父加载器为空(找到顶了)则默认使用启动类加载器作为父加载器
    2. 假如父类加载器加载失败, 抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载

举个例子来说:

1
2
大家所熟知的Object类,直接告诉大家,Object默认情况下是启动类加载器进行加载的。假设我也自定义一个Object,并且制定加载器为自定义加载器。现在你会发现自定义的Object可以正常编译,但是永远无法被加载运行。
这是因为申请自定义Object加载时,总是启动类加载器,而不是自定义加载器,也不会是其他的加载器。

这样做有什么好处呢?

  1. 避免类的重复加载。一旦一个类被父类加载器加载之后,就不会再被委派给子类进行加载。
  2. 保护程序安全,可以防止核心 API 被随意篡改
  • 沙箱安全机制
1
2
3
4
5
6
7
8
9
10
package java.lang; // 包命名为java.lang


public class Start {

public static void main(String[] args) {
System.out.println("hello!");
}
}

运行 main 函数,需要加载 Start,根据双亲委派机制,加载请求会被向上委派到引导类加载器(记住第 1 小节的工作原理图);

引导类加载器一看,包是 java.lang,所以是由它来进行加载。

image-20210415211657918

但这个 lang 包已经在 base 包中被加载过了,因此会直接报错.

从这点我们体现出了沙箱安全机制作用:保护核心类,防止打破双亲委派机制,防篡改,如果重名的话就报异常,这里的重名指包名加类名都重复

破坏双亲委派模型

重写 loadClass()方法破坏双亲委派模型

现在有个类 A

我们希望通过自定义加载器 直接从某个路径下读取 A.class . 而不是说 通过自定义加载器 委托给 AppClassLoader ——> ExtClassLoader —-> BootClassLoader 这么走一遍,都没有的话,才让自定义加载器去加载 A.class . 这么一来 还是 双亲委派。

我们期望的是 A.class 及时在 AppClassLoader 中存在,也不要从AppClassLoader 去加载。

说白了,就是 直接让自定义加载器去直接加载 A.class 而不让它取委托父加载器去加载,不要去走双亲委派那一套。

我们知道 双亲委派的机制是在ClassLoader # loadClass方法中实现的,打破双亲委派,那我们是不是可以考虑从这个地方下手呢?

因此我们就知道了,打破双亲委派方法是通过 —— 重写ClassLoader # loadClass方法

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

public class MyClassLoaderTest {

static class MyClassLoader extends ClassLoader {
private String classPath;

public MyClassLoader(String classPath) {
this.classPath = classPath;
}

private byte[] loadByte(String name) throws Exception {
name = name.replaceAll("\\.", "/");
FileInputStream fis = new FileInputStream(classPath + "/" + name
+ ".class");
int len = fis.available();
byte[] data = new byte[len];
fis.read(data);
fis.close();
return data;
}



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);
//自己的类路径下的对象走我自己的classLoader
if ("com.xxx.xxx.Test".equals(name)) {
c = findClass(name);
}
else {
// 交由父加载器去加载
c = this.getParent().loadClass(name);
}
return c;
}
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] data = loadByte(name);
//defineClass将一个字节数组转为Class对象,这个字节数组是class文件读取后最终的字节数组。
return defineClass(name, data, 0, data.length);
} catch (Exception e) {
e.printStackTrace();
throw new ClassNotFoundException();
}
}
}


线程上下文类加载器
  • 这个类加载器能够通过 java.lang.Thread 类的 setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。

JNDI 服务(是 Java 的标准服务)使用这个线程上下文类加载器去加载所需的 SPI 服务(即接口,Service Provider Interface,SPI)代码,这是一种父类加载器去请求子类加载器完成类加载的行为。按照之前所学的,这肯定是违背双亲委派模型的一般性原则。(留了个后门)

  • 应用

在 JDK 6 时,JDK 提供了 java.util.ServiceLoader 类,以 META-INF/services 中的配置信息,辅以责任链模式,解决 SPI 的加载问题。

1
2
3
4
5
6
7
//传统加载方式 1
Class.forName("com.mysql.jdbc.Driver");
Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:33061/xxx?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull", "root", "123456");

//传统加载方式 2
System.setProperty("jdbc.drivers","com.mysql.jdbc.Driver");
Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:33061/xxx?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull", "root", "123456");
1
2
//SPI加载方式
Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:33061/xxx?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull", "root", "123456");

SPI 加载机制中,不需要手动设置驱动为 com.mysql.jdbc.Driver。

spi 服务的模式原本过程

  1. 从 META-INF/services/java.sql.Driver 文件中获取具体的实现类名:”com.mysql.jdbc.Driver”
  2. 加载这个类(使用class.forName("com.mysql.jdbc.Driver")

image-20210415220215431

使用了这个线程上下文后,mysql 的驱动加载过程:

  1. 从 META-INF/services/java.sql.Driver 文件中获取具体的实现类名:”com.mysql.jdbc.Driver”
  2. 加载这个类(使用class.forName("com.mysql.jdbc.Driver")
  3. 使用线程上下文类加载器去加载 Driver 类,使得父级加载器可以加载子级类加载器路径中的类

程序编译与代码优化

Java 技术中的编译器有多个过程:

  1. 前端编译器(叫“编译器的前端”更准确一些)把.java 文件转变成.class 文件的过程
  2. 指 Java 虚拟机的即时编译器(常称 JIT 编译器,Just In Time Compiler)运行期把字节码转变成本地机器码的过程
  3. 使用静态的提前编译器(常称 AOT 编译器,Ahead Of Time Compiler)直接把程 序编译成与目标机器指令集相关的二进制代码的过程

对应这三类编译过程的三类比较有代表性的编译产品分别为:

  1. 前端编译器:JDK 的 Javac、Eclipse JDT 中的增量式编译器(ECJ)
  2. 即时编译器:HotSpot 虚拟机的 C1、C2 编译器,Graal 编译器
  3. 提前编译器:JDK 的 Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET。

前端编译与优化

编译期处理 - 语法糖

默认构造器
1
public class Candy1 { }

编译成 class 后的代码为:

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

基本类型和其包装类型的相互转换过程,称为拆装箱

JDK 5以后,它们的转换可以在编译期自动完成(也就是说第一段代码在 JDK 5 前无法编译通过,而第二段可以)

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

实质的转换过程:

1
2
3
4
5
6
7
8
public class Demo2 {
public static void main(String[] args) {
//基本类型赋值给包装类型,称为装箱
Integer x = Integer.valueOf(1);
//包装类型赋值给基本类型,称谓拆箱
int y = x.intValue();
}
}

可以明显看出来第二段代码比第一段代码麻烦很多,因此 JVM 提供了这么个糖给我们吃,省去不少麻烦

【泛型 - 泛型擦除】
  • 重点

Java 选择的泛型实现方式叫作“类型擦除式泛型”(Type Erasure Generics),与之对应的是 c#的是“具现化式泛型”(Reified Generics)

(具现化和特化、偏特化这些名词最初都是源于 C++模版语 法中的概念)

区别:

  1. 道 C#里面泛型无论在程序源码里面、编译后的中间语言表示(Intermediate Language,这时候泛型是一个占位符)里面,抑或是运行期的 CLR 里面都是切实存在的,List< int >与 List< string >就是两个不同的类型,它们由系统在运行期生成,有着自己独立的虚方法表和类型数据。

  2. 而 Java 语言中的泛型则不同,它只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换 为原来的裸类型(Raw Type,稍后我们会讲解裸类型具体是什么)了,并且在相应的地方插入了强制转型代码。due to that,对于处在运行期的 Java 语言来说的话,List< int >与 List< string >其实是同一个类型的。

    这也是 类型擦除中的擦除的含义

上面都是一些概念类型的知识,下面用实例代码来体验一下

1
2
3
4
5
6
7
8
public class Candy {

public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(10); // 实际调用的是 List.add(Object e)
Integer x = list.get(0); // 实际调用的是 Object obj = List.get(int index);
}
}
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
 public cn.itcast.jvm.t3.candy.Candy();
descriptor: ()V
flags: 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 11: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcn/itcast/jvm/t3/candy/Candy3;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
-------new ArrayList对象----------
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;
//add方法的参数类型实际是Object类型
14: invokeinterface #5, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
19: pop
20: aload_1
21: iconst_0
//get方法的参数类型实际是Object类型
22: invokeinterface #6, 2 // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
// 强制类型转换
27: checkcast #7 // class java/lang/Integer
30: astore_2
31: return
LineNumberTable:
line 8: 0
line 9: 8
line 10: 20
line 11: 31
LocalVariableTable:
Start Length Slot Name Signature
0 32 0 args [Ljava/lang/String;
8 24 1 list Ljava/util/List;
LocalVariableTypeTable:
Start Length Slot Signature
8 24 1 Ljava/util/List<Ljava/lang/Integer;>;
  • list.add 和 get 方法在 bycecode 层面上的参数类型都是 Object 类型,最后会执行 checkcast 指令进行类型转换为真实类型

上面我们提及了,泛型信息 在编译为字节码之后就丢失了,实际的类型都当做了 Object 类型来处理:

既然现在是 Object 类型的,而我们想去的值为 Integer 类型,就必须进行一次类型转换操作(在编译器真正生成字节码的过程中进行)

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

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

1
2
// 需要将 Object 转为 Integer, 并执行拆箱操作
int x = ((Integer)list.get(0)).intValue();
  • 类型擦除把字节码上泛型信息(方法体内泛型信息)擦除了,但在LocalVariableTypeTable(局部变量类型表)保留了方法参数泛型信息

    1
    2
    3
      LocalVariableTypeTable:
    Start Length Slot Signature
    8 24 1 Ljava/util/List<Ljava/lang/Integer;>;

虽然这个信息被保留了,但无法通过反射去获得

只有方法参数和返回值上带泛型信息,才能通过反射获取泛型信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public Set<Integer> test(List<String> list, Map<Integer, Object> map) { }

Method test = Candy3.class.getMethod("test", List.class, Map.class);
Type[] types = test.getGenericParameterTypes();
for (Type type : types) {
//判断是不是参数化泛型类型
if (type instanceof ParameterizedType) {
ParameterizedType parameterizedType = (ParameterizedType) type;
System.out.println("原始类型 - " + parameterizedType.getRawType());
//getActualTypeArguments()获取到的是<>中的参数
Type[] arguments = parameterizedType.getActualTypeArguments();
for (int i = 0; i < arguments.length; i++) {
System.out.printf("泛型参数[%d] - %s\n", i, arguments[i]);
}
}
}

输出:

1
2
3
4
5
原始类型 - interface java.util.List
泛型参数[0] - class java.lang.String
原始类型 - interface java.util.Map
泛型参数[0] - class java.lang.Integer
泛型参数[1] - class java.lang.Object
可变参数(变长参数)
1
2
3
4
5
6
7
8
9
10
11
public class Varargs {
public static void print(String... args) {
for(String str : args){
System.out.println(str);
}
}

public static void main(String[] args) {
print("hello", "world");
}
}

print(String... strs) 中的 … 表示参数个数是可以不定的

上述代码 本质:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Varargs {
public Varargs() {
}

public static void print(String... args) {
String[] var1 = args;
int var2 = args.length;
//增强for循环的数组实现方式
for(int var3 = 0; var3 < var2; ++var3) {
String str = var1[var3];
System.out.println(str);
}

}

public static void main(String[] args) {
//变长参数转换为数组
print(new String[]{"hello", "world"});
}
}
foreach 循环
1
2
3
4
5
6
7
8
9
public class Demo5 {
public static void main(String[] args) {
//数组赋初值的简化写法也是一种语法糖。
int[] arr = {1, 2, 3, 4, 5};
for(int x : arr) {
System.out.println(x);
}
}
}

对于上述代码来说,编译器会帮助我们转换为如下代码:

1
2
3
4
5
6
7
8
9
10
11
public class Demo5 {
public Demo5 {}

public static void main(String[] args) {
int[] arr = new int[]{1, 2, 3, 4, 5};
for(int i=0; i<arr.length; ++i) {
int x = arr[i];
System.out.println(x);
}
}
}

如果是集合去使用了 foreach 的话:

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

其本质就是使用了迭代器 Iterator

因此,能够使用 foreach 去遍历的集合一定是实现了Iterator接口的

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Demo5 {
public Demo5 {}

public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
//获得该集合的迭代器
Iterator<Integer> iterator = list.iterator();
while(iterator.hasNext()) {
Integer x = iterator.next();
System.out.println(x);
}
}
}
switch 字符串
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Demo6 {
public static void main(String[] args) {
String str = "hello";
switch (str) {
case "hello" :
System.out.println("h");
break;
case "world" :
System.out.println("w");
break;
default:
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
30
31
32
33
34
35
36
37
38
39
public class Demo6 {
public Demo6() {

}
public static void main(String[] args) {
String str = "hello";
int x = -1;
//通过字符串的hashCode+value来判断是否匹配
switch (str.hashCode()) {
//hello的hashCode
case 99162322 :
//再次比较,因为字符串的hashCode有可能相等
if(str.equals("hello")) {
x = 0;
}
break;
//world的hashCode
case 11331880 :
if(str.equals("world")) {
x = 1;
}
break;
default:
break;
}

//用第二个switch在进行输出判断
switch (x) {
case 0:
System.out.println("h");
break;
case 1:
System.out.println("w");
break;
default:
break;
}
}
}

从第二份代码我们可以看出,一个 switch 操作其实是由两个 switch 配合完成的

  1. 第一个用来匹配字符串,并给 x 赋值,x 才是最后用来决定走哪个方法的
    • 字符串的匹配用到了字符串的 hashCode,还用到了 equals 方法
    • 使用 hashCode 是为了提高比较效率,使用 equals 是防止有 hashCode 冲突
  2. 使用第一步确定的 x 去决定输出
switch 枚举
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Demo7 {
public static void main(String[] args) {
SEX sex = SEX.MALE;
switch (sex) {
case MALE:
System.out.println("man");
break;
case FEMALE:
System.out.println("woman");
break;
default:
break;
}
}
}

enum SEX {
MALE, FEMALE;
}

编译器中执行的代码如下:

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 Demo7 {
/**
* 定义一个合成类(仅 jvm 使用,对我们不可见)
* 用来映射枚举的 ordinal 与数组元素的关系
* 枚举的 ordinal 表示枚举对象的序号,从 0 开始
* 即 MALE 的 ordinal()=0,FEMALE 的 ordinal()=1
*/
static class $MAP {
//数组大小即为枚举元素个数,里面存放了case用于比较的数字
static int[] map = new int[2];
static {
//ordinal即枚举元素对应所在的位置,MALE为0,FEMALE为1
map[SEX.MALE.ordinal()] = 1;
map[SEX.FEMALE.ordinal()] = 2;
}
}

public static void main(String[] args) {
SEX sex = SEX.MALE;
//将对应位置枚举元素的值赋给x,用于case操作
int x = $MAP.map[sex.ordinal()];
switch (x) {
case 1:
System.out.println("man");
break;
case 2:
System.out.println("woman");
break;
default:
break;
}
}
}

enum SEX {
MALE, FEMALE;
}
枚举类
1
2
3
enum SEX {
MALE, FEMALE;
}

编译器中执行的代码如下:

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
public final class Sex extends Enum<Sex> {
//对应枚举类中的元素
public static final Sex MALE;
public static final Sex FEMALE;
private static final Sex[] $VALUES;

static {
//调用构造函数,传入枚举元素的值及ordinal
MALE = new Sex("MALE", 0);
FEMALE = new Sex("FEMALE", 1);
$VALUES = new Sex[]{MALE, FEMALE};
}

//调用父类中的方法
private Sex(String name, int ordinal) {
super(name, ordinal);
}

public static Sex[] values() {
return $VALUES.clone();
}
public static Sex valueOf(String name) {
return Enum.valueOf(Sex.class, name);
}

}
匿名内部类
1
2
3
4
5
6
7
8
9
10
public class Demo8 {
public static void main(String[] args) {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("running...");
}
};
}
}

编译器中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Demo8 {
public static void main(String[] args) {
//用额外创建的类来创建匿名内部类对象
Runnable runnable = new Demo8$1();
}
}

//创建了一个额外的类,实现了Runnable接口
final class Demo8$1 implements Runnable {
public Demo8$1() {}

@Override
public void run() {
System.out.println("running...");
}
}

假如匿名内部类中引用了局部变量的话

1
2
3
4
5
6
7
8
9
10
11
public class Demo8 {
public static void main(String[] args) {
int x = 1;
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(x);
}
};
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
final class Demo8$1 implements Runnable {
//多创建了一个变量
int val$x;
//变为了有参构造器
public Demo8$1(int x) {
this.val$x = x;
}

@Override
public void run() {
System.out.println(val$x);
}
}

后端编译与优化

前期知识储备(JVM java 编译器 java 解释器)

img

JVM:JVM 有自己完善的硬件架构,如处理器、堆栈(Stack)、寄存器等,还具有相应的指令系统(字节码就是一种指令格式)。JVM 屏蔽了与具体操作系统平台相关的信息,使得 Java 程序只需要生成在 Java 虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM 是 Java 平台无关的基础。

JVM 负责运行字节码:JVM 把每一条要执行的字节码交给解释器,翻译成对应的机器码,然后由解释器执行

JVM 解释执行字节码文件:就是JVM 操作 Java 解释器,将字节码翻译成机器码,然后执行的过程

Java 编译器:Java 源文件(.java 文件)编译成字节码文件(.class 文件,是特殊的二进制文件,二进制字节码文件),这种字节码就是 JVM 的“机器语言”javac.exe可以简单看成是 Java 编译器。

Java 解释器:是 JVM 的一部分,用来解释执行 Java 编译器编译后的程序,即将字节码解释为机器码java.exe可以简单看成是 Java 解释器。

注意:通常情况下,一个平台上的二进制可执行文件不能在其他平台上工作,因为此可执行文件包含了对目标处理器的机器语言。而 Class 文件这种特殊的二进制文件,是可以运行在任何支持 Java 虚拟机的硬件平台和操作系统上的!

个人理解:

解释器,翻译一点执行一点;

编译器是将源代码翻译成平台能理解的目标语言,然后交由平台直接执行。

概述

img

程序语言 → 字节码 → 指令集。

运行 java 程序的过程是先用 javac 编译,然后用 java 解释。而一经编译成功后,就可以直接用 java.exe 随处解释运行了。

  • 前端编译器(编译器的前端):主要是把*.java 文件转变成*.class 文件(特殊的二进制字节码文件)
  • 后端编译器
    • JIT 编译器(即时编译器):字节码转化为本地机器代码
    • AOT 编译器(提前编译器):在程序执行前生成 Java 方法的本地代码,以便在程序运行时直接使用本地代码
  • 解释执行与编译执行
    • 解释执行:需要执行什么代码,才对对应的源码进行翻译,将其变为机器码,因此也提高了启动时效率;
      • 优点:可以大大提高程序启动时的效率
    • 编译执行:将中间代码(字节码)全部编译成了与机器相关的本地代码,并且在这一阶段,有些编译器还会对编译后的代码进行初步的优化,这也使得效率更加的优秀
      • 优点:可以获得更高的执行效率

即时编译器 JIT

概述
  • 概念:是指一种在运行时期把字节码编译成原生机器码的技术,一句一句翻译源代码,但是会将翻译过的代码缓存起来以降低性能耗损

  • 作用:改善虚拟机的性能

  • 原本 Java 程序:经过解释执行的,其执行速度肯定比可执行的二进制字节码程序慢

  • 引入了 JIT 后:在运行时,JIT 会把翻译过来的机器码保存起来,以备下次使用

而如果 JIT 对每条字节码都进行编译,则会负担过重,所以,JIT 只会对经常执行的字节码进行编译,如循环,高频度使用的方法等。

(虚拟机将这些运行频繁的方法/代码块认定为:热点代码

它会以整个方法为单位,一次性将整个方法的字节码编译为本地机器码,然后直接运行编译后的机器码。

热点代码

上面我们提到了“热点代码”这个概念,即频繁执行的代码块,那么 JVM 如何进行热点代码的判断呢?

  • 基于采样:周期性的检查栈顶,如果一段代码频繁出现在栈帧顶部,那么就判断其是热点代码。
    • 优点:实现简单,快;
    • 缺点:探测很容易收到线程阻塞的影响。例如一个方法因为线程阻塞,一直在栈顶,但其实其执行次数并不多,那么将其判定为热点代码就是不合理的。
  • 基于计数器:为每个方法甚至是代码块建立计数器来统计执行次数,如果统计的次数达到了一定的条件则说明是热点代码
    • 优点:结果精确
    • 缺点:实现就比较麻烦了,需要维护计数器

HotSpot 中采取的是第二种方案,因为频繁执行的代码有如下两种:

  • 方法的频繁执行
  • 一段代码的频繁执行

image-20210417163733386

回边计数器
  • 回边计数器,它的作用是统计一个方法中循环体代码执行的次数[3],在字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge)”,很显然建立回边计数 器统计的目的是为了触发栈上的替换编译。

工作流程:

当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有 的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回 边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求, 并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果

image-20210417163939413

编译过程

HotSpot 中的即时编译器有两种,分别称为 Client Complier 客户端编译器 和 Server Complier 服务端编译器,或者简称为 C1 和 C2,目前虚拟机一般采用解释器和一个即时编译器直接配合的方式来运行,这种模式称之为 混合模式

既然是两者合作,那么久需要考虑一个调度的问题,即何时使用编译执行,何时采用解释执行,多少的比例可以获得最佳平衡,得到最高的效率。

在 HotSpot 中是通过 分层编译 的策略来达到最优解的。其本质的思想如下所示:

  • 第 0 层:程序解释执行,解释器不开启性能监控(Profiling),触发第一层;
  • 第 1 层:C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启性能监控
  • 第 2 层:C1 编译,仅开启方法及回边次数统计等有限的性能监控功能
  • 第 3 层:C1 编译,,开启全部性能监控,除了第 2 层的统计信息外,还会收集如 分支跳转、虚方法调用版本等全部的统计信息。
  • 第 4 层层:C2 编译,也是将字节码编译为本地代码,但其会启动一些耗时较长的优化,甚至会根据监控的信息采取一些激进(不可靠)的优化措施。

这种分层编译的方式可以达到一定情况的最优解:用 C1 获取更快的编译速度,用 C2 获取更好的编译质量,解释执行的时候也无需增加性能监控的任务,反而拖累了启动效率。

提前编译器 AOT

  • AOT 编译器的基本思想是:在程序执行生成 Java 方法的本地代码,以便在程序运行时直接使用本地代码。

但是 Java 语言本身的动态特性带来了额外的复杂性,影响了 Java 程序静态编译代码的质量。例如 Java 语言中的动态类加载,因为 AOT 是在程序运行前编译的,所以无法获知这一信息,所以会导致一些问题的产生。

总的来说,AOT 编译器从编译质量上来看,肯定比不上 JIT 编译器。其存在的目的在于避免 JIT 编译器的运行时性能消耗或内存消耗,或者避免解释程序的早期性能开销。

在运行速度上来说,AOT 编译器编译出来的代码比 JIT 编译出来的慢,但是比解释执行的快。而编译时间上,AOT 也是一个始终的速度。

所以说,AOT 编译器的存在是 JVM 牺牲质量换取性能的一种策略。就如 JVM 其运行模式中选择 Mixed 混合模式一样,使用 C1 编译模式只进行简单的优化,而 C2 编译模式则进行较为激进的优化。充分利用两种模式的优点,从而达到最优的运行效率。

编译器总结

在 JVM 中有三个非常重要的编译器,它们分别是:前端编译器、JIT 编译器、AOT 编译器。

前端编译器,最常见的就是我们的 javac 编译器,其将 Java 源代码编译为 Java 字节码文件。JIT 即时编译器,最常见的是 HotSpot 虚拟机中的 Client Compiler 和 Server Compiler,其将 Java 字节码编译为本地机器代码。而 AOT 编译器则能将源代码直接编译为本地机器码。这三种编译器的编译速度和编译质量如下:

  • 编译速度上,解释执行 > AOT 编译器 > JIT 编译器。
  • 编译质量上,JIT 编译器 > AOT 编译器 > 解释执行。

而在 JVM 中,通过这几种不同方式的配合,使得 JVM 的编译质量和运行速度达到最优的状态。

优化技术

方法内联

内联函数就是在程序编译时,编译器将程序中出现的内联函数的调用表达式用内联函数的函数体来直接进行替换

  • JVM 内联函数

C++是否为内联函数由自己决定,Java 由编译器决定。Java 不支持直接声明为内联函数的,如果想让他内联,你只能够向编译器提出请求: 关键字final 修饰 用来指明那个函数是希望被 JVM 内联的,如

1
2
3
public final void doSomething() {
// to do something

总的来说,一般的函数都不会被当做内联函数,只有声明了 final 后,编译器才会考虑是不是要把你的函数变成内联函数

如果 JVM 监测到一些小方法被频繁的执行,它会把方法的调用替换成方法体本身,如:

1
2
3
4
5
6
7
8
private int add4(int x1, int x2, int x3, int x4) {
//这里调用了add2方法
return add2(x1, x2) + add2(x3, x4);
}

private int add2(int x1, int x2) {
return x1 + x2;
}Copy

方法调用被替换后

1
2
3
4
private int add4(int x1, int x2, int x3, int x4) {
//被替换为了方法本身
return x1 + x2 + x3 + x4;
}

总的来说就是将小段代码,直接复制到调用的方法里面,不去真实的进行方法调用

逃逸分析
基本原理
  • 方法逃逸:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部 方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸
  • 线程逃逸:甚至还有可能被外部线程访 问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸

从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度

按照逃逸程度的高低,为对象的实例采取不同程度的优化,就是逃逸分析的优化基本原理。

优化办法
  • 栈上分配(Stack Allocations):我们知道,对象的分配是在上进行,而,Java 堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引 用,就可以访问到堆中存储的对象数据。在 GC 的时候:标记、回收、整理都要耗费大量资源。如果确定一个对 象不会逃逸出线程之外,那让这个对象在栈上分配内存岂不是很好?对象所占用的内存空间就可以随栈帧出栈而销毁。

  • 标量替换(Scalar Replacement)

    • 标量:无法再分解为更小数据的数据,例如 JVM 中的原始数据类型(int、long、reference 等)。
    • 聚合量:可以继续分解的数据,例如 Java 中的对象。

    所谓「标量替换(Scalar Replacement)」,就是根据实际访问情况,将一个对象“拆解”开,把用到的成员变量恢复为原始类型来访问。

    简单来说,就是把聚合量替换为标量。

    若一个对象不会逃逸出「方法」,且可以被拆散,那么程序真正执行时就可能不去创建这个对象,而是直接创建它的若干个被该方法使用的成员变量代替。将对象拆分后,除了可以让对象的成员变量在栈上 (栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件

    (标量替换可以视作栈上分配的一种特例)

  • 同步消除(Synchronization Elimination):线程同步本身相对耗时,如果逃逸分析能够确定一个变量不会逃逸出线程,则该变量的读写就不会有线程安全问题,对该变量的同步措施就可以安全的消除了。

    换句话说,如果对线程安全的数据(不会逃逸出该线程的数据)加了锁,JVM 就可以把它优化消除

    1
    2
    3
    4
    5
    6
    7
    public void t() {
    // 变量 o 不会逃逸出线程。因此,对它加的锁就可以被消除
    Object o = new Object();
    synchronized (o) {
    System.out.println(o.toString());
    }
    }
代码演示

原始代码:

1
2
3
4
5
6
// 完全未优化的代码
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}
  1. 将 Point 的构造函数和 getX()方法进行内联优化:

    1
    2
    3
    4
    5
    6
    7
    8
    // 步骤1:构造函数内联后的样子
    public int test(int x) {
    int xx = x + 2;
    Point p = point_memory_alloc(); // 在堆中分配P对象的示意方法
    p.x = xx; // Point构造函数被内联后的样子
    p.y = 42
    return p.x; // Point::getX()被内联后的样子
    }
  2. 经过逃逸分析,发现在整个 test()方法的范围内 Point 对象实例不会发生任何程度的逃逸, 这样可以对它进行标量替换优化,把其内部的 x 和 y 直接置换出来,分解为 test()方法内的局部变量,从 而避免 Point 对象实例被实际创建

    1
    2
    3
    4
    5
    6
    7
    / 步骤2:标量替换后的样子
    public int test(int x) {
    int xx = x + 2;
    int px = xx;
    int py = 42
    return px;
    }
  3. 通过数据流分析,发现 py 的值其实对方法不会造成任何影响,那就可以放心地去做无效 代码消除得到最终优化结果

    1
    2
    3
    4
    // 步骤3:做无效代码消除后的样子
    public int test(int x) {
    return x + 2;
    }
公共子表达式消除

所谓公共子表达式,就是当有一个表达式 E 在以前被计算过,而且下次再遇到的时候 E 的所有变量都未改变,则这次 E 的出现就被称为「公共子表达式」。就像学习 DP 的时候,记忆集的概念。

根据作用域,公共子表达式的消除可分为两种:局部公共子表达式消除和全局公共子表达式消除

  1. 局部公共子表达式消除:优化仅限于程序基本块内
  2. 全局公共子表达式消除:优化的范围涵盖了多个基本块
1
2
3
4
5
6
7
public class Test {
    public int t1() {
        int a=1, b=2, c=3;
        int d = (c * b) * 12 + a + (a + b * c);
        return d;
    }
}

该段代码产生的字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  public int t1();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=4, locals=5, args_size=1
         # ...
         6: iload_3
         7: iload_2
         8: imul    # 计算 b*c
         9: bipush        12
        11: imul    # 计算 (c * b) * 12
        12: iload_1
        13: iadd    # 计算 (c * b) * 12 + a
        14: iload_1
        15: iload_2
        16: iload_3
        17: imul    # 计算 b*c
        18: iadd    # 计算 (a + b * c)
        19: iadd    # 计算 (c * b) * 12 + a + (a + b * c)
        20: istore        4
        22: iload         4
        24: ireturn
        # ...

而相同的这段代码,进入虚拟机即时编译器后,它将进行如下优化:编译器检测到 cb 与 bc 是一样的表达 式,而且在计算期间 b 与 c 的值是不变的

因此这条表达式就可能被视为:

1
int d = E * 12 + a + (a + E);

此时,编译器还可能进行代数化简(Algebraic Simplification):

1
int d = E * 13 + a + a;
数组边界检查消除

在遍历数组的时候:必须满足i >= 0 && i < arr.length,否则就抛出异常:java.lang.ArrayIndexOutOfBoundsException

1
2
3
4
5
public void test1() {
String[] array = new String[]{"a", "b", "c"};
// 数组越界
String s = array[3];
}

安全起见,数组边界检查这件事是一定要做的,但是数组边界检查是不是一定得在运行期间发生就是不一定的了。

如果编译器只 要通过数据流分析(前端编译)就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那么在循环中就可 以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。

JVM 内存模型

  • JVM 内存模型定义的是线程堆栈和堆之间的内存划分,它和 Java 内存模型是有区别的,参照《深入理解 Java 虚拟机》中的解释:

    这两者本没有关系。如果一定要勉强对应,那从变量、主内存、工作内存的定义来看,主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存就是物理内存,而为了获取更好的执行速度,虚拟机(甚至是硬件系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为运行时主要访问——读写的是工作内存。

img

(Java 内存模型 JMM 的相关知识,将在 java 并发部分进行学习)

技术参考

参考博客、书籍和视频:

  1. 博客

  2. 书籍

    • 《深入理解 JVM 虚拟机》
  3. 视频

    • 黑马程序员 JVM