运行时数据区结构
方法区(JDK1.7)
JDK1.8以前,方法区位于堆内存中,大小固定,容易引发内存溢出(java.lang.OutOfMemoryError: PermGen space)。
为什么容易引发内存溢出?
- 方法区的内存大小固定不易扩展
- 类的卸载要满足(实际上很难都条件,所以基本不会回收,导致一些没用的类没法卸载):
- 该类的所有实例都被垃圾回收
- 加载该类的类加载器已经被垃圾回收等
- 一些框架会动态生成类,这些动态生成的类也会占用空间
元空间(JDK1.8)
元空间位于本地内存中,可以动态扩展(理论上取决于本地内存大小),减少内存溢出风险。
存储内容
元空间中主要存储了一下信息:
- 类的结构信息:类名,父类名,接口列表
- 字段信息:名称,数据类型,修饰符
- 方法信息:方法名,返回类型,参数列表,字节码指令(执行方法指令),异常信息表等
- 类常量池:
- 字面量:
- 字符串字面量(双引号内的字符串)
- 基本数据类型的值;
- 类中被final修饰的常量(字面量赋值,引用类型的话类常量池中存储的是符号引用)
- 字面量:
运行时常量池
JVM加载类时,会将class文件常量池中的内容导入,并在程序运行时动态添加新的常量
存储内容
- 类常量池数据
- 符号引用:包括类的,字段的,方法等的符号引用,在运行时将这些符号引用解析为直接引用地址
- JVM读取到.java文件,Java编译器将.java文件编译成.class文件(字节码文件),此时的class文件存放在文件系统中(target目录下)
- 类加载器加载class文件,将类信息存放在元数据空间
- 运行时常量池是在类加载器加载类时创建的
- 每个类都有自己独立的运行时常量池,它的内容是由类常量池转换来的
堆
JVM管理的最大一块内存区域,用于存储所有的实例对象和数组。它是垃圾回收(GC)的主要区域。堆的设计直接影响程序性能,内存利用率和稳定性。
存储内容
- 对象实例:
- 通过new关键字创建的对象(Object obj = new Object())
- 实例变量(成员变量)的值,包括基本类型数据和对象引用
- 数组:所有数组类型(int[],String[])的数据都存放在堆中
- 字符串常量池:存储字符串对象的引用
- 其他缓存对象:如缓存框架(EhCache、Guava Cache)中的对象,或静态集合(如
static List
)长期持有的对象
堆的分代结构
- 新生代
- Eden区:新对象首次被分配到的区域(大约80%的新生代空间)
- Survivor区(S0/S1):存活对象在Minor GC后复制到此处,S0,S1交替使用(各占10%)
- 老年代:多次GC后存活的对象存放至此(如长期缓存,全局配置对象)
- 新生代的设计是为了快速回收短期存货对象(如局部变量,临时对象)
- 多次GC后存活的对象晋升至老年代,可以减少GC对长期存活对象的扫描开销(如果长期存活对象依然存放在新生代,每次Minor GC时都会去扫描)
- 大对象直接分配到老年代(若对象大小超过
-XX:PretenureSizeThreshold
参数值)
垃圾回收算法
新生代
复制算法:将Eden区域和其中一个Survivor区(From区)的存活对象复制到另一个Survivor区(To区),清空原区内存。
优点是:垃圾回收后内存连续(无碎片),分配高效
缺点是:需要预留一块内存空间用于复制存活对象;存活对象较多时,复制成本增加
新生代触发GC:
- Eden区满:当新生代中的Eden区没有足够的空间分配给新对象的时候,会触发Minor GC(Java对象大多都是"朝生暮死",所以Minor GC很频繁,一般回收速度很快)
- Survivor区域的To区空间不足:这些对象会进入老年代,若老年代也无法容纳,会触发Full GC
- 大对象会直接进入老年代:需要大量连续空间存储的对象(如长字符串,数组)会直接在老年代中分配,以免在新生代中频繁触发GC
- 当Survivor区中相同年龄或更高年龄的对象所占用的空间总和超过Survivor区空间的一半时,这些对象就会被直接晋升到老年代
- 当对象的年龄到达阈值的时候,会晋升到老年代
老年代
标记-清除算法:标记无用对象后清除(可能会产生内存碎片)
标记-整理算法:标记后整理内存,消除碎片
老年代触发GC:
- 老年代空间不足:当老年代的空余空间不足以容纳从新生代晋升来的对象时,会触发Full GC
- 方法区(永久代或者元空间)空间不足,会触发Full GC
- 调用System.gc():调用后不一定会立即执行Full GC
分代收集
结合不同的垃圾回收算法,新生代和老年代采用不同的垃圾回收算法和策略进行垃圾回收,提高回收效率,减少STW(Stop-The-World)时间
如G1收集器将堆划分为多个Region,动态分代管理
复制算法详解
对象分配阶段
新对象被分配到Eden区域,如果Eden区域空间不足,则触发Minor GC。
垃圾回收阶段
- 标记存活对象:从根对象(GC Root)出发,标记所有可达对象
- 复制存活对象:将Eden区域和Survivor(From区域)的存活对象复制到Survivor(To区域)。存活对象每经历一次GC年龄+1
- 更新引用:更新所有指向复制对象的引用,使其指向新地址
- 交换角色:将Survivor的From区域和To区域互换,等待下次GC
- 清空Eden和From区:清空Eden区域和Survivor的From区域中剩余的对象
- 晋升老年代:如果存活对象的年龄超过阈值(15,
-XX:PretenureSizeThreshold
参数值),或者To区域空间不足,对象直接晋升到老年代
特点
- 垃圾回收后的内存空间是连续的,能提高分配效率
- 需要暂停应用线程(Stop-The-World)
- 需要预留内存空间,始终有一块内存空闲(可以通过调整Eden和S0/S1比例减少内存浪费)
- 如果存活对象过多,复制成本显著增加
- 在复制存活对象时,有些收集器(ParNew收集器)会采用并行复制。多线程并行复制减少STW(Stop-The-World)时间
- 可以通过调整Eden和S0/S1比例减少内存浪费
标记-清除算法详解
标记阶段
- 从根对象(GC Roots)出发,遍历所有直接引用对象
- 递归标记可达对象(间接或直接引用最终能在堆中找到对象)
- 通过深度优先搜索(DFS)或者广度优先搜索(BFS)遍历对象图,标记可达对象为"存活"
清除阶段
- 遍历堆内存,检查每个对象的标记状态
- 回收未标记对象:如果对象没有被标记(不可达),直接回收(释放内存);已标记的对象重置标记位,为下一次GC做准备
特点
- 回收后的内存是碎片化的(不连续),可能会引发内存碎片问题
- 标记和清除阶段会暂停所有的应用线程(Stop-The-World),可能会产生明显的延迟
- 根对象(GC Roots)包括:线程栈中的局部变量表中的引用对象;本地方法栈中局部变量表的引用对象;元空间中的静态变量(引用类型),其他常量引用等
- 标记阶段的标记实现方式:1. 对象头标记位:在对象头中设置标记位,标记是否存活
2.独立位图:使用单独的内存区域记录存活对象的地址,避 免修改对象头
标记-整理算法详解
标记阶段
- 从GC Roots出发,遍历对象引用链,标记可达的对象
整理阶段
- 计算对象新地址:计算每个存活对象移动后的目标地址
- 更新对象引用:将存活对象更新后的地址更新到指向这些对象的引用
- 移动对象:将存活对象按顺序复制到目标地址,覆盖原有的内存区域
清除阶段
- 回收空闲内存:整理完成后,存活的对象集中在内存的一端,另一端的内存空间(包括原存活对象移动后留下的空隙)被一次性回收
特点
- 无内存碎片:内存连续分配,提升分配效率
- 内存利用率高:无需预留内存(对比复制算法)
- 高延迟:整理阶段需要移动对象并更新引用,导致长时间STW(Stop-The-World)
- 实现复杂:需要处理对象移动,引用更新,跨代引用(新生代对象中引用了老年代的对象)等问题
算法对比
吞吐量:系统在单位时间内处理的请求总数(包括TPS和QPS)
算法 | 适用场景 | 内存碎片 | 吞吐量 | 停顿时间 |
---|---|---|---|---|
标记-整理 | 老年代 | 无 | 中 | 长(大堆时显著) |
标记-清除 | 老年代 | 有 | 高 | 短(仅标记清除) |
复制算法 | 新生代 | 无 | 高 | 短(低存活率时) |
栈
栈是线程私有的,每个线程在创建时都会分配独立的栈空间。线程独立管理自己的方法调用栈
线程私有:虚拟机栈,本地方法栈,PC寄存器(程序计数器)
虚拟机栈
在JVM中,栈帧是调用方法的基本单元,每当一个方法被调用时,就会创建一个栈帧压入当前线程的虚拟机栈中;方法执行完毕,栈帧会被弹出。
栈帧的结构
局部变量表
存储方法参数和方法内部定义的局部变量
数据结构:
- 以索引为单位的数组,索引从0开始
- 基本类型(如 int,float)和对象引用(如Object)占一个索引位(槽位),long和double占两个连续的索引位(槽位)
- 实例方法(非static)的索引0存储的是this引用,后续一次为方法参数和局部变量
示例:
public int calculate(int x, int y) {
long sum = x + y;
return (int) sum;
}
局部变量表结构:
|
操作数栈
存储字节码指令的操作数,用于执行计算、方法调用和返回结果
数据结构:
- 后进先出(LIFO)的栈结构,最大深度在编译时确定
- 字节码指令(如
iadd
、invokevirtual
)从栈顶取出操作数,并将结果压回栈顶
示例
int a = 10;
int b = 20;
int c = a + b;
-
操作数栈执行流程:
-
bipush 10
→ 栈顶压入10
。 -
istore_1
→ 栈顶值10
存入局部变量表索引1(变量a
)。 -
bipush 20
→ 栈顶压入20
。 -
istore_2
→ 栈顶值20
存入索引2(变量b
)。 -
iload_1
→ 加载a
的值10
到栈顶。 -
iload_2
→ 加载b
的值20
到栈顶。 -
iadd
→ 弹出10
和20
,计算结果30
压入栈顶。 -
istore_3
→ 栈顶值30
存入索引3(变量c
)。
-
动态连接
将符号引用(Symbolic References)转换为直接引用(Direct References)
实现机制:
- 符号引用:类、方法、字段的名称描述(如
java/lang/Object.toString()
) - 直接引用:目标方法在内存中的实际地址(如方法入口指针)
- 解析过程可能在类加载阶段(静态解析)或运行时(动态解析,如多态方法调用)
示例
多态方法调用:
Animal animal = new Dog();
animal.speak(); // 动态链接到Dog类的speak()方法
方法返回地址
记录方法执行结束后应返回的位置
两种情况:
- 正常返回:程序计数器(PC寄存器)记录调用方法的下一条指令地址
- 异常返回:异常处理表(Exception Table)决定跳转位置
示例:
调用方法A()
内部调用B():
void A() {
B(); // PC记录此处地址+1
System.out.println("A done");
}
当B()
执行完毕,根据返回地址回到System.out.println("A done")
其他附加信息
异常处理表(Exception Table):
- 记录
try-catch
块的范围和异常处理器入口 - 发生异常时,JVM查找匹配的
catch
块并跳转
运行时常量池引用:
指向当前方法所属类的常量池,用于动态链接时的符号解析
特点
- 线程私有:每个线程独立管理栈帧,避免并发冲突
- 轻量高效:基于栈的操作模型简化指令集设计,适合跨平台执行
- 动态扩展:支持方法嵌套调用,深度由栈大小限制
- 内存隔离:栈帧随方法调用自动创建/销毁,内存管理简单可靠
本地方法栈
独立于虚拟机栈,专门用于本地方法的调用,与Java虚拟机栈功能类似,但处理的是非Java语言(如C或C++)编写的方法
HotSpot虚拟机不区分虚拟机栈和本地方法栈,两者是一块的。这意味着它们共享相同的栈空间
特点
- 线程私有:与虚拟机栈类似,每个线程独立分配
- 避免本地方法执行干扰Java代码的栈管理
程序计数器(PC寄存器)
JVM中的程序计数器(Program Counter Register)是一块较小的内存空间,主要用于存储当前线程正在执行的字节码指令的地址
- 线程切换的恢复点:在多线程环境下,CPU会在不同线程之间进行切换。程序计数器记录了每个线程当前执行的位置,当线程切换回来时,可以根据程序计数器的值继续从正确的位置执行
- 方法调用和返回的地址记录:程序计数器会记录方法调用和返回的地址,确保方法执行完成后能够正确返回到调用方法的下一条指令
- 异常处理的恢复点:当发生异常时,程序计数器记录了异常发生时的位置,便于异常处理器在处理后恢复到异常发生前的执行位置
在多线程的环境下,CPU切换线程时,会保存当前线程的上下文信息(包括寄存器信息、栈信息等)
切换回当前线程时,根据上下文信息继续执行
过度的上下文切换会导致程序的性能下降