Java漫游笔记-15-JVM

JVM(Java 虚拟机,Java Virtual Machine)是整个 Java 平台的基石,是 Java 实现平台无关(不依赖于特定硬件和操作系统)的关键部分,是 Java 语言的运行平台,也是保障用户机器免受恶意代码损害的保护屏障。
要学好、用好 Java ,JVM 是我们绕不过去的一道槛。

JDK 、JRE 、JVM 的关系

Java 相关的概念很多,例如:JDK 、JRE 、JVM 就是我们经常提及,又容易混淆概念。
在学习 JVM 之前,我们首先要弄清楚 JDK 、JRE 、JVM 之间的关系。
在 Java 官方的 Developer Guides 中,有这样的描述:

Oracle has two products that implement Java Platform Standard Edition (Java SE) 8: Java SE Development Kit (JDK) 8 and Java SE Runtime Environment (JRE) 8.
JDK 8 is a superset of JRE 8, and contains everything that is in JRE 8, plus tools such as the compilers and debuggers necessary for developing applets and applications. JRE 8 provides the libraries, the Java Virtual Machine (JVM), and other components to run applets and applications written in the Java programming language. Note that the JRE includes components not required by the Java SE specification, including both standard and non-standard Java components.
The following conceptual diagram illustrates the components of Oracle’s Java SE products:
JavaSE产品组件

简单来说就是:
JDK(Java 开发工具包,Java Develop Kit) 和 JRE(Java 运行环境,Java Runtime Environment,) 都实现了 Java 标准版。
JDK 是 JRE 的超集,它除了包含 JRE 中所有的组件,还包含编译器、调试器等工具。因为这些工具很多都是采用 Java 语言编写的(例如:javac.exe、javap.exe 等,大多是 %JAVA_HOME%/lib/tools.jar 类库的一层薄包装),所以,JDK 自身也需要附带一套 JRE 。
JRE 是 Java 应用的运行环境,主要包含 JVM 和 Java 的核心类库。需要注意的是 JRE 包含的组件不局限于 Java SE 规范,它还包含其它的组件(例如:JavaFX)。
JVM 是 JRE 的一部分,其主要工作是解释自己的指令集(即字节码)并转换到本地 CPU 的指令集。JVM 是平台相关的,也正是因为它的存在,屏蔽了这种相关性,使得 Java 语言能够做到跨平台运行。

串起来就是:
我们参照 Java API 编写代码,通过 JDK 中的编译工具(javac.exe)将 Java 源代码文件(.java)编译成 Java 字节码文件(.class),在 JRE 上运行 Java 字节码,再由 JVM 转换成具体平台的机器指令。

Java工作机制

JVM 架构

让我们首先来看一下 JVM 的整体架构:

JVM架构

