自动内存管理之内存区域-Java虚拟机(一)
1 运行时数据区域

运行时数据区域可以划分为由所有线程共享的方法区、堆和线程隔离的虚拟机栈、本地方法栈、程序计数器。
1.1 程序计数器(Program Counter Register)-线程隔离
程序计数器是一块较小的内存空间,它是当前线程所执行的字节码的行号指示器。
Java虚拟机的多线程是通过多线程轮流切换、分配处理器执行时间的方式来实现的。为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地方法(Native),这个计数器值则应为空。
该内存区域是唯一一个不存在OutOfMemoryError
情况的区域
1.2 Java虚拟机栈(Java Virtual Machine Stack)-线程隔离
Java虚拟机栈是线程私有的,它的生命周期与线程相同。每个Java方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
局部变量表存放了编译期Java虚拟机基本数据类型、对象引用和returnAddress类型(指向了一条字节码指令的地址)
局部变量表中的存储空间以局部变量槽(Slot)来表示,long和double类型的数据占用两个变量槽,其余数据类型占用一个。
局部变量表所需内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间时完全确定的,方法运行期间不会改变局部变量表的大小(即变量槽的数量)
如果线程请求的栈深大于虚拟机所允许的深度,将抛出StackOverflowError
;如果Java虚拟机栈容量可以动态扩展,当扩展时无法申请到足够的内存,会抛出OutOfMemoryError
。
1.3 本地方法栈(Native Method Stacks)-线程隔离
虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用本地(Native)方法服务。
与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError
异常和OutOfMemoryError
异常
1.4 堆(Java Heap)-线程共享
Java堆是虚拟机所管理的内存中最大的一块,Java堆是被所有线程所共享的一块内存区域,在虚拟机启动时创建。
The heap is the runtime data area from which memory for all class instances and arrays is allocated.
所有的对象实例以及数组都应当在堆上分配
-《Java虚拟机规范》
Java堆的作用是存放对象实例,几乎所有的对象实例都在这里分配内存。随着即时编译技术的发展,Java对象实例分配在堆上也变得不是那么绝对了。
Java堆也称GC堆,是垃圾收集器管理的区域。大部分垃圾收集器都是基于分代收集理论设计的,所以往往我们会将Java堆划分为新生代、老年代等不同区域,以便更好地回收内存。
虽然Java堆是线程共享的,但是我们也可以为各个线程划分出私有的分配缓冲区,以便更快地分配堆内存
Java堆既可以被实现成固定大小的,也可以是可扩展的(一般是可扩展的,通过-Xms和-Xmx设定堆大小)。当Java堆内存不足以完成实例分配时,将会抛出OutOfMemoryError
。
1.5 方法区(Method Area)-线程共享
方法区也是各线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。《Java虚拟机规范》将此区域描述为堆的一个逻辑部分。
HotSpot团队将垃圾收集器的分代收集理论扩展至方法区,使用永久代来实现方法区,省去了编写专门的针对方法区的内存管理代码。这种设计使方法区容易遇到内存溢出问题,同时会使HotSpot虚拟机在少数情况下与别的虚拟机有不同的表现。
后续HotSpot舍弃了永久代的设计,移出永久代里的字符串常量池、静态变量放至堆中。采用本地内存来实现方法区,更名永久代为元空间,在该区域保存类型信息(主要)、代码缓存、运行时常量池等数据
字符串常量池和运行时常量池有什么区别呢?
在还存在永久代的设计方案里,字符串常量池是被包括在运行时常量池中的,JDK1.7后字符串常量池就被移至堆中。
运行时常量池作为方法区的一部分,存放着Class文件中的常量池表(Constant Pool Table)这项信息。这张表里存放着编译期生成的各种字面量与符号引用(包括由符号引用翻译出来的直接引用)。
字符串常量池类似一个缓存区。考虑到大量频繁地创建字符串,会影响到程序的性能,因此专门为字符串常量开辟了字符串常量池,用户创建字符串常量时,首先查询常量池中是否存在该字符串,如果存在直接返回引用;不存在时,创建新的字符串常量并放入池中。
方法区的内存,也可以固定大小或者可扩展大小,该区域甚至可以选择不实现垃圾收集。这块区域的垃圾收集目标主要是针对常量池的回收和对类型的卸载。如果方法区无法满足新的内存分配需求,会抛出OutOfMemoryError
异常
1.6 直接内存(Direct Memory)
直接内存不属于虚拟机运行时数据区的一部分。
某些机制(如,NIO)可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,避免在Java堆和Native堆中来回复制数据
直接内存不受Java堆大小的限制,但仍受本机总内存大小的限制。当程序员根据实际内存情况配置各内存区域大小参数(如,-Xmx)时,遗漏直接内存的使用空间,就容易导致动态扩展时出现OutOfMemoryError
2 HotSpot虚拟机对象
2.1 对象的创建
创建对象(不包括数组和Class对象)通常是通过字节码new
指令。
创建流程包括:
- 检查这个指令的参数是否能在常量池(方法区)中定位到一个类的符号引用
- 检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,必须先执行相应的类加载过程
- 为新生对象分配内存(对象所需内存的大小在类加载完成后便可完全确定
- 将分配到的内存空间都初始化为零值
- 对对象进行必要的设置,即在对象的对象头(Object Header)中存放一些信息(如,GC分代年龄信息、是否启用偏向锁、这个对象是哪个类的实例等)
new
指令之后会接着执行invokespecial
指令,即<init>()
方法,按照程序员的意愿对对象进行初始化(Java编译器会在遇到new
关键字的地方同时生成new
指令与invokespecial
指令)
其中第3步,在堆上为对象分配空间有两种方式:
- 垃圾收集器带有空间压缩整理的能力,则Java堆是绝对规整的,则可通过指针碰撞(Bump the Pointer,指针向空闲空间方向挪动一段与对象大小相等的距离)的方式分配内存
- 其他垃圾收集器(如CMS这种基于标记-清除算法的收集器),需要采用较为复杂的空闲列表来分配内存
分配内存还需要考虑到多线程并发的情形,我们可以通过两种方式实现线程安全:
- 堆分配内存空间的动作进行同步处理(如CAS方式)
- 每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB,Thread Local Allocation Buffer,可以通过配置参数设定是否设置分配缓冲)
2.2 对象的内存布局
对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
对象头中包括两类信息:
- 一类是对象自身的运行时数据(如,哈希码HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等),这部分数据被称为Mark Word
- 一类是类型指针,即对象指向它的类型元数据的指针,可以通过这个指针来确定该对象是哪个类的实例。如果对象是一个Java数组,对象头中还需要记录数组的长度
实例数据部分是对象真正存储的有效信息。即我们在程序代码里面所定义的各种类型的字段内容,包括从父类继承下来的和子类中定义的字段。默认情况下,相同宽度的字段会被分配在一起存放,父类中定义的变量会出现在子类之前,还会受字段在源码中定义顺序的影响。也可以通过配置调整分配策略
对齐填充起着占位符的作用,因为HotSpot虚拟机要求对象的起始地址必须是8字节的整数倍
2.3 对象的访问
句柄访问对象
通过句柄访问,Java堆中会划分一块内存作为句柄池,reference中存储对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息
优点:当对象被移动(如,垃圾收集)时只需要改变句柄中的实例数据指针,reference本身不需要被修改
直接指针访问对象
使用直接指针访问,Java堆中的对象中需要存放访问类型数据的信息,reference中直接存储对象地址
优点:直接指针访问速度更快,节省一次指针定位的时间开销。因为对象访问十分频繁,所以在总体的执行成本优化上十分可观
3 内存溢出
3.1 Java堆
1)场景:不断创建对象
// VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
public static void main(String[] args) {
List<Object> list = new ArrayList<Object>();
while(true) {
list.add(new Object());
}
}
2)报错:java.lang.outOfMemoryError: Java heap space
3)分析:可以对dump出来的堆转储快照进行分析
- 首先明确出现了内存泄露(Memory Leak)还是内存溢出(Memory Overflow)
- 内存泄漏指程序申请的内存在使用完之后未能被释放。可以通过工具查看泄露对象到GC Roots的引用链,找到泄露对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收他们。
- 内存溢出指内存中这些大量的对象确实都是必须存活的,只是没有足够的内存空间容纳他们了。可以调整堆参数(-Xmx)看看否有向上调整的空,或者检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况
3.2 虚拟机栈和本地方法栈
1)场景:无限递归调用Java方法
// VM Args:-Xss128k
private static void stackLeak() {
stackLeak();
}
public static void main(String[] args) {
stackLeak();
}
2)报错:java.lang.StackOverflowError
3)分析:栈帧数量过多,虚拟机栈容量小,新的栈帧在内存中无法分配时,将导致内存溢出。然而当虚拟机的栈内存不允许动态扩展时,抛出StackOverflowError
,允许扩展仍然无法申请到足够内存时,抛出OutOfMemoryError
(HotSpot虚拟机是不支持动态扩展的)
4)场景:无限生成新的线程(容易导致操作系统假死,不建议测试)
// VM Args: -Xss2m
public static void main(String[] args) throws Throwable{
while(true) {
new Thread(() -> {
while (true);
}).start();
}
}
5)报错:java.lang.OutOfMemoryError: unable to create native thread
6)分析:栈空间是线程隔离的,每一个线程都会有自己的栈空间,当线程过多时,每个线程都需要申请一定大小的栈空间(栈空间越大,能够申请的线程数量越少),而操作系统分配给每个进程的内存是有限的(如,单个进程最大内存限制为2GB)
3.3 方法区和运行时常量池
1)场景:借助CGLib不断动态生成新类
// -XX:MaxMetaspaceSize=10M --add-opens java.base/java.lang=ALL-UNNAMED
public static void main(String[] args) throws Throwable{
while(true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(MyObject.class);
enhancer.setUseCache(false);
enhancer.setCallback((MethodInterceptor) (o, method, objects, methodProxy) -> methodProxy.invokeSuper(o, objects));
enhancer.create();
}
}
static class MyObject {
}
2)报错:java.lang.OutOfMemoryError: Metaspace
3)分析:Java8以后,方法区的内容被放在元空间,而方法区的主要职责是存放类型的相关信息,当我们在运行时生成大量动态类(如,CGLib字节码增强和动态语言Groovy、大量JSP文件第一次运行被编译为Java类、基于OSGi的应用使用不同的类加载器加载同一个类文件生成不同的类等)就会导致方法区溢出
4)你可能会遇到:java.lang.reflect.InaccessibleObjectException-->Unable to make protected final java.lang.Class的报错信息,这是由于JDK 8中有关反射相关的功能自从JDK 9开始就已经被限制了,为了兼容原先的版本,需要在运行项目时添加 --add-opens java.base/java.lang=ALL-UNNAMED
选项来开启这种默认不被允许的行为
3.4 本机直接内存
1)场景:使用Unsafe类分配本机内存
// -Xmx20M -XX:MaxDirectMemorySize=10M
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws Throwable{
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(_1MB);
}
}
2)报错:java.lang.OutOfMemoryError: Unable to allocate 1048576 bytes
3)分析:由直接内存导致的内存溢出,一个明显特征是在Heap Dump文件中不会看见有什么明显的异常情况。如果发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(如,NIO),就可能是直接内存溢出了
参考文献
- 参考书籍:《深入理解Java虚拟机》
- 深刻理解运行时常量池、字符串常量池 - 掘金 (juejin.cn)
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix