这是《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
大对象过多会导致:
- Humongous 区域占用高;
- 复制成本高;
- 碎片或停顿增加;
- 堆有效容量下降。
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 支持和参数授权。优先思路:
- 减少共享写入;
- 使用 LongAdder;
- 线程本地累加;
- 降低写频率;
- 用基准测试证明问题。
5.10 压缩指针
参数:
-XX:+UseCompressedOops
-XX:-UseCompressedOops
开启时:
- 引用通常 4 字节;
- 可减少对象引用占用;
- 堆地址需在可压缩范围;
- 超大堆可能自动关闭。
查看:
java -Xmx32g -XX:+PrintFlagsFinal -version | grep UseCompressedOops
不要硬记阈值,以当前 JDK 实际输出为准。
5.11 生产案例
案例一:缓存对象过多
现象:Old 区持续增长,Full GC 后仍高。
jcmd <pid> GC.class_histogram | head -30
发现大量 CacheEntry。处理:
- 设置缓存上限;
- 使用 TTL;
- 拆分热点缓存;
- 迁移集中缓存;
- 按容量和成本评估。
案例二:大对象导致停顿
jstat -gcutil <pid> 1000
jcmd <pid> GC.heap_info
方向:
- 大 SQL 结果集;
- 一次性文件读取;
- 大 JSON 序列化;
- 批处理包过大;
- DTO 字段冗余。
处理:分页、流式处理、拆批和限制响应大小。
本章小结
对象由对象头、实例数据和对齐填充组成。字段大小、引用大小、数组长度和压缩指针会影响对象占用。高频对象要关注分配速率、缓存成本和大对象;生产分析用 JOL、class histogram、MAT 和 retained heap 结合判断。
思考题
- 对象头包含哪些信息?
- shallow heap 和 retained heap 有什么区别?
- 开启压缩指针对对象大小有什么影响?
- 逃逸分析可能带来哪些优化?
- 如何定位缓存导致的老年代增长?