其主要的组成部分有:

  • 类加载器(Class Loader)
    JVM 使用类加载器把字节码加载到内存中。
  • 运行时数据区(Run-Time Data Areas)
    • 程序计数器(Program Counter Register)
      程序计数器存放当前方法中将要执行的 JVM 指令地址。
      在任意确定的一个时刻,一个 JVM 线程只会执行一个方法中的一条指令。
      需要注意的是,如果当前方法是本地方法,那么,程序计数器保存的值为 undefined
    • JVM 栈(Java Virtual Machine Stack)
      每一个 JVM 线程都有自己私有的 JVM 栈。
      这个栈与线程同时创建,用于存储栈帧。
      • 栈帧(Frame)
        栈帧是用来存储局部变量表、操作数栈、动态链接和方法出口等信息的数据结构。
        栈帧随着方法调用而创建,随着方法结束(无论是正常完成还是异常完成)而销毁。
        因此,我们可以把栈帧看作是当前正在执行的方法
        每一个方法被调用直至执行完成的过程,就对应着一个栈帧在 JVM 栈中从入栈到出栈的过程。
        栈帧是线程本地私有的数据,不同线程中的栈帧互不可见。
        • 局部变量表(Local Variables)
          局部变量表是用来存储方法内局部变量的空间.
          JVM 使用局部变量表来完成方法调用时的参数传递,并且,如果是实例方法,那么第 0 个局部变量肯定是 this 。
          局部变量表所需的内存空间会在编译期间完成分配。
        • 操作数栈(Operand Stack)
          操作数栈用来存储 JVM 指令操作所需要用到的数据,这些数据可能来自局部变量,也可能来自常量,还有可能是虚拟机指令操作的结果数据。
        • 动态链接(Dynamic Linking)
          每一个栈帧内部都包含一个指向运行时常量池的引用,来支持当前方法的代码实现动态链接。
    • Java 堆(Java Heap)
      Java 堆在虚拟机启动的时候就被创建,是供所有类实例和数组对象分配内存的区域,可供各个线程共享。
      Java 堆由垃圾收集器(Garbage Collector)管理。
      如果从内存回收的角度看,还可以细分为:新生代(包括:Eden Space、Survivor 0 Space 和 Survivor 1 Space)和旧生代。
    • 方法区(Method Area)
      方法区在虚拟机启动的时候被创建,存储了每一个类的结构信息,例如:运行时常量池、Class 对象、类变量等等。
      JVM 规范中提到:虽然方法区是堆的逻辑组成部分,但是简单的虚拟机实现可以选择在这个区域不实现垃圾收集。
      • 运行时常量池(Runtime Constant Pool)
        运行时常量池是方法区的一部分,在类和接口被加载到虚拟机后,对应的运行时常量池就被创建出来。
        包括:编译期可知的各种字面量和符号引用,以及运行时产生的新常量。
    • 本地方法栈(Native Method Stack)
      本地方法栈用来支持使用到本地方法的执行。
      功能上,和 JVM 栈类似,有的虚拟机(例如:HotSopt VM) 甚至会采用一样的实现。
  • 垃圾收集器(Garbage Collector)
    垃圾收集器是 JVM 内存回收的具体实现,会自动地回收内存。
  • 执行引擎
    执行引擎负责执行 JVM 指令。

源代码编译机制

Java 源代码编译器虽然不属于 JVM 的范畴,但是,JVM 所加载和运行的字节码都是由源代码编译器生成的,因此,我们还是很有必要,弄清楚源代码编译器究竟是如何把源代码转换成字节码的。

JVM 规范精确定义了 class 文件的结构,但是,它并没有说明如何把符合 Java 语言规范的源文件转换成符合 JVM 规范的 class 文件。JDK 的厂商需要自行实现源代码编译器。而 Oracle 提供的源代码编译器就是 javac (其实现的代码位于 com.sun.tools.javac 包下)。
使用 javac 编译源代码,主要有以下几个步骤:

  1. 词法分析
    首先,我们需要读取 Java 源代码,识别出哪些字符是合法的关键字,哪些是用户自定义的名称,哪些是符号等等。这些提取出来的内容,我们一般统称为标记(Token)。
  2. 语法分析
    接下来,为了方便后续的操作,我们还会把这些标记按照结构化的方式进行组织,也就是会生成一个抽象的语法树。
  3. 填充符号表
    但是,光有源代码的标识和结构信息,还是不足以完成整个编译的,因此,我们还需要把这些标记所涉及的相关信息(例如:变量的名称、类型)都保存起来,这里我们所用到的数据结构就是符号表。
    另外,在这一步,还会添加默认的构造方法。
  4. 注解处理
    在 JDK 1.5 以后,Java 提供了对注解的支持。而有些注解会对代码进行操作,这时候就需要重新进行前面的步骤,来生成更准确的信息。
  5. 语义分析
    经过前面几步,我们已经得到了一个结构正确的抽象语法树,但是源程序是否符合逻辑还需要进一步分析。主要包括:
    • 标注检查
      检查变量使用前是否声明,类型是否匹配,进行常量折叠操作等等。
    • 数据及控制流分析
      检查局部变量在使用前是否进行初始化,非运行异常是否处理,去除无用代码等等。
    • 解语法糖
      例如:自动拆装箱、for-each 转换等等。
  6. 生成字节码
    最后,在完成少量的代码添加和转换后,源代码编译器会将生成的信息转换成字节码,并按照 class 文件格式输出。

