复习一下:垃圾回收主要发生在 JVM 堆,也就是堆内存。堆内存和方法区共同组成线程共享区域,其中 方法区 主要存放 类的元数据 主要存放 对象实例。方法区在 JDK 1.8 之后对应的是元空间(Metaspace),它位于本地内存中,不再直接受传统 JVM 堆大小的限制,但依然会受到机器总内存的约束。

类和对象的创建过程?线程私有区域有哪些?分别有什么用?


JVM内存总图

JVM 堆主要存放对象。从分代视角来看,堆通常分为新生代和老年代;如果放到 JDK 1.8 之前的语境里,还会提到永久代,这里暂时不展开。新生代内部又会继续拆成 Eden 区和两个 Survivor 区(通常记作 S0、S1 或 From、To)。

分代回收机制_3代

之所以要做分代,是因为不同对象的生命周期差别很大。大多数对象“朝生夕死”,少量对象会长期存活,因此把它们放在不同区域、采用不同算法,回收效率会更高。

  1. Minor GC / Young GC:当 Eden 区放不下新创建的对象时触发,主要负责新生代回收。Minor GC 一般采用标记-复制算法,因为新生代里大部分对象都活不久,适合直接把少量存活对象复制到另一块空闲 Survivor 区。对象每经历一次 Minor GC,年龄通常会加 1;当年龄达到阈值,或者 Survivor 放不下时,对象就可能晋升到老年代。
  2. 动态年龄计算:并不是所有对象都必须机械地等到 MaxTenuringThreshold 才进入老年代。JVM 会统计 Survivor 区中各年龄对象的总大小,当某个年龄及以上对象的累计大小超过 Survivor 区一半时,取 min{该年龄, 年龄阈值} 作为新的晋升基准。这样可以更灵活地控制晋升,避免 Survivor 被长期挤爆。除此之外,如果对象本身特别大,也可能直接进入老年代。
  3. Major GC / Old GC:通常指针对老年代的回收。这个说法在不同收集器实现里并不总是严格统一,面试里把它理解成“主要回收老年代”即可。
  4. Full GC:整堆回收,不仅会处理新生代和老年代,通常也会顺带回收方法区 / 元空间。常见触发场景包括 老年代空间不足、元空间不足、分配担保失败、显式调用 System.gc() 等。Full GC 的代价通常很高,停顿时间也更明显,因此线上排查时一般都会重点关注它。

为什么要分代

默认情况下,绝大多数垃圾收集器都会采用分代收集思想,本质原因就是“对象存活时间不一样,适合的回收方式也不一样”。

  • 新生代中的对象存活时间通常比较短,垃圾对象很多,所以更适合复制算法。这样每次只需要复制少量存活对象,效率往往很高。
  • 老年代中的对象存活时间通常比较长,存活率更高,如果还使用复制算法,成本就会明显偏大,因此更常搭配标记-清除标记-整理算法。
  • 例如 CMS 垃圾收集器主要基于标记-清除,而 Serial Old 更偏向标记-整理。

方法区的回收

方法区的回收主要包含两部分:运行时常量池回收类卸载。常量池里会放字符串字面量、类型/字段/方法的符号引用等内容,这部分回收通常会伴随类卸载一起发生。类卸载并不是“类不用了就立刻卸载”,条件相对苛刻,所以在实际运行过程中,方法区虽然也会回收,但频率通常远低于堆。

引用和死亡

垃圾回收要先解决一个问题:谁该死,谁还活着。也就是说,GC 在真正回收对象前,得先判断对象是否还存活。

死亡判断方法_2种

常见判断思路有两种:

  1. 引用计数法:给对象维护一个引用计数器,每被引用一次就加 1,引用失效就减 1,计数为 0 时说明对象可回收。它实现简单,但无法很好解决循环引用问题,因此主流 JVM 基本不采用它来做对象生死判断。
  2. 可达性分析法:从一组固定的起点出发向下搜索,这些起点叫作 GC Roots。如果一个对象能从 GC Roots 直接或间接到达,就说明它还存活;反之,如果不可达,就可以判定为垃圾对象。

可以作为 GC Roots 的对象通常包括:

  1. 虚拟机栈中引用的对象,也就是各个栈帧局部变量表里正在使用的对象。
  2. 本地方法栈中被 Native 方法引用的对象。
  3. 方法区中类静态属性引用的对象。
  4. 方法区中常量引用的对象。
  5. 所有被同步锁持有的对象
  6. JNI(Java Native Interface)引用的对象

引用类型_4种

