2.1 堆:新生代、老年代与对象分配一线


2.1 堆:新生代、老年代与对象分配一线

本节摘要:堆是全体线程共享的对象住所,按分代假说划成新生代(Eden 与两块 Survivor)与老年代。本节讲清分区比例、对象在区间的流转规则(年龄阈值、大对象直入、动态判定)、以及 TLAB 如何把高频分配做到近乎无锁,并以 jstat 会话实证整套流转。

上一章的切片总图把堆画在共享区正中,本节从它开始下刀——不只因为它是 GC 的主战场,更因为后续第五章的回收算法、第六章的调参处方,全都建立在对这套分区结构的理解上。看懂对象在堆里怎么走,第五章的收集器行为就不再是天书。

为什么要分区:两条假说撑起的设计

堆如果是一整块,每次回收就得检查所有对象——但绝大多数对象朝生暮死。弱分代假说指出:绝大多数对象都是朝生夕死的;强分代假说补充:熬过越多次回收的对象越可能继续活下去。两条假说合起来给出了顺理成章的优化路径:把堆划成两块,新对象集中在新生代频繁回收,熬出来的对象搬进老年代低频回收。各自用最划算的算法(第五章展开),互不拖累。

HotSpot 的典型布局把新生代再切成一块 Eden 与两块对等的 Survivor(默认比例 Eden 比 Survivor 为八比一比一,可用 -XX:SurvivorRatio 调整)。任何时刻只有一块 Survivor 在役,另一块留作下次回收时的搬运目的地。老年代则占堆的其余部分,默认新生代与老年代比例约一比二(-XX:NewRatio)。

图 2-1:堆分代结构与对象流转标注图

图 2-1:堆分代结构与对象流转标注图

对象的一生:从 Eden 到老年代的完整路径

用一段代码把流转规则演出来。下面的程序持续制造短命对象,再制造少量"长寿"对象,观察它们分流:

import java.util.ArrayList; import java.util.List; public class ObjectFlow { static List<byte[]> survivors = new ArrayList<>(); public static void main(String[] args) { for (int round = 0; round < 20; round++) { // 短命对象:分配后立即变垃圾,留在新生代等回收 for (int i = 0; i < 10_000; i++) { byte[] ephemeral = new byte[1024]; // 每个一 KB } // 长命对象:每轮留住两 MB,最终被晋升进老年代 survivors.add(new byte[2 * 1024 * 1024]); try { Thread.sleep(50); } catch (InterruptedException ignored) { } } System.out.println("held bytes: " + survivors.size() + " x 2MB"); } }

运行时加参数 -Xms64m -Xmx64m -XX:+UseSerialGC -verbose:gc(Serial 收集器日志最简,适合教学观察),能看到典型的 Minor GC 序列:Eden 满,回收后存活对象被搬进 Survivor 或直接晋升;若干轮后老年代占用持续上涨——那两兆一块的幸存者在堆里完成了自己的晋升之路。

再用 jstat 隔着窗口看内部账目(进程号用 jps 查):

$ jps 28164 ObjectFlow $ jstat -gc 28164 1000 S0C S1C S0U S1U EC EU OC OU YGC YGCT FGC 512.0 512.0 0.0 320.4 4608.0 512.0 44032.0 6144.2 4 0.021 0 512.0 512.0 320.4 0.0 4608.0 3072.6 44032.0 10240.5 6 0.033 0 512.0 512.0 0.0 352.8 4608.0 480.9 44032.0 14336.9 8 0.047 0

逐列解读:S0C 与 S1C 是两块 Survivor 的容量,注意任何时刻只有一侧的已用(S0U 或 S1U)非零——这正是"一块在役、一块备用"的实证;EU 随分配快速上涨、随 Minor GC 归零再涨;OU 是老年代已用,随长命对象晋升稳定爬升;YGC 次数随之增加而 FGC 始终为零,说明回收一直发生在新生代。这份会话的读法,第六章还会作为体检基本功再练一遍。

分配为什么快:TLAB 把堆切成小账本

对象分配是最高频的内存操作,若每次都要在共享 Eden 里抢位置,锁竞争会把性能拖垮。HotSpot 的办法是 TLAB(Thread Local Allocation Buffer):给每根线程在 Eden 里预租一小块私有空间,线程在自己的 TLAB 内挪指针分配,无锁无竞争;TLAB 用尽再申请新块。默认开启(-XX:+UseTLAB),绝大多数分配都在这里完成。

想亲眼确认,打印分配统计即可:

$ java -XX:+PrintFlagsFinal -version | grep -i tlab bool UseTLAB = true {product} bool ResizeTLAB = true {product}

两类例外不走 TLAB:大对象(超过 -XX:PretenureSizeThreshold 设定的值,且在 Serial 与 ParNew 下生效)直接进老年代,避免在 Eden 与 Survivor 之间来回搬运;TLAB 放不下的大块分配则退回 Eden 共享区加锁完成。大对象直入是个值得记住的行为——一次性 new 超大数组,哪怕立刻不用,也可能直接挤占老年代。

流转规则的三个易错点

⚠️ 常见坑一:年龄阈值死记成"一定是十五"。十五只是 HotSpot 默认的最大值上限(受对象头里年龄字段位宽限制),实际晋升阈值由收集器动态调整——G1 会根据 Survivor 空闲度自适应,动态年龄判定还会把"同龄对象总和超过 Survivor 一半"的整批对象提前晋升。别用静态数字推断晋升时机,用 GC 日志验证。

⚠️ 常见坑二:Survivor 空间给太小。Survivor 太小会让本轮存活对象装不下,回收器只好提前把对象塞进老年代(空间担保),长期后果是老年代被短命对象污染、Full GC 提前到来。看到"Minor GC 后老年代明显上涨"的日志特征,先查 Survivor 配置。

⚠️ 常见坑三:把 -Xmx 当成进程内存上限。堆只是进程内存的一部分,Metaspace、线程栈、直接内存都在堆外(本章后面三节逐一切)。容器里只按堆大小划内存配额,是第六章要重点拆解的经典事故来源。

💡 关键直觉:分代不是"堆被这样规定划分",而是回收算法与对象寿命分布共同催生的工程折中。理解了这一点,第五章看到不同收集器对分代的取舍(G1 的 Region、ZGC 干脆淡化分代)时,就不会觉得突兀。

要点回顾

  • 两条分代假说是分区依据:绝大多数对象朝生暮死,熬得越久越可能继续存活;
  • 新生代三区结构:Eden 加两块 Survivor,默认八比一比一,任何时刻一块 Survivor 在役;
  • 流转三规则:熬过回收年龄加一、达阈值晋升、大对象直入老年代,动态年龄判定可整批提前;
  • TLAB 无锁分配:线程在 Eden 私有小块内挪指针,覆盖绝大多数分配;大块与大对象是例外;
  • jstat 是堆的体检单:EU 涨跌看新生代节奏,OU 爬升看晋升,FGC 增长是警示信号。

堆讲完,下一节转向同一共享区的另一块切片:方法区。类加载进来的元数据放哪、永久代为何被 Metaspace 取代、类元数据溢出长什么样——标本柜的故事在下一节展开。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U