JVM 篇1:Java 内存结构 + 对象分配全流程理解(HotSpot 视角,JDK 8~21 适用)
Java 程序运行时,JVM 会把内存划分为多个区域来管理数据。这些区域有线程私有和线程共享之分。
一、JVM 运行时数据区(Runtime Data Areas)概览
线程私有(每个线程独立一份,互不干扰):
- 程序计数器(Program Counter Register)
- Java 虚拟机栈(Java Virtual Machine Stack)
- 本地方法栈(Native Method Stack)
线程共享(所有线程共用):
- 堆(Heap)
- 方法区(Method Area) → JDK8+ 叫 元空间(Metaspace)
非运行时数据区,但也常提到:
- 直接内存(Direct Memory) → NIO 使用
简明对比表(JDK8+ 主流 HotSpot 实现)
| 区域 | 归属 | 主要存储内容 | 是否会 OOM | 是否会被 GC |
|---|---|---|---|---|
| 程序计数器 | 线程私有 | 当前线程执行的字节码指令地址 | 不会 | 否 |
| 虚拟机栈 | 线程私有 | 栈帧(局部变量表、操作数栈、动态链接等) | 会(StackOverflowError / OOM) | 否 |
| 本地方法栈 | 线程私有 | native 方法的栈帧 | 同上 | 否 |
| 堆 | 线程共享 | 对象实例、数组(几乎全部对象) | 会(OOM) | 是(主战场) |
| 方法区(元空间) | 线程共享 | 类信息、常量、静态变量、JIT 编译代码 | 会(OOM) | 是(JDK8+) |
| 直接内存 | 非 JVM 管 | NIO ByteBuffer 分配的 off-heap 内存 | 会(OOM) | 否 |
二、每个区域详细说明
- 程序计数器(最小的内存区域)
- 记录当前线程正在执行的字节码指令地址
- 唯一一个不会出现 OOM 的区域
- 线程切换时靠它恢复执行位置
- 如果执行 native 方法,则计数器值为空(undefined)
- Java 虚拟机栈
- 线程私有,生命周期与线程相同
- 每调用一个方法,就压入一个栈帧(Stack Frame)
- 栈帧包含:
- 局部变量表(基本类型、对象引用)
- 操作数栈
- 动态链接(指向方法区中的方法引用)
- 方法返回地址
- 常见异常:
- StackOverflowError:栈深度超出(递归太深)
- OutOfMemoryError:栈扩展失败(极少见,-Xss 很小)
- 本地方法栈
- 与虚拟机栈功能类似,但服务于 native 方法(JNI 调用 C/C++)
- HotSpot 中很多实现把本地方法栈和虚拟机栈合二为一
- 堆(Heap) —— GC 主战场
- 存放几乎所有对象实例和数组
- 线程共享,JVM 启动时创建
- 分代(主流分代假设:大多数对象朝生夕死) HotSpot 典型分代结构(G1/ZGC 前主流)
堆 Heap
├─ 新生代 Young Generation(1/3 左右)
│ ├─ Eden(8/10)
│ └─ Survivor(From + To,各 1/10)
└─ 老年代 Old Generation(2/3 左右)
- 新对象 → Eden
- 经过 Minor GC 仍存活 → Survivor(复制算法)
- 多次 GC 仍存活(默认 15 次)→ 老年代
- 大对象(-XX:PretenureSizeThreshold)可直接进老年代
- 方法区(Metaspace)
- JDK7 及之前:永久代(PermGen),容易 OOM
- JDK8+:元空间,使用本地内存(直接内存),由操作系统管理
- 存储:
- 已加载的类信息(字段、方法、构造器)
- 运行时常量池(字面量 + 符号引用)
- 静态变量
- JIT 编译后的代码缓存
三、Java 对象创建 & 内存分配全流程(new 一个对象发生了什么)
new Person() 完整过程(HotSpot 视角):
- 类加载检查
- 检查常量池中是否有 Person 类的符号引用
- 如果没有 → 触发类加载(加载 → 链接 → 初始化)
- 分配内存(最核心步骤)
- 在堆上为新生对象分配一块确定大小的内存
- 两种分配方式(取决于堆是否规整):
- 指针碰撞(Pointer Bumping):Serial、ParNew、CMS 等带压缩的收集器
- 堆规整 → 用一个指针记录已用边界 → 分配就是指针后移
- 空闲列表(Free List):CMS 不压缩、标记-清除收集器
- 堆不规整 → 维护空闲块列表 → 从列表中挑一块合适大小
- 每个线程在 Eden 区预先分配一块小空间(默认几 KB)
- 线程内分配直接在 TLAB 上指针碰撞(无锁)
- TLAB 用完再去 Eden 申请新 TLAB
- 极大提升并发分配效率(-XX:+UseTLAB 默认开启)
- 初始化零值
- 给对象的所有字段赋零值(int=0, Object=null 等)
- 保证对象在构造方法执行前字段有合法初始值
- 设置对象头
- Mark Word(标记字段):哈希码、GC 分代年龄、锁状态、偏向线程 ID 等
- Klass Pointer(类型指针):指向方法区中该类的元数据
- (可选)数组长度(如果是数组对象)
- 执行构造方法 ()
- 按照代码顺序执行父类构造 → 子类构造
- 字段显式赋值、初始化块、构造方法体
总结流程图(文字版)
new Person()
↓
检查常量池 → 无符号引用 → 触发类加载
↓
堆上分配内存(TLAB → 指针碰撞 / 空闲列表)
↓
内存空间清零(零值初始化)
↓
设置对象头(Mark Word + Klass Pointer)
↓
执行 <init>() → 父类构造 → 子类构造
↓
返回对象引用
四、常见面试/实战问题快速对应
- 为什么大多数对象在新生代 Eden 分配?
→ 弱代假说:朝生夕死 → Minor GC 回收快 - 大对象为什么直接进老年代?
→ 避免在 Survivor 区来回复制,浪费性能(-XX:PretenureSizeThreshold) - TLAB 是什么?为什么重要?
→ 线程本地分配缓冲 → 避免并发分配时的 CAS 开销 - JDK8 为什么把永久代换成元空间?
→ 永久代大小固定 → 容易 OOM;元空间用本地内存 → 更灵活 - 堆内存 OOM 常见场景?
- 集合无限 add 对象
- 缓存无淘汰策略
- 大对象数组
- 内存泄漏(ThreadLocal 未 remove)
五、快速记忆口诀
- 私有:计栈本地(程序计数器、虚拟机栈、本地方法栈)
- 共享:堆元(堆 + 方法区/元空间)
- 对象出生:Eden → TLAB 指针碰撞 → 零值 → 对象头 →
- 新生代:Eden 8 : Survivor 1 : Survivor 1
- 元空间:JDK8+ 本地内存,不再是永久代
有想深入的点(例如:TLAB 详细参数、对象头 Mark Word 位图、G1/ZGC 下的分配差异、逃逸分析与栈上分配等),可以告诉我,我继续展开讲解!