JVM内存结构
JVM学习路线:

程序计数器
下面是 JVM 指令与对应 Java 代码的一个示例:
1 | 二进制字节码 Java源码 |
Java 源代码经过编译后得到二进制字节码,字节码中包含许多的 JVM 指令。
CPU 并不能直接执行 JVM 指令,在这中间还需要一个「解释器」。「解释器」将 JVM 指令解释为机器码,之后再由 CPU 执行这些机器码。
上述示例的 JVM 指令前都有一个数字,它们是 JVM 指令的执行地址。
当前一条指令执行完成后,解释器会去「程序计数器」中取得下一条 JVM 指令的执行地址,然后再执行。
Java 程序支持多线程运行。当系统中有多个线程正在运行时,CPU 会为每个线程分配时间片。如果一个线程中的逻辑在一个时间片内未执行完,CPU 会将该线程的状态进行暂存,然后切换到另一个线程并执行它的逻辑,后续又轮到第一个线程执行时,能够继续执行 剩余 逻辑。
由于「程序计数器」是线程私有的,线程在时间片轮转的过程中能够轻松得知下一条指令的执行地址,完成剩余逻辑的执行。
作用:记录当前线程正在执行的 JVM 指令地址
特点:线程私有,每个线程都有独立的程序计数器,互不干扰。
虚拟机栈
每个线程运行时所需要的内存,称为「虚拟机栈」,由于物理内存的大小是一定的,当虚拟机栈的大小越大时, 可供使用的线程数就会越少。
每个栈由多个「栈帧」(Frame)组成,对应着每次方法调用时所占用的内存,栈帧包括参数,局部变量,返回地址等等
每个线程只能有一个活动栈帧(也就是栈顶的栈帧),对应着当前正在执行的那个方法
特点:
线程私有,生命周期与线程一致。
栈深度有限,超出时抛出
StackOverflowError(如递归调用无终止条件);栈扩展失败时抛出OutOfMemoryError。
垃圾回收不涉及栈内存
栈内存可以通过 -Xss 虚拟机参数来指定
(指定为1m:-Xss1m
-Xss1024k
-Xss1048576)
问:方法内的局部变量是否是线程安全的?
如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
如果局部变量引用了对象,并逃离方法的作用范围,就需要考虑线程安全问题
eg:
1 | 二进制字节码 Java源码 |
m1() 方法中的 sb 对象是线程安全的;
m2() 方法中的 sb 对象并不是线程安全的,因为 sb 是通过参数传入的,它可能会被其他线程使用
m3() 方法中的 sb 对象也不是线程安全的,它通过方法返回值返回出去后,可以被多个线程使用
栈内存溢出:
什么情况下会导致栈内存溢出(java.lang.StackOverflowError)?
栈帧过多
栈帧过大
绝大多数栈内存溢出都是由于栈帧过多导致
本地方法栈
作用:为 JVM 执行本地方法(如 JNI 调用的 C/C++ 方法)提供内存空间。
特点:
- 线程私有,与虚拟机栈逻辑类似,但针对本地方法。
- 同样会抛出
StackOverflowError和OutOfMemoryError。
堆
通过 new 关键字创建的对象都会使用堆内存。
特点:
线程共享,所有线程都可访问堆中的对象。
可通过 -Xms(初始堆大小)和 -Xmx(最大堆大小)配置,如 -Xms512m -Xmx1024m。
堆内存不足时抛出 OutOfMemoryError: Java heap space。
细分(分代回收):
- 新生代(Young Generation):存储新创建的对象,分为 Eden 区、Survivor From 区、Survivor To 区(比例默认 8:1:1)。
- 老年代(Old Generation):存储存活时间长的对象(新生代多次 GC 后仍存活的对象)。
- 元空间(Metaspace):JDK 8 后替代永久代,属于堆外内存,存储方法区数据。
方法区
作用:存储类信息(类名、访问修饰符、字段、方法)、常量池、静态变量、即时编译器编译后的代码等。
JDK 版本差异:
- JDK 7 及之前:方法区又称 “永久代”(PermGen),属于堆的一部分,可通过
-XX:PermSize和-XX:MaxPermSize配置,溢出时抛出OutOfMemoryError: PermGen space。 - JDK 8 及之后:移除永久代,改用元空间(Metaspace),元空间使用本地内存(直接内存),默认无上限(可通过
-XX:MetaspaceSize和-XX:MaxMetaspaceSize限制),溢出时抛出OutOfMemoryError: Metaspace。
特点:
- 线程共享,全局唯一。
- 常量池是方法区的核心部分,存放字面量(如字符串)和符号引用,JDK 7 后常量池移至堆中。

