一、JVM 内存结构与区域
-
JVM 的内存区域
- 规范说明
JVM 只是一个规范,常见的内存区域包括:方法区、堆、虚拟机栈、本地方法栈、直接内存等。 - 方法区
根据《深入理解 Java 虚拟机》的描述,方法区用于存储虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等。
- 规范说明
-
运行时数据区与直接内存
- 例如在一台 8G 内存的机器上,JVM 启动时可能分配 5G 作为运行时数据区,其余 3G 可通过特殊方式申请使用(直接内存)。
- 直接内存绕过了 JVM 的内存分配和垃圾回收机制,虽然速度更快,但容易引发内存泄漏、覆盖等问题。通常通过
Unsafe
类进行底层操作,而ByteBuffer
则能避免部分风险。
-
虚拟机栈与本地方法栈
- 虚拟机栈存放的是每个线程的栈帧,参数大小由
-Xss
控制,默认值通常为 1MB。 - 本地方法栈与虚拟机栈类似,HotSpot 虚拟机中两者合二为一,不再严格区分。
- 虚拟机栈存放的是每个线程的栈帧,参数大小由
二、字节码、栈帧与程序执行
- 字节码与栈帧示例
下面代码展示了一个简单的 Java 类,通过字节码解读说明了方法调用、局部变量与操作数栈的工作原理:
java
public class Person {
public int work() throws Exception { // 每个方法对应一个栈帧
int x = 1; // 字节码:iconst_1、istore_1
int y = 2; // 字节码:iconst_2、istore_2
int z = (x + y) * 10;
return z;
}
public static void main(String[] args) throws Exception {
Person person = new Person();
person.work(); // 调用 work() 方法,字节码偏移量对应行号(例如 3)
person.hashCode(); // 调用 hashCode() 方法,属于本地方法,使用本地方法栈
}
}
- 指令与偏移
每条字节码指令并不等同于一行代码,例如bipush 10
占用两行,程序计数器记录的是相对于本方法的字节码偏移量。 - 操作数栈
所有数据运算都通过操作数栈完成,类似于寄存器,且该栈空间在同一线程内复用。 - 局部变量表
变量先进入操作数栈,然后再存入局部变量表。加载(iload)和存储(istore)的字节码指令实现了这种数据传输。
- 方法返回与跳转
- 方法返回时,返回值会压入操作数栈,并由调用者通过操作数栈取回。
- 每个方法都有一个"完成出口"(返回地址),记录下一条将要执行的指令偏移。
- 动态链接与多态
- 动态链接机制与多态相关,在执行过程中根据实际类型确定方法调用。
- 注意:只有非静态方法需要使用动态链接(涉及
this
指针)。
三、对象内存分配与垃圾回收
-
对象分配与晋升
- 对象一般先在新生代 Eden 区分配,通过 TLAB(线程本地分配缓冲)提高分配效率。
- 若对象经过约 15 次垃圾回收仍然存活,则会被提升到老年代。
- 大对象(例如大于
-XX:PretenureSizeThreshold
设置的阈值,通常如 4MB)直接进入老年代。
-
垃圾回收算法
-
新生代:采用复制算法,适用于生命周期短的对象。
-
老年代:常见算法包括标记清除(CMS,可能产生碎片)和标记整理算法。
-
全局回收:Minor GC(新生代回收)、Full GC(新生代和老年代整体回收)、Major GC(单独针对老年代的回收)。
-
并行与响应优先
- Parallel Scavenge 与 Parallel Old 采用并行多线程回收,优先保证吞吐量。
- CMS 注重响应优先,但存在 CPU 占用高、浮动垃圾和碎片等问题。
-
-
垃圾回收辅助技术
- 逃逸分析:在 JIT 编译时分析对象是否只在局部使用,从而可能将对象分配到栈上。
- 空间分配担保:JVM 根据历次回收情况判断是否需要进行 Full GC,以保证新生代与老年代空间的分配平衡。
四、对象结构与内存模型
-
对象头与填充
- 对象头包含运行时数据、类型指针;若是对象数组,还包括长度信息。
- 根据对象大小和内存对齐要求,可能会有额外的填充。
-
对象引用
- 可以通过句柄(reference → 句柄 → 实际对象)访问对象,也可以采用直接指针,HotSpot 主流采用直接指针方式以提高效率。
-
CAS 与原子操作
- JVM 中使用 CAS(Compare-And-Swap)指令,利用 CPU 指令和锁机制确保原子性,适用于多核环境下的并发控制。
-
内存分配方式
-
JVM 内存分配有两种方式:
- 指针碰撞:连续空间内快速分配。
- 空闲列表:在不连续内存中查找合适空间。
-
五、类加载与常量池
-
类加载与 .class 文件
- 类文件按需加载,加载后的类存放于方法区。
- 类加载时会将符号引用转换为直接引用,存储在运行时常量池中。
-
常量池
- 运行时常量池保存直接引用信息,其中包括字符串常量池(虽然规范未定义,但作为优化技术广泛使用)。
- String 的
intern()
方法可实现相同内容字符串共享同一常量池对象,从而实现复用。
-
类的卸载条件
- 要卸载一个类,必须满足:
(1) 该类所有实例均被回收;
(2) 加载该类的类加载器也被回收;
(3) 类自身没有被任何引用持有(包括反射);
(4) 未启用阻止类卸载的参数(如-Xnoclassgc
)。
- 要卸载一个类,必须满足:
六、引用类型与内存回收机制
-
各种引用
- 软引用:在内存不足(OOM)时回收。
- 弱引用:每次 GC 都会回收。
- 虚引用(PhantomReference) :对象即将被回收时会加入引用队列,但无法直接获得对象,常用于管理堆外内存(如 NIO)。
-
引用队列
- 虚引用必须与引用队列(ReferenceQueue)联合使用,便于程序在对象回收前采取必要行动。
-
可达性分析与 Finalize
- 垃圾回收器通过 GC Roots(如静态变量、线程栈、常量池、JNI 指针等)确定对象可达性。
- 对象回收前会进行两次标记,其中一次是 Finalize 阶段,允许对象在被回收前"自救"(但 finalize() 只会被调用一次,优先级较低)。
七、垃圾回收调优与性能优化
-
内存分配与回收比例
- 新生代通常采用 8:1:1 的比例(Eden:From Survivor:To Survivor)。
- 当对象总大小超过 From 区的一半时,会直接进入老年代。
-
回收器选择与调优
- PS(Parallel Scavenge 及 Parallel Old)组合适合吞吐量优先的场景。
- 堆的扩容或缩容依据参数 MinHeapFreeRatio 与 MaxHeapFreeRatio,调优时常建议将最大堆和最小堆设为相同值。
-
CMS 垃圾回收器
- 虽然 CMS 采用并发清理、响应优先,但它对 CPU 要求高、可能产生浮动垃圾和内存碎片,因此从未成为默认回收器。
- 回收过程中还涉及预清理、重新标记等阶段,以减少停顿时间。
-
TLAB 的作用
- TLAB(线程本地分配缓冲)为每个线程分配专用内存区域,减少多线程竞争,提升分配效率;若禁用,则可能回落使用 CAS 等原子操作。
八、其他细节
-
线程与对象分配
- 对象在满足逃逸分析条件时可分配到栈上,栈是线程私有的,避免了多线程竞争;但并非所有对象都支持栈上分配。
-
GC 期间的暂停
- 垃圾回收通常需要暂停所有业务线程(Stop-The-World),尽管可以调整 GC 时间,但难以准确控制每次 GC 的耗时。
-
字符串拼接优化
- 使用 StringBuilder 进行字符串拼接可以避免频繁创建新对象,从而提高性能。
-
C/C++ 与 Java 的内存管理对比
- C 语言通过 malloc/free,C++ 通过 new/delete;Java 则由 JVM 自动管理对象内存,开发者无需手动释放空间。
其他补充
1. 字符串长度限制为何为 65536?
JVM 对字符串的存储有固定规定:
-
字符串存储结构由三部分构成:tag + length + 具体内容。
-
每个部分均以字节形式表示,其中:
- tag 标识数据类型(例如 01 表示 UTF-8 编码的字符串),
- length 用 2 个字节记录字符串内容的字节数(因此最大值为 2^16,即 65536),
- 具体内容存储实际字符串数据。
相比之下,对于 Int 类型数据,JVM 不需要记录长度,因为其固定为 4 字节;同理,double 和 long 也是固定长度,不需要额外的 length 字段。
2. 常量池的结构与排列
-
常量池中的常量存储格式
不同类型的常量(如字符串、数字、类信息等)都有各自的格式:
- 对于字符串,其存储格式为 tag、length 以及实际内容;
- 对于数值(如 Integer、double、long),由于长度固定,仅需 tag 与固定字节数表示值,不需要额外记录长度。
- 常量池排列规则
常量池中各个常量按照特定规则排列,直至达到 const_pool_count 的大小;这样就能通过格式和顺序确定各类型常量的数量。
同时,整个常量池可看作一张表,每个条目都遵循固定格式。
3. 字节码与栈帧结构
java中,栈帧所包含的操作数栈,局部变量表,动态链接,返回地址,这些是怎么协同工作的? 在 Java 中,每次方法调用都会在调用线程的虚拟机栈上创建一个新的栈帧,而栈帧中的各个部分协同工作,保证方法调用、参数传递、运算以及返回的正确执行。主要组件和它们的协同工作过程如下:
-
局部变量表
- 用于存储方法的参数和局部变量。
- 当方法被调用时,传入的参数会先复制到局部变量表中,后续方法体中声明的局部变量也都在这里分配空间。
-
操作数栈
- 用于执行过程中存放临时数据和中间运算结果。
- 字节码指令会依次从局部变量表中加载数据到操作数栈,执行运算,再将结果推回操作数栈,或者存入局部变量表中。
-
返回地址
- 用来记录当前方法调用结束后,程序应跳转回调用点的地址。
- 当方法调用时,当前执行位置的下一条指令地址被保存为返回地址,等方法执行完毕后,JVM利用该地址恢复调用方法的执行。
-
动态链接信息
- 包含常量池中的符号引用,用于在运行时解析方法调用和字段引用。
- 这使得方法调用能在运行时绑定到具体的实现(支持多态),即根据实际对象动态选择方法。
协同工作过程:
-
方法调用时 :
JVM创建新的栈帧,并将调用方法的参数复制到该栈帧的局部变量表中。与此同时,当前方法的返回地址(即调用点的下一条指令)也会被保存到调用方法的栈帧中。
-
方法执行时 :
字节码指令通过操作数栈进行数据的加载、计算和传递。局部变量表提供稳定的存储空间,而操作数栈则负责短期数据的临时存储。动态链接信息则帮助 JVM 在执行过程中解析符号引用,确定具体的类、方法或字段地址。
-
方法返回时 :
被调用的方法执行完成后,其返回值会被推入操作数栈,然后当前栈帧被弹出,控制权回到调用方法的栈帧。此时,JVM利用之前保存的返回地址,将返回值传递给调用者,继续执行后续指令。
-
方法编译后的信息
编译时,每个方法的操作数栈大小与本地变量表大小均已确定;
非静态方法会隐含一个 this 参数,存放在本地变量表的第 0 位;
JVM 指令总数超过 100 条,每条指令可能占用 1 个或多个字节。
- 语法糖的影响
Java 代码中大量使用语法糖,编译后生成的字节码会包含更多隐含信息,从而使得最终字节码比源代码冗长。
下面给出一个简单的例子,并在代码注释中说明每个部分如何协同工作:
java
public class StackFrameDemo {
public static void main(String[] args) {
// main 方法的栈帧被创建,局部变量表中存放参数 args(此处可以忽略)
int x = 10; // x 存储在 main 的局部变量表中
int y = 20; // y 同样存储在局部变量表中
// 调用 add 方法时:
// 1. JVM 创建一个新的栈帧用于 add 方法,
// 2. 将 main 中传入的 x 与 y 复制到 add 方法的局部变量表中,
// 3. 同时保存 main 中下一条要执行的指令地址作为返回地址。
int sum = add(x, y);
// add 方法返回结果后,该返回值会先被推到 main 的操作数栈上,
// 然后 main 方法的栈帧继续执行,最后调用 System.out.println 进行输出。
System.out.println("Sum: " + sum);
}
public static int add(int a, int b) {
// 进入 add 方法时,a 和 b 已存放在 add 的局部变量表中(通过动态链接解析确定了正确的方法实现)
// 操作数栈用于临时存储计算数据:
// 将 a 和 b 从局部变量表加载到操作数栈上,执行加法运算。
int result = a + b; // 此时 a 与 b 通过操作数栈相加,结果存入局部变量表的 result 位置
// 将 result 推入操作数栈作为返回值,然后将当前 add 方法的栈帧弹出
return result;
}
}
协同工作说明
-
栈帧的创建与销毁
- 每次方法调用都会创建一个新的栈帧。比如,调用
add(x, y)
时,JVM 会为add
方法创建一个新的栈帧。 - 当方法执行结束(如
add
返回)时,对应的栈帧会被弹出,返回值传递到调用者的操作数栈上。
- 每次方法调用都会创建一个新的栈帧。比如,调用
-
局部变量表
- 每个栈帧都有自己的局部变量表,用来存储方法的参数和局部变量。在
add
方法中,参数a
、b
以及局部变量result
都存储在此表中。
- 每个栈帧都有自己的局部变量表,用来存储方法的参数和局部变量。在
-
操作数栈
- 操作数栈用于存储运算中产生的临时数据。例如,在
add
方法中,a
和b
会被加载到操作数栈,然后进行加法运算,结果再存入局部变量表或直接作为返回值推入调用者的操作数栈。
- 操作数栈用于存储运算中产生的临时数据。例如,在
-
动态链接
- 动态链接负责在运行时将符号引用解析为实际的内存地址。在调用
add
方法时,JVM 根据常量池中的信息动态解析到正确的方法实现,支持多态等特性。
- 动态链接负责在运行时将符号引用解析为实际的内存地址。在调用
-
返回地址
- 指的是方法执行完成之后应该返回的地址,以便让之前的逻辑继续执行。
4. 内部类与外部类的引用机制
- 内部类的原理
内部类在编译后会在构造方法中包含对外部类的引用,用以访问外部类成员。 - Kotlin 的优化
如果内部类没有使用外部类成员,则不会传入外部类引用,只有在需要访问外部类时,才会包含此引用。
5. 泛型擦除与 Signature 信息
- 泛型擦除导致的问题
由于编译时泛型被擦除,在运行时直接使用会出现类型不匹配的报错。 - Signature 字段的作用
编译后,JVM 会在 class 文件中存储一个 Signature 字段,保存完整的泛型信息,从而允许通过反射获取。
6. 同步代码块及方法锁机制
- 同步代码块
字节码中,同步块会有两次退出指令,以确保在异常情况下也能正确退出同步状态。
- 伪代码简化
对应的简化伪代码展示了同步代码块的退出处理。
- 方法锁机制
方法上的锁在进入时会先尝试偏向锁(直接记录线程 ID),若竞争失败或调用了 hashCode,则升级为轻量级或重量级锁。锁只能升级,不能降级,这样可以避免频繁的竞争和阻塞。(偏向锁:直接在锁上边添加了线程的ID,如果不是自己,就会尝试去替换,如果替换不了,就会升级为偏向锁,下次该线程需要锁的时候,不需要竞争,直接操作就行了。轻量级锁,不会阻塞,通过自旋不断地去尝试获取锁。自旋次数有限制,达到限制后就会升级为重量级锁。锁只能升级,不能降级。 hashCode需要经过调用才会产生的。并不是一调用就会有的,而一旦对象调用了hashCode,这个对象就不可能再变成偏向锁了。同时,如果一个对象本身就是偏向锁状态,一旦调用hashCode,就会直接升级为重量级锁。因此,三方库里边都是通过直接创建对象设置锁,而不是通过某个类的class对象来作为锁。这样可以在一定程度上防止锁变成重量级锁。)
7. 内存模型与类加载
- 内存区域划分
除了堆和方法区(包含常量池)外,其余区域(如虚拟机栈、本地方法栈)均为线程私有;运行时常量池由方法区分配,存储了 final static 变量以及各种类的信息。
- 常量池
常量池本身也可视为一张表,按固定规则排列各类型常量。
- 类加载与对象初始化
如果在使用时发现类尚未加载,JVM 会先加载该类到方法区,然后在堆上分配对象内存,并将对象引用压入栈帧的操作数栈;但此时对象还未完成初始化。
每个栈帧的结构包含局部变量表、操作数栈等信息。
- 对象结构
对象由对象头和实例数据组成,数组对象还包含记录长度的字段。
8. 垃圾回收算法与内存回收
-
常见 GC 算法
- 标记清除:标记所有可达对象后清除不可达对象,但可能产生内存碎片。
- 标记整理:在标记阶段后整理内存,减少碎片问题。
- 复制算法:常用于新生代,将存活对象复制到另一空间并清空原空间。
- Minor GC 在新生代中的过程
在 young 区域,每次 Minor GC 会对存活对象年龄加 1,并将它们复制到目标区(to 区),同时清空原区(from 区);若目标区空间不足,部分对象将直接晋升到老年代。较大的对象为了减少复制成本,也会直接进入老年代。
- Card Table 优化
老年代被分割为多个区域,每个区域对应 Card Table 数组的一个元素。若某个区域没有跨代引用,Minor GC 时可跳过扫描该区域,从而减少扫描开销。