堆内存详解:堆的分代设计、对象分配过程、TLAB与堆参数调优

堆内存,说白了就是Java程序运行时最核心的“仓库”。我刚开始接触JVM时,总觉得堆就是个简单的内存池,后来线上出过几次OOM,才真正意识到——不懂堆的分代设计,调优就是瞎蒙。

今天咱们就把堆内存彻底掰开揉碎。从分代设计到对象分配,从TLAB到参数调优,一步到位。

1. 堆的分代设计:年轻代与老年代

为什么堆要分代?你想想看,大部分对象都是“朝生暮死”的。比如一个循环里的临时变量,用完了就没人引用了。如果每次GC都扫描整个堆,那效率得多低?

所以JVM把堆分成了两块:

  • 年轻代(Young Generation):存放“短命”对象。GC频率高,但每次只扫一小块,速度快。
  • 老年代(Old Generation):存放“长寿”对象。GC频率低,但每次扫描范围大,耗时久。

我记得有一次线上服务频繁Full GC,一看监控,老年代一直在涨。后来发现是某个缓存对象没设置过期时间,全堆到老年代去了。嗯,这就是典型的分代设计没用好。

年轻代内部结构

年轻代又细分为三块:

  • Eden区:对象刚出生都在这。大部分对象在这里“夭折”。
  • Survivor 0区(S0):存活下来的对象,从Eden挪到这里。
  • Survivor 1区(S1):和S0轮流用,谁空着谁就是“接收方”。

为什么要两个Survivor?说白了就是“复制算法”的需要。每次GC后,把存活对象从Eden+一个Survivor复制到另一个Survivor,然后清空原来的区域。这样就不会有内存碎片。

核心比例:默认Eden : S0 : S1 = 8 : 1 : 1。但这是可以通过参数调的,后面会讲。

2. 对象分配过程:从出生到老去

一个Java对象从创建到回收,大致走这么几步:

  1. 优先在Eden分配:new出来的对象,先扔到Eden区。如果Eden空间不够,触发Minor GC。
  2. Minor GC后存活:Eden里没被回收的对象,挪到S0(或S1)。对象头上会记一个“年龄”,初始为1。
  3. 年龄增长:每次Minor GC,存活下来的对象年龄+1,并在S0和S1之间来回倒腾。
  4. 晋升老年代:当年龄达到阈值(默认15),对象就晋升到老年代。
  5. 大对象直接进老年代:如果对象大小超过-XX:PretenureSizeThreshold,直接分配在老年代。我遇到过有人把一个大数组new在Eden,结果频繁Minor GC,性能惨不忍睹。

为什么会这样?因为大对象在Eden里占地方,Minor GC时复制成本太高。直接扔老年代反而省事。

个人经验:我习惯把-XX:PretenureSizeThreshold设成1MB左右。超过1MB的对象,直接进老年代。这样能减少年轻代的GC压力。

3. TLAB:线程本地分配缓冲区

你想想看,多线程同时new对象,都在Eden里分配,那不得抢锁?抢锁就有竞争,有竞争就有性能损耗。

TLAB就是解决这个问题的。每个线程在Eden里划一小块私有的缓冲区,线程内分配对象时,直接在自己的TLAB里分配,不用加锁。

说白了,TLAB就是“线程私有的小Eden”。

  • 默认开启:-XX:+UseTLAB(JDK 8默认就是开启的)。
  • 大小可调:-XX:TLABSize可以指定TLAB大小。我一般不动这个,让JVM自适应。
  • 分配失败怎么办:如果TLAB空间不够,对象会直接在Eden里分配(加锁)。或者JVM会重新申请一个新的TLAB。

我记得有一次压测,发现GC次数异常高。排查了半天,发现是TLAB分配失败率太高,导致大量对象直接在Eden里加锁分配。后来调大了TLABSize,问题就解决了。

注意:TLAB不是越大越好。太大了会浪费Eden空间,导致其他线程的TLAB不够用。一般保持默认就行,除非你明确知道瓶颈在哪。

4. 堆参数调优:实战经验

调堆参数,说白了就是回答三个问题:堆多大?年轻代多大?晋升阈值多少?

常用参数一览

参数 作用 我的建议值
-Xms 初始堆大小 和-Xmx设成一样,避免动态扩容
-Xmx 最大堆大小 根据机器内存,一般不超过物理内存的70%
-Xmn 年轻代大小 堆的1/3到1/2之间
-XX:SurvivorRatio Eden与Survivor比例 默认8,我常用6或4(看对象存活率)
-XX:MaxTenuringThreshold 晋升老年代年龄阈值 默认15,我常用6或8
-XX:PretenureSizeThreshold 大对象直接进老年代 1MB左右

调优案例:一次Full GC优化

我曾经接手过一个项目,线上每10分钟一次Full GC。一看GC日志,老年代占用率一直在90%以上。分析后发现:

  • 年轻代太小(只有堆的1/5),对象频繁晋升到老年代。
  • 晋升阈值太高(默认15),但对象其实活不过几次GC。

我的调整方案:

-Xms4g -Xmx4g -Xmn2g
-XX:SurvivorRatio=6
-XX:MaxTenuringThreshold=6
-XX:PretenureSizeThreshold=1m

调整后,Full GC降到了每2小时一次。说白了,就是让年轻代多扛一会儿,别动不动就把对象扔老年代。

避坑指南:我曾经把-Xmn设得太大(堆的3/4),结果老年代太小,大对象没地方放,直接OOM。年轻代不是越大越好,要留够老年代的空间。

5. 总结:堆调优的核心思路

嗯,到这里堆内存的核心内容就差不多了。我最后总结几条经验:

  • 先看GC日志:别瞎调。用jstat、GCeasy分析清楚问题在哪。
  • 年轻代大小是关键:大部分调优都在调-Xmn和SurvivorRatio。
  • TLAB一般不动:除非你明确知道线程分配是瓶颈。
  • 大对象单独处理:用PretenureSizeThreshold直接扔老年代。

堆内存调优,说白了就是“让短命对象死在年轻代,让长寿对象安稳待老年代”。你想想看,是不是这个理?