常量池:就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
运行时常量池:常量池存在于 .class 文件中,当该类被加载,它的常量池信息就会被放入运行时常量池,并把里面的符号地址变为真实地址***
eg:
1 | public class HelloWorld { |
要运行上述代码,需要将其编译成二进制字节码,它主要包括:
类的基本信息
常量池
类的方法定义(包含虚拟机指令)
使用 javap -v HelloWorld.class 反编译字节码:
1 | // ------------- 以下是「类的基本信息」------------- |
每条虚拟机指令都会对应常量池表中一个地址(比如 getstatic #2 中的 #2 就是常量池中的一个地址),常量池表中的地址可能对应着一个类名、方法名、参数类型等信息。
StringTable(字符串常量池)
StringTable(字符串常量池 / 字符串池)是 JVM 为字符串专门设计的一块内存区域,本质是一个哈希表(HashTable),用于存储字符串常量的引用,目的是避免字符串重复创建,节省内存并提升性能(字符串复用)
字符串变量拼接的原理是使用 StringBuilder(JDK 1.8)
字符串常量拼接会被编译器优化
可以使用 String.intern() 方法主动向 StringTable 中放入不存在的字符串对象
eg:
1 | public class Demo_3_1 { |
编译上述代码后,使用 javap -v 反编译:
1 | public static void main(java.lang.String[]); |
StringTable 是一个 Hashtable 结构,并且不能扩容,程序刚运行时,StringTable 内没有任何元素。
在加载 Demo 时,其常量池中的信息会被加载到「运行时常量池」中,这时 a、b 和 ab 都是常量池中的符号,不是 Java 字符串对象。
当执行到 ldc #2 时,会把符号 a 变为 “a” 字符串对象,b 和 ab 也是如此(字符串延迟加载)。
转化后如果常量池没有就入池

位置:

StringTable 的垃圾回收:
在堆内存空间不足时,会触发 StringTable 的垃圾回收。
性能调优:
1.使用 -XX:StringTableSize 参数
桶的个数大一点时间短一点
2.当系统中存在大量、可重复的字符串时,可以考虑调用 String.intern() 方法将字符串添加到 StringTable 中,以减少堆内存的使用。
直接内存:
直接内存(Direct Memory)也叫堆外内存,是一块分配在 JVM 堆之外、直接向操作系统申请的内存区域,不受 JVM 堆内存管理,但受物理机总内存限制。是操作系统和 Java 代码都可以直接访问的一块区域
特点:
| 特性 | 说明 |
|---|---|
| 不受堆大小限制 | -Xmx 仅限制堆内存,直接内存大小由 -XX:MaxDirectMemorySize 控制(默认等于堆最大内存) |
| 读写性能更高 | 传统堆内存 IO:JVM 堆 ↔ 操作系统内核缓冲区 ↔ 磁盘 / 网络;直接内存 IO:直接内存 ↔ 磁盘 / 网络(减少一次数据拷贝),适合高频 IO 场景。 |
| 手动管理风险 | 直接内存的分配 / 释放不由 JVM GC 自动处理,若忘记释放会导致内存泄漏,最终触发 OutOfMemoryError: Direct buffer memory。 |
| 分配 / 释放成本高 | 向操作系统申请 / 释放内存的开销比堆内存大(需系统调用),适合「一次分配、多次使用」的场景,不适合频繁创建 / 销毁。 |
底层原理:通过 Unsafe 类的 allocateMemory(long size) 方法向操作系统申请内存
回收方式:
自动回收:
DirectByteBuffer内部关联了一个Cleaner(虚引用),当DirectByteBuffer对象在堆中被 GC 回收时,Cleaner会触发run()方法,调用 Unsafe 的freeMemory()释放直接内存;- 缺点:回收时机不可控 ——Finalizer 线程优先级低,可能延迟释放,导致直接内存临时泄漏。
手动回收:
- 主动调用
Cleaner.clean()方法释放直接内存; - 底层调用 Unsafe 类的
freeMemory()方法。