先说结论
执行
new时 JVM 先确认目标类已加载和初始化,然后在堆上分配空间、把内存清零、设置对象头,最后执行实例初始化和构造器。具体分配方式取决于堆是否规整,可能是指针碰撞或从空闲列表中选取。
为减少多线程争抢分配指针,JVM 常给线程分配 TLAB,线程优先在自己的本地缓冲区快速分配小对象。
对象布局
对象通常包含对象头、实例数据和对齐填充。对象头保存类型指针、哈希、GC 年龄和锁状态等信息;实际大小受压缩指针、字段排列和对齐参数影响。
从 new 到构造完成的顺序
确认类已加载、初始化
↓
在堆或 TLAB 中分配空间
↓
零值初始化实例字段
↓
设置对象头与类型指针
↓
执行父类构造器、实例字段赋值、构造器代码
↓
安全发布给其他线程
零值初始化让 Java 可以保证对象字段在未显式赋值时具有默认值。构造器执行则按照 Java 语义先调用父类构造,再执行当前类实例变量初始化和构造器主体;不要把对象头设置、零值和业务构造混成一个步骤。
指针碰撞与空闲列表
当堆中可用空间连续时,用一个分配指针向前移动即可,称为指针碰撞;若收集器保留了许多碎片,JVM 需要维护空闲块列表选择足够空间。采用哪种方式取决于收集器和堆当前组织,不是某个 Java 语法特性决定的。
TLAB 是每线程在 Eden 等区域预留的一小块连续空间,常见小对象只需移动本地指针。TLAB 用完、大对象或不适合分配时会退回共享路径,可能触发慢分配或 GC。
对象大小怎么估算
对象大小不能只把字段字节相加。还要考虑对象头、引用宽度、字段对齐、数组长度字段与整体对齐。32/64 位 JVM、压缩普通对象指针、压缩类指针和对齐粒度都会改变结果。
可用 JOL 等工具在目标 JVM 上测量,不要把某一环境“对象头 12 字节”的经验当固定事实。容量设计中更重要的是总对象数量、生命周期和引用关系,而非单个小对象的理论值。
逃逸分析的实际边界
int sum(int x, int y) {
Point point = new Point(x, y);
return point.x + point.y;
}
若 Point 不逃出方法,JIT 可能把字段直接作为标量寄存器或栈上值处理,连对象分配都不存在。若对象返回、写入字段、传给未知虚调用或被其他线程访问,优化可能无法进行。是否优化受热点程度、代码形态和 JIT 决策影响,不能依赖它保证内存表现。
大对象与分配压力
频繁创建短生命周期小对象未必有问题,现代分代收集器为此优化;真正危险的是高分配速率让年轻代回收过频,或大对象、长寿命集合直接推高老年代。通过 JFR、分配火焰图和 GC 日志定位分配热点,比从代码肉眼猜“new 太多”可靠。
优化与边界
JIT 的逃逸分析可能进行标量替换,消除某些对象分配;这不应简单表述为“对象都在栈上”。大对象、TLAB 放不下的对象和不同收集器可能走特殊分配路径。
容易踩坑的地方
引用变量的位置与对象位置是两件事。局部引用可能在线程栈帧中,但它指向的对象通常在堆;能否被优化掉由实际运行与 JIT 决定。
常见问题
追问:对象分配是线程安全的吗?
是。TLAB 把常见分配隔离到线程本地;共享区域分配则通过 CAS 等同步手段保证原子更新。
追问:对象创建后立刻对其他线程可见吗?
不一定。内存分配安全不等于安全发布。需要通过锁、volatile、线程启动、并发容器或其他 happens-before 关系把构造结果交给其他线程。
追问:为什么大量 String 拼接会产生 GC 压力?
循环中反复生成中间 String 和数组会提升分配速率。使用 StringBuilder 能减少中间对象,但最终仍要按结果大小和生命周期评估。
追问:对象头里的 hashCode 是什么时候计算?
通常是首次调用 identity hash 相关操作时按需生成,具体存储方式会受锁状态和 JVM 实现影响,不应假定每个对象创建时都已计算。