封面来源:本文封面来源于网络,如有侵权,请联系删除。

参考链接:黑马程序员JVM快速入门

本文涉及的代码:mofan212/jvm-demo

除特别注明外,本文内容都基于 JDK 1.8

1. 程序计数器

Program Counter Register,程序计数器(寄存器)。

作用:记住下一条 JVM 指令的执行地址

特点:

  • 线程私有
  • 不存在内存溢出

JVM内存结构之程序计数器

下面是 JVM 指令与对应 Java 代码的一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0: getstatic     #20                        // PrintStream out = System.out; 
3: astore_1 // --
4: aload_1 // out.println(1);
5: iconst_1 // --
6: invokevirtual #26 // --
9: aload_1 // out.println(2);
10: iconst_2 // --
11: invokevirtual #26 // --
14: aload_1 // out.println(3);
15: iconst_3 // --
16: invokevirtual #26 // --
19: aload_1 // out.println(4);
20: iconst_4 // --
21: invokevirtual #26 // --
24: aload_1 // out.println(5);
25: iconst_5 // --
26: invokevirtual #26 // --
29: return

Java 源代码经过编译后得到二进制字节码,字节码中包含许多的 JVM 指令。

CPU 并不能直接执行 JVM 指令,在这中间还需要一个「解释器」。「解释器」将 JVM 指令解释为机器码,之后再由 CPU 执行这些机器码。

上述示例的 JVM 指令前都有一个数字,它们是 JVM 指令的执行地址。

当前一条指令执行完成后,解释器会去「程序计数器」中取得下一条 JVM 指令的执行地址,然后再执行。

Java 程序支持多线程运行。当系统中有多个线程正在运行时,CPU 会为每个线程分配时间片。如果一个线程中的逻辑在一个时间片内未执行完,CPU 会将该线程的状态进行暂存,然后切换到另一个线程并执行它的逻辑,后续又轮到第一个线程执行时,能够继续执行 剩余 逻辑。

由于「程序计数器」是线程私有的,线程在时间片轮转的过程中能够轻松得知下一条指令的执行地址,完成剩余逻辑的执行。

2. 虚拟机栈

2.1 栈与栈帧

Java Virtual Machine Stacks,Java 虚拟机栈。

  • 每个线程运行时所需要的内存,称为「虚拟机栈」
  • 每个栈由多个「栈帧」(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧(也就是栈顶的栈帧),对应着当前正在执行的那个方法

JVM内存结构之虚拟机栈

每个 Java 线程在运行时需要一定的内存空间,这个内存空间就是「虚拟机栈」。如果有多个运行的线程,也对应着多个「虚拟机栈」。

「虚拟机栈」中存放的元素是「栈帧」,一个「栈帧」对应着一次「方法调用」,是每个方法运行时需要的内存。方法可以接受参数,内部可以定义局部变量,还可以有返回值,这些信息都需要占用内存,因此在方法执行前需要预先分配一定的内存空间。

Java 线程在执行一个方法时,会为该方法划分一段栈帧空间,并将「栈帧」压入「虚拟机栈」内;当方法执行完成后,再将对应「栈帧」从「虚拟机栈」中移除。

2.2 问题辨析

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

不涉及,栈中的数据在作用域结束后自行释放,垃圾回收只会回收堆中数据。

栈内存分配越大越好吗?

可以通过 -Xss 虚拟机参数来指定栈内存。64 位的 Linux 和 macOS 系统下,其默认值为 1024KB,而在 Windows 操作系统下,其默认值取决于虚拟内存。

下面示例展示了使用不同单位设置虚拟机栈大小为 1m:

1
2
3
-Xss1m
-Xss1024k
-Xss1048576

在 IDEA 中,可以在程序的 VM options 中设置,设置方式与上述一致。

每个线程在创建的时候都会创建一个「虚拟机栈」,由于物理内存的大小是一定的,当虚拟机栈的大小越大时, 可供使用的线程数就会越少。

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

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

存在 m1()m2()m3() 共三个方法,判断每个方法使用的 sb 对象是否是线程安全的:

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

public static void m2(StringBuilder sb) {
sb.append(1);
sb.append(2);
sb.append(3);
System.out.println(sb.toString());
}

public static StringBuilder m3() {
StringBuilder sb = new StringBuilder();
sb.append(1);
sb.append(2);
sb.append(3);
return sb;
}
  • m1() 方法中的 sb 对象是线程安全的;
  • m2() 方法中的 sb 对象并不是线程安全的,因为 sb 是通过参数传入的,它可能会被其他线程使用
  • m3() 方法中的 sb 对象也不是线程安全的,它通过方法返回值返回出去后,可以被多个线程使用

2.3 栈内存溢出

什么情况下会导致栈内存溢出(java.lang.StackOverflowError)?

  • 栈帧过多
  • 栈帧过大

绝大多数栈内存溢出都是由于 栈帧过多 导致,比如:

  • 没有正确设置递归终止条件
  • 将存在循环引用的对象进行 JSON 序列化

2.4 线程运行诊断

CPU 占用过多

【下篇】Linux 基础 中的 「排查 Java 进程导致 CPU 飙升」

程序运行很长时间没有效果

这往往是因为产生了「死锁」,借助 jstack pid 查看对应进程下 JVM 中当前时刻的线程快照。

仔细阅读最后的 Java stack information for the threads listed above: 信息即可定位到问题代码。

3. 本地方法栈

Native Method Stack,本地方法栈。

用于管理通过 JNI(Java Native Interface) 调用的非 Java 代码(如 C/C++)的执行。

与虚拟机栈类似,只不过虚拟机栈用于处理 Java 方法的调用,本地方法栈用于处理 Native 方法的调用。

本地方法栈也是线程私有的。

在某些 JVM 的实现中,会将本地方法栈和虚拟机栈合并为一个栈,以简化实现。

JVM内存结构之本地方法栈

4. 堆

4.1 堆的特点

Heap,堆。

通过 new 关键字创建的对象都会使用堆内存。

特点:

  • 堆是线程共享的,堆中对象都需要考虑线程安全的问题
  • 有垃圾回收机制

JVM内存结构之堆

4.2 堆内存溢出

堆内存溢出的异常信息:java.lang.OutofMemoryError :java heap space。比如:

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

try {
ArrayList<String> list = new ArrayList<>();
String a = "hello";
// 不断向 list 中添加对象
while (true) {
list.add(a);
a = a + a;
i++;
}
} catch (Throwable e) {
e.printStackTrace();
System.out.println(i);
}
}
}

