文章目录
- 1. 程序计数器
- 补充
- 2. 虚拟机栈
- 2.1 栈帧
- 1. 局部变量表
- 2. 操作数栈
- 3. 动态链接
- 4. 方法返回地址
- 补充
- 3. 本地方法栈
- 4. 堆
- 5. 方法区
- 静态常量池(Class常量池)
- 运行时常量池
- 字符串常量池
- (1)位置变化
- (2)放入字符串常量池的3种途径
- 1. 字面量赋值
- 2. new String("")
- 3. **intern()**
- 4. StringTable垃圾回收
- 5. StringTable调优
- 6. 直接内存
- (1)概念
- (2)特性
- (3)管理与回收机制
- 一、直接内存的分配与对象结构
- 二、释放流程
- 三、关键机制深度解析
- 其他
- 1. 永久代为什么被替换为元空间
1. 程序计数器
(1)定义
一块比较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
- JVM解释器通过程序计数器读取下一条需要执行的字节码指令。
- 在Java虚拟机的概念模型里(代表了虚拟机的统一外观,但各个Java虚拟机不一定一定要按照概念模型的定义来实现),字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器实现。
(2)特点
-
线程私有
内存隔离:程序计数器属于线程私有内存
作用:避免多线程竞争,确保线程独立性。 -
无OOM异常
JVM规范未定义程序计数器的OOM(OutOfMemoryError)场景。
因为它不占用堆或方法区,仅存储当前指令的地址。 -
占用较小内存
仅保存一个指令地址(或Native方法的undefined),内存消耗可忽略。 -
无垃圾回收
不涉及对象存储,无需GC管理。
补充
-
这个线程如果正在执行的是一个Java方法,那么这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是一个本地(Native)方法,这个计数器值应该为空
-
程序计数器是否可能为null?
- Java方法执行时:始终指向有效指令地址
- Native方法执行时:值为undefined,但不为null
-
工作流程:
0: iload_1 // 加载局部变量1到操作数栈 1: iload_2 // 加载局部变量2到操作数栈 2: iadd // 执行加法 3: istore_3 // 将结果存储到局部变量3
- 当线程执行到iadd(地址2)时,程序计数器值为2。
- 执行完iadd后,程序计数器自动更新为3,指向istore_3。
2. 虚拟机栈
(1)定义
用于存储方法的调用和执行信息(保存方法的局部变量、操作数栈、动态链接、方法返回地址等)。每个方法调用对应一个栈帧(Stack Frame)。
虚拟机栈由一个个栈帧组成,每个方法在运行时,JVM都会同步创建一个栈帧,然后将栈帧压入到虚拟机栈中。每次方法调用的数据都是通过栈传递的。
(2)异常
-
栈内存溢出(栈帧过多或栈帧过大):
StackOverflowError
- 触发条件:线程请求的栈深度超过JVM允许的最大深度(如无限递归调用)
- 默认栈大小:不同JVM实现不同,HotSpot默认为1MB(可通过
-Xss
参数调整,如-Xss256k
)
-
OutOfMemoryError
- 触发条件:如果Java虚拟机栈容量可以动态扩展,栈扩展时无法申请到足够内存(如多线程场景下系统内存耗尽)
2.1 栈帧
每一个方法被调用到执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
每个栈帧存储以下信息:
1. 局部变量表
存放的是编译器可知的各种基本数据类型、对象引用和returnAddress类型。
- A. 对象引用(reference类型,不等同于对象本身)
- B. returnAddress类型(指向了一条字节码指令的地址)
局部变量表的容量:以变量槽(Slot)为最小单位(32位类型占1个Slot,64位如long/double占2个Slot,其余的数据类型只占用1个)。
2. 操作数栈
用于执行字节码指令的临时数据存储区(如算术运算、方法参数传递)。
i++ 和 ++i的区别:
- i++:从局部变量表取出局部变量 i 并压入操作栈后,对局部变量表中的 i 自增 1。线程取出使用操作数栈栈顶值的自增前的值。
- ++i:先对局部变量表的 i 自增 1,然后取出并压入操作栈,线程再从操作栈栈顶值取出自增之后的值使用。
3. 动态链接
4. 方法返回地址
方法退出的过程就是栈帧在虚拟机栈上的出栈过程,因此退出时的操作可能有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者的操作数栈,每条整pc计数器的值指向调用该方法的后一条指令。
补充
-
垃圾回收是否涉及栈内存?
- 垃圾回收主要针对的是堆内存。栈内存的生命周期由方法调用和返回自动管理,因此不需要GC机制介入。
-
栈内存分配越大越好吗?
- 优点:较大的栈内存可以允许更深的递归调用和更复杂的调用栈
- 缺点:每个线程占用更多内存,容易耗尽总内存资源
-
方法内的局部变量是否线程安全?
- 通常线程安全,因为每个线程有独立的栈空间。
- 例外:若局部变量引用了可变对象并被多个线程共享,则需要同步控制。
3. 本地方法栈
(1)定义
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的:
- 虚拟机栈为虚拟机执行Java方法(也就是字节码)服务
- 本地方法栈则是为虚拟机使用到的本地(Native)方法服务
(2)作用
为 JVM 执行 Native 方法(非 Java 代码实现的方法,如 C/C++ 编写的 JNI 方法)提供内存空间。
(3)归属
线程私有,每个线程在创建时都会分配独立的本地方法栈。
(4)栈合并
HotSpot 将虚拟机栈与本地方法栈合并,通过一个统一的栈结构管理。
(5)异常
StackOverflowError
:栈深度超过 JVM 限制时抛出(如递归调用过深)OutOfMemoryError
:栈扩展失败时抛出(如内存不足)
4. 堆
(1)定义
JVM 中最大的内存区域,用于存储所有对象实例和数组(通过 new 关键字创建的对象)。
此内存区域的唯一作用就是存放对象实例。
(2)归属
线程共享,所有线程均可访问堆中的对象。
(3)生命周期
对象在堆中分配内存,由垃圾回收器(GC)自动回收(无显式释放)。
(4)异常
OutOfMemoryError
:堆内存不足且无法扩展时抛出(如内存泄漏或对象过多)
(5)虚拟机栈
栈帧中存储对象的引用(指向堆中的对象实例)。
5. 方法区
(1)定义
方法区是JVM规范中定义的逻辑内存区域,用于存储类元数据、运行时常量池、静态变量、即时编译器(JIT)编译后的代码等数据。
(2)内容
- 类元数据存储:类名、父类、字段、方法、访问修饰符等
- 静态变量:类级别的static变量直接存储在方法区
- JIT热点代码:编译后的本地机器码存储在“代码缓存”(Code Cache)
- 运行时常量池:动态解析符号引用,支持运行时添加常量
(3)垃圾回收
- 可以不实现垃圾回收
- 回收目标主要是常量池和类型卸载
- 方法区无法满足内存需求时抛出
OutOfMemoryError
静态常量池(Class常量池)
(1)定义
每个Java类被编译后形成的 .class
文件中包含常量池信息,用于存放编译器生成的各种字面量和符号引用。
(2)位置
保存在编译后的 .class
文件中。
(3)存储内容
-
字面量:
- 文本字符串(用双引号包裹的值)
- 被声明为final的常量
- 基本数据类型值
- null
-
符号引用:
- 类符号引用(完全限定名)
- 字段/方法的名称和描述符
运行时常量池
(1)定义
方法区的一部分,用于存储类文件中的常量数据(字面量)和符号引用。
(2)位置
- JDK 7 及之前:永久代(PermGen)
- JDK 8 起:Metaspace
(3)作用
- 内存优化:共享重复常量
- 加速访问:减少解析开销
(4)来源
- 编译期字面量与符号引用
- 运行期动态生成常量(如
String.intern()
)
(5)结构组成
- 字符串常量池(JDK 7 起移至堆中)
- 符号引用表
(6)关键特性
- 字符串字面量直接引用池中对象,
new String("a")
创建堆中新对象 intern()
可强制加入池(注意性能)- 包装类常量池(如 Integer 缓存 -128~127)
- 类加载时解析符号引用为直接引用
以下是不改动文章内容,仅转换为 Markdown 格式后的结果:
字符串常量池
(1)位置变化
- JDK 1.6及之前:作为运行时常量池的一部分,位于永久代。
- JDK 1.7:从永久代分离,移动到堆内存中。
- JDK 1.8及之后:仍保留在堆内存中。
(2)放入字符串常量池的3种途径
1. 字面量赋值
直接存入常量池
- 字符串变量拼接原理是 StringBuilder
- 字符串常量拼接原理是编译期优化
String s1 = "a"; // 延迟:运行到这一步才会放入串池
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2; // new StringBuilder().append(s1).append(s2).toString() 即 new String("ab")
String s5 = "a" + "b"; // javac在编译器的优化
System.out.println(s3 == s4); // f
System.out.println(s3 == s5); // t
流程步骤:
- 检查常量池:JVM 解析代码时发现字面量
"abc"
,首先检查字符串常量池中是否存在哈希值相同的字符串。 - 存在则复用:若池中存在
"abc"
(通过equals
确认内容相同),直接返回池中对象的引用(s1 和 s2 指向同一对象)。 - 不存在则创建:若池中无
"abc"
,在常量池中创建该字符串对象,并返回引用。
底层实现:
- 字符串常量池本质是哈希表(StringTable),默认桶数为 60013(可通过
-XX:StringTableSize
调整)。 - 哈希值由字符串内容计算,冲突时通过链表或红黑树处理。
2. new String(“”)
堆对象与池交互
String s3 = new String("abc");
String s4 = s3.intern();
System.out.println(s3 == s4); // JDK 7+ 中为 false(s4 指向池对象,s3 指向堆对象)
流程步骤:
- 堆中创建对象:
new String("abc")
会在堆中创建一个新的字符串对象。 - 处理字面量:
- 若字面量
"abc"
不在常量池中,JVM 先在池中创建"abc"
,再将堆对象指向该字面量的字符数组。 - 若池中已有
"abc"
,堆对象直接引用池中的字符数组(但堆对象本身是独立实例)。
- 若字面量
3. intern()
String s5 = new StringBuilder("ja").append("va").toString();
System.out.println(s5.intern() == s5); // JDK 7+ 输出 true(池中无 "java" 时)
流程步骤(以 JDK 7+ 为例):
- 检查常量池:调用
intern()
时,检查池中是否存在与当前字符串内容相同的对象。 - 存在则返回引用:直接返回池中对象的引用。
- 不存在则驻留:
- JDK 1.8:将当前字符串对象自身的引用直接加入常量池,并返回这个引用。
- JDK 1.6:在常量池中复制(也就是在永久代中创建一份新的字符串对象)后返回该副本的引用,这样原来在堆中创建的字符串对象和常量池中的对象就不是同一个引用了。
4. StringTable垃圾回收
- JDK6及之前:StringTable 位于永久代(PermGen),仅在 Full GC 时回收,且永久代空间有限,易导致 OutOfMemoryError。
- JDK7及之后:StringTable 移至堆内存,随堆的 Minor GC 或 Major GC 触发回收,内存管理更灵活。
- StringTable 中的字符串对象会参与垃圾回收,但其哈希表结构本身不受 GC 影响。
StringTable 的底层结构
- 哈希表存储:
StringTable 本质是一个固定大小的哈希表(HashTable),采用数组 + 链表(或红黑树)结构,默认桶数为 60013(可通过-XX:StringTableSize
调整)。
5. StringTable调优
-
调整哈希表桶数量
- StringTable 底层由 HashTable 实现,其性能与桶(Bucket)数量直接相关。桶数量越大,哈希冲突概率越低,查询效率越高。
- 默认值:
- JDK6及之前:1009(永久代)
- JDK7+:60013(堆内存)
- 调整桶数:
-XX:StringTableSize=<size>
(JDK8+最小值为 1009)
-
主动控制字符串入池(intern()方法)
-
适用场景:大量重复字符串(如地址、配置项)通过
intern()
复用,减少堆内存占用。List<String> address = new ArrayList<>(); address.add(line.intern()); // 入池后重复字符串引用同一对象
-
6. 直接内存
(1)概念
直接内存(Direct Memory)是 Java 通过 java.nio.ByteBuffer.allocateDirect()
分配的堆外内存,由操作系统直接管理,不属于 JVM 运行时数据区,但可通过 JVM 参数 -XX:MaxDirectMemorySize
限制其大小。
(2)特性
- 高性能:减少数据在 JVM 堆与操作系统内核之间的拷贝,适用于高频 I/O 操作(如文件读写、网络通信)。
- 手动管理:内存分配和释放需开发者控制,但通过
DirectByteBuffer
对象间接管理,实际回收依赖 JVM 垃圾回收机制和虚引用。 - 零拷贝:支持直接与本地 I/O 交互,避免传统 I/O 的双缓冲区复制。
(3)管理与回收机制
一、直接内存的分配与对象结构
- 分配入口
调用ByteBuffer.allocateDirect()
时,底层通过DirectByteBuffer
构造函数触发内存分配:DirectByteBuffer(int cap) {super(...);// 通过 Unsafe 类分配直接内存long base = unsafe.allocateMemory(cap);unsafe.setMemory(base, cap, (byte) 0);// 创建 Cleaner 虚引用,绑定释放逻辑cleaner = Cleaner.create(this, new Deallocator(base, cap)); }
- 关键对象关系
- DirectByteBuffer 对象:位于堆内存,作为直接内存的引用句柄。
- Cleaner 对象:继承自
PhantomReference
,维护释放内存的回调逻辑。 - Deallocator 对象:实现
Runnable
,最终调用Unsafe.freeMemory()
释放内存。
二、释放流程
阶段 1:DirectByteBuffer 对象被标记为不可达
- 触发条件:堆中的 DirectByteBuffer 对象不再被任何 GC Roots 引用(如局部变量失效、强引用置为 null)。
- GC 扫描:在 Young GC 或 Full GC 时,垃圾回收器识别该对象为垃圾。
阶段 2:Cleaner 虚引用入队
- 引用队列:Cleaner 作为虚引用(PhantomReference),当 DirectByteBuffer 对象被回收时,JVM 将其关联的 Cleaner 对象加入
ReferenceQueue
。 - 内部线程处理:JVM 的
ReferenceHandler
线程(高优先级守护线程)监控该队列,发现新加入的 Cleaner 对象后,触发其清理逻辑。
阶段 3:执行 Deallocator 释放内存
- 回调逻辑:
Cleaner 对象的clean()
方法被调用,执行其绑定的Deallocator.run()
:public void run() {if (address != 0) {// 调用 Unsafe 释放内存unsafe.freeMemory(address);address = 0;} }
- 内存释放:
Unsafe.freeMemory()
直接向操作系统释放对应的物理内存或虚拟内存。
三、关键机制深度解析
- 虚引用(PhantomReference)的作用
- 与 Finalizer 的区别:
- Finalizer 通过
finalize()
方法实现资源释放,但存在执行延迟和不确定性。 - Cleaner 使用虚引用 +
ReferenceQueue
,确保释放逻辑更及时、更可靠。
- Finalizer 通过
避免内存泄漏:
- 虚引用不阻止对象被回收(
get()
始终返回 null),确保DirectByteBuffer
对象可被正常 GC。
- 显式 GC 的触发问题
- 若 JVM 启动参数包含
-XX:+DisableExplicitGC
,显式调用System.gc()
将失效,或者未显式调用System.gc()
且未触发 Full GC,则ByteBuffer
、DirectByteBuffer
对象可能长期未被回收,导致直接内存未释放。
- 若 JVM 启动参数包含
其他
1. 永久代为什么被替换为元空间
- 永久代设置空间大小是很难确定的,因为可能某个实际的业务场景中有不断的类加载等工作,但是元空间时使用本地内存,默认情况下是本地大小限制的。
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
- 字符串存在永久代中,容易出现性能问题和内存溢出。这些也是