new · TLAB · 指针碰撞 · 快速路径 / 慢速路径 · refill_waste_limit · 清零 · mark word · 压缩类指针 · 字段重排 · best fit · 对象对齐 · 压缩指针 · 32 GB · JOL · 紧凑对象头 · JEP 450 / 519 / 534
new 一个对象的全过程:在自己的 TLAB 里挪一下指针,再按规则把字段排进 48 字节
new Order(...) 这一行背后有五步:确认类已加载、分内存、清零、写对象头、跑构造方法。它快,是因为每个线程在 Eden 里有一块自己专用的缓冲区(TLAB),分内存只是把一个指针往后挪,不用和别的线程抢。对象多大由三样东西决定:12 字节对象头,按大小重排、见缝插针的字段,最后补齐到 8 的倍数。实测 单线程 new 5000 万个 Order:开着 TLAB 用 290 ms,关掉要 5628 ms;关掉以后,两个线程一起分配,总吞吐和一个线程一样。Order 实测 48 字节,堆调到 32 GB 就变成 56 字节。
全景:一个对象从 new 到被回收 · 第 3 站(也涉及第 1、2、10 站)地图:J03 · 内存结构 + 对象创建、对象头与内存布局(第 1、2 个知识点,下一圈)上一圈:JVM 内存结构实验:JDK 17.0.20 和 21.0.12,2 核 ARM 实测
①为什么需要:new 是最频繁的操作,还要算得清占多少
在全景第 3 站,这一步要解决两件事。第一,快:下单高峰时,几十个 Tomcat 线程同时在 new 订单、DTO、字符串、集合节点,每秒几百万次分配;如果每次都要和别的线程抢锁,CPU 都会耗在排队上。第二,算得清:第 6 站要把 100 万个订单放进本地缓存,到底占 48 MB 还是 96 MB?堆从 31 GB 调到 32 GB,为什么反而更容易满?
5.8 ns
开着 TLAB,单线程 new 一个 Order 的平均时间(含构造方法) 实测
113 ns
关掉 TLAB,每次都走慢速路径 实测
48 字节
一个 Order:字段加起来 36 字节,对象头 12 字节 实测
56 字节
同一个 Order,堆设成 32 GB,压缩指针关掉以后 实测
new 这一行字节码之后 JVM 做了哪五步,TLAB 的快速路径和慢速路径,对象头里放了什么,字段为什么不按声明的顺序排,压缩指针为什么卡在 32 GB。②没有它会怎样:所有线程抢同一个指针
Eden 是一整片连续的空闲内存。最朴素的分配方法是:堆里放一个指针 top,谁要 48 字节,就把 top 往后挪 48。单线程时这已经很快;可几十个线程同时挪同一个指针,就必须用 CAS 或者加锁,大家在同一个缓存行上排队。
还有一种更老的办法:像 C 的 malloc 一样维护一张空闲块列表,每次找一块够大的。它不要求空闲内存连续,但每次分配都要查表,比挪指针慢得多(③)。
③相邻方案:内存从哪来
| 做法 | 怎么分 | 前提 | 谁在用 |
|---|---|---|---|
| 空闲列表 | 记下所有空闲块,找一块够大的切下来 | 不要求空闲内存连续 | C 的 malloc;HotSpot 的 CMS 老年代(JDK 14 已移除) |
| 共享指针碰撞 | 一个 top,挪一下就是分配;多线程用 CAS | 空闲内存必须连续,所以 GC 要会整理或复制 | HotSpot 在 TLAB 外分配时(慢速路径) |
| TLAB + 指针碰撞 | 每个线程先领一块,再在自己那块里挪指针,不用 CAS | 同上,另外要容忍每块尾巴上的一点浪费 | HotSpot 默认(-XX:+UseTLAB),所有 GC 都用 |
| 根本不分配 | JIT 发现对象不逃逸,把字段拆成局部变量(标量替换) | 代码已被 C2 编译,对象确实不逃逸 | 全景第 2 站;待做页“JIT、逃逸分析与常见的性能误区” |
选择规则:年轻代对象多、死得快,用复制算法,所以空闲内存天然连续,指针碰撞就成立;再给每个线程一块 TLAB,连 CAS 都省了。本页学这一条,“根本不分配”留给 JIT 那一页。
④原理:五步、两条路、一个头、一套排法
先知道这些
④-1new 的五步(HotSpot 的实现,规范只规定结果)
1类检查
new 指令带着常量池里的类名。第一次执行时类还没加载,先走类加载(全景第 1 站);加载过了就直接拿到它的元数据,里面记着实例多大:48 字节。
2分配
在当前线程的 TLAB 里把 top 往后挪 48。解释器和 JIT 都把这几条指令直接生成成机器码,不进 JVM 的 C++ 代码。放不下才走慢速路径(④-2)。
3清零
把对象头以外的部分全部写成 0。所以字段不赋值也能读到 0、false、null。TLAB 领来时默认不清零(ZeroTLAB 默认关),在这一步按对象清。
4写对象头
mark word 写成“无锁、年龄 0、还没有哈希”(0x…01),再写类指针,指向第 1 步那份元数据。到这里,GC 已经能认出这是一个 Order。
5构造方法
invokespecial <init>:先跑父类的构造方法,再按代码给字段赋值。跑完,引用被存进栈帧里的局部变量 o。
第 3、4 步和第 5 步之间,对象已经存在、字段全是 0。如果引用在第 5 步跑完之前就被别的线程看到,它会读到“半初始化”的对象。这就是双重检查锁要加 volatile 的原因(⑥ 坑 3,JMM 页的双重检查锁)。
④-2TLAB:快速路径和三条慢速路径
TLAB 多大?不固定(ResizeTLAB 默认开)。JVM 的目标是:一个线程在两次 GC 之间大约领 50 次 TLAB(100 ÷ (2 × TLABWasteTargetPercent),后者默认 1),所以分配越多的线程,TLAB 越大。本机用 -Xmx64m 启动、加 -Xlog:gc+tlab=trace,main 线程第一块 TLAB 的 desired_size 是 122 KB。
HotSpot:每个线程一块 TLAB线程私有的指针碰撞
- 快速路径只有比较和加法,可以内联进 JIT 编译出的代码
- 每几十 KB 才碰一次共享的 Eden,线程越多越显出好处
- 每块 TLAB 的尾巴可能浪费掉(目标是不超过 1%),要填成假对象,让堆能被顺序扫描
- 前提是空闲内存连续,所以年轻代必须用复制或整理的 GC
被放弃的:所有线程共用一个 top或者空闲列表
- 没有浪费,实现简单
- 每次 new 都是一次 CAS,几十个线程打同一个缓存行
- 空闲列表还要找块、切块、合并碎片,分配和回收都更贵
决定胜负的约束:分配是 Java 程序里最频繁的操作,而且一定是多线程的。所以要把共享的那一步摊薄到几十 KB 才发生一次,用一点尾巴浪费换掉每一次的 CAS。
④-3对象头:8 字节 mark word + 4 字节类指针
锁状态怎么在 mark word 里切换,见 synchronized 页 ④-1。偏向锁 JDK 15 默认关(JEP 374),JDK 18 把代码删了。
④-4字段怎么排:先大后小,见缝插针,最后补齐到 8
JDK 15 起 HotSpot 用这套规则(JDK-8237767):
- 1对象头占前 12 字节。字段从第 12 字节往后放,但每个字段的起点必须是它自己大小的倍数:
long只能从 8 的倍数开始,int和压缩后的引用从 4 的倍数开始。 - 2先放基本类型,按大小从大到小(
long/double→int/float→short/char→byte/boolean),再放引用。不按声明顺序;大小相同的几个字段谁先谁后,Linux 上没有保证。 - 3每个字段放进最小的、能放下的空位(best fit),找不到才接到末尾。所以 12~16 这 4 字节的空隙,会被第一个 4 字节字段填上。
- 4整个对象补齐到 8 的倍数(
ObjectAlignmentInBytes,默认 8)。父类的字段排在前面,子类的字段可以填父类留下的空隙。
左边是“按声明顺序硬排”的结果:第一个 long 只能从 16 开始,对象头后面空出 4 字节;status 放完停在 52,还要再补 4 字节,一共 56。右边是 HotSpot 的排法,也是 ⑤ 里 JOL 实测的结果:skuId 塞进对象头后面的空隙,正好 48 字节,一个字节都不浪费。
④-5为什么对齐到 8,又为什么卡在 32 GB
压缩指针 + 8 字节对齐HotSpot 64 位默认
- 引用占 4 字节,引用多的对象明显更小:⑤ 里 String 是 24 字节,关掉后是 32
- 对象更小,同样的 CPU 缓存装得下更多对象
- 每次读写引用多一次移位,成本很低
- 补齐会浪费几个字节;堆上限 32 GB
被放弃的:引用就是 8 字节真实地址堆 ≥ 32 GB 时只能这样
- 没有解码,堆想多大就多大
- 所有引用翻倍,同样的数据要多占一截堆
决定胜负的约束:Java 堆里大部分是小对象,引用占了很大一块,而绝大多数服务的堆不到 32 GB。“对齐到 8”本来就有(CPU 读未对齐的 long 更慢),顺手把它变成 3 位免费的地址空间。真要更大,可以把 -XX:ObjectAlignmentInBytes 设成 16,上限变成 64 GB,代价是每个对象补齐更多。
// 依赖 org.openjdk.jol:jol-core:0.17
System.out.println(ClassLayout.parseInstance(order).toPrintable()); // 一个对象:每个字段在第几字节
System.out.println(GraphLayout.parseInstance(order).toFootprint()); // 连同它引用的对象一共多大
# 和这页有关的 JVM 参数(都是默认值,一般不用动)
-XX:+UseTLAB -XX:+ResizeTLAB -XX:+UseCompressedOops -XX:ObjectAlignmentInBytes=8
-Xlog:gc+tlab=trace # 看每个线程的 TLAB 大小、领了几次、浪费多少
jcmd <pid> GC.class_histogram # 每个类有多少个实例、一共多少字节
TLAB 外的分配怎么在线上发现、紧凑对象头(JDK 25 可开,27 默认)、JDK 8 和 17 的差别,放到第⑥步。
⑤亲眼看到:用 JOL 拆开一个 Order,再关掉 TLAB 计时
交易靶场里的 Order,和全景页是同一个类。--release 8 编译,JDK 17.0.20 运行,JDK 21.0.12 上的布局完全一样。完整代码:study-labs/java/jvm/jvm-object-new-layout →
public class Order {
long id; long userId; int skuId; int qty; long amountCents; String status;
}
Order o = new Order(1001L, 1024L, 88, 2, 17600L, "PAID");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
实验 1:Order 的布局,算过哈希以后,再把压缩指针关掉
$ java -cp … Layout # 默认参数 OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0x01085040 12 4 int Order.skuId 88 16 8 long Order.id 1001 24 8 long Order.userId 1024 32 8 long Order.amountCents 17600 40 4 int Order.qty 2 44 4 java.lang.String Order.status (object) Instance size: 48 bytes identityHashCode = 3b0143d3 0 8 (object header: mark) 0x0000003b0143d301 (hash: 0x3b0143d3; age: 0) java.lang.Long Instance size: 24 bytes # 12 头 + 4 空隙 + 8 的 long int[3] Instance size: 32 bytes # 16 头(含长度)+ 12 + 4 补齐 Order@… footprint: [B 24 + String 24 + Order 48 = 96 $ java -XX:-UseCompressedOops -cp … Layout 8 4 (object header: class) 0x010c5868 # 类指针还是 4 字节 40 4 int Order.qty 2 44 4 (alignment/padding gap) 48 8 java.lang.String Order.status (object) Instance size: 56 bytes Order@… footprint: [B 24 + String 32 + Order 56 = 112
- 1skuId 跑到了第 12 字节:声明在两个 long 后面,却被排到最前,填掉对象头后面的空隙(④-4 规则 3)。
- 2哈希是写进去的:调了
identityHashCode以后,mark word 从…01变成…3b0143d301,中间那段就是哈希值(④-3)。 - 3关掉压缩指针:
status变成 8 字节,44 那个 4 字节的空隙放不下它,只能接到 48,对象变成 56。对象头仍是 12 字节:JDK 15 起压缩类指针不再跟着压缩指针一起关(JDK-8241825)。 - 496 字节是一个订单真正的成本,前提是每个订单的 status 都是不同的 String 对象。这里的
"PAID"是字面量,100 万个订单共用一个;从 JSON 解析出来的就不是了(⑥ 坑 1)。
实验 2:new 100 万个,线程分配了多少;关掉 TLAB 计时
$ java -Xms1g -Xmx1g -cp target/classes …AllocCost 1 new 1,000,000 个 Order:线程分配了 48,000,048 字节,平均每个 48.0 字节 第 1 轮:1 个线程各 new 50,000,000 个 Order,用时 706 ms # 还没被 JIT 编译完 第 3 轮:1 个线程各 new 50,000,000 个 Order,用时 290 ms $ java -Xms1g -Xmx1g -XX:-UseTLAB -cp target/classes …AllocCost 1 第 3 轮:1 个线程各 new 50,000,000 个 Order,用时 5,628 ms
| 第 3 轮(第 2 轮相差不到 5%,第 1 轮含 JIT 预热) | 1 个线程 × 5000 万 | 2 个线程 × 5000 万 | 两个线程的总吞吐 |
|---|---|---|---|
| 开着 TLAB(默认) | 290 ms | 314 ms | 接近翻倍 |
-XX:-UseTLAB | 5,628 ms | 11,038 ms | 没有增加 |
- 1每个 48.0 字节:和 JOL 算的一样,
getThreadAllocatedBytes统计的就是 TLAB 里挪过的字节数。 - 2单线程也慢 19 倍:没有竞争也慢,说明主要花在“每次都从机器码调进 JVM 的 C++ 慢速路径”上(④-2)。
- 3两个线程等于一个线程:开着 TLAB,两个线程的耗时几乎不变,吞吐接近翻倍;关掉以后,两个线程的总耗时正好翻倍,它们在共享的分配指针上排成了一队。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
- 按字段估算“100 万个订单 × 36 字节 ≈ 36 MB”,按下面的账一算,一个
HashMap<Long, Order>大约要 160 MB。 - 根源
- 每个对象都有 12 字节头和补齐。
Map<Long, Order>里每一项还有 32 字节的HashMap.Node、24 字节的Long键、数组里 4 字节的槽;status 如果是解析出来的字符串,每个订单再加 48 字节。 - 做法
- 上线前用
GraphLayout.parseInstance(cache).toFootprint()量一次;键用long的原生集合,状态用enum或byte,重复的字符串用intern或常量。
- 现象
- 加了 1 GB 堆,GC 更频繁了。
- 根源
- 本机实测
-Xmx31g时UseCompressedOops=true,-Xmx32g时变成false(堆还要让出开头的空页,所以 32g 整就过线了)。所有引用从 4 字节变 8 字节。 - 做法
- 要么不超过 31 GB,要么直接上到 48 GB 以上,把翻倍的引用开销吃回来。用
java -Xmx… -XX:+PrintFlagsFinal -version | grep UseCompressedOops确认。
- 现象
- 双重检查锁的单例,偶尔读到
null的字段;构造方法里把this注册进监听器,回调时字段还没赋值。 - 根源
- 分配和清零在构造方法之前,对象“存在”的时候字段全是 0;没有
volatile或final的保证,“把引用写出去”可能被重排到“给字段赋值”之前。 - 做法
- 单例用
volatile或静态内部类;字段尽量final;构造方法里不要把this交给别人。详见 JMM 页。
- 现象
- 两个计数器之间写了
long p1…p7想隔开缓存行,压测还是慢。 - 根源
- JVM 先把所有
long排在一起,填充字段不一定在你以为的位置;没被读过的字段也可能被优化掉。 - 做法
- 用
@jdk.internal.vm.annotation.Contended(应用代码要加-XX:-RestrictContended),或者用 JDK 的LongAdder。伪共享本身见 JMM 下一圈。
- 现象
- 导出接口每次 new 一个 16 KB 的缓冲区,CPU 火焰图里
MemAllocator那一层可能很显眼。 - 根源
- 对象比 TLAB 剩下的空间大,而剩下的又多到舍不得丢,就会在 TLAB 外用 CAS 分配,每次都走慢速路径。
- 做法
- 用 JFR 的
jdk.ObjectAllocationOutsideTLAB事件找出是谁;缓冲区按线程复用,或者改成流式处理。大到半个 region 的,就是全景第 4 站的大对象。
使用规范
用 JOL 的 GraphLayout 或 jcmd GC.class_histogram 拿真实字节数,不要按字段估算。
坑 1。
31 GB 以下,或者明显更大。
坑 2。
分配只要几纳秒,池子会把短命对象变成长命对象。数据库连接、线程这种创建贵的才池化。
② 的误用。
让“对象建好了”和“别人能看到它”之间有保证。
坑 3。
深处:版本、紧凑对象头、实验台
| 这一页讲的东西 | JDK 8 | JDK 17 / 21 | JDK 25 / 27 |
|---|---|---|---|
| 偏向锁 | 默认开 | 15 起默认关(JEP 374),18 删掉代码 | 没有了 |
| 字段布局算法 | 旧算法 | 15 起换成 ④-4 的新算法(JDK-8237767),少数类在 8 和 17 上大小不同 | 同 17 |
| 关掉压缩指针时的对象头 | 16 字节(类指针一起变 8 字节) | 12 字节(15 起两者独立,JDK-8241825) | 同 17 |
| 紧凑对象头 | 无 | 无(24 起可以实验性开启,JEP 450) | 25:产品特性,-XX:+UseCompactObjectHeaders 开(JEP 519);27:默认开(JEP 534) |
紧凑对象头把类指针压到 22 位,塞进 mark word 的高位,对象头从 12 字节变成 8 字节。它省不省得下来,要看补齐:Order 的 4 字节空隙原本就被 skuId 填上了,省下的 4 字节又被补齐吃掉,还是 48;Long 从 24 变 16。这两个数是按 ④-4 的规则推算的,本机没有 JDK 25,没有实测。下面的实验台可以自己试。
布局实验台先猜,再排
排法就是 ④-4 那四条:基本类型从大到小,再放引用,每个字段放进最小的能放下的空位,最后补齐到 8;大小相同的字段这里按声明顺序,真实 JVM 不保证。同一个函数也能排数组(对象头 + 4 字节长度,再放元素)。在 node 里核对过:Order 48 / 56、OrderLite 40、Long 24、Object 16、int[3] 32,和 JOL 实测一致;8 字节对象头那一档是推算。
- 读 HotSpot 的
MemAllocator和ThreadLocalAllocBuffer:TLAB 的大小怎么随 GC 调整,refill_waste_limit 怎么涨 - JIT 的分配快速路径:C2 怎么把 new 内联成几条指令,再加上逃逸分析(地图上的待做页)
- 在 JDK 25 上开紧凑对象头重跑 ⑤ 实验 1,核对本页推算的 Order 48、Long 16
- 线上分配画像:JFR 的
jdk.ObjectAllocationSample、async-profiler 的-e alloc,找出谁分配最多(J03 的 GC 调优页) - 把交易靶场的订单缓存从
HashMap<Long, Order>换成原生 long 键的集合,量一下省了多少
↺回到⑤:改造你的案例
1. 给 Order 瘦身
按坑 1,把 status 换成 byte、qty 换成 short,几个金额和 ID 换成 int(代码里的 OrderLite)。先按 ④-4 自己排一遍,再用 JOL 核对。做对了:40 字节,而不是你以为的 32。
2. 量缓存
把 100 万个订单放进 HashMap<Long, Order>,status 用 new String("PAID"),用 GraphLayout 量总大小,再和 "PAID" 字面量的版本比。做对了:差出大约 48 MB。
3. 看 TLAB 日志
给 AllocCost 加 -Xlog:gc+tlab=debug,在 GC 日志里找每个线程的 TLAB 大小、领了几次(refills)、浪费比例(waste)。看哪个线程的 TLAB 最大,和它分配得多不多对得上吗。
1. 用一句话说出这一页的精华。
new 快,是因为每个线程在 Eden 里有自己的 TLAB,分配只挪一个指针,不用和别人抢;对象多大,等于 12 字节对象头加上按“先大后小、见缝插针”排好的字段,再补齐到 8,引用在堆小于 32 GB 时只占 4 字节。
2. 没有 TLAB 时,疼在哪?
每次 new 都要在共享的分配指针上做 CAS、并且走 JVM 的慢速路径:⑤ 实测单线程慢 19 倍,两个线程的总吞吐和一个线程一样(②、④-2)。
3. 关掉压缩指针后,Order 为什么是 56 而不是 52?根源是哪条原理?
④-4 的对齐规则:8 字节的 status 必须从 8 的倍数开始,44 那个 4 字节空位放不下,只能接到 48,结尾是 56;而且对象本身也要补齐到 8 的倍数,52 不是一个合法的大小。
4. 预测:在 Order 里再加一个 int version,对象变成多大?再加一个 boolean deleted 呢?
加 int:12 的空隙已经被 skuId 占了。三个 long 在 16~40,另外两个 int 在 40、44,status 接到 48,结尾 52,补齐到 56。再加 boolean:基本类型先排,它放在 48;status 要从 4 的倍数开始,挪到 52,结尾 56,还是 56,49~52 成了空隙(④-4 规则 1~4)。可以在实验台里核对。
5. 预测:一个线程在循环里不停 new 8 KB 的 byte[],其余都是小对象。TLAB 剩 20 KB 时会怎样?剩 1 KB 时呢?
设 TLAB 是 128 KB,refill_waste_limit 初值是 128 KB ÷ 64 = 2 KB。剩 20 KB:放得下,挪指针。剩 1 KB:放不下,而且 1 KB 小于 2 KB,舍得丢,把这 1 KB 填成假对象,领一块新 TLAB,在新的里分配。最麻烦的是剩 5 KB 这种:放不下 8 KB,又比 2 KB 多,舍不得丢,这个数组就在 TLAB 外用 CAS 分配,走慢速路径(④-2、坑 5)。
6. 放回全景:第 3 站前后各发生了什么?如果第 3 站的 TLAB 用得特别快(比如每个请求都 new 一堆大缓冲),整条链路会怎样?
前面是第 1 站(类加载,类指针指向的元数据从这来)和第 2 站(JIT:编译以后快速路径才是几条机器指令,逃逸分析能让部分 new 根本不发生);后面是第 4 站(太大的直接进 Humongous)和第 5 站(Eden 被 TLAB 一块块领完就触发 Young GC)。分配得快,Eden 就满得快,Young GC 更频繁;对象来不及死就被复制、提早晋升(第 6 站),老年代涨得快,第 7 站的并发标记更忙,最坏退化成第 8 站的 Full GC。