源代码编译机制

编译生成的字节码使用二进制格式来表示,并且通常以文件的形式存储,因此一般称之为 class 文件。
class 文件是一个完整的自描述文件,精确定义了类或接口的信息,其结构如下(其中 u 表示无符号数,数字为字节长度,_info 表示是复合结构,例如:表):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClassFile {
u4 magic; // 魔数,标识符,是固定值:0xCAFEBABE
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]; // 属性表,包括预先定义属性和自定义属性
}

例如,我们定义这样一个类:

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

public static final String NAME = "App";

public void run() {
System.out.println(NAME);
}
}

编译后生成的 class 文件是一组以 8 个字节为单元的二进制流,如果使用二进制编辑器打开,可以看到如下内容:

1
cafe babe 0000 0034 0021 0700 0201 0003
4170 7007 0004 0100 106a 6176 612f 6c61
6e67 2f4f 626a 6563 7401 0004 4e41 4d45
0100 124c 6a61 7661 2f6c 616e 672f 5374
7269 6e67 3b01 000d 436f 6e73 7461 6e74
5661 6c75 6508 0002 0100 063c 696e 6974
3e01 0003 2829 5601 0004 436f 6465 0a00
0300 0d0c 0009 000a 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 124c 6f63
616c 5661 7269 6162 6c65 5461 626c 6501
0004 7468 6973 0100 054c 4170 703b 0100
0372 756e 0900 1400 1607 0015 0100 106a
6176 612f 6c61 6e67 2f53 7973 7465 6d0c
0017 0018 0100 036f 7574 0100 154c 6a61
7661 2f69 6f2f 5072 696e 7453 7472 6561
6d3b 0a00 1a00 1c07 001b 0100 136a 6176
612f 696f 2f50 7269 6e74 5374 7265 616d
0c00 1d00 1e01 0007 7072 696e 746c 6e01
0015 284c 6a61 7661 2f6c 616e 672f 5374
7269 6e67 3b29 5601 000a 536f 7572 6365
4669 6c65 0100 0841 7070 2e6a 6176 6100
2100 0100 0300 0000 0100 1900 0500 0600
0100 0700 0000 0200 0800 0200 0100 0900
0a00 0100 0b00 0000 2f00 0100 0100 0000
052a b700 0cb1 0000 0002 000e 0000 0006
0001 0000 0001 000f 0000 000c 0001 0000
0005 0010 0011 0000 0001 0012 000a 0001
000b 0000 0037 0002 0001 0000 0009 b200
1312 08b6 0019 b100 0000 0200 0e00 0000
0a00 0200 0000 0600 0800 0700 0f00 0000
0c00 0100 0000 0900 1000 1100 0000 0100
1f00 0000 0200 20

显然,这样的十六进制表示,我们是很难直接读懂。不过,JDK 为我们提供了 javap 工具,可以用来生成更友好的文件格式——非正式的“虚拟机汇编语言(Virtual Machine Assembly Language)”格式。

1
javap -c -s -l -verbose App > App.txt

