JVM 新生代垃圾回收:避免全堆扫描的核心技术

新生代垃圾回收频繁发生,如果每次都扫描整个堆找出存活对象,性能将极差。那么 JVM 是如何巧妙避免这一问题的?

跨代引用:新生代垃圾回收的挑战

在 JVM 分代回收机制中,新生代对象存活率低,垃圾回收频繁。但存在一个关键问题:当老年代对象引用新生代对象时,只回收新生代时必须知道哪些对象被老年代引用着,否则可能错误回收"活"对象。

最简单的方法是扫描整个老年代,但这会导致本应快速完成的新生代回收变得缓慢,特别是当堆内存较大时。

记忆集:避免全堆扫描的关键

JVM 引入了"记忆集"(Remembered Set)来解决这个问题。记忆集记录了从非收集区域指向收集区域的所有引用信息。对于新生代 GC,记忆集记录了哪些老年代区域可能存在指向新生代的引用。

在进行新生代回收时,只需要将记忆集作为额外的 GC Roots 扫描,而不需要遍历整个老年代。

卡表:记忆集的高效实现

HotSpot 虚拟机使用"卡表"(Card Table)实现记忆集,其原理是:

  1. 将整个堆划分为固定大小的卡页(Card Page),通常为 512 字节
  2. 使用一个字节数组作为卡表,每个元素对应一个卡页
  3. 当某卡页中有对象存在跨代引用时,对应卡表元素被标记为"脏"

如上图所示,卡页 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 等不采用分代或采用不同屏障技术的收集器,则有不同的实现机制(如读屏障)。

启示

  1. 理解短期与长期对象: 设计应用时,应有意识地区分对象的生命周期。尽量避免长期存活的对象(尤其是在老年代中的集合、缓存)大量、频繁地引用生命周期极短的新生代对象。

  2. 谨慎使用大缓存 : 全局静态缓存是跨代引用的重灾区。如果必须使用,考虑使用WeakReferenceSoftReference来减弱引用关系,或采用如Google Guava Cache 或更现代的Caffeine 等高性能缓存库。这些库已经为您封装了弱引用/软引用、基于ReferenceQueue的自动清理、线程安全以及复杂的淘汰策略(如 LRU、LFU),是生产环境中的首选方案,可以避免重复造轮子。

  3. 性能调优视角 : 当观察到 Young GC 时间异常长时,除了分析新生代对象分配情况,也应怀疑是否存在大量跨代引用导致"脏卡"扫描耗时过长。通过 GC 日志(如 G1 的GC-Card-Table-Scanned)可以进行佐证。

总结

技术 作用 实现方式
记忆集 记录跨代引用信息 卡表、RSet 等数据结构
卡表 标记可能存在跨代引用的内存区域 堆分卡页,字节数组标记
写屏障 实时维护记忆集 引用赋值操作时自动执行的额外代码
脏卡队列 减少应用线程负担 应用线程放入队列,GC 或辅助线程异步处理
卡表标记单向性 简化实现,降低开销 一旦标记为脏,仅在 GC 后重置
跟踪精度问题 卡表精度损失带来的开销 多个对象共享卡页导致额外扫描
应用层优化 减少跨代引用 合理管理对象生命周期,使用弱引用
相关推荐
lang201509281 小时前
打造专属Spring Boot Starter
java·spring boot·后端
曹牧1 小时前
C#:数组不能使用Const修饰符
java·数据结构·算法
YA3331 小时前
java设计模式六、装饰器模式
java·设计模式·装饰器模式
回忆是昨天里的海2 小时前
k8s集群-节点间通信之安装kube-flannel插件
java·docker·kubernetes
信仰_2739932432 小时前
Mybatis-Spring重要组件介绍
java·spring·mybatis
盖世英雄酱581362 小时前
java深度调试【第二章通过堆栈分析性能瓶颈】
java·后端
没有bug.的程序员3 小时前
AOP 原理深剖:动态代理与 CGLIB 字节码增强
java·spring·aop·动态代理·cglib
2401_837088503 小时前
ResponseEntity - Spring框架的“标准回复模板“
java·前端·spring
lang201509283 小时前
Spring Boot RSocket:高性能异步通信实战
java·spring boot·后端
默默coding的程序猿3 小时前
1.北京三维天地公司-实施实习生
java·sql·技术支持·面经·实施·实施工程师·三维天地