堆内存溢出OOM示例

与使用 -Xss 设置栈内存大小类似,可以使用 -Xmx 来指定最大堆内存大小。

4.3 堆内存诊断

jps 工具:查看当前系统中有哪些 Java 进程

jmap 工具:查看某一时刻堆内存占用情况,比如 jmap -heap pid

jconsole 工具:多功能图形界面监测工具,可以连续监测

jvisualvm 工具:图形化的虚拟机工具,可以利用它的「堆转储」(dump)功能分析某一时刻堆的使用情况。注意,在 JDK9 之后的版本中已经不再自带该工具,需要额外下载

5. 方法区

5.1 定义与组成

JVM内存结构之方法区

以 JDK8 为例,JVM 规范中对于 方法区 的定义如下:

JVM规范中对方法区的定义

用 DeepSeek 翻译下主要内容:

  • Java 虚拟机拥有一个被所有线程共享的方法区。方法区类似于传统编程语言中存储编译代码的存储区域,也类似于操作系统进程中的「text」段。它存储每个类的结构信息,包括运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括用于类初始化、实例初始化和接口初始化的特殊方法(就是类的构造方法,更多细节见 §2.9)。

  • 方法区在虚拟机启动时创建。尽管在逻辑上方法区属于堆内存的一部分,但简单的实现可以选择不进行垃圾回收或内存整理。本规范不限定方法区的具体存储位置,也不规定管理已编译代码的策略。方法区的大小可以是固定的,也可以根据计算需求进行扩展,如果不再需要较大的方法区时还可以进行收缩。方法区的内存空间不需要是连续的。

  • 当方法区的内存无法满足分配请求时,Java 虚拟机将抛出 OutOfMemoryError

JDK 1.8 版本之前方法区使用堆的内存,叫永久代;JDK 1.8 之后用操作系统的内存,叫元空间。

Java6中JVM的堆与方法区

Java8中JVM的堆与方法区.

5.2 方法区内存溢出

不同 Java 版本中方法区的实现不相同,因此方法区内存溢出时的错误信息也有所区别:

  • Java 8 以前会导致 永久代 内存溢出:java.lang.OutOfMemoryError: PermGen space
  • Java 8 以后会导致 元空间 内存溢出:java.lang.OutOfMemoryError: Metaspace

Java 8 中元空间的大小默认与系统内存相关,为方便演示内存溢出,执行下列演示代码前需要先设置 JVM 参数 -XX:MaxMetaspaceSize=10m,指定最大元空间的大小为 10m:

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
import com.sun.xml.internal.ws.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;

