封面来源:由博主个人绘制,如需使用请联系博主。
参考链接:黑马程序员JVM快速入门
本文涉及的代码:mofan212/jvm-demo
除特别注明外,本文内容都基于 JDK 1.8
1. 程序计数器
Program Counter Register,程序计数器(寄存器)。
作用:记住下一条 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)组成,对应着每次方法调用时所占用的内存
每个线程只能有一个活动栈帧(也就是栈顶的栈帧),对应着当前正在执行的那个方法
每个 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 的实现中,会将本地方法栈和虚拟机栈合并为一个栈,以简化实现。
4. 堆
4.1 堆的特点
Heap,堆。
通过 new 关键字创建的对象都会使用堆内存。
特点:
堆是线程共享的,堆中对象都需要考虑线程安全的问题
有垃圾回收机制
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);
}
}
}
与使用 -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:
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);
}
}
}
5.3 运行时常量池
常量池 :就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
运行时常量池 :常量池存在于 *.class 文件中,当该类被加载,它的常量池信息就会被放入运行时常量池,并把里面的符号地址变为真实地址
1 2 3 4 5 public class HelloWorld {
public static void main ( String [] args ) {
System . out . println ( "Hello World" );
}
}
要运行上述代码,需要将其编译成二进制字节码,它主要包括:
类的基本信息
常量池
类的方法定义(包含虚拟机指令)
使用 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 时,其常量池中的信息会被加载到「运行时常量池」中,这时 a、b 和 ab 都是常量池中的符号,不是 Java 字符串对象。
当执行到 ldc #2 时,会把符号 a 变为 "a" 字符串对象,b 和 ab 也是如此(字符串延迟加载)。
在 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: 处执行了 StringBuilder 的 append() 方法,并将刚刚加载的 s1 作为参数传入,对应 Java 代码是 append(s1)。
后续使用同样的方式加载 s2,并将其作为参数调用 append() 方法。
来到 24: 处,这里调用了 StringBuilder 的 toString() 方法。
结合前面内容,整体对应的 Java 代码为:
1 ( new StringBuilder ()) . append (s1). append (s2). toString ();
最后在 27: 处执行 astore 4,表示将 toString() 方法的结果放入 LocalVariableTable 中 Slot = 4 的位置。
经过上述步骤得到的 s4 并不在 StringTable 中,与其中的 ab 显然不是同一个对象,因此:
1 System . out . println (s3 == s4); // false
如果在 main() 方法后再加一句呢?
同样反编译字节码:
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 由常量 a 和 b 进行拼接,其值能够直接在编译期间确定,因此编译器对其进行优化。那么:
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 中的字符串 a 和 b(2 个)
堆中的 new String("a") 和 new String("b")(2 个)
经过拼接后,调用 StringBuilder 的 toString() 方法,这个过程又会 new 一个 String 对象(1 个)
得到的 s 的值是 ab,注意此时 ab 并不在 StringTable 中。
加一句代码:
尝试将字符串对象 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 中,然后又会将 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() 方法:
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 中使用的时间。
可以使用以下虚拟机参数调整桶个数:
可选的桶个数范围是 [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 的内存回收管理,因此使用不当可能会导致内存溢出。
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() 方法释放直接内存。