Java 里常见的引用强度从强到弱依次是:强引用、软引用、弱引用、虚引用。不同引用类型决定了对象在 GC 面前“有多容易被回收”。

  1. 强引用:程序里最常见的引用赋值就是强引用,例如 Object obj = new Object()。只要强引用还存在,垃圾回收器就不会回收这个对象。
  2. 软引用:比强引用弱一些,主要适合做内存敏感型缓存。内存足够时,软引用关联的对象通常不会被回收;内存吃紧时,GC 会优先清理这些对象,以尽量避免 OOM。比如图片缓存、可重建缓存等场景就比较适合使用软引用。下面这个例子需要配合 -Xmx64m 来观察效果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 1. 创建软引用对象
Object softObj = new Object();
SoftReference<Object> softRef = new SoftReference<>(softObj);
System.out.println("初始状态 - 软引用对象存在: " + (softRef.get() != null));

// 2. 解除强引用,但软引用仍保留对象(内存充足时)
softObj = null;
System.gc(); // 建议 GC,但软引用在内存充足时通常不会被回收
try { Thread.sleep(100); } catch (InterruptedException e) {}

System.out.println("GC后(内存充足)- 软引用对象仍存在: " + (softRef.get() != null));

// 3. 模拟内存压力(触发软引用回收)
System.out.println("\n尝试制造内存压力...");
try {
// 分配大数组制造内存压力(根据 JVM 堆大小调整)
byte[][] memoryHog = new byte[1024][];
for (int i = 0; i < 1024; i++) {
memoryHog[i] = new byte[1024 * 1024]; // 每次分配 1MB
}
} catch (OutOfMemoryError e) {
System.out.println("内存压力已触发: " + e.getMessage());
}

System.gc();
try { Thread.sleep(100); } catch (InterruptedException e) {}

System.out.println("内存压力后 - 软引用对象状态: " + (softRef.get() != null));

// 当内存不足时,JVM 会自动清理软引用缓存,尽量避免 OOM
  1. 弱引用:只要垃圾回收器一运行,无论当前内存是否充足,弱引用关联的对象通常都会被回收。它比软引用更“脆弱”,适合表达“有最好,没有也无所谓”的关联关系。
1
2
3
4
5
Object weakObj = new Object();
WeakReference<Object> weakRef = new WeakReference<>(weakObj);
weakObj = null; // 解除强引用
System.gc(); // 尝试进行垃圾回收
System.out.println("Weak reference after GC: " + weakRef.get()); // 此时很可能为 null

WeakHashMap 的键就是弱引用,因此它常被用来做“键可自动失效”的缓存结构。当键不再被强引用时,WeakHashMap 会自动移除对应条目。典型例子包括 Class 对象及其关联元数据,或者 ClassLoader 和相关类信息。

ThreadLocal 里的 ThreadLocalMap 也用到了弱引用,它的 key 是 ThreadLocal 实例对象(记作 tl)。但它和 WeakHashMap 的行为并不一样:即使执行了 tl = null,tl 对应的 value 也可能暂时留在线程私有的 ThreadLocalMap 中,直到后续再次发生 set/get/remove 等操作时才被顺手清理;而 WeakHashMap 会借助 ReferenceQueue 感知 key 已失效,再把对应 Entry 惰性清掉,所以 value 也会随之消失。这也是为什么 ThreadLocal 使用不当容易造成“看起来 key 没了,但 value 还在”的内存泄漏问题。

  1. 虚引用:虚引用是四种引用里最弱的一种,必须和引用队列 ReferenceQueue 配合使用。它本身几乎不影响对象生命周期,主要作用是跟踪对象即将被回收的时机,常用于比直接依赖 finalize 更安全的资源释放通知机制。

当垃圾回收器准备回收一个对象时,如果发现它还关联着虚引用,就会在真正回收对象内存之前先把这条虚引用放入关联的引用队列中。程序就可以通过这个队列感知“对象马上要被 GC 了”,然后执行额外的清理动作。

ReferenceQueue

ReferenceQueue 就是引用队列,通常与软引用 SoftReference弱引用 WeakReference虚引用 PhantomReference 配合使用。它可以提供一种“通知机制”,让程序通过 poll()remove() 及时得知:某个对象虽然还不能直接拿到,但已经被 GC 关注,或者已经进入可回收阶段。

.get()SoftReferenceWeakReferencePhantomReference 这些引用对象的方法,它们都继承自 Reference。调用 .get() 的作用是尝试拿到它所关联的实际对象;对于软引用和弱引用,如果对象还没被回收,就能拿到对象,否则得到 null;而虚引用无论何时调用 .get(),结果都始终是 null

ReferenceQueue 本身更像一个“回收通知队列”。引用对象在创建时可以和它绑定,等到 GC 到了合适的时机,就会把对应的引用对象塞进队列里,程序再通过 poll() 非阻塞获取,或者通过 remove() 阻塞等待。也就是说,.get() 是“拿对象”,poll()/remove() 是“拿已经入队的引用对象”。

