JVM 内存结构
封面来源:本文封面来源于网络,如有侵权,请联系删除。
参考链接:黑马程序员JVM快速入门
本文涉及的代码:mofan212/jvm-demo
除特别注明外,本文内容都基于 JDK 1.8
1. 程序计数器
Program Counter Register,程序计数器(寄存器)。
作用:记住下一条 JVM 指令的执行地址
特点:
- 线程私有
- 不存在内存溢出
下面是 JVM 指令与对应 Java 代码的一个示例:
1 | 0: getstatic #20 // PrintStream out = System.out; |
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 | -Xss1m |
在 IDEA 中,可以在程序的 VM options
中设置,设置方式与上述一致。
每个线程在创建的时候都会创建一个「虚拟机栈」,由于物理内存的大小是一定的,当虚拟机栈的大小越大时, 可供使用的线程数就会越少。
方法内的局部变量是否是线程安全的?
- 如果方法内 局部变量没有逃离方法的作用范围,它是线程安全的
- 如果 局部变量引用了对象,并 逃离方法的作用范围,就需要考虑线程安全问题
存在 m1()
、m2()
和 m3()
共三个方法,判断每个方法使用的 sb
对象是否是线程安全的:
1 | public static void m1() { |
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 | public class Demo_1 { |
与使用 -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 | import com.sun.xml.internal.ws.org.objectweb.asm.ClassWriter; |
5.3 运行时常量池
- 常量池:就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
- 运行时常量池:常量池存在于
*.class
文件中,当该类被加载,它的常量池信息就会被放入运行时常量池,并把里面的符号地址变为真实地址
1 | public class HelloWorld { |
要运行上述代码,需要将其编译成二进制字节码,它主要包括:
- 类的基本信息
- 常量池
- 类的方法定义(包含虚拟机指令)
使用 javap -v HelloWorld.class
反编译字节码:
1 | // ------------- 以下是「类的基本信息」------------- |
每条虚拟机指令都会对应常量池表中一个地址(比如 getstatic #2
中的 #2
就是常量池中的一个地址),常量池表中的地址可能对应着一个类名、方法名、参数类型等信息。
6. StringTable
6.1 特性
StringTable(字符串常量池) 有如下特性:
- 运行时常量池中的字符串仅是符号,只有在被用到时才会转化为对象
- 利用 StringTable 的机制,避免重复创建字符串对象
- 字符串变量拼接的原理是使用
StringBuilder
(JDK 1.8) - 字符串常量拼接会被编译器优化
- 可以使用
String#intern()
方法主动向 StringTable 中放入不存在的字符串对象
1 | public class Demo_3_1 { |
编译上述代码后,使用 javap -v
反编译 Demo_3_1.class
:
1 | public static void main(java.lang.String[]); |
StringTable 是一个 Hashtable 结构,并且不能扩容,程序刚运行时,StringTable 内没有任何元素。
在加载 Demo_3_1.class
时,其常量池中的信息会被加载到「运行时常量池」中,这时 a
、b
和 ab
都是常量池中的符号,不是 Java 字符串对象。
当执行到 ldc #2
时,会把符号 a
变为 "a"
字符串对象,b
和 ab
也是如此(字符串延迟加载)。
在 main()
末尾再增加一句代码:
1 | public static void main(String[] args) { |
再使用 javap
反编译字节码:
1 | public static void main(java.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 | String s5 = "a" + "b"; |
同样反编译字节码:
1 | 6: ldc #4 // String ab |
这时执行的是 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 中。
加一句代码:
1 | String s2 = s.intern(); |
尝试将字符串对象 s
放入 StringTable 中(如果 StringTable 内已经有了对应的字符串,则不会放入),然后返回 StringTable 中对象的引用。
在这时,字符串对象 s
对应的 ab
字符串已经存在于 StringTable 中。
1 | private static void intern1() { |
那如果这样呢?
1 | private static void intern2() { |
首先将 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 | public class Demo_5 { |
控制台输出类似以下信息:
[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 堆内存中,相比前一种方式减少了一次数据复制,进而提高了效率。
7.2 内存溢出
使用 ByteBuffer.allocateDirect()
方法来分配一块直接内存。
由于直接内存不受 JVM 的内存回收管理,因此使用不当可能会导致内存溢出。
1 | public class Demo_6_1 { |
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 | public class Demo_6_2 { |
在 Windows 下使用任务管理器查看系统使用内存,运行上述 main()
方法后,发现新增一个 Java 进程,它占用了 1GB 左右的内存。
在 IDEA 控制台键入回车后,刚刚新增的 Java 进程在任务管理器中消失,并且使用的系统内存下降了 1GB。
不是说直接内存不受 JVM 的内存回收管理吗?怎么在执行 System.gc()
方法后,使用的系统内存下降了 1GB?消失的这 1GB 内存是因为触发了垃圾回收吗?
内存释放原理
开门见山地说,直接内存的分配与 Unsafe
类相关:
1 | public class Demo_6_3 { |
查看 ByteBuffer#allocateDirect()
方法的源码:
1 | public static ByteBuffer allocateDirect(int capacity) { |
1 | DirectByteBuffer(int cap) { // package-private |
可以看到确实调用了 Unsafe#allocateMemory()
方法来申请内存,但并没有释放内存的代码。
关键在 Cleaner#create()
方法中,先看传入的 Deallocator
对象:
1 | private static class Deallocator |
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 | public void clean() { |
禁用显式垃圾回收对直接内存的影响
调用 System.gc()
方法显式地触发的垃圾回收是一次 Full GC,这里不展开 Full GC 的内容,知道 Full GC 是一种比较影响性能的垃圾回收即可。
为防止程序员在代码中使用了 System.gc()
方法,在进行 JVM 调优时可以使用 -XX:+DisableExplicitGC
参数来禁用这种显式地垃圾回收。
1 | ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB); |
在使用 -XX:+DisableExplicitGC
参数后,那么上述代码中的 System.gc()
方法会失效,无法触发垃圾回收。尽管没有引用 byteBuffer
的地方(byteBuffer = null;
),但在堆内存充裕的情况下,byteBuffer
对象还会一直存活,那么其对应的直接内存也无法及时得到释放,只有等到 JVM 回收 byteBuffer
对象时,这块直接内存才会被释放。
既然这样,那把 -XX:+DisableExplicitGC
参数去掉呗?
这肯定也不行。
如果要想及时释放不再使用的直接内存,应当使用 Unsafe
类来分配直接内存,在使用完之后调用 Unsafe#freeMemory()
方法释放直接内存。