JVM篇1:java的内存结构 + 对象分配理解

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)

二、每个区域详细说明

  1. 程序计数器(最小的内存区域)
  • 记录当前线程正在执行的字节码指令地址
  • 唯一一个不会出现 OOM 的区域
  • 线程切换时靠它恢复执行位置
  • 如果执行 native 方法,则计数器值为空(undefined)
  1. Java 虚拟机栈
  • 线程私有,生命周期与线程相同
  • 每调用一个方法,就压入一个栈帧(Stack Frame)
  • 栈帧包含:
    • 局部变量表(基本类型、对象引用)
    • 操作数栈
    • 动态链接(指向方法区中的方法引用)
    • 方法返回地址
  • 常见异常:
    • StackOverflowError:栈深度超出(递归太深)
    • OutOfMemoryError:栈扩展失败(极少见,-Xss 很小)
  1. 本地方法栈
  • 与虚拟机栈功能类似,但服务于 native 方法(JNI 调用 C/C++)
  • HotSpot 中很多实现把本地方法栈和虚拟机栈合二为一
  1. 堆(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)可直接进老年代
  1. 方法区(Metaspace)
  • JDK7 及之前:永久代(PermGen),容易 OOM
  • JDK8+:元空间,使用本地内存(直接内存),由操作系统管理
  • 存储:
    • 已加载的类信息(字段、方法、构造器)
    • 运行时常量池(字面量 + 符号引用)
    • 静态变量
    • JIT 编译后的代码缓存

三、Java 对象创建 & 内存分配全流程(new 一个对象发生了什么)

new Person() 完整过程(HotSpot 视角):

  1. 类加载检查
  • 检查常量池中是否有 Person 类的符号引用
  • 如果没有 → 触发类加载(加载 → 链接 → 初始化)
  1. 分配内存(最核心步骤)
  • 上为新生对象分配一块确定大小的内存
  • 两种分配方式(取决于堆是否规整):
    • 指针碰撞(Pointer Bumping):Serial、ParNew、CMS 等带压缩的收集器
    • 堆规整 → 用一个指针记录已用边界 → 分配就是指针后移
    • 空闲列表(Free List):CMS 不压缩、标记-清除收集器
    • 堆不规整 → 维护空闲块列表 → 从列表中挑一块合适大小
    加速分配:TLAB(Thread Local Allocation Buffer)
  • 每个线程在 Eden 区预先分配一块小空间(默认几 KB)
  • 线程内分配直接在 TLAB 上指针碰撞(无锁)
  • TLAB 用完再去 Eden 申请新 TLAB
  • 极大提升并发分配效率(-XX:+UseTLAB 默认开启)
  1. 初始化零值
  • 给对象的所有字段赋零值(int=0, Object=null 等)
  • 保证对象在构造方法执行前字段有合法初始值
  1. 设置对象头
  • Mark Word(标记字段):哈希码、GC 分代年龄、锁状态、偏向线程 ID 等
  • Klass Pointer(类型指针):指向方法区中该类的元数据
  • (可选)数组长度(如果是数组对象)
  1. 执行构造方法 ()
  • 按照代码顺序执行父类构造 → 子类构造
  • 字段显式赋值、初始化块、构造方法体

总结流程图(文字版)

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 下的分配差异、逃逸分析与栈上分配等),可以告诉我,我继续展开讲解!

文章已创建 4426

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

相关文章

开始在上面输入您的搜索词,然后按回车进行搜索。按ESC取消。

返回顶部