/**
* 演示元空间内存溢出:java.lang.OutOfMemoryError: Metaspace
* -XX:MaxMetaspaceSize=10m
*
* @author mofan
* @date 2025/5/3 21:07
*/
public class Demo_2 extends ClassLoader { // 用来加载类的二进制字节码
public static void main(String[] args) {
int j = 0;
try {
Demo_2 test = new Demo_2();
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);
}
} finally {
System.out.println(j);
}
}
}

方法区内存溢出OOM示例

5.3 运行时常量池

  • 常量池:就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
  • 运行时常量池:常量池存在于 *.class 文件中,当该类被加载,它的常量池信息就会被放入运行时常量池,并把里面的符号地址变为真实地址
1
2
3
4
5
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World");
}
}

要运行上述代码,需要将其编译成二进制字节码,它主要包括:

  1. 类的基本信息
  2. 常量池
  3. 类的方法定义(包含虚拟机指令)

使用 javap -v HelloWorld.class 反编译字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// ------------- 以下是「类的基本信息」-------------
Classfile /D:/Code/Java/MyCode/jvm-demo/structure/target/classes/indi/mofan/HelloWorld.class
Last modified 2025-5-3; size 555 bytes
MD5 checksum ac4ed814f06117795da5bc9fe3c3c0ef
Compiled from "HelloWorld.java"
public class indi.mofan.HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
// ------------- 以上是「类的基本信息」-------------

// ------------- 以下是「常量池」-------------
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."<init>":()V
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // Hello World
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // indi/mofan/HelloWorld
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lindi/mofan/HelloWorld;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 HelloWorld.java
#20 = NameAndType #7:#8 // "<init>":()V
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 Hello World
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 indi/mofan/HelloWorld
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (Ljava/lang/String;)V
// ------------- 以上是「常量池」-------------

// ------------- 以下是「类的方法定义」-------------
{
public indi.mofan.HelloWorld();
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 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lindi/mofan/HelloWorld;

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello World
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 10: 0
line 11: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
// ------------- 以上是「类的方法定义」-------------

SourceFile: "HelloWorld.java"

每条虚拟机指令都会对应常量池表中一个地址(比如 getstatic #2 中的 #2 就是常量池中的一个地址),常量池表中的地址可能对应着一个类名、方法名、参数类型等信息。

6. StringTable

6.1 特性

StringTable(字符串常量池) 有如下特性:

  • 运行时常量池中的字符串仅是符号,只有在被用到时才会转化为对象
  • 利用 StringTable 的机制,避免重复创建字符串对象
  • 字符串变量拼接的原理是使用 StringBuilder(JDK 1.8)
  • 字符串常量拼接会被编译器优化
  • 可以使用 String#intern() 方法主动向 StringTable 中放入不存在的字符串对象
1
2
3
4
5
6
7
public class Demo_3_1 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
}
}

编译上述代码后,使用 javap -v 反编译 Demo_3_1.class

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=4, 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: return

StringTable 是一个 Hashtable 结构,并且不能扩容,程序刚运行时,StringTable 内没有任何元素。

在加载 Demo_3_1.class 时,其常量池中的信息会被加载到「运行时常量池」中,这时 abab 都是常量池中的符号,不是 Java 字符串对象。

当执行到 ldc #2 时,会把符号 a 变为 "a" 字符串对象,bab 也是如此(字符串延迟加载)。

main() 末尾再增加一句代码:

1
2
3
4
5
6
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
}