一个最常见的配合方式如下:

1
2
3
4
5
6
7
8
9
ReferenceQueue<Object> queue = new ReferenceQueue<>();
WeakReference<Object> weakRef = new WeakReference<>(new Object(), queue);

System.gc();

Reference<? extends Object> ref = queue.poll();
if (ref != null) {
System.out.println("有引用进入队列,说明对应对象已经进入可回收/已回收阶段");
}

需要区分一点:

  • 软引用弱引用一般是在对象被回收后入队,因此这时已经无法再通过 .get() 拿到对象。
  • 虚引用是在对象真正回收前入队,但无论什么时候调用 .get(),得到的都始终是 null

垃圾回收算法

下面这些垃圾回收算法,本质上都是针对某一块内存区域使用的,例如新生代、老年代,或者某些收集器划分出的逻辑分区。

标记-清除 标记-整理 复制
速度/效率 中等 最慢 最快
空间开销 少(有碎片) 少(无碎片) 最多(无碎片)
是否需要移动对象(修改引用)

STW(Stop The World)指的是垃圾回收过程中,用户线程需要暂停一段时间。不同收集器只是“停多久”和“哪些阶段会停”不一样,并不是完全没有停顿。

标记-清除 Mark-Sweep 算法

标记-清除的核心思想很直接:先找出哪些对象活着,再把剩下的垃圾清掉。

  1. 标记阶段:从 GC Roots 出发,遍历出所有可达对象,并在对象头或其他元数据结构中打上“存活”标记。
  2. 清除阶段:线性扫描整块内存,把没有标记的对象释放掉。

它的优点是实现相对简单,而且不需要移动存活对象;缺点是通常要扫描两遍内存,并且回收后容易产生内存碎片。碎片多了以后,即使总空闲空间够,也可能因为没有足够大的连续空间而触发额外 GC。

复制 Copying 算法

复制算法的思路是“与其清理垃圾,不如直接搬活人”。

  1. 把内存划分成两块大小相近的区域,例如 S0 和 S1。
  2. 平时只使用其中一块。
  3. 回收时,把当前区域里的存活对象复制到另一块空闲区域,然后直接清空原区域。
  4. 下一轮回收时,再交换两块区域的角色。

它的优点是速度快、没有内存碎片,特别适合“垃圾多、存活对象少”的场景,也就是新生代;缺点是需要额外预留一块空闲空间,并且复制对象时还要修改相关引用。因此从遍历角度看,它通常只需要重点处理存活对象,而不像标记-清除那样既要标记又要清理整片区域。

copying

标记-整理 Mark-Compact 算法

标记-整理可以看成是“标记-清除”的改良版:先标记存活对象,再把它们往一端压缩,最后把边界以外的空间整体清理掉。

  1. 标记:和标记-清除一样,先找出存活对象。
  2. 整理:把所有存活对象移动到内存的一端,让它们连续排布。
  3. 清理:清掉边界之外的整段空间。

这样做的优点是不会产生内存碎片,也不需要像复制算法那样长期空出一半空间;缺点是整理过程要移动对象,成本更高,停顿时间一般也更长,所以更常见于老年代回收。

垃圾收集器

垃圾收集器

可以把垃圾收集器理解成“算法的工程化实现”。算法解决的是“怎么回收”,收集器解决的是“在什么区域、用什么线程模型、以什么停顿目标去回收”。

  1. Serial GC / Serial Old GC:分别用于新生代和老年代,通常采用复制算法和标记-整理算法,特点是单线程、实现简单,适合内存不大或客户端模式下的场景。
  2. ParNew GC / CMS GC:ParNew 可以理解为 Serial 的多线程新生代版本;CMS 以低停顿为目标,经典流程包括初始标记、并发标记、重新标记、并发清除。CMS 的缺点是会产生内存碎片,而且对 CPU 更敏感,后续也逐渐退出主流舞台。
  3. Parallel Scavenge GC / Parallel Old GC:同样强调多线程回收,但它更关注吞吐量,也就是用户代码执行时间占总时间的比例,因此常见于更偏后台计算的场景。
  4. G1(Garbage First):把整个堆划分成很多大小相等的 Region,不再死板地区分一整块新生代和老年代,而是按 Region 维护回收价值列表,在满足停顿目标的前提下优先回收收益更高的区域。它兼顾吞吐量和停顿控制,是现代 JVM 中非常常见的默认选择。
  5. ZGC:目标是把停顿时间压得更低,适合超大堆内存场景。补充阅读:https://mp.weixin.qq.com/s/Ywj3XMws0IIK-kiUllN87Q

