新生代垃圾回收频繁发生,如果每次都扫描整个堆找出存活对象,性能将极差。那么 JVM 是如何巧妙避免这一问题的?
跨代引用:新生代垃圾回收的挑战
在 JVM 分代回收机制中,新生代对象存活率低,垃圾回收频繁。但存在一个关键问题:当老年代对象引用新生代对象时,只回收新生代时必须知道哪些对象被老年代引用着,否则可能错误回收"活"对象。
最简单的方法是扫描整个老年代,但这会导致本应快速完成的新生代回收变得缓慢,特别是当堆内存较大时。

记忆集:避免全堆扫描的关键
JVM 引入了"记忆集"(Remembered Set)来解决这个问题。记忆集记录了从非收集区域指向收集区域的所有引用信息。对于新生代 GC,记忆集记录了哪些老年代区域可能存在指向新生代的引用。
在进行新生代回收时,只需要将记忆集作为额外的 GC Roots 扫描,而不需要遍历整个老年代。
卡表:记忆集的高效实现
HotSpot 虚拟机使用"卡表"(Card Table)实现记忆集,其原理是:
- 将整个堆划分为固定大小的卡页(Card Page),通常为 512 字节
- 使用一个字节数组作为卡表,每个元素对应一个卡页
- 当某卡页中有对象存在跨代引用时,对应卡表元素被标记为"脏"

如上图所示,卡页 2 中有对象引用了新生代对象,对应的卡表元素被标记为"脏"。在新生代 GC 时,只需要扫描卡表中标记为"脏"的卡页,大大减少了扫描范围。
卡表的跟踪精度问题
由于卡页的大小(如 512 字节)远大于一个对象引用的大小(4 或 8 字节),一个卡页内可能包含多个对象。如果这些对象中只有一个发生了跨代引用,整个卡页也会被标记为脏。在 GC 时,扫描线程需要扫描这个卡页内的所有对象,以找出那个真正的跨代引用。
这种由于跟踪粒度较粗而导致的额外扫描,其根本原因与 CPU 缓存中的"伪共享"(False Sharing)问题类似------不相关的数据项因处于同一个管理单元(卡页/缓存行)而互相影响。虽然存在这种精度损失,但其带来的巨大性能提升远超这点开销。
卡表标记的单向性
值得注意的是,标记卡表为"脏"通常是一个单向操作。一旦卡页被标记为脏,即使导致该标记的跨代引用后来被设置为null
,该卡页仍然保持脏状态。卡表只有在垃圾回收周期(如 Young GC)完成并扫描了所有脏卡后才会被重置和清理。
这种设计简化了写屏障逻辑,因为它不需要跟踪引用的移除,进一步减少了对应用线程的开销。但这也意味着,如果应用程序频繁地创建后又移除跨代引用,脏卡页可能会累积,导致扫描工作增加。
写屏障:维护卡表的机制
卡表的维护是通过"写屏障"(Write Barrier)技术实现的。写屏障是 JVM 在引用类型字段赋值时自动插入的一段代码,用于在跨代引用发生时更新卡表。
java
// 写屏障的伪代码实现
void oop_field_store(oop* field, oop new_value) {
// 步骤1:执行原始的字段赋值操作
*field = new_value;
// 步骤2:写屏障逻辑 - 检查是否产生跨代引用
// is_old_gen(field): 判断持有引用的对象是否在老年代
// is_young_gen(new_value): 判断被引用的新对象是否在新生代
if (is_old_gen(field) && is_young_gen(new_value)) {
// 步骤3:如果满足条件,则将持有引用的对象所在的内存卡页标记为"脏"
// card_table->mark_card_as_dirty(...): 在卡表中执行标记操作
card_table->mark_card_as_dirty((HeapWord*)field);
}
}
虽然写屏障解决了 GC 时的全堆扫描问题,但它本身是有成本的,会轻微降低应用程序的执行速度。这是一种用少量运行时开销换取 GC 停顿时间显著缩短的典型"空间换时间"思想。
写屏障的执行流程:

脏卡队列:减少应用线程负担
JVM 中通常存在一个"脏卡队列"(Dirty Card Queue)。当应用线程通过写屏障把一个卡标记为"脏"时,并不会立即处理,而是将这个脏卡的标识放入一个队列中。GC 线程在开始工作前,会先处理这个队列,将这些信息更新到记忆集中,从而实现应用线程和 GC 线程的解耦,减少对应用线程的影响。
实际案例:频繁跨代引用问题
以下是一个可能导致大量跨代引用的示例代码:
java
import java.util.ArrayDeque;
import java.util.Deque;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class CrossGenReferenceExample {
private static final Logger logger = LoggerFactory.getLogger(CrossGenReferenceExample.class);
// 静态缓存,通常会进入老年代
private static final Deque<Object[]> CACHE = new ArrayDeque<>();
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
processData();
// 注意:仅用于演示目的,生产环境不应使用System.gc()
// 因为这只是一个建议而非强制命令,且可能导致不可预测的长时间STW
if (i % 100 == 0) {
logger.info("触发GC第{}次", i / 100);
System.gc();
}
}
}
private static void processData() {
// 创建新的数据对象(在新生代)
Object[] newData = new Object[100];
for (int i = 0; i < 100; i++) {
newData[i] = new byte[1024]; // 1KB的数据
}
// 将新生代对象添加到老年代缓存中,产生跨代引用
CACHE.addLast(newData);
// 限制缓存大小
while (CACHE.size() > 100) {
CACHE.removeFirst(); // 使用ArrayDeque的O(1)操作代替ArrayList的O(n)操作
}
}
}
这个例子中,CACHE
作为静态字段长期存活,会被提升到老年代。每次processData()
都会创建新对象并添加到CACHE
中,形成从老年代到新生代的引用,导致大量卡表标记操作。
优化方案:
java
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
// 自定义的WeakReference子类,持有其在Map中的key
class KeyedWeakReference<T> extends WeakReference<T> {
public final String key;
public KeyedWeakReference(String key, T referent, ReferenceQueue<? super T> q) {
super(referent, q);
this.key = key;
}
}
public class OptimizedExample {
private static final Logger logger = LoggerFactory.getLogger(OptimizedExample.class);
// 使用ConcurrentHashMap实现线程安全的缓存,存储自定义的KeyedWeakReference
private static final ConcurrentHashMap<String, KeyedWeakReference<Object[]>> CACHE = new ConcurrentHashMap<>();
private static final ReferenceQueue<Object[]> REF_QUEUE = new ReferenceQueue<>();
private static final int MAX_CACHE_SIZE = 100;
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
processData();
if (i % 100 == 0) {
logger.info("缓存大小: {}", CACHE.size());
}
}
}
private static void processData() {
// 必须在添加新元素前调用清理,防止在大小检查上出现竞态条件
cleanCache();
if (CACHE.size() >= MAX_CACHE_SIZE) {
// 实际应用中应实现真正的淘汰策略(如LRU、LFU)
// 本例简单起见,达到上限就不再添加
logger.warn("缓存已满,不再添加新数据");
return;
}
Object[] newData = new Object[100];
for (int i = 0; i < 100; i++) {
newData[i] = new byte[1024];
}
// 生成唯一键并存储带键的弱引用
// 注意:此优化仅适用于这些对象可以被回收的缓存场景
String key = UUID.randomUUID().toString();
CACHE.put(key, new KeyedWeakReference<>(key, newData, REF_QUEUE));
}
// 使用KeyedWeakReference高效清理被回收的弱引用
// 在实际应用中,这可以由专门的清理线程定期执行
private static void cleanCache() {
Reference<?> ref;
while ((ref = REF_QUEUE.poll()) != null) {
// 通过instanceof确保类型安全
if (ref instanceof KeyedWeakReference) {
KeyedWeakReference<?> keyedRef = (KeyedWeakReference<?>) ref;
CACHE.remove(keyedRef.key); // O(1)复杂度的移除操作!
logger.info("从缓存中清理了一个过期引用,键为: {}", keyedRef.key);
}
}
}
}
G1 垃圾收集器的记忆集实现
G1 收集器使用更复杂的记忆集实现,称为 RSet(Remembered Set)。G1 将堆划分为多个相等大小的区域(Region),每个 Region 都有自己的 RSet,用于记录其他 Region 中对象对本 Region 中对象的引用。
与卡表记录"我(老年代)指向谁(新生代)"(出向引用 )不同,G1 的 RSet 记录的是"谁指向我"(入向引用)。每个 Region 的 RSet 都记录了来自其他 Region 的、指向本 Region 内对象的引用。这种设计的粒度更细,但 RSet 本身也占用了更多内存,并且维护起来更复杂,需要更复杂的写屏障逻辑(G1 使用了 Pre-Write Barrier 和 Post-Write Barrier 两种屏障)。
G1 的 RSet 采用了哈希表的形式,记录了每个引用的精确位置,而不仅仅是一个标记位,使得 G1 能够实现更精确的增量回收。

值得一提的是,这些技术(尤其是卡表和写屏障)是 HotSpot 虚拟机中 Parallel Scavenge、CMS、G1 等主流分代收集器的共同基础。而像 ZGC、Shenandoah 等不采用分代或采用不同屏障技术的收集器,则有不同的实现机制(如读屏障)。
启示
-
理解短期与长期对象: 设计应用时,应有意识地区分对象的生命周期。尽量避免长期存活的对象(尤其是在老年代中的集合、缓存)大量、频繁地引用生命周期极短的新生代对象。
-
谨慎使用大缓存 : 全局静态缓存是跨代引用的重灾区。如果必须使用,考虑使用
WeakReference
或SoftReference
来减弱引用关系,或采用如Google Guava Cache 或更现代的Caffeine 等高性能缓存库。这些库已经为您封装了弱引用/软引用、基于ReferenceQueue
的自动清理、线程安全以及复杂的淘汰策略(如 LRU、LFU),是生产环境中的首选方案,可以避免重复造轮子。 -
性能调优视角 : 当观察到 Young GC 时间异常长时,除了分析新生代对象分配情况,也应怀疑是否存在大量跨代引用导致"脏卡"扫描耗时过长。通过 GC 日志(如 G1 的
GC-Card-Table-Scanned
)可以进行佐证。
总结
技术 | 作用 | 实现方式 |
---|---|---|
记忆集 | 记录跨代引用信息 | 卡表、RSet 等数据结构 |
卡表 | 标记可能存在跨代引用的内存区域 | 堆分卡页,字节数组标记 |
写屏障 | 实时维护记忆集 | 引用赋值操作时自动执行的额外代码 |
脏卡队列 | 减少应用线程负担 | 应用线程放入队列,GC 或辅助线程异步处理 |
卡表标记单向性 | 简化实现,降低开销 | 一旦标记为脏,仅在 GC 后重置 |
跟踪精度问题 | 卡表精度损失带来的开销 | 多个对象共享卡页导致额外扫描 |
应用层优化 | 减少跨代引用 | 合理管理对象生命周期,使用弱引用 |