javap 生成的内容如下:

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
Classfile /CLASS_PATH/App.class
Last modified 2015-8-15; size 503 bytes
MD5 checksum 1f983972ae029d766ef0001abf8e0fc1
Compiled from "App.java"
public class App
// Class 文件的格式版本号
minor version: 0
major version: 52
// 类的访问权限及基础属性
flags: ACC_PUBLIC, ACC_SUPER
// 常量池,存放所有字符串常量、 类或接口名、字段名和其它常量。
Constant pool:
#1 = Class #2 // App
#2 = Utf8 App
#3 = Class #4 // java/lang/Object
#4 = Utf8 java/lang/Object
#5 = Utf8 NAME
#6 = Utf8 Ljava/lang/String;
#7 = Utf8 ConstantValue
#8 = String #2 // App
#9 = Utf8 <init>
#10 = Utf8 ()V
#11 = Utf8 Code
#12 = Methodref #3.#13 // java/lang/Object."<init>":()V
#13 = NameAndType #9:#10 // "<init>":()V
#14 = Utf8 LineNumberTable
#15 = Utf8 LocalVariableTable
#16 = Utf8 this
#17 = Utf8 LApp;
#18 = Utf8 run
#19 = Fieldref #20.#22 // java/lang/System.out:Ljava/io/PrintStream;
#20 = Class #21 // java/lang/System
#21 = Utf8 java/lang/System
#22 = NameAndType #23:#24 // out:Ljava/io/PrintStream;
#23 = Utf8 out
#24 = Utf8 Ljava/io/PrintStream;
#25 = Methodref #26.#28 // java/io/PrintStream.println:(Ljava/lang/String;)V
#26 = Class #27 // java/io/PrintStream
#27 = Utf8 java/io/PrintStream
#28 = NameAndType #29:#30 // println:(Ljava/lang/String;)V
#29 = Utf8 println
#30 = Utf8 (Ljava/lang/String;)V
#31 = Utf8 SourceFile
#32 = Utf8 App.java
{
// 常量值属性
public static final java.lang.String NAME;
descriptor: Ljava/lang/String;
flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: String App

// 语义分析时生成的默认构造方法
public App();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #12 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LApp;

// run() 方法的元数据
public void run();
descriptor: ()V
flags: ACC_PUBLIC
Code:
// 操作数栈的深度,局部变量表的容量,参数的个数
// 实例方法,第一个局部变量为 this
stack=2, locals=1, args_size=1
// 字节码指令表
0: getstatic #19 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #8 // String App
5: invokevirtual #25 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
// 源文件行号与字节码指令偏移量的对应关系
LineNumberTable:
line 6: 0
line 7: 8
// 局部变量表
LocalVariableTable:
// 这里可以解释为什么 locals=1, args_size=1
Start Length Slot Name Signature
0 9 0 this LApp;
}
SourceFile: "App.java"

从 Java 源代码到字节码,看似只是在存储格式上迈进了一小步,然而,这却是编程语言发展的一大步。
这种字节码,正是 Java 语言实现平台无关的关键所在。
从更宽泛的角度来讲,我们也可以认为 Java 虚拟机与 Java 语言并没有必然的联系,它只与特定的二进制文件(即Class 文件)格式所关联。
任何语言(例如:Groovy 、 Scala )只要产生了有效的 Class 文件,就能够被 Java 虚拟机加载、执行。

类加载机制

在我们成功编译源代码之后,就要开始考虑如何把 class 文件加载到 JVM 。

JVM 加载类的过程可以分为三个步骤:加载、链接与初始化。

