本文从对象创建初始化、JVM 内存分配、对象头结构、类元信息、JDK8 元空间变迁、四大引用与 ThreadLocal 原理,完整梳理 Java 对象从诞生到运行存活全过程。
一、对象初始化:new Object () 背后全流程
java 运行
Object obj = new Object();
// 这行代码发生了什么?
类加载检查 -> 分配内存 -> 初始化 -> 设置对象头 -> 执行方法
通过<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0);">javap -c</font>反编译字节码可以发现,new 指令会生成运行时常量池索引,索引存储类的符号引用(类全限定名),不会直接指向堆中的 Class 对象。
比如这个字节码的<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0);">#5</font>:
aload_0
getfield #5 // 常量池索引#5指向字段描述
1.1 类的懒加载机制
JDK 内置核心类(Object、Math 等)在虚拟机启动阶段预先加载;自定义业务类采用懒加载(就是咱们自己写的类),只有第一次<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0);">new</font>实例化时才触发类加载:
- JVM 维护全局「已加载类字典」缓存,key 为类全限定名,value 为堆中的 Class 对象;
- 首次创建对象,查询缓存,不存在则通过类加载器完成类加载;
- 类已被加载,直接基于类元信息分配堆内存。
二、堆内存三种分配方案:指针碰撞、空闲列表、TLAB
堆内存分配一共三种实现策略,选型由 GC 收集器决定:
2.1 指针碰撞
内存空间规整连续,用一个分界指针分割已使用和空闲内存,新对象直接在指针后方分配,分配后指针向后偏移。 优点:分配速度快;缺点:多线程并发创建对象时,全局指针竞争激烈,需要加锁。
2.2 空闲列表
JVM 维护一张记录表,记录所有不连续空闲内存块位置,分配时筛选合适空闲区块。 适用场景:内存碎片化严重(CMS 标记清除 GC,无内存整理,产生大量碎片); 缺点:分配效率低,查找空闲内存耗时。
选型总结:
- Serial、ParNew、Parallel Scavenge、G1:复制 / 标记整理,内存规整→指针碰撞
- CMS 老年代:标记清除产生内存碎片→空闲列表兜底
做个比喻,停车
**指针碰撞**就是从头停到尾,新来的去最后面
规整,但是多个车同时停车会抢位置
空闲列表****是管理员有个本子,哪些车位是空的,哪些有车,查本子找车位
灵活,但是效率低,而且可能找不到合适的车位
而并发时,就是多线程**<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0);">new</font>**对象时,就像多个人同时抢车位
传统方法:
给车位加锁,每次停车前先锁住车位,停好后解锁,但是一旦加锁,所有线程都会排队,效率很低
2.3 TLAB:解决多线程 new 对象并发竞争
为了解决刚刚多个人抢车位的问题,JVM 设计师想了个办法:
- 把大停车场(堆内存)划分成每个线程一小块私人区域(TLAB)。
- 每个线程在自己的小区域里用“指针碰撞”(放自己的小牌子),不用跟别人抢。
- 私区用完了,再去公共区申请一块新的私区(这时才需要同步)。
TLAB 全称线程本地分配缓冲区,是 JVM 优化多线程对象分配的核心设计:
- Eden 区内存按线程划分独立私有小块内存,每个线程独占自己的 TLAB;
- 线程在私有 TLAB 内部使用指针碰撞分配对象,无需全局同步锁;
- TLAB 空间耗尽后,向 Eden 公共区申请一块全新独立 TLAB,原有 TLAB 剩余边角内存作废(TLAB 内存浪费)。
示例代码:
// 三个线程循环创建对象,各自在私有TLAB分配,无锁竞争
// 线程1
for (int i = 0; i < 100; i++) {new User();}
// 线程2
for (int i = 0; i < 100; i++) {new Order();}
// 线程3
for (int i = 0; i < 100; i++) {new Product();}
Eden 内存布局:<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0);">[线程1-TLAB][线程2-TLAB][线程3-TLAB][Eden公共剩余区]</font>
TLAB到底分配多少个区域,取决于当前进程中有多少个线程在同时跑,有三个线程,就会分配三个TLAB区域。
而空闲列表的使用取决于当前空间的碎片化程度,碎片化越严重,空闲列表越会兜底使用。
三、对象组装三步流水线:归零→对象头→init 初始化
内存分配成功后,JVM 按照工厂流水线模式组装对象实例:
- 内存归零:所有实例字段统一赋默认零值(int=0、引用 = null、boolean=false),清除原有内存残留脏数据;
- 填充对象头:写入 Mark Word、类型指针等头部信息;
- 执行:调用构造方法,完成成员变量显式赋值、父类构造调用、自定义构造逻辑。
这个过程就像工厂流水线:
先清空货架(归零)→ 贴上产品标签(对象头)→ 按订单安装配件(构造代码)→ 完成可用的产品。
3.1 对象头存储结构
普通对象头(12字节):
┌─────────────────────┬─────────────────────────┬─────────────────────┐
│ Mark Word(8字节) │ Klass Pointer (4字节) │ 实例数据... │
└─────────────────────┴─────────────────────────┴─────────────────────┘
数组对象头(16字节):
┌─────────────────────┬─────────────────────┬─────────────────────┬─────────────────────┐
│ Mark Word(8字节) │ Klass Pointer(4字节)│ 数组长度 (4字节) │ 数组数据... │
└─────────────────────┴─────────────────────┴─────────────────────┴─────────────────────┘
Mark Word
对象头的核心,存储对象的运行时状态。不同状态存储不同的信息
┌─────────────────────────────────────────────────────────────┐
│ 哈希码 (31bit) │ GC年龄(4bit) │ 偏向模式(1) │ 锁标志(01) │
└─────────────────────────────────────────────────────────────┘
保存的是对象的位置、GC的年龄(超过15岁就晋升老年代)、后两位都是锁的状态,
偏向锁标记位、锁状态标志位。
Klass Pointer(类型指针)
指向元空间内当前类的元信息。告诉JVM,我是哪个类的实例。
通过这个指针,JVM知道:
- 对象属于哪个类
- 类的继承关系
- 有哪些方法和字段
最后一步就是执行init方法,init方法包含了调用父类的init方法,变量显式值的初始化,构造方法。
所以类每个构造方法都存在于init中,有两个构造方法,就有两个init方法。
四、类元信息与 JDK8 元空间演进
public class Person {
private static String planet = "地球";//静态变量
private String name;
private int age;
public void say(){}
public static final int MAX_AGE = 150;
}
准确的说,运行时常量池是类元信息的一部分,专门存放字面量 + 符号引用。
类元信息是真正类的词典
JVM加载某个类时,JVM会在方法区(JDK8后是元空间)创建一个Class对象
- 包含类的基本结构,比如类的文件版本(java8),访问修饰符(public、abstract、interface 等)、类索引
- 包含类的字段信息,比如类的字段名、字段类型,修饰符等
- 保护类的方法信息,比如类的方法名,返回值类型、访问权限等各种信息
五、Java 四大引用体系 & ThreadLocal 实战解析
Java 分为强、软、弱、虚四种引用,控制对象可达性与 GC 回收时机,
日常开发看似很少手动编码,但底层框架大量落地。
| 引用类型 | 回收规则 | 典型应用 |
|---|---|---|
| 强引用 | 只要引用存活,永远不被 GC 回收 | 常规对象定义 Object o = new Object () |
| 软引用 | 内存不足触发 GC 时回收 | 图片缓存、大资源缓存 |
| 弱引用 | 只要发生 GC,立刻回收 | ThreadLocalMap 的 Key |
| 虚引用 | 无法获取实例,仅用于监控对象回收 | 堆外内存回收监控 |
比如ThreadLocal里面就是弱引用
ThreadLocal里面是维护了一个map的,key是线程,value是你存的东西
而ThreadLocalMap是继承了弱引用的,在下次GC的时候就会回收。
5.1 高频面试:ThreadLocal 内存泄漏原理与解决方案
底层源码关键点
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // value为强引用
Entry(ThreadLocal<?> k, Object v) {
super(k); // key:弱引用存储
value = v;
}
}
ThreadLocalMap 的 Entry 中,Key (ThreadLocal 实例) 是弱引用,Value 是强引用;
如果在ThreadLocal使用结束并没有调用remove方法
gc清理后会把弱引用的key清理(key设置为null),但是value是强引用,所以不会清理
所以这个value就再也找不到,也再也清理不到了,就属于内存泄漏了
ThreadLocalMap是有**自清洁机制**的,就是你再操作get、set、remove方法时,会清理key为null的entry
其实最稳妥的方式还是,使用trycatch,在finally中把entry删除掉
解决方案
- 规范写法:finally 中调用 remove ()
public void process() {
try {
userHolder.set(currentUser());
// 业务代码
} finally {
userHolder.remove(); // 必须!
}
}
- 全局工具类 + 拦截器自动回收
public class ThreadLocalUtils {
private static ThreadLocal<User> userHolder = new ThreadLocal<>();
public static void setUser(User user) {
userHolder.set(user);
}
public static User getUser() {
return userHolder.get();
}
public static void clear() {
userHolder.remove();
}
// 配合拦截器/过滤器自动清理
public static class CleanInterceptor implements HandlerInterceptor {
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
ThreadLocalUtils.clear(); // 请求结束自动清理
}
}
}
文末小结
本篇我们完整追踪了 Java 对象的“一生”:从类加载、堆内存分配、对象实例化,到类元信息在方法区的变迁,再到引用体系的落地,至此对象创建与早期生命周期已闭环。
但对象的旅程尚未结束。下篇我们将进入对象的“晚年生活”——聚焦 GC Roots 判定、垃圾回收算法、分代收集策略以及各类经典收集器,揭开 JVM 自动内存管理的神秘面纱。
面试题整理:
ThreadLocal 内存泄漏原理与解决方案
提示关键词:内存
⭐️jdk8后,为什么官方要“大动干戈”用元空间替代永久代?难道仅仅是为了规避 **<font style="color:#DF2A3F;">PermGen space</font>**错误吗?
欢迎在评论区留下你的见解,下期我们将深度剖析这个架构演进背后的根本原因。