1. 先聊一个压测现场:对象创建和回收为什么值得琢磨
去年我们组做支付网关的性能压测,有个接口单次请求要生成十二个DTO对象、两个线程池任务封装对象,外加一堆日志包装类。压到 800 TPS 的时候,服务突然出现规律性停顿,每次卡顿大约 400 毫秒。查 GC 日志发现,CMS 的 Remark 阶段频繁被触发,老年代占用率在短时间内从 30% 跳到 80%。追根溯源,就是代码里一次循环对同一个业务数据调了多次对象拷贝方法,产生了大量瞬时对象,同时还有一个静态 Map 把本该回收的中间对象引用长期持有。那次之后,我对“对象创建方式”和“内存回收机制”的关系有了很直观的认识——这两件事从来不是孤立的,创建路径决定了对象在堆里的位置,而回收机制又反过来决定了创建方式的风险边界。
对大多数 Java 开发者来说,可能天天写new User(),但真被问到“这个对象到底怎么来的?JVM 怎么知道它该被回收?”时,能把这个链路讲清楚的人并不多。尤其是面试八股里常出现的“对象创建方式有几种”“GC Roots 是什么”“什么时候进入老年代”,这些问题看起来基础,实际背后牵扯到 JVM 的类加载、内存分配、垃圾收集器选型等一系列设计决策。这篇文章我想从对象创建的完整链路聊起,一直说到内存回收的分代策略,中间穿插我们真实项目里踩过的坑。适合刚入门的同学建立整体认知,也适合工作三五年的人查漏补缺,尤其是准备 Java 面试的朋友,可以把这篇文章当作一条完整的复习线索。
2. Java 对象创建的完整链路:从 new 到堆内存落地
2.1 创建对象的六条路径,面试题常问的那几种
先说结论,Java 里创建一个对象,严格数下来有六条路:
- new 关键字:最常见,对应 JVM 的
new指令,触发类加载、分配内存、初始化实例。 - 反射:
Class.forName(...).newInstance()或Constructor.newInstance(),本质也是调用构造器,只是入口在运行时动态完成。 - clone():
Object.clone()方法,不调用构造函数,直接从已有对象复制内存块,生成一个新对象。需要注意,浅拷贝和深拷贝的区别就在这里体现。 - 反序列化:
ObjectInputStream.readObject()从字节流重建对象,不需要构造函数,甚至不需要类实现Serializable之外的任何标记(当然不实现Serializable会抛异常)。 - Unsafe.allocateInstance():
sun.misc.Unsafe提供的方法,绕过构造函数、绕过初始化、绕过所有安全检查,直接分配一块内存并返回对象,仅初始化默认值。 - 构造器引用:
User::new这种写法,配合Supplier或Function接口使用,本质上是延迟到调用时执行构造方法。
这六条路里,反射和反序列化是八股题常客,而Unsafe.allocateInstance()是很多面试官用来区分“看没看过源码”的点。实际开发中,普通业务代码基本只用 new 和构造器引用,反射出现在框架层(Spring Bean 创建、MyBatis 结果映射),反序列化出现在 RPC 和消息中间件(Dubbo 的响应体、Kafka 消息反序列化),Unsafe 则常见于高性能框架(Netty、Hazelcast)和序列化库(Kryo)。
有个细节值得提一下:反射创建对象时,newInstance()在 JDK 9 之后被标记为 deprecated,原因在于它只能调用无参构造器并且会把 checked exception 包装为InvocationTargetException,更推荐的写法是用Constructor.getDeclaredConstructor(...).newInstance(args)。我们曾经在旧项目里用反射创建大量小对象,压测时发现耗时比直接 new 高出一个数量级,后来改成缓存Constructor对象才把性能追回来——反射慢主要慢在安全检查上,setAccessible(true)能绕开一部分访问检查,但代价是绕过了 JVM 的模块系统校验,生产环境慎用。
2.2 new 一个对象时 JVM 内部发生了什么
很多人以为new就是“分配一块内存”,实际上new指令背后是一个完整的链路,每一步都有 JVM 规范在约束。我用一个最简单的User user = new User(20, "张三");举例,展开来看:
第一步是类加载。如果User类还没有被加载,JVM 会通过类加载器(一般是 AppClassLoader)加载它的字节码,经过加载、验证、准备、解析、初始化五个阶段后,类才算真正可用。这个过程的产物是java.lang.Class对象,存放在方法区(JDK 8 以后是元空间)。注意“准备”阶段会给类的静态变量分配内存并设置默认值,而不是在 new 的时候。
第二步是分配堆内存。对象所需的内存大小在类加载阶段就能确定,JVM 根据对象头、实例字段、对齐填充的规则算出总大小,然后在堆里找一块连续区域。分配方式有两种:指针碰撞(Bump the Pointer)和空闲列表(Free List)。指针碰撞适用于内存规整的收集器(如 Serial、ParNew 配合 Compact 策略),只需要移动一个指针就能完成分配;空闲列表适用于不规整的内存(如 CMS),需要遍历空闲链表找到足够大的区域。具体用哪种,取决于垃圾收集器是否带压缩整理功能。
第三步是内存清零。JVM 会把分配到的内存空间全部初始化为零值,这样对象的整型字段默认是 0、引用字段默认是 null、布尔字段默认是 false。这也是为什么有些场景下你可以绕过构造函数直接使用对象——字段已经具备“零值安全”,只是业务上无效而已。
第四步是设置对象头。对象头里存了两类信息:标记字(Mark Word)和类型指针(Klass Pointer)。标记字保存哈希码、GC 分代年龄、锁状态标志等;类型指针指向方法区里的类元数据,说明这个对象是哪个类的实例。如果开启了压缩指针(-XX:+UseCompressedOops,JDK 8 默认开启),类型指针和实例引用都能压缩到 32 位,减少堆内存占用。
第五步是调用构造函数。字节码层面会有invokespecial <init>指令,执行构造方法体,完成实例字段的显式赋值,然后user变量才真正指向这个对象。
这些步骤里容易被忽略的是TLAB(Thread Local Allocation Buffer)。JVM 默认给每个线程分配一块私有的堆内存缓冲区,新对象优先在 TLAB 里分配,避免多线程争用同一块内存时的锁竞争。TLAB 不够用时会“慢速分配”,走指针碰撞或空闲列表的全局路径。-XX:+PrintTLAB可以观察 TLAB 的使用率,我之前排查过一个高频创建小对象的服务,就是靠这个参数发现 TLAB 设置偏小导致频繁慢速分配,把-XX:TLABSize调大之后吞吐量提升了 5%。
2.3 对象的内存布局:对象头、实例数据、对齐填充
对象在堆内存里的布局,可以分为三个区域:
| 区域 | 内容 | 典型大小(64 位 JVM,指针压缩开启) |
|---|---|---|
| 对象头(Header) | Mark Word + Klass Pointer,数组还有数组长度 | 12 字节 |
| 实例数据(Instance Data) | 非静态字段的值,含父类字段,按对齐规则排列 | 不定 |
| 对齐填充(Padding) | 保证对象大小是 8 字节的倍数 | 0-7 字节 |
这看起来好像只是理论课内容,但实际影响很大。举个例子,如果一个类有 9 个boolean字段,JVM 并不会给你打包成一个字节——每个boolean在实例数据区可能占用 1 个字节,再加上对象头和填充,一个极小的对象可能也要占 32 字节。我们有个内存优化项目,把 24 个布尔字段改成int按位存储,对象体积从 200 字节降到 56 字节,GC 压力立刻小了很多。这就是为什么像 RoaringBitmap 这类组件会这么受欢迎——它们把位图的存储压缩到了极致。
Disruptor 框架里有个著名的“缓存行填充”技巧:用 7 个long字段填满 56 字节,让核心字段和相邻对象落到不同的缓存行,避免伪共享(False Sharing)。这正好也是对象内存布局在实践中的反向应用——不是减少字段,而是主动加字段来避免多线程修改相邻字段导致的缓存失效。
所以对象布局不是纯面试知识点,它是实实在在影响内存占用和并发性能的东西。面试官问你“对象由哪几部分组成”,你如果能顺口说出“对象头里 Mark Word 有分代年龄和锁状态,实例数据排列会影响缓存命中率,对齐填充是为了配合 8 字节对齐的硬件访问方式”,基本就能比大多数人多说一层。
3. 对象回收的第一步:怎么判断一个对象已经“该死”
3.1 为什么主流 JVM 不用引用计数法
很多初学者第一次接触 GC 时会想:这不就是给对象加个计数器,有引用就加一,引用失效就减一,归零就回收吗?这个思路本身没问题,很多脚本语言(如 Python 的早期版本)确实用它。但 JVM 没有采用引用计数,原因有两个关键缺陷:
- 循环引用无法回收。两个对象互相引用,且不再被任何外部对象引用,它们的计数都不为零,永远无法被回收。比如 A 持有 B,B 持有 A,外部把这两个对象都丢弃了,引用计数还是 1 和 1,内存泄漏。
- 每次引用变更都要同步更新计数器,带来极大的性能开销,而且在高并发下保证计数的原子性本身就很难做。
循环引用问题在真实业务里一点不稀奇。我之前排查过一个 OOM 的定时任务,就是因为一个链表节点里持有了回调对象,回调对象反过来引用了链表头,清理逻辑只把链表头置空,结果整条链被“死锁”在堆里。用引用计数法,这个问题无解;用可达性分析,只要 GC Roots 不再指向链头,整条链都能被判定为垃圾。
3.2 可达性分析算法与 GC Roots
JVM 采用的可达性分析算法,原理是:从一系列被称为GC Roots的根节点出发,沿着引用链遍历,凡是能到达的对象就标记为“存活”,遍历不到的对象全部判定为“可回收”。这个过程类似在一棵树上点灯——从根节点开始点亮所有相连的节点,没点亮的叶子就是要剪掉的。
那 GC Roots 具体包括哪些?网上很多答案说“栈里的局部变量、静态变量、常量池引用”,这个说法方向对,但不完整。我按 HotSpot 的视角给你列全一点:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。正在执行的方法里的局部变量指向的对象。
- 方法区中静态属性引用的对象。
public static字段指向的对象。 - 方法区中常量引用的对象。字符串常量池里的字符串引用、类常量池里的引用。
- 本地方法栈中 JNI 引用的对象。Java 代码调 Native 代码时传过去的对象。
- JVM 内部的引用。系统类加载器、基本类型的 Class 对象、常驻异常对象(如
NullPointerException的预分配实例)、JMX 回调等。 - 被同步锁(synchronized)持有的对象。
- JVM 平台里的各类回调,比如 Java 虚拟机内部代码的全局集合。
这个列表看着繁琐,但实际排查内存问题时非常有用。后面我会讲“静态集合持有对象导致无法回收”的案例,那个问题本质上就是 GC Roots 层面出错——静态字段本身就是根,根指向的对象永远不会被回收,哪怕业务代码已经不需要它了。
值得补充的是:可达性分析要求“全局暂停用户线程”,因为如果一边遍历对象图一边有线程在修改引用,分析结果就不准确。这就是 STW(Stop The World)的由来。JVM 里所有垃圾收集器在 GC Roots 枚举阶段都必须 STW,区别只在于停顿时间长短。
3.3 四种引用类型在回收里的真实作用
Java 提供了四种引用类型,从强到弱排列:
| 引用类型 | 回收时机 | 典型用途 |
|---|---|---|
| 强引用(Strong Reference) | 永不回收(除非不可达) | 普通new的对象 |
| 软引用(Soft Reference) | 内存充足时保留,OOM 前回收 | 缓存框架 |
| 弱引用(Weak Reference) | 下一次 GC 被回收 | ThreadLocal 的 ThreadLocalMap |
| 虚引用(Phantom Reference) | 随时可能回收,无法通过 get() 获得对象 | 堆外内存回收、CMS 的后置通知 |
这里的核心点是:“引用类型”影响的是对象在可达性分析之后的待遇,而不是判断对象是否是垃圾的唯一标准。强引用对象只要从 GC Roots 可达就一直存活;软引用对象在内存足够时可以存活,在即将 OOM 时会被回收;弱引用对象活不过下一次 GC。
以ThreadLocal为例:如果ThreadLocal是强引用持有,线程存活期间ThreadLocal对象永远不会被回收,而每个线程都有一个ThreadLocalMap,它的 key 是弱引用——这样一旦外部对ThreadLocal的强引用断了,key 就会在下一次 GC 时被回收,value 则留下一个 null 的空槽。这就是ThreadLocal内存泄漏隐患的结构性原因,最终需要通过remove()清理 value。我在生产环境里见过因为线程池中ThreadLocal未清理导致的内存泄漏,场景是用户登录信息存进ThreadLocal,请求结束不清理,线程池复用线程导致下个用户读到上一个人的身份。这不是 GC 的锅,是引用类型设计带来的使用规范问题。
4. 分代回收机制:对象从哪里来,到哪里去
4.1 对象晋升老年代的几个关键条件
HotSpot 把堆分成新生代(Young Generation)和老年代(Old Generation),新生代继续拆成 Eden、Survivor From、Survivor To(默认比例 8:1:1)。绝大多数对象在新生代的 Eden 区诞生,经历若干次 Minor GC 后进入老年代。那“经历若干次”到底是多少次?有哪些条件会把对象提前“赶进”老年代?我把关键条件列一下:
- 年龄阈值(默认 15)。对象每熬过一次 Minor GC,年龄加 1,达到阈值就晋升老年代。阈值可以通过
-XX:MaxTenuringThreshold调整。 - 动态年龄判定。如果 Survivor 区里同年龄对象的大小总和超过 Survivor 空间的一半,年龄大于等于这些对象的对象直接进入老年代,不用等 15 次。这是 HotSpot 的优化,避免 Survivor 空间不够。
- 大对象直接进老年代。
-XX:PretenureSizeThreshold可以设置阈值,超过这个大小的对象直接在老年代分配。目的是避免大对象在新生代里复制来复制去造成开销。 - Survivor 空间不足。Minor GC 时,如果 Survivor 区容纳不下存活对象,多余的对象会通过分配担保机制直接进入老年代。
- TLAB 外部分配。某些大对象在 TLAB 分配失败后也可能因为空间问题直接进入老年代。
这些条件背后有一个共同逻辑:新生代的回收用复制算法,成本与存活对象数量成正比;老年代的回收用标记整理或标记清除,成本与整个区域中的对象数量相关。把存活时间长的对象挪到老年代,是为了让新生代保持“绝大部分对象朝生夕灭”的高效特性。如果你的业务代码大量产生长生命周期对象,却还在新生代反复折腾,GC 就会既频繁又慢。
4.2 常见收集器怎么配合分代策略:Serial、CMS、G1 与 ZGC
收集器的选型直接决定了对象回收的停顿表现。我按“从简单到复杂”的顺序说几个有代表性的:
Serial / Serial Old:单线程收集器,进行 GC 时要暂停所有工作线程。适合单核 CPU、内存小于几百兆的简单应用,没有线程切换开销。现代服务端基本不用了,但在客户端模式或嵌入式场景仍然存在。
Parallel Scavenge / Parallel Old:JDK 8 默认收集器组合,多线程并行收集,关注吞吐量(单位时间内处理请求数)。它的特点是自适应调节——JVM 根据目标的吞吐量自动调堆大小、晋升阈值等参数。如果你的诉求是“压测时吞吐量最大”,Parallel 是合适的。
CMS(Concurrent Mark Sweep):目标是低停顿,收集过程只有初始标记和重新标记两个阶段短暂 STW,并发标记和并发清除阶段可以和业务线程同时跑。CMS 的致命缺点是碎片化——标记清除不整理内存,老年代长期运行后空间碎片严重,触发 Full GC 时停顿不可控。JDK 14 之后 CMS 被移除了,但很多老项目还在 JDK 8 + CMS 组合上跑。
G1(Garbage First):JDK 9 之后的默认收集器。G1 把堆划分成若干 Region,不同 Region 服务于伊甸园、幸存者、老年代,每块都能独立回收。G1 最核心的思想是维护一个优先回收列表,每次回收价值最大的 Region,并利用 Remembered Set 追踪跨 Region 引用。停顿时间可以通过-XX:MaxGCPauseMillis设置目标值——注意这是“目标”不是“保证”,实际停顿还要参考堆大小和对象分配速率。
ZGC(Z Garbage Collector):JDK 15 开始正式支持,基于 Region 和染色指针(Colored Pointers),绝大部分 GC 操作与业务线程并发,停顿时间不超过 10 毫秒,且与堆大小几乎无关。ZGC 用读屏障(Load Barrier)来保证并发期间引用访问的正确性,代价是 CPU 占用更高。
我建议的选型思路是:JDK 8 项目保持 Parallel 或评估迁移到 G1;JDK 11+ 优先 G1;如果延迟敏感且堆内存足够大,考虑 ZGC。不要盲目追求“最新”,收集器的行为特征和你的对象分配模式必须匹配。我们线上一个 4G 堆的业务系统在 JDK 8 上从 Parallel 换到 G1,吞吐量没有明显提升,但停顿更加平滑,接口 p99 显著下降——这就是合理的收益。
4.3 从 GC 日志看对象分配与回收节奏
讲理论不落地没意思。假设我们的服务启用了 G1,GC 日志大概长这样:
[GC pause (G1 Evacuation Pause) (young) (initial-mark) 1024M->512M(2048M), 0.0123456 secs] [GC pause (G1 Humongous Allocation) (young) (initial-mark) 1056M->540M(2048M), 0.0081234 secs] [GC concurrent-root-region-scan-start] [GC concurrent-mark-start] [GC concurrent-mark-end, 0.1002345 secs]第一行(young)表示新生代回收,堆从 1024M 降到 512M,耗时约 12 毫秒。(initial-mark)说明这个年轻代回收附带执行了老年代初始标记,为后续并发标记做准备。第二行是Humongous Allocation——G1 里超过 Region 大小一半(Region 默认 1MB 到 32MB)的对象被视为“巨型对象”,会直接进入老年代的连续 Region 中分配,同时会触发一次 young GC 来腾空间。
通过观察 GC 日志,你会看到几个关键指标:
- 分配速率(Allocation Rate):单位时间内对象占用的内存量,可以用
jstat -gcutil观察E(Eden 区)的变化速率。如果 Eden 区每秒涨几十 MB,说明你的代码在疯狂产生临时对象。 - 晋升速率(Promotion Rate):年轻代对象向老年代增长的速率,关注“老年代会不会过早被打满”。晋升过快说明大多数对象都不该在新生代“长留”。
- GC 耗时:如果 young GC 从几毫秒涨到几十毫秒,往往意味着存活对象太多,复制成本飙升。
线上定位对象分配热点的标准做法是:先看jstat确认 GC 频率和堆使用趋势,再用jmap -histo:live看存量对象类型分布,最后结合-XX:+PrintGCDetails打出明细。下面这段是排查“某服务频繁 Minor GC”时的真实输出:
[GC (Allocation Failure) [PSYoungGen: 611840K->12800K(644096K)] 704512K->171980K(1046528K), 0.0281331 secs] [Times: user=0.05 sys=0.00, real=0.03 secs]新生代回收了约 60 万 K,老年代从约 92M 涨到约 159M,说明这一轮 Minor GC 晋升了约 67M 的对象到老年代——晋升速率明显异常。后来代码审计发现有段循环里调用了远程接口的批量查询,每个结果都手动 new 了一个包装对象,而且包装对象被另一个长期业务容器持有,对象生命周期被强行拉长了三倍。调整后,晋升速率立刻降到几 M 的级别。
5. 实际项目里最常见的三个对象管理坑
5.1 大对象直接进老年代,FGC 频次飙升
很多从没配置过-XX:PretenureSizeThreshold的人会对“大对象直接进老年代”没有概念。举个实际例子:一个支付对账模块,每个对账文件批次要加载 10 万条流水,每条流水是一个包含 30 个字段的FlowRecord对象。这批对象如果同时在内存中,单个对象按 300 字节估算,总内存约 30MB,Tenuring Threshold 还没到,Survivor 区也大概率塞不下——结果就是大批对象通过分配担保直接进入老年代。
问题在什么地方呢?老年代空间有限,如果这类大对象频繁进入,老年代很快就会被撑到触发 Full GC。Full GC 要停所有线程,停顿几十毫秒到几百毫秒不等。解决这类问题的思路有几个方向:
- 减小对象体积。能用基本类型就不用包装类,能用
long就不用BigDecimal存储金额(改为分单位整型)。 - 控制并发加载批次数量。把“一次全量加载”改成“分页游标处理”,限制常驻内存的对象总量。
- 配置合适的
-XX:PretenureSizeThreshold。如果确认某个对象是“短命但巨大”的,反而要避免它直接进老年代,因为大对象复制成本比标记清除成本更容易被摊平。这里要看你用的收集器——在 G1 中,设置-XX:G1HeapRegionSize让 Region 变大,可以让部分大对象留在年轻代处理。
这里我想强调一个日常容易忽略的点:“大对象”不一定指字节数特别大的对象,而是指超过收集器预设阈值(比如 G1 Region 大小的 50%)的对象。一个 1.5MB 的byte[]在默认 1MB Region 的 G1 下就是巨型对象,它会在老年代连续 Region 里分配。一旦这种对象高频出现,老年代空间碎片会加剧,其他正常小对象的分配也容易受干扰。
5.2 静态集合无意间持有了本该回收的对象
这是我最近一个项目里最典型的“教科书级”案例。业务需求是做一个短链接服务,为了性能,启动时把一批“热链接”加载到静态HashMap里,之后热点数据会定期通过定时任务刷新。问题出现在刷新逻辑上:
public class ShortLinkCache { private static Map<String, ShortLinkDO> cache = new HashMap<>(); public static void refresh() { List<ShortLinkDO> newData = loadFromDB(); Map<String, ShortLinkDO> newCache = new HashMap<>(newData.size()); for (ShortLinkDO item : newData) { newCache.put(item.getCode(), item); } // 这里本应重新赋值,但某个版本误写了成 cache.putAll(newCache) cache.putAll(newCache); } }如果只调用putAll而不清空旧cache,那么旧数据和新数据的对象引用同时被静态 Map 持有。静态字段是 GC Roots 的一种,这意味着这些对象永远不会被 GC 回收。短链接每日新增几百万,每次刷新都会把前一天的旧链接对象“钉”在内存里。两周之后,堆使用量上升了 4 倍,老年代涨到 70%,GC 从每分钟几次变成每几秒一次。
排查链路也很典型:先用jstat -gcutil看到老年代持续增长且回收不掉;再用jmap -dump:format=b,file=heap.hprof导出堆快照;用 MAT(Memory Analyzer)的 Dominator Tree 找大对象,发现ShortLinkCache.cache挂了几千万个ShortLinkDO。定位到问题后,修掉putAll,改成cache = newCache整体替换,马上就好了。
这里的核心教训是:只要对象被 GC Roots 直接或间接引用,它在可达性分析中就永远是活的,回收器不会管“你的业务逻辑觉得它已经不必要了”。开发者在设计静态缓存时,一定要考虑容量上限和替换策略。LinkedHashMap的removeEldestEntry、ConcurrentHashMap的批量替换方式,都比无脑putAll安全得多。
5.3 System.gc() 调用:不是盖章生效的“内存清理键”
很多团队遇到“内存不够”的第一反应是在代码里调System.gc(),这在我看过的项目里出现频率高得惊人。希望读者记住一句话:System.gc()只是“建议” JVM 执行一次垃圾收集,而不是命令。是否真的触发 GC,取决于 JVM 参数和收集器策略。
HotSpot 对System.gc()的处理有几个细节:
- 默认情况下,调用
System.gc()会触发一次Full GC。在 G1 下是 Full GC(会 STW),在 Parallel 下也是 Full GC。 - 如果设置了
-XX:+DisableExplicitGC,则System.gc()调用被完全忽略,变成空操作。 - 在 CMS 时代,
System.gc()还可能触发一次 CMS GC,而不是真正的 Full GC,行为取决于参数。
我们有个海量导出任务,代码在循环体里隔三岔五调用System.gc(),本意是“防止堆爆”,结果每次调用都带来一次全局停顿,直接拖垮了服务的吞吐量。后来把System.gc()全部删掉,用-Xmx和-Xms把堆拉平,让 JVM 自己根据分配速率决定 GC 节奏,压测数据反而更好。
如果你真需要在特定节点回收内存(比如批量任务结束后),我建议优先考虑:
- 对批量数据用流式处理或分批提交,而不是一次性灌进内存。
- 使用弱引用 +
ReferenceQueue主动追踪对象被回收的信号。 - 触碰堆外内存时,用
DirectByteBuffer的cleaner机制或 Netty 的引用计数工具管理生命周期,而不是依赖 GC 去帮你兜底。
5.4 一个小工具组合:jmap 与 jstat 定位对象堆积
排查对象相关问题时,jmap和jstat是最基础也最有效的组合。我把常用的命令和适用场景列成一张表,方便收藏:
| 命令 | 用途 | 关键输出 |
|---|---|---|
jstat -gcutil <pid> 1000 20 | 每秒输出一次堆各区域使用率和 GC 次数 | 新生代、老年代占用率,FGC 次数 |
jmap -histo:live <pid> | 打印堆中存活对象的数量和大小 | Class名、实例数、字节数 |
jmap -dump:format=b,file=heap.hprof <pid> | 导出堆快照,供 MAT 分析 | 对象引用图、Dominator Tree |
jcmd <pid> GC.heap_dump | JDK 7 之后推荐的堆导出替代命令 | 和 jmap dump 类似,路径更可控 |
实际操作中,我会先跑几轮jstat确认老年代是不是“只涨不跌”,再jmap -histo:live看堆积的类是什么。如果发现某个业务类实例数异常庞大,就jmap -dump导出快照,用 MAT 查它的引用链——从 GC Roots 看看到底是哪条链路把它拽住了。
有一个容易被忽略的细节是:jmap -histo:live会触发一次 Full GC。在线上执行这个命令时要注意影响,尽量在低峰期做。而jmap -dump一般不会强制 Full GC(新版本里可以配置-dump:live选项),但 dump 本身就可能耗时数十秒,内存大的堆还会让磁盘 IO 飙升,同样要谨慎。
堆 vs 非堆的问题也顺便提一句。方法区(元空间)如果被类加载器泄漏占满,表现是OutOfMemoryError: Metaspace,这类问题通常不在对象回收范畴内,而是动态生成类(CGLIB、反射代理)导致的。定位方法是用-XX:MaxMetaspaceSize限制元空间,再周期性 dump 检查增长的类加载器。我见过一个用 ASM 生成动态类的框架,每创建一个用户规则就生成一个类,结果元空间被撑爆——从对象角度排查了三天,最后发现根本不是堆的问题。
6. 针对“对象创建与回收”的面试题串讲
聊到这里,其实很多 Java 面试八股已经被串进去了。我按面试官常见的提问顺序整理一下,你可以当作复习 Check List 用。
第一题:Java 对象创建的几种方式?答题时别只答“new、反射、克隆、反序列化”,把Unsafe.allocateInstance()和构造器引用也带上,并且说明反射底层走的是Constructor.newInstance(),绕过构造器的只有克隆、反序列化和 Unsafe。加分项是提到“Spring 单例 Bean 用的是反射创建,MyBatis 返回结果用的是反射+构造器”。
第二题:对象在 JVM 中是怎么存储的?回答对象头(Mark Word 和类型指针)、实例数据、对齐填充三部分,再解释 Mark Word 可能存放的内容(哈希码、分代年龄、偏向锁标记等)。有经验的人还会提到指针压缩(-XX:+UseCompressedOops)对内存占用和 GC 扫描速度的影响。
第三题:如何判断一个对象可以被回收?不要一上来就只答“引用计数”然后被吊打。从“为什么 JVM 不用引用计数”切入,讲循环引用问题,再引出可达性分析,最后把 GC Roots 的种类列全。可以顺便提到软引用、弱引用、虚引用的回收时机差异——ThreadLocalMap的 key 是弱引用这是一个非常经典的追问点。
第四题:什么时候触发 Minor GC / Major GC / Full GC?Minor GC 通常发生在 Eden 区空间不足时;Major GC 指老年代 GC(很多语境下与 Full GC 混用,建议面试时先明确术语);Full GC 发生在老年代空间不足、元空间不足、System.gc()被调用且未禁用等场景。G1 里巨型对象分配也可能触发 young GC,这是个容易被遗漏的细节。
第五题:类的加载过程是什么?虽然题目偏“类加载”而不是“对象创建”,但对象创建的第一步就是类加载。回答加载、验证、准备、解析、初始化五个阶段时,重点区分“准备”阶段分配的是静态变量的零值,“初始化”阶段执行的是<clinit>和静态变量赋值。对象实例字段的初始化在<init>构造方法里,不要混在一起。
我个人的建议是:面试回答不要死记硬背定义,而是每个问题都带上“为什么这样设计”和“实际开发中怎么体现”。比如聊到对象头时,可以联系锁升级(偏向锁、轻量级锁、重量级锁)过程中 Mark Word 的变化;聊到分代年龄时,可以联系-XX:MaxTenuringThreshold调优对 GC 停顿的影响。这样既展现实战感,也让整场问答更自然。
说到最后,我在实际项目里的体会是:JVM 的对象管理并不玄学,它是一套环环相扣的工程取舍。创建方式决定了对象在堆中的初始位置和本轮 GC 的分配成本,内存回收机制决定了对象的存活期限和潜在风险。排查线上问题的时候,先把“对象是怎么来的”和“对象被谁持有”这两件事理清楚,90% 的内存和 GC 问题都能有个明确的方向。代码里少一点毫无意义的临时对象、少一点静态缓存的无脑累积,比调任何一个 GC 参数都管用。