类加载机制

  1. 加载
    加载阶段,主要做 3 件事:

    • 根据类的全限定名查找类或接口的二进制字节流。
    • 将字节流转换成方法区的运行时数据结构。
    • 在内存中(方法区)生成一个代表这个类的 Class 对象。
      其中,“根据类的全限定名查找类或接口的二进制字节流”,就需要用到类加载器(ClassLoader)。JVM 规范定义了两种类加载器,分别是:引导类加载器和用户自定义类加载器。
      引导类加载器(Bootstrap ClassLoader),使用 C++ 实现(对于 HotSpot 虚拟机而言),是 JVM 的一部分,完全受 JVM 控制。
      而每个用户自定义的类加载器都应该是抽象类 ClassLoader 的某个子类的实例,采用 Java 实现,独立于 JVM 之外。
      从层次结构上,大致如此:

    ClassLoader层次结构

    我们可以通过代码来简单验证一下:

    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 class App {

    public static void main(String[] args) {
    ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();
    System.out.println(appClassLoader); // sun.misc.Launcher$AppClassLoader

    ClassLoader extClassLoader = appClassLoader.getParent();
    System.out.println(extClassLoader); // sun.misc.Launcher$ExtClassLoader

    ClassLoader classLoader = extClassLoader.getParent();
    System.out.println(classLoader); // null


    int [] array = new int[10];
    System.out.println(array.getClass().getClassLoader()); // null

    Object [] objects = new Object[10];
    System.out.println(objects.getClass().getClassLoader()); // null

    App [] apps = new App[10];
    System.out.println(apps.getClass().getClassLoader()); // sun.misc.Launcher$AppClassLoader
    }

    }

    从以上代码我们能知道以下几点:

    • 首先,AppClassLoaderExtClassLoader 都是 sun.misc.Launcher 的内部类。
    • 其次,ExtClassLoaderAppClassLoader 的父类加载器,并且是我们能访问到的最高层级的类加载器。
    • 再次,基本类型和 Object 类型的数组的类加载器为 null (可以认为它们的加载请求被委派给引导类加载器),其它类型的数组与其元素类型的类加载器是相同的(由 Class.getClassLoader() 返回)。

    JVM 的 ClassLoader 采用的是树形结构,使用委托模型来搜索类。
    简单来说,就是:当一个 ClassLoader 实例,接收到类加载的请求时,它首先会判断该类是否已经被加载过了,如果没有,则把任务委托给其父类加载器(其父子关系一般不是通过继承实现的,而是通过组合来复用代码),只有父类加载器无法加载,它才会尝试自己去加载。
    这样做的好处有两个:一方面,可以避免重复加载类,因为如果父类加载器已经加载了某个类,那么子类加载器就没必要再去加载了; 另一方面,这样做也更加安全,因为在虚拟机启动的时候,Java 的核心类库就已经被引导类加载器(Bootstrap ClassLoader)加载,通过双亲委托模型可以避免这些类被窜改。

  2. 链接
    加载和连接是交叉进行的,加载未完成,链接可能已经开始。

    验证是链接的第一步,主要是为了保证字节码符合 JVM 规范,并且不会危害到 JVM 的安全。其主要分为四个环节:

    • 文件格式验证。例如:魔数是否正确,版本号是否在可处理的范围之内等等。
    • 元数据验证。进行语义分析,例如:是否继承了不允许别继承的类,接口的方法是否被实现等等。
    • 字节码验证。分析对类方法体的数据流和控制流,例如:类型转换是否正确,跳转指令是否正确等等。
    • 符号引用验证。发生在解析阶段,会检查符号引用中类、字段和方法是否存在。

    准备是链接的第二步,主要是为类变量( static 修饰的变量)在方法区分配内存,并设置初始值,以及完成方法表的初始化等操作。
    这里容易混淆:

    1
    2
    public static int value = 1; // 要注意的是,在准备阶段过后,value 的值为 0 而不是 1,让其等于 1 的赋值指令是在初始化阶段执行的。
    // 这也解释了:为什么类变量可以不用初始化就能使用,而局部变量则不行?

    解析是指解析类或接口中的符号引用,是根据运行时常量池的符号引用来动态决定具体的值的过程,它是链接过程中可选的部分。

    符合引用可以看作一组用于唯一标记引用目标的字符串,与内存布局无关,而直接引用,则可以看作是直接指向目标的指针、相对偏移量或者是间接指向目标的句柄。

    这个阶段还会加载当前类(通过 extendsimplements 、字段、方法等方式)引用的其他类。

  3. 初始化
    类或接口的初始化是指执行类或接口的初始化方法 <clinit>()
    需要注意的是,<clinit>() 方法,不是类的构造方法,它是由编译器自动收集类中所有静态语句块(static{})和类变量的赋值语句合并而成的方法。

类的加载,对于我们来说,大部分过程都是透明的,除了加载阶段,如果使用自定义的类加载器会有所参与,其余动作都是 JVM 在背后默默地帮我们完成。
类的加载、链接和初始化都是在程序运行期完成的,这无疑会增加性能开销,但是,这也带来了灵活性。例如,当我们选择面向接口编程时,就可以在运行期再指定具体的实现类。

类执行机制

