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 后重置
跟踪精度问题 卡表精度损失带来的开销 多个对象共享卡页导致额外扫描
应用层优化 减少跨代引用 合理管理对象生命周期,使用弱引用
相关推荐
Mr_Xuhhh8 分钟前
信号与槽的总结
java·开发语言·数据库·c++·qt·系统架构
纳兰青华18 分钟前
bean注入的过程中,Property of ‘java.util.ArrayList‘ type cannot be injected by ‘List‘
java·开发语言·spring·list
coding and coffee24 分钟前
狂神说 - Mybatis 学习笔记 --下
java·后端·mybatis
千楼28 分钟前
阿里巴巴Java开发手册(1.3.0)
java·代码规范
reiraoy42 分钟前
缓存解决方案
java
安之若素^1 小时前
启用不安全的HTTP方法
java·开发语言
ruanjiananquan991 小时前
c,c++语言的栈内存、堆内存及任意读写内存
java·c语言·c++
chuanauc1 小时前
Kubernets K8s 学习
java·学习·kubernetes
一头生产的驴2 小时前
java整合itext pdf实现自定义PDF文件格式导出
java·spring boot·pdf·itextpdf
YuTaoShao2 小时前
【LeetCode 热题 100】73. 矩阵置零——(解法二)空间复杂度 O(1)
java·算法·leetcode·矩阵