再使用 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
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=5, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
16: aload_1
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
20: aload_2
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: astore 4
29: return
LocalVariableTable:
Start Length Slot Name Signature
0 30 0 args [Ljava/lang/String;
3 27 1 s1 Ljava/lang/String;
6 24 2 s2 Ljava/lang/String;
9 21 3 s3 Ljava/lang/String;
29 1 4 s4 Ljava/lang/String;

0:8: 与原先一样,在 9: 处使用了 new 关键字,然后在 13: 处执行了 StringBuilder<init> 方法,也就是执行了 StringBuilder 的构造方法,对应 Java 代码是 new StringBuilder()

接着执行 16: aload_1,这会从 LocalVariableTable 中加载 Slot = 1 处的变量,也就是 s1;然后在 17: 处执行了 StringBuilderappend() 方法,并将刚刚加载的 s1 作为参数传入,对应 Java 代码是 append(s1)

后续使用同样的方式加载 s2,并将其作为参数调用 append() 方法。

来到 24: 处,这里调用了 StringBuildertoString() 方法。

结合前面内容,整体对应的 Java 代码为:

1
(new StringBuilder()).append(s1).append(s2).toString();

最后在 27: 处执行 astore 4,表示将 toString() 方法的结果放入 LocalVariableTableSlot = 4 的位置。

经过上述步骤得到的 s4 并不在 StringTable 中,与其中的 ab 显然不是同一个对象,因此:

1
System.out.println(s3 == s4); // false

如果在 main() 方法后再加一句呢?

1
String s5 = "a" + "b";

同样反编译字节码:

1
2
3
4
5
6: ldc            #4                  // String ab
8: astore_3

29: ldc #4 // String ab
31: astore 5

这时执行的是 ldc 指令,从运行时常量池中加载 #4 位置的符号,对应 ab。没有加载符号 a,也没有加载符号 b,而是直接加载的 ab

String s3 = "ab"; 对应的字节码进行对比,它们几乎一样。

这时 javac 在编译期间的优化,s5 由常量 ab 进行拼接,其值能够直接在编译期间确定,因此编译器对其进行优化。那么:

1
System.out.println(s3 == s5); // true

JDK 1.8 中的 String#intern()

先来句代码:

1
String s = new String("a") + new String("b");

执行这句代码后会得到 6 个对象:

  • 要进行字符串拼接,首先得有一个 StringBuilder 对象(1 个)

  • StringTable 中的字符串 ab(2 个)

  • 堆中的 new String("a")new String("b")(2 个)

  • 经过拼接后,调用 StringBuildertoString() 方法,这个过程又会 new 一个 String 对象(1 个)

得到的 s 的值是 ab,注意此时 ab 并不在 StringTable 中。

加一句代码:

1
String s2 = s.intern();

尝试将字符串对象 s 放入 StringTable 中(如果 StringTable 内已经有了对应的字符串,则不会放入),然后返回 StringTable 中对象的引用。

在这时,字符串对象 s 对应的 ab 字符串已经存在于 StringTable 中。

1
2
3
4
5
6
7
8
private static void intern1() {
String s = new String("a") + new String("b");
String s2 = s.intern();
// true
System.out.println(s2 == "ab");
// true
System.out.println(s == "ab");
}

那如果这样呢?

1
2
3
4
5
6
7
private static void intern2() {
String x = "ab";
String s = new String("a") + new String("b");
String s2 = s.intern();
System.out.println(s2 == x);
System.out.println(s == x);
}

首先将 ab 添加到 StringTable 中,然后又会将 ab 添加到 StringTable 中。

执行 s.intern() 时,由于 StringTable 中已经有字符串 ab 了,那么不会再把 s 对象添加到 StringTable 中,intern() 方法返回 StringTable 中 ab 字符串的引用。

因此:

  • s2x 都指向 StringTable 中的 ab 字符串,故 s2 == x 返回 true
  • 由于 StringTable 已经先有 ab 字符串了,不会再把 s 对象放入 StringTable 中,因此 sx 指向不同的对象,那么 s == x 返回 false

6.2 所在位置

Java6中JVM的堆与方法区

Java8中JVM的堆与方法区

6.3 StringTable 的垃圾回收

先介绍几个待会要用的虚拟机参数:

  • -Xmx10m:老熟人了,用于指定最大堆内存的大小
  • -XX:+PrintStringTableStatistics:打印字符串常量池信息
  • -XX:+PrintGCDetails-verbose:gc:打印 GC 次数,耗费时间等信息

添加以上虚拟机参数后,运行下面的 main() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Demo_5 {
public static void main(String[] args) {
int i = 0;
try {
for (int j = 0; j < 10000; j++) {
String.valueOf(i).intern();
i++;
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
}

控制台输出类似以下信息:

[GC (Allocation Failure) [PSYoungGen: 2046K->496K(2560K)] 2046K->872K(9728K), 0.0007887 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

这证明触发了 GC。

在堆内存空间不足时,会触发 StringTable 的垃圾回收。

6.4 性能调优

使用 -XX:StringTableSize 参数

StringTable 的底层由 Hashtable 实现。当 Hashtable 中的桶数量太少,而添加到 StringTable 中的字符串太多时,会增加哈希碰撞的几率,也会增加将字符串添加到 StringTable 中使用的时间。

可以使用以下虚拟机参数调整桶个数:

1
-XX:StringTableSize=桶个数

可选的桶个数范围是 [1009, 2305843009213693951]

尝试将字符串添加到 StringTable

当系统中存在大量、可重复的字符串时,可以考虑调用 String#intern() 方法将字符串添加到 StringTable 中,以减少堆内存的使用。

7. 直接内存

7.1 定义

Direct Memory,直接内存。

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

Java 本身并不具备磁盘读写的能力,需要调用操作系统提供的函数来实现磁盘读写:

  • CPU 层面从「用户态」切换到「内核态」,调用本地(native)方法读取磁盘文件
  • 内存层面将读取到的磁盘文件分次存入「系统缓存区」(总不能一次性把大文件都读到内存吧),但是 Java 代码不能在「系统缓存区」中运行,于是需要在 Java 堆内存中分配一块 Java 缓存区(也就是写 IO 相关代码时使用的 byte[1024] 数组),然后又把「系统缓存区」中的信息读取到 Java 缓存区
  • 使用这种方式读取磁盘文件时,相关数据会被复制两次,因此效率也比较低下

使用Java读取磁盘文件

要想提高效率,那么数据就不能复制两次。

「直接内存」是操作系统和 Java 代码都可以直接访问的一块区域,不用再将系统内存中的数据复制到 Java 堆内存中,相比前一种方式减少了一次数据复制,进而提高了效率。

Java使用直接内存读取磁盘文件

7.2 内存溢出

使用 ByteBuffer.allocateDirect() 方法来分配一块直接内存。

由于直接内存不受 JVM 的内存回收管理,因此使用不当可能会导致内存溢出。

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

public static void main(String[] args) {
List<ByteBuffer> list = new ArrayList<>();
int i = 0;
try {
while (true) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100MB);
list.add(byteBuffer);
i++;
}
} finally {
System.out.println(i);
}
}
}
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
	at java.nio.Bits.reserveMemory(Bits.java:695)
	at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
	at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
	at indi.mofan.Demo_6.main(Demo_6.java:21)

7.3 内存释放原理

直接内存不受 JVM 的内存回收管理。

做一个实验:

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

static int _1GB = 1024 * 1024 * 1024;

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();
System.in.read();
}
}