JVM 规范在 “公有设计,私有实现” 章节中提到:JVM 应该有共同的外观(Class 文件格式以及字节码指令集等),但是,可以有不同的实现。
也就是说:JVM 只要能够正确读取 Class 文件之中每一条字节码指令,并且能够正确执行这些指令所蕴含的操作即可。至于虚拟机内部究竟是如何处理 Class 文件,这完全是实现者自己的事情。
JVM 的实现者可以利用这种伸缩性来让 JVM 获得更高的性能、更低的内存消耗或者更好的可移植性。而最终选择哪种特性,则取决于 JVM 的实现目标和关注点是什么。

对于 class 文件的执行,主要有两种实现方式:

  • 解释执行
    即通过解释器执行。
  • 编译执行
    即通过即时编译器产生本地代码执行。

HotSpot VM 采用了解释器和编译器并存的架构。
Java 程序最初是通过解释器执行的,不过,当 JVM 检测到某个方法或代码块被频繁执行时,就会把这些代码认定为“热点代码(Hot Spot Code, 这也是 HotSpot VM 的名称来源)”,并即时编译成本地相关的机器码,编译期间会进行一系列多层次的优化,来提升执行效率。
执行过程如下所示:

方法执行机制

在程序运行过程中,最频繁的操作是方法调用。方法调用并不是执行方法体,而是确定被执行的方法版本的过程。
方法调用可以分为解析和分派:

  • 解析
    如果被调用的方法,在真正运行前就已经确定,并且在运行期不可变,这类方法的调用就称为解析。而满足这种要求的主要是:静态方法、私有方法、构造方法。
  • 分派(Dispatch)
    • 静态分派
      静态分派会根据传入参数的静态类型选择被调用方法的执行版本。
      对于同一个类或接口中的重载(Overload)方法,会采用静态分派。
    • 动态分派
      动态分派会根据调用者的实际类型选择被调用方法的执行版本。
      子类对父类的覆盖(Override)方法,会采用动态分派。
      动态分派需要在运行时匹配合适的方法版本,一般是通过在方法区建立方法表来实现。

其实,不管是虚拟机还是物理机,要想提供代码执行能力,首先都需要设计一套指令集。
JVM 的指令集是基于栈架构设计的,这里的栈指的是操作数栈。
它的大多数指令操作都是从当前栈帧的操作数栈取出 1 个或多个操作数,或将运算结果压入操作数栈中。
每调用一个方法,都会创建一个新的栈帧,并创建对应方法所需的操作数栈和局部变量表。
每个线程在运行时的任意时刻,都会包含若干个由不同方法嵌套调用而产生的栈帧,但是只有当前栈帧中的操作数栈才是活动的。

基于栈的指令集最主要的好处是可移植,另外,编译器的实现也会更简单,因为不用考虑空间分配的问题。而缺点则是执行速度会相对基于寄存器的架构要慢一些,因为栈实现在内存中,并且栈操作产生的指令数量也会多一些。

内存管理

Java 的内存管理既简单又复杂。
简单是指,我们在编写程序的时候,已经不用自己手动地去分配和回收内存空间,JVM 会自动地帮我们完成。
也正因为存在这种自动管理机制,如果我们不清楚这些隐藏在背后的实现细节,一旦出现内存泄露、溢出等问题,我们也就无从下手了。

内存分配

要想运行程序,首先要向操作系统申请内存。通常操作系统会以进程为单位,分配独立的内存地址空间。对于 Java 而言,每一个应用程序都是一个进程,对应着一个 JVM 实例。
前面我们已经了解了 JVM 运行时数据区的结构,JVM 在运行时,会把不同类型的数据分别存储在不同的区域,具体每个区域会存放什么样的数据这里就不再赘述。

我们重点关注 Java 堆的内存分配,因为一般而言,它是 JVM 所管理的最大的,也是最活跃的内存区域:
为了让内存回收更加高效,JVM 对 Java 堆进行分代管理。
既然要“分代”,那么首先就得区分哪些对象是新的,哪些对象是旧的?
JVM 为了解决这个问题,给每个对象都定义一个“年龄”(计数器)。

如果一个对象在新生代的 Eden 区出生,并且经历第一次 Minor GC(在新生代发生的垃圾回收) 后还仍然存活,同时,Survivor 区也有容纳它的空间,那么它就可以搬去 Survivor 区,这时候该对象的年龄为 1 。
这个对象在 Survivor 区每熬过一次 Minor GC 年龄就 + 1 ,直到长到一定岁数(默认 15 ,可配置),就会进入旧生代区。

