JVMNotes

第 05 章:对象内存布局

zjc 于 2026-01-05 发布

这是《JVM 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 对象在堆里不只是字段数据,还包括对象头、可能的对齐填充和引用关系。理解对象大小和字段布局,有助于估算缓存容量、解释 false sharing、分析指针压缩和定位大对象。

5.1 对象创建流程

类加载检查
  -> 分配空间
     -> 初始化零值
        -> 设置对象头
           -> 执行构造函数 <init>

分配路径:

路径 条件
栈上分配 逃逸分析判断未逃逸,可能被优化
TLAB 线程本地分配缓冲,常见
Eden TLAB 分配失败或不使用 TLAB
Old / 大 region 大对象,GC 相关

TLAB 示例参数:

-XX:+UseTLAB
-XX:+PrintTLAB

PrintTLAB 属于诊断参数,输出与 JDK 版本有关,不建议生产长期开启。

5.2 对象结构

Object
  |-- Object Header
  |   |-- Mark Word
  |   |-- Klass Pointer
  |   +-- Array Length(数组对象)
  |-- Instance Data
  +-- Padding

64 位 HotSpot 常见情况:

状态 Mark Word Klass Pointer
开启压缩指针 8 字节 4 字节
关闭压缩指针 8 字节 8 字节

压缩指针默认行为与堆大小、JDK 和启动参数有关:

java -XX:+PrintFlagsFinal -version | grep UseCompressedOops

5.3 空对象大小

public class Empty {}

开启压缩指针时,常见估算是对象头 12 字节,再按 HotSpot 对齐策略处理。实际布局由 JVM 实现、字段类型和对齐策略决定,生产应以 JOL 输出为准。

JOL 依赖:

<dependency>
  <groupId>org.openjdk.jol</groupId>
  <artifactId>jol-core</artifactId>
  <version>0.17</version>
</dependency>

查看:

System.out.println(ClassLayout.parseInstance(new Empty()).toPrintable());

5.4 字段布局

public class FieldOrder {
    private boolean flag;
    private long value;
    private int id;
    private byte data;
}

JVM 会按策略重排字段,通常为了对齐和节省空间。字段声明顺序不保证内存顺序。

类型大小:

类型 大小
byte 1
boolean 通常 1
short / char 2
int / float 4
long / double 8
reference 开启压缩指针常见 4

缓存影响示例:

缓存 100 万个对象
  每个对象多 16 字节
    -> 额外约 16MB

5.5 数组对象

数组对象头多一个数组长度字段。

byte[] bytes = new byte[16];
int[] ints = new int[16];
Object[] refs = new Object[16];

估算:

byte[16] = header + 16
int[16] = header + 64

大数组可能直接进入老年代或大 region,具体阈值由 GC 和对象大小决定。G1 可调整 region:

-XX:G1HeapRegionSize=4m

大对象过多会导致:

  1. Humongous 区域占用高;
  2. 复制成本高;
  3. 碎片或停顿增加;
  4. 堆有效容量下降。

5.6 引用类型

引用 回收时机 用途
strong 可达不回收 普通对象
soft 内存不足前后 缓存,行为依赖实现
weak 下次 GC 弱引用缓存、规范化映射
phantom 对象 finalize 后 堆外资源清理

示例:

Map<Object, byte[]> cache = new WeakHashMap<>();
Object key = new Object();
cache.put(key, new byte[1024]);
key = null;

弱引用键被回收后,entry 才可能被清理。缓存语义要明确,不能把 WeakHashMap 当作容量上限。

5.7 逃逸分析

public long noEscape() {
    Point point = new Point(1, 2);
    return point.x() + point.y();
}

如果 point 不逃逸方法,JIT 可能进行标量替换、消除对象分配,甚至消除关联锁。查看诊断参数:

java -XX:+DoEscapeAnalysis -XX:+PrintEscapeAnalysis -version

诊断参数在不同 JDK 中可用性不同。不要为“优化”故意写不逃逸代码而牺牲可读性,应让 JIT 自动优化。

5.8 对象大小分析

JOL:

System.out.println(GraphLayout.parseInstance(object).toFootprint());

堆直方图:

jcmd <pid> GC.class_histogram
jmap -histo:live <pid>

MAT:

Histogram -> 按对象类型和 shallow / retained heap 排序
Dominator Tree -> 看谁持有大量内存
Path to GC Roots -> 查看引用链
指标 含义
shallow heap 对象自身大小
retained heap 回收它可释放的总大小

5.9 false sharing

CPU 缓存行通常为 64 字节。两个高频修改变量落在同一缓存行时,多核之间会反复失效缓存行。

public class Counters {
    private volatile long a;
    private volatile long b;
}

Java 有 @Contended 相关能力,但通常属于 JDK 内部注解,普通业务使用需要 JVM 支持和参数授权。优先思路:

  1. 减少共享写入;
  2. 使用 LongAdder;
  3. 线程本地累加;
  4. 降低写频率;
  5. 用基准测试证明问题。

5.10 压缩指针

参数:

-XX:+UseCompressedOops
-XX:-UseCompressedOops

开启时:

  1. 引用通常 4 字节;
  2. 可减少对象引用占用;
  3. 堆地址需在可压缩范围;
  4. 超大堆可能自动关闭。

查看:

java -Xmx32g -XX:+PrintFlagsFinal -version | grep UseCompressedOops

不要硬记阈值,以当前 JDK 实际输出为准。

5.11 生产案例

案例一:缓存对象过多

现象:Old 区持续增长,Full GC 后仍高。

jcmd <pid> GC.class_histogram | head -30

发现大量 CacheEntry。处理:

  1. 设置缓存上限;
  2. 使用 TTL;
  3. 拆分热点缓存;
  4. 迁移集中缓存;
  5. 按容量和成本评估。

案例二:大对象导致停顿

jstat -gcutil <pid> 1000
jcmd <pid> GC.heap_info

方向:

  1. 大 SQL 结果集;
  2. 一次性文件读取;
  3. 大 JSON 序列化;
  4. 批处理包过大;
  5. DTO 字段冗余。

处理:分页、流式处理、拆批和限制响应大小。

本章小结

对象由对象头、实例数据和对齐填充组成。字段大小、引用大小、数组长度和压缩指针会影响对象占用。高频对象要关注分配速率、缓存成本和大对象;生产分析用 JOL、class histogram、MAT 和 retained heap 结合判断。

思考题

  1. 对象头包含哪些信息?
  2. shallow heap 和 retained heap 有什么区别?
  3. 开启压缩指针对对象大小有什么影响?
  4. 逃逸分析可能带来哪些优化?
  5. 如何定位缓存导致的老年代增长?