在 Windows 下使用任务管理器查看系统使用内存,运行上述 main() 方法后,发现新增一个 Java 进程,它占用了 1GB 左右的内存。

在 IDEA 控制台键入回车后,刚刚新增的 Java 进程在任务管理器中消失,并且使用的系统内存下降了 1GB。

不是说直接内存不受 JVM 的内存回收管理吗?怎么在执行 System.gc() 方法后,使用的系统内存下降了 1GB?消失的这 1GB 内存是因为触发了垃圾回收吗?

内存释放原理

开门见山地说,直接内存的分配与 Unsafe 类相关:

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 Demo_6_3 {
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();
}

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

查看 ByteBuffer#allocateDirect() 方法的源码:

1
2
3
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
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
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#allocateMemory() 方法来申请内存,但并没有释放内存的代码。

关键在 Cleaner#create() 方法中,先看传入的 Deallocator 对象:

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
private static class Deallocator
implements Runnable
{

private static Unsafe unsafe = Unsafe.getUnsafe();

private long address;
private long size;
private int capacity;

private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}

public void run() {
if (address == 0) {
// Paranoia
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}

}

Deallocator 实现了 Runnable 接口,是一个「回调任务」对象,在重写的 run() 方法中调用了 Unsafe#freeMemory() 方法。

那么是怎么使用 Cleaner 将它们串联起来呢?

Cleaner 是虚引用类型,当其关联的对象被回收时,就会触发它的 clean() 方法。

在调用 Cleaner#create() 方法时,传入的第一个参数就是它关联的对象:

1
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));

this 表示 DirectByteBuffer 对象。

也就是当 DirectByteBuffer 对象被垃圾回收时,就会触发 Cleaner#clean() 方法:

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

禁用显式垃圾回收对直接内存的影响

调用 System.gc() 方法显式地触发的垃圾回收是一次 Full GC,这里不展开 Full GC 的内容,知道 Full GC 是一种比较影响性能的垃圾回收即可。

为防止程序员在代码中使用了 System.gc() 方法,在进行 JVM 调优时可以使用 -XX:+DisableExplicitGC 参数来禁用这种显式地垃圾回收。

1
2
3
4
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
// --snip--
byteBuffer = null;
System.gc();

在使用 -XX:+DisableExplicitGC 参数后,那么上述代码中的 System.gc() 方法会失效,无法触发垃圾回收。尽管没有引用 byteBuffer 的地方(byteBuffer = null;),但在堆内存充裕的情况下,byteBuffer 对象还会一直存活,那么其对应的直接内存也无法及时得到释放,只有等到 JVM 回收 byteBuffer 对象时,这块直接内存才会被释放。

既然这样,那把 -XX:+DisableExplicitGC 参数去掉呗?

这肯定也不行。

如果要想及时释放不再使用的直接内存,应当使用 Unsafe 类来分配直接内存,在使用完之后调用 Unsafe#freeMemory() 方法释放直接内存。