这里需要注意的是,在 Minor GC 之前,会首先判断旧生代的最大连续可用空间是否能够放地下新生区所有的对象,如果可以,那就意味着这次 Minor GC 是安全的,否则就要看看虚拟机是否允许冒险(空间分配担保失败),如果允许,则继续判断,如果旧生代的连续可用空间大于历次进入旧生代的对象的大小平均值,那么会尝试执行 Minor GC ,否则执行 Full GC 。

对象什么时候进入新生代区

大多数情况下,对象优先在新生代的 Eden 区分配,如果 Eden 区没有足够的空间,会发起一次 Minor GC (旧生代为 Full GC/Major GC)。

对象什么时候进入旧生代区

首先,大对象(占用大量连续空间的对象,例如:大数组)会直接进入旧生代。
这样做的目的是减少大对象在 Eden 、Survivor 区之间的复制操作开销。
其次,长期存活的对象进入旧生代。

内存回收

关于内存的自动回收,我们要弄清楚三个问题:

  • 哪些内存需要回收?
  • 如何回收?
  • 什么时候回收?

哪些内存需要回收

我们知道,程序计数器、JVM 栈、本地方法栈,这 3 个区域的生命周期和 JVM 线程相同,线程结束,内存就跟着被回收了。
而 JVM 规范中也有提到,JVM 的实现者可以选择在方法区不实现垃圾收集。其主要原因是,在方法区存放的都是一些常量、还有方法和字段的符号引用,回收的性价比比较低(只能回收一些废弃的常量和无用的类),因此,垃圾收集器也很少光顾方法区。
因此,Java 堆是垃圾收集器重点管理的区域。

如何回收

在垃圾收集器开始回收内存之前,首先要确定哪些对象是“活”的,哪些是“死”的。
简单来说,所谓“死”的对象,也就是不会再被使用的对象。

常见的判断对象生死的算法,有以下 2 种:

  • 引用计数算法
    简单来说:就是给对象添加一个引用计数器,每当有一个地方引用它时,引用计数器的值就 + 1 ,当引用失效时,则 - 1 ,只要引用计数器的值为 0 ,就可以判断该对象不再被使用,也就可以被垃圾回收了。
    这种算法的优点是实现简单,判断效率高。
    但是,无法解决对象之间循环引用问题,因此未被采用。
  • 可达性分析算法
    以根对象集合(一系列的名为 “GC Roots” 的对象)为起点,向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到根对象集合没有任何引用链相连(用图论的话来说就是从根对象集合到这个对象不可达)时,则判定该对象是不可用的,也就有可能会被回收(这时候处于“死缓”的状态,如果该对象在其 finalize() 方法中,与其它存活对象重新建立关联,那么就可以逃脱死亡的命运。但是,这个方法被调用的不确定性大,不建议使用)。
    这里需要注意的是,能够作为 “GC Roots” 对象的,主要有以下几种:
    • JVM 栈帧的本地变量表中引用的对象。
    • 方法区中类静态属性引用的对象。
    • 方法区中常量引用的对象。
    • 本地方法栈中本地方法引用的对象。

能够判断对象是否存活,也就具备了垃圾回收的前提,常见的垃圾收集算法有:

  • 标记-清除算法
    首先标记出所有需要回收的对象,在标记完成后,统一回收。
    优点是简单,缺点是效率不高,并且会产生大量的内存碎片。
  • 复制算法
    将内存空间均分成两半,每次只使用其中一半。(例如:将新生成的对象放在其中的一半空间,旧对象放在另一个半空间,当对象不可用时,将其删除,然后把还“存活”的新对象复制到旧对象的那半空间)。
    当空间中存活对象较少时,比较高效,并且不会产生内存碎片,缺点是,可用的内存缩小了一半。
  • 标记-整理算法
    和标记-清除算法有点类似,主要区别在于,清除阶段,它会让所有存活的对象都向一端移动,集中起来,也就是说不会产生碎片。
  • 分代收集算法
    现在的主流算法。
    其核心思想是,把对象按照存活时间进行分类存放(一般是分为新生代和旧生代),然后在不同区域使用最合适的算法(例如:在新生代使用复制算法,在旧生代使用标记压缩算法)。

