封面来源:由博主个人绘制,如需使用请联系博主。
参考链接:黑马程序员JVM快速入门
本文涉及的代码:mofan212/jvm-demo
除特别注明外,本文内容都基于 JDK 1.8
1. 程序计数器
Program Counter Register,程序计数器(寄存器)。
作用:记住下一条 JVM 指令的执行地址
特点:
- 线程私有
- 不存在内存溢出
下面是 JVM 指令与对应 Java 代码的一个示例:
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)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧(也就是栈顶的栈帧),对应着当前正在执行的那个方法
每个 Java 线程在运行时需要一定的内存空间,这个内存空间就是「虚拟机栈」。如果有多个运行的线程,也对应着多个「虚拟机栈」。
「虚拟机栈」中存放的元素是「栈帧」,一个「栈帧」对应着一次「方法调用」,是每个方法运行时需要的内存。方法可以接受参数,内部可以定义局部变量,还可以有返回值,这些信息都需要占用内存,因此在方法执行前需要预先分配一定的内存空间。
Java 线程在执行一个方法时,会为该方法划分一段栈帧空间,并将「栈帧」压入「虚拟机栈」内;当方法执行完成后,再将对应「栈帧」从「虚拟机栈」中移除。
2.2 问题辨析
垃圾回收是否涉及栈内存?
不涉及,栈中的数据在作用域结束后自行释放,垃圾回收只会回收堆中数据。
栈内存分配越大越好吗?
可以通过 -Xss 虚拟机参数来指定栈内存。64 位的 Linux 和 macOS 系统下,其默认值为 1024KB,而在 Windows 操作系统下,其默认值取决于虚拟内存。
下面示例展示了使用不同单位设置虚拟机栈大小为 1m:
-Xss1m
-Xss1024k
-Xss1048576
在 IDEA 中,可以在程序的 VM options 中设置,设置方式与上述一致。
每个线程在创建的时候都会创建一个「虚拟机栈」,由于物理内存的大小是一定的,当虚拟机栈的大小越大时,可供使用的线程数就会越少。
方法内的局部变量是否是线程安全的?
- 如果方法内 局部变量没有逃离方法的作用范围,它是线程安全的
- 如果 局部变量引用了对象 ,并 逃离方法的作用范围,就需要考虑线程安全问题
存在 m1()、m2() 和 m3() 共三个方法,判断每个方法使用的 sb 对象是否是线程安全的:
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 的实现中,会将本地方法栈和虚拟机栈合并为一个栈,以简化实现。
4. 堆
4.1 堆的特点
Heap,堆。
通过 new 关键字创建的对象都会使用堆内存。
特点:
- 堆是线程共享的,堆中对象都需要考虑线程安全的问题
- 有垃圾回收机制
4.2 堆内存溢出
堆内存溢出的异常信息:java.lang.OutofMemoryError :java heap space。比如:
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);
}
}
}

与使用 -Xss 设置栈内存大小类似,可以使用 -Xmx 来指定最大堆内存大小。
4.3 堆内存诊断
jps 工具:查看当前系统中有哪些 Java 进程
jmap 工具:查看某一时刻堆内存占用情况,比如 jmap -heap pid
jconsole 工具:多功能图形界面监测工具,可以连续监测
jvisualvm 工具:图形化的虚拟机工具,可以利用它的「堆转储」(dump)功能分析某一时刻堆的使用情况。注意,在 JDK9 之后的版本中已经不再自带该工具,需要额外下载
5. 方法区
5.1 定义与组成
以 JDK8 为例,JVM 规范中对于 方法区 的定义如下:

用 DeepSeek 翻译下主要内容:
-
Java 虚拟机拥有一个被所有线程共享的方法区。方法区类似于传统编程语言中存储编译代码的存储区域,也类似于操作系统进程中的「text」段。它存储每个类的结构信息,包括运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括用于类初始化、实例初始化和接口初始化的特殊方法(就是类的构造方法,更多细节见 §2.9)
-
方法区在虚拟机启动时创建。尽管在逻辑上方法区属于堆内存的一部分,但简单的实现可以选择不进行垃圾回收或内存整理。本规范不限定方法区的具体存储位置,也不规定管理已编译代码的策略。方法区的大小可以是固定的,也可以根据计算需求进行扩展,如果不再需要较大的方法区时还可以进行收缩。方法区的内存空间不需要是连续的
-
当方法区的内存无法满足分配请求时,Java 虚拟机将抛出
OutOfMemoryError
JDK 1.8 版本之前方法区使用堆的内存,叫永久代;JDK 1.8 之后用操作系统的内存,叫元空间。
5.2 方法区内存溢出
不同 Java 版本中方法区的实现不相同,因此方法区内存溢出时的错误信息也有所区别:
- Java 8 以前会导致 永久代 内存溢出:
java.lang.OutOfMemoryError: PermGen space - Java 8 以后会导致 元空间 内存溢出:
java.lang.OutOfMemoryError: Metaspace
Java 8 中元空间的大小默认与系统内存相关,为方便演示内存溢出,执行下列演示代码前需要先设置 JVM 参数 -XX:MaxMetaspaceSize=10m,指定最大元空间的大小为 10m:
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);
}
}
}