整堆回收

很多时候会把整堆回收和 Full GC 近似地放在一起理解,但实际语境里最好稍微分开:

  • Young GC / Minor GC 主要只处理新生代。
  • Mixed GC(例如 G1 中)会在回收新生代的同时,顺带挑一部分回收价值高的老年代 Region。
  • Full GC / 整堆回收 才是真正意义上代价最大的那类回收,往往需要扫描整个堆,甚至连方法区/元空间也一起考虑进去。

也正因为 Full GC 成本很高,线上一旦频繁出现,往往就意味着堆配置、对象生命周期、缓存策略或者代码分配行为存在问题。

常见参数

内存参数

  1. -Xms<size>:等同于 -XX:InitialHeapSize=<size>,用于设置 JVM 启动时的初始堆内存大小,例如 java -Xms512m MyWebApp
  2. -Xmx<size>:等同于 -XX:MaxHeapSize=<size>,用于设置 JVM 运行时最大堆内存大小,例如 java -Xmx2g MyWebApp。通常会让 -Xms-Xmx 设成一样,这样可以避免 JVM 在运行过程中反复扩缩容。
  3. -Xmn<size>:等同于 -XX:NewSize=<size>,用于设置新生代初始大小。
  4. -XX:NewRatio=<n>:配置新生代和老年代的比例。默认值通常是 2,也就是新生代约占整个堆的 1/3,老年代约占 2/3。如果明确知道长生命周期对象较多,可以适当调大老年代比例。
  5. -XX:SurvivorRatio=<n>:配置 Eden:S0:S1 的比例关系,默认常见值是 8,表示 Eden:S0:S1 = 8:1:1。
  6. -XX:MetaspaceSize=<size>-XX:MaxMetaspaceSize=<size>:分别配置元空间的初始阈值和最大容量。默认情况下元空间会按需扩容,直到接近系统内存上限。

垃圾回收参数

  1. -XX:+UseSerialGC:启用 Serial + Serial Old 收集器组合。
  2. -XX:+UseParNewGC-XX:+UseConcMarkSweepGC:启用 ParNew 和 CMS 组合。
  3. -XX:+UseParallelGC:启用 Parallel Scavenge + Parallel Old 组合。
  4. -XX:+UseG1GC-XX:+UseZGC:分别启用 G1 与 ZGC。
  5. -XX:MaxGCPauseMillis=<n>:常见于 G1 调优中,用来给 JVM 一个期望停顿时间目标,但它是“目标”而不是强保证。

监控排查

  1. -XX:+PrintGC-XX:+PrintGCDetails:用于打印 GC 日志,后者更详细。老版本 JDK 中比较常见。

  2. -XX:+PrintCommandLineFlags:打印 JVM 最终采用的命令行参数,适合排查启动参数是否真的生效。

  3. -Xlog:gc*:在较新的 JDK 版本里更推荐使用这类统一日志参数来观察 GC 过程。

  4. jstat -gc <pid>:这里的 -gc 表示查看一组综合 GC 统计信息,包括各代空间使用情况、Young GC / Full GC 次数和耗时,适合第一时间判断“GC 是不是太频繁了”“老年代是不是一直在涨”。例如 jstat -gc 12345 1000 10 表示每 1 秒采样一次,共看 10 次。常见字段里,YGC / YGCT 表示 Young GC 的次数和总耗时,FGC / FGCT 表示 Full GC 的次数和总耗时,OU 则表示老年代当前已使用空间。常见的变体还有 jstat -gcutil <pid>(更直观看各区使用率)、jstat -gccause <pid>(看 GC 原因)、jstat -class <pid>(看类加载情况)。

  5. jmap -heap <pid>:查看当前堆配置和各代分布,比如堆总大小、新生代大小、Eden / Survivor 比例、使用的垃圾收集器等,更适合做“静态快照式”的确认,例如想知道线上 JVM 到底开的多大、用的是哪种 GC。它偏重结构信息,不像 jstat 那样适合连续观察变化。

  6. jcmd <pid> GC.heap_info:查看堆和 GC 的概要信息,通常比 jmap 更轻量,也更推荐优先尝试。除了 GC.heap_info,实际排查时还常见 jcmd <pid> GC.class_histogram 用来查看实例数量最多的对象,或者 jcmd <pid> VM.flags 看最终生效的 JVM 参数。

  7. 一个常见排查思路是:先用 jstat -gc 观察趋势,确认问题是 Young GC 过多、Full GC 频繁,还是老年代回收不上来;再用 jcmdjmap 看堆布局和对象分布,最后结合 GC 日志进一步定位是参数问题、缓存打满,还是代码里确实有对象泄漏。