JVM 规范并没有规定要选用哪种垃圾收集的算法,事实上,不同厂商、不同版本的 JVM 所实现的垃圾收集器也各不相同。由于每种算法都有其适用场景,因此,厂商一般会提供若干种可用的垃圾收集器,以便用户组合使用。

什么时候回收

不管采用哪种垃圾收集算法,我们都需要分析对象的引用关系,来完成标记。
而对象的引用类型,又可以分为以下几种:

  • 强引用

    1
    2
    Object obj = new Object(); // 强引用
    Object other = obj; // 只有强引用还存在,永远不会被 GC
  • 软引用

    1
    2
    Object obj = new Object();
    SoftReference<Object> softRef = new SoftReference<Object>(obj); // 软引用对象,内存不足时,会被回收,可用于实现简单缓存
  • 弱引用

    1
    2
    Object obj = new Object();
    WeakReference<Object> softRef = new WeakReference<Object>(obj); // 弱引用对象,只能活到下一次 GC 发生之前
  • 虚引用

    1
    2
    3
    Object obj = new Object();
    ReferenceQueue<Object> refQueue = new ReferenceQueue<Object>(); // 引用队列
    PhantomReference<Object> softRef = new PhantomReference<Object>(obj, refQueue); // 虚引用对象,对被引用的对象不构成影响,可以用来跟踪对象是否已经从内存中删除

然而运行中的程序,对象的引用关系总是在不断变化的。
因此,为了保证对象引用关系分析结果的准确性,就需要把整个执行系统冻结在某个时间点上,也就是说,当 JVM 进行垃圾回收时,必须停顿所有的执行线程(Stop The World)。
但是,实际上,在程序在执行的过程中,并不是所有的地方都能够安全地停下来的。
因此,我们就需要选定一些特殊的位置,来让程序暂停。这些特殊的位置,一般称之为:安全点(Safepoint)。
可以说,安全点为垃圾收集器开展工作提供了一个潜在的入口。
那么问题又来了:

  • 什么样的位置才能够成为安全点呢?
    首先,安全点的数量不能太少,否则,垃圾收集器等待的时间就会很长,名存实亡。
    但也不能太多,不然垃圾收集器就会忙得不可开交,以至于增大运行时的开销。
    显然,在一条字节码指令中间放置安全点是不合适的,理论上来说,在解释器中的每一条字节码指令的边界处,都可以放置一个安全点。但是大部分指令的执行时间都比较短,如果都设置就太多了,因此,一般会在执行时间比较长的指令(例如:方法调用、循环跳转、异常跳转等)边界处设置安全点。
  • 当程序运行到安全点的时候,JVM 又是如何做到中断所有线程的?
    JVM 采用主动式中断线程:当垃圾收集器需要中断线程的时候,不是直接对线程操作,而是简单地设置一个标识,让各个线程主动去轮询这个标识,如果发现标识为真,就把自己挂起。
    然而,JVM 在中断全部线程之前,线程是可能处在不同的状态的。例如,线程已经处于 BLOCKED 或 WAITING 状态时,也不可能走到安全点来挂起。这时候就需要安全区域(Safe Region)来解决了。我们可以把安全区域可以看作是一个引用关系不会发生变化的代码片段,在这个区域内的触发 GC 都是安全的。

总结

JVM 所涉及的内容很多,并且和具体的虚拟机实现也紧密相关。在这里没有办法做到面面俱到,还有待于后续的补充、学习。
我们学习 JVM 的知识,从大的方面来说,可以加深对 Java 平台的理解;从小的方面来说,可以帮助我们实践性能调优和故障处理。
作为一个有志向、负责任的 Java 开发者,掌握 JVM 的核心知识,不可或缺。

参考资源