5.3 运行时常量池
- 常量池:就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
- 运行时常量池:常量池存在于
*.class文件中,当该类被加载,它的常量池信息就会被放入运行时常量池,并把里面的符号地址变为真实地址
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
要运行上述代码,需要将其编译成二进制字节码,它主要包括:
- 类的基本信息
- 常量池
- 类的方法定义(包含虚拟机指令)
使用 javap -v HelloWorld.class 反编译字节码:
// ------------- 以下是「类的基本信息」-------------
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 中放入不存在的字符串对象
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:
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 时,其常量池中的信息会被加载到「运行时常量池」中,这时 a、b 和 ab 都是常量池中的符号,不是 Java 字符串对象。
当执行到 ldc #2 时,会把符号 a 变为 "a" 字符串对象,b 和 ab 也是如此(字符串延迟加载)。
在 main() 末尾再增加一句代码:
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
}
再使用 javap 反编译字节码:
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: 处执行了 StringBuilder 的 append() 方法,并将刚刚加载的 s1 作为参数传入,对应 Java 代码是 append(s1)。
后续使用同样的方式加载 s2,并将其作为参数调用 append() 方法。
来到 24: 处,这里调用了 StringBuilder 的 toString() 方法。
结合前面内容,整体对应的 Java 代码为:
(new StringBuilder()).append(s1).append(s2).toString();
最后在 27: 处执行 astore 4,表示将 toString() 方法的结果放入 LocalVariableTable 中 Slot = 4 的位置。
经过上述步骤得到的 s4 并不在 StringTable 中,与其中的 ab 显然不是同一个对象,因此:
System.out.println(s3 == s4); // false
如果在 main() 方法后再加一句呢?
String s5 = "a" + "b";
同样反编译字节码:
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 由常量 a 和 b 进行拼接,其值能够直接在编译期间确定,因此编译器对其进行优化。那么:
System.out.println(s3 == s5); // true
JDK 1.8 中的
String#intern()
先来句代码:
String s = new String("a") + new String("b");
执行这句代码后会得到 6 个对象:
-
要进行字符串拼接,首先得有一个
StringBuilder对象(1 个) -
StringTable 中的字符串
a和b(2 个) -
堆中的
new String("a")和new String("b")(2 个) -
经过拼接后,调用
StringBuilder的toString()方法,这个过程又会new一个String对象(1 个)
得到的 s 的值是 ab,注意此时 ab 并不在 StringTable 中。
加一句代码:
String s2 = s.intern();
尝试将字符串对象 s 放入 StringTable 中(如果 StringTable 内已经有了对应的字符串,则不会放入),然后返回 StringTable 中对象的引用。
在这时,字符串对象 s 对应的 ab 字符串已经存在于 StringTable 中。
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");
}
那如果这样呢?
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 中,然后又会将 a 和 b 添加到 StringTable 中。
执行 s.intern() 时,由于 StringTable 中已经有字符串 ab 了,那么不会再把 s 对象添加到 StringTable 中,intern() 方法返回 StringTable 中 ab 字符串的引用。
因此:
s2和x都指向 StringTable 中的ab字符串,故s2 == x返回true- 由于 StringTable 已经先有
ab字符串了,不会再把s对象放入 StringTable 中,因此s和x指向不同的对象,那么s == x返回false
6.2 所在位置
6.3 StringTable 的垃圾回收
先介绍几个待会要用的虚拟机参数:
-Xmx10m:老熟人了,用于指定最大堆内存的大小-XX:+PrintStringTableStatistics:打印字符串常量池信息-XX:+PrintGCDetails和-verbose:gc:打印 GC 次数,耗费时间等信息
添加以上虚拟机参数后,运行下面的 main() 方法:
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 中使用的时间。
可以使用以下虚拟机参数调整桶个数:
-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 堆内存中,相比前一种方式减少了一次数据复制,进而提高了效率。
7.2 内存溢出
使用 ByteBuffer.allocateDirect() 方法来分配一块直接内存。
由于直接内存不受 JVM 的内存回收管理,因此使用不当可能会导致内存溢出。
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 的内存回收管理。
做一个实验:
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 进程,它占用了 1 GB 左右的内存。
在 IDEA 控制台键入回车后,刚刚新增的 Java 进程在任务管理器中消失,并且使用的系统内存下降了 1 GB。
不是说直接内存不受 JVM 的内存回收管理吗?怎么在执行 System.gc() 方法后,使用的系统内存下降了 1 GB?消失的这 1 GB 内存是因为触发了垃圾回收吗?
内存释放原理
开门见山地说,直接内存的分配与 Unsafe 类相关:
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() 方法的源码:
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
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 对象:
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() 方法时,传入的第一个参数就是它关联的对象:
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
this 表示 DirectByteBuffer 对象。
也就是当 DirectByteBuffer 对象被垃圾回收时,就会触发 Cleaner#clean() 方法:
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 参数来禁用这种显式地垃圾回收。
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() 方法释放直接内存。