xiaozhigang

长风破浪会有时,直挂云帆济沧海。

JVM Card Table详解:垃圾收集器的加速利器

前言

在JVM垃圾收集的世界里,有一个默默无闻但极其重要的技术——Card Table(卡片表)。它就像垃圾收集器的”加速器”,让分代垃圾收集器能够高效地处理跨代引用。本文将深入解析Card Table的工作原理、实现细节,以及它在不同垃圾收集器中的应用。

一、什么是Card Table

基本概念

Card Table(卡片表)是垃圾收集器用来记录跨代引用跨Region引用的数据结构,用于加速垃圾收集过程中的引用扫描。

解决的核心问题

在分代垃圾收集中,一个关键挑战是如何高效地找到跨代引用

1
2
3
4
5
6
问题场景:
老年代对象 → 引用 → 新生代对象

挑战:
如果新生代GC时需要扫描整个老年代来找到这些引用,
那么GC效率会极低!

基本原理

分代内存结构

1
2
3
4
5
6
7
8
堆内存布局:
┌─────────────────┬─────────────────┐
│ 老年代 │ 新生代 │
│ │ │
│ 老对象A ──────→ ─→ 新对象B │ ← 跨代引用
│ │ │
│ 老对象C │ 新对象D │
└─────────────────┴─────────────────┘

Card Table结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
卡片表(内存中的位图):
每1个Card对应512字节的堆内存

┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 0 │ 1 │ 0 │ 0 │ 1 │ 0 │ ← 脏卡标记
└───┴───┴───┴───┴───┴───┴───┴───┘
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ └── Card 7:有引用变化
│ │ │ │ │ │ └─────── Card 6:有引用变化
│ │ │ │ │ └─────────── Card 5:干净
│ │ │ │ └─────────────── Card 4:有引用变化
│ │ │ └─────────────────── Card 3:干净
│ │ └─────────────────────── Card 2:有引用变化
│ └─────────────────────────── Card 1:干净
└─────────────────────────────── Card 0:干净

关键概念

  • Card(卡片):对应堆内存中的512字节区域
  • Dirty Card(脏卡):标记为1的卡片,表示该区域有引用变化
  • Clean Card(干净卡):标记为0的卡片,表示该区域无引用变化

二、Card Table工作机制

工作流程可视化

sequenceDiagram
    participant App as 应用线程
    participant WB as 写屏障
    participant CT as Card Table
    participant GC as GC线程

    Note over App,GC: 写屏障标记过程
    App->>WB: 老年代对象引用新生代对象
    WB->>WB: 检测跨代引用
    WB->>CT: 标记对应Card为脏
    Note over CT: Card状态: 0→1

    Note over App,GC: GC扫描过程
    GC->>CT: 遍历Card Table
    CT->>GC: 返回脏卡列表
    GC->>GC: 只扫描脏卡对应内存区域
    GC->>GC: 查找跨代引用
    GC->>CT: 清理脏卡标记

Card Table工作流程图

flowchart TD
    START[应用运行] --> WRITE[对象引用更新]
    WRITE --> CHECK{是否跨代引用?}
    CHECK -->|是| MARK[标记Card为脏]
    CHECK -->|否| NORMAL[正常执行]
    MARK --> CONTINUE[继续应用执行]
    NORMAL --> CONTINUE

    CONTINUE --> GC_TRIGGER[GC触发]
    GC_TRIGGER --> SCAN[扫描Card Table]
    SCAN --> FILTER[只处理脏卡区域]
    FILTER --> RECOVER[回收垃圾对象]
    RECOVER --> CLEAR[清理Card Table]
    CLEAR --> END[GC完成]

    style MARK fill:#ffeb3b
    style SCAN fill:#add8e6
    style FILTER fill:#90ee90

写屏障实现

1. 基本写屏障

1
2
3
4
5
6
7
8
9
10
11
12
13
// 伪代码:写屏障在引用更新时的工作
void write_field(Object obj, Object new_value) {
if (is_in_old_gen(obj) && is_in_young_gen(new_value)) {
// 老年代对象引用新生代对象
mark_card_dirty(obj); // 标记对应的Card为脏
}
obj.field = new_value; // 执行实际的写操作
}

void mark_card_dirty(Object obj) {
int card_index = get_card_index(obj);
card_table[card_index] = DIRTY; // 标记为脏卡
}

2. 精确标记优化

1
2
3
4
5
6
7
8
9
10
11
12
// 优化:更精确的脏卡判断
void precise_mark_card(Object obj, Object new_value) {
if (should_mark_card(obj, new_value)) {
// 只在真正需要时才标记
int card_index = get_card_index(obj);
card_table[card_index] = DIRTY;
}
}

boolean should_mark_card(Object old_obj, Object new_obj) {
return is_cross_generation_reference(old_obj, new_obj);
}

GC扫描过程

1
2
3
4
5
6
7
8
9
10
老年代回收扫描过程:

1. 枚举老年代GC Roots
2. 遍历Card Table:
├── 找到脏卡(标记为1的卡片)
├── 扫描对应内存区域的所有对象
├── 查找指向新生代的引用
└── 将新生代对象加入根集合

3. 扫描完成,清理Card Table

三、不同垃圾收集器中的Card Table实现

不同GC中的跨代引用处理对比

graph TD
    subgraph "Parallel GC"
        P1[传统Card Table]
        P2[全局共享]
        P3[串行更新]
        P4[简单可靠]
    end

    subgraph "CMS GC"
        C1[并发Card Table]
        C2[原子操作]
        C3[预清理阶段]
        C4[处理并发冲突]
    end

    subgraph "G1 GC"
        G1[Remembered Set]
        G2[Region级别]
        G3[精细化记录]
        G4[高效查询]
    end

    subgraph "ZGC"
        Z1[染色指针]
        Z2[读屏障]
        Z3[无Card Table]
        Z4[全并发]
    end

    style P1 fill:#add8e6
    style C1 fill:#ffeb3b
    style G1 fill:#90ee90
    style Z1 fill:#ff6b6b

Card Table vs Remembered Set 对比

特性 Card Table Remembered Set
粒度 粗粒度(512字节) 细粒度(对象级)
存储方式 全局共享位图 Region独立存储
更新开销 中等
查询效率 中等
内存占用 低(约1%堆) 高(约10-20%堆)
并发支持 需要特殊处理 原生支持并发

跨代引用处理演进图

graph LR
    subgraph "演进路径"
        A[全局Card Table] --> B[并发Card Table]
        B --> C[Remembered Set]
        C --> D[染色指针]
    end

    subgraph "技术特点"
        A1[简单] --> A2[串行]
        B1[原子操作] --> B2[预清理]
        C1[Region级] --> C2[精细化管理]
        D1[无额外结构] --> D2[指针编码]
    end

    style A fill:#add8e6
    style B fill:#ffeb3b
    style C fill:#90ee90
    style D fill:#ff6b6b

四、各种GC的具体实现

1. Parallel GC中的Card Table

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
特点:
- 传统分代模型
- 单个Card Table
- 简单的脏卡标记

Card Table布局:
[Young Gen Cards] [Old Gen Cards]
0-1024 1024-2048

优势:
- 实现简单
- 内存开销小
- 串行GC无并发问题

劣势:
- 粒度较粗
- 扫描效率一般

2. CMS GC中的Card Table

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
特点:
- 针对并发优化
- 支持并发更新
- 需要处理并发问题

并发更新策略:
- 使用原子操作标记脏卡
- 可能出现"脏卡延迟清理"问题
- 需要额外的预清理阶段

问题:并发标记过程中的脏卡清理
┌─────────────────────────────────┐
│ 并发标记阶段 │
├─────────────────────────────────┤
│ 1. 清理脏卡 │
│ 2. 应用线程继续执行,产生新脏卡 │
│ 3. 标记线程处理新脏卡 │
│ 4. 循环处理... │
└─────────────────────────────────┘

解决方案:
- 预清理阶段:提前处理新脏卡
- 重新标记阶段:最终确认所有引用

3. G1 GC中的Remembered Set

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
G1不是使用传统Card Table,而是使用Remembered Set:

每个Region有自己的Remembered Set:
Region 1: [Card Set 1]
Region 2: [Card Set 2]
Region 3: [Card Set 3]

Remembered Set结构:
├── Hash Table:快速查找
├── Per-Card Table:精细记录
└── Bitmap:空间优化

G1 GC改进:
1. 每个Region独立的Remembered Set
2. 更精细的引用记录
3. 支持并发更新

与传统Card Table对比:
┌─────────────┬─────────────┬─────────────┐
│ 特性 │ Card Table │ Remembered │
├─────────────┼─────────────┼─────────────┤
│ 粒度 │ 粗粒度 │ 细粒度 │
│ 更新开销 │ 低 │ 中等 │
│ 扫描效率 │ 中等 │ 高 │
│ 内存占用 │ 低 │ 高 │
└─────────────┴─────────────┴─────────────┘

4. ZGC中的替代方案

1
2
3
4
5
6
7
8
9
ZGC不使用Card Table,而是:
- 染色指针包含引用信息
- 读屏障处理引用更新
- 无需额外的Card Table开销

优势:
- 无额外内存开销
- 完全并发处理
- 指针本身携带信息

五、Card Table的性能影响

优点

1. 减少扫描范围

1
2
3
4
5
6
7
8
9
10
传统扫描:扫描整个老年代
Card Table:只扫描脏卡对应的内存区域

性能提升:扫描范围可能减少90%+

示例:
4GB老年代:
- 传统扫描:扫描4GB全部内容
- Card Table:扫描100MB脏卡区域
- 性能提升:40倍

2. 提高GC效率

  • 新生代GC时间显著缩短
  • 老年代GC的根扫描时间减少
  • 整体GC吞吐量提升

缺点

1. 写屏障开销

  • 每次引用更新都要检查是否需要标记Card
  • 增加应用程序的执行时间
  • 对写密集型应用影响较大

2. 内存开销

  • Card Table占用额外内存(约堆大小的1%)
  • 在大堆上开销显著

3. 并发复杂性

  • 并发GC需要处理Card Table的并发更新
  • 可能产生伪脏卡

六、Card Table的优化技术

1. 批量处理

1
2
3
4
5
6
7
8
9
// 优化:批量清理脏卡
void batch_clear_dirty_cards() {
// 批量清理,减少系统调用
for (int i = 0; i < batch_size; i++) {
if (card_table[current_batch + i] == DIRTY) {
clear_and_scan_card(current_batch + i);
}
}
}

2. 卡片大小优化

1
2
3
4
5
6
7
8
9
10
11
12
13
不同GC的卡片大小:
- Parallel GC:512字节(默认)
- CMS GC:512字节
- G1 GC:使用Remembered Set,逻辑类似但更精细

选择考虑:
- 太小:Card Table开销大
- 太大:扫描粒度粗,效率低

512字节是一个经验平衡点:
- 内存开销合理(堆的1/1024)
- 扫描粒度适中
- 缓存友好

3. 并发优化

原子操作

1
2
3
4
5
// 并发安全的脏卡标记
void atomic_mark_dirty(int card_index) {
// 使用原子操作避免并发问题
atomic_compare_and_swap(card_table[card_index], CLEAN, DIRTY);
}

延迟清理

1
2
3
4
5
6
7
// 延迟清理策略,减少清理频率
void lazy_clear_cards() {
if (dirty_card_count > threshold) {
batch_clear_dirty_cards();
dirty_card_count = 0;
}
}

七、实际应用案例

案例1:电商网站的GC优化

问题描述
某电商网站在高峰期出现频繁的Young GC,每次耗时较长。

分析过程

  1. 使用GC日志分析发现每次Young GC的根扫描时间过长
  2. 发现老年代到新生代的跨代引用较多
  3. Card Table的脏卡比例很高(80%+)

优化方案

1
2
3
4
5
# 优化JVM参数
-XX:+UseParallelGC # 使用并行GC
-XX:ParallelGCThreads=8 # 设置GC线程数
-XX:+UseParallelOldGC # 老年代并行回收
-XX:ParallelCMSThreads=4 # 并发标记线程数

效果

  • Young GC时间减少40%
  • 系统吞吐量提升25%
  • 用户体验明显改善

案例2:微服务架构的GC调优

场景
微服务应用使用G1 GC,但Mixed GC频率过高。

问题诊断

1
2
3
- Remembered Set占用内存过多(15%堆内存)
- 跨Region引用更新频繁
- Card Table维护开销大

解决方案

1
2
3
4
5
6
# G1 GC优化参数
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目标停顿时间
-XX:G1HeapRegionSize=16m # Region大小
-XX:G1RSetUpdatingPauseTimePercent=5 # RSet更新时间占比
-XX:G1RSetSparseRegionEntries=8 # 稀疏RSet配置

结果

  • Remembered Set内存占用减少到8%
  • Mixed GC频率降低50%
  • 整体GC效率提升30%

八、Card Table相关JVM参数

通用参数

1
2
3
4
# 显示Card Table相关信息
-XX:+PrintGCApplicationStoppedTime # 显示应用暂停时间
-XX:+PrintGCDetails # 详细GC信息
-XX:+PrintGCApplicationConcurrentTime # 应用执行时间

CMS相关参数

1
2
3
4
5
-XX:+UseCMSInitiatingOccupancyOnly     # 仅根据占用率启动CMS
-XX:CMSInitiatingOccupancyFraction=70 # 老年代占用70%时启动CMS
-XX:+UseCMSInitiatingOccupancyOnly # 只根据占用率触发CMS
-XX:+CMSParallelRemarkEnabled # 并行重新标记
-XX:+CMSScavengeBeforeRemark # Remark前进行Young GC

G1相关参数

1
2
3
4
-XX:G1RSetUpdatingPauseTimePercent=5  # RSet更新时间占比
-XX:G1RSetSparseRegionEntries=8 # 稀疏RSet条目数
-XX:G1RSetRegionEntries=64 # RSet条目数
-XX:G1MixedGCCountTarget=8 # 混合GC目标次数

九、总结

Card Table作为JVM垃圾收集器的重要优化技术,通过空间换时间的策略显著提高了垃圾收集的效率。

关键价值

  1. 性能提升:大幅减少GC扫描范围,提高GC效率
  2. 分代支持:使分代垃圾收集器成为可能
  3. 可扩展性:支持大内存堆的垃圾收集

技术演进

从传统的Card Table到现代的Remembered Set和染色指针,跨代引用处理技术在不断演进,目标是:

  • 更少的内存开销
  • 更高的处理效率
  • 更好的并发支持

最佳实践

  1. 监控Card Table状态:定期检查脏卡比例和维护开销
  2. 合理配置参数:根据应用特点调整JVM参数
  3. 选择合适的GC:根据场景选择最适合的垃圾收集器
  4. 持续优化:结合监控数据持续调优

Card Table虽然只是JVM垃圾收集中的一个组件,但理解它的工作原理对于进行JVM性能调优和问题诊断具有重要意义。它是连接理论与实践的桥梁,也是深入理解JVM内存管理的关键。

JVM可达性分析与三色标记法详解:垃圾收集的核心算法

前言

在JVM垃圾收集的世界里,有两个核心算法构成了现代垃圾收集器的基础:可达性分析三色标记法。可达性分析帮助我们确定哪些对象是”活”的,而三色标记法则解决了并发环境下进行垃圾收集的难题。本文将深入解析这两个算法的工作原理、存在的挑战,以及各大垃圾收集器是如何实现和优化的。

一、可达性分析(Reachability Analysis)

什么是可达性分析

可达性分析是现代垃圾收集器判断对象是否存活的算法

基本原理:从一组称为”GC Roots”的根节点开始,通过引用关系向下搜索,所有能够访问到的对象都被认为是存活的对象,不可访问的对象则被判定为垃圾对象。

GC Roots(垃圾回收根)

GC Roots是垃圾收集的起点,它们是虚拟机内存中一些特殊类型的引用,保证了在垃圾收集过程中这些引用的对象不会被回收。

GC Roots的类型

1. 虚拟机栈(栈帧中的本地变量表)中引用的对象

1
2
3
4
5
public void method() {
Object obj1 = new Object(); // obj1是GC Root
String str = "hello"; // str是GC Root
int[] array = new int[10]; // array是GC Root
}
  • 方法参数、局部变量、临时变量等
  • 正在运行的线程栈中的引用对象

2. 方法区中类静态属性引用的对象

1
2
3
4
public class MyClass {
private static Object staticObj = new Object(); // staticObj是GC Root
public static List<String> list = new ArrayList<>(); // list是GC Root
}
  • 类的静态字段(static fields)
  • 常量池中的引用对象

3. 方法区中常量引用的对象

1
2
3
public class Constants {
public static final String CONST_STRING = "constant"; // 常量引用是GC Root
}
  • 字符串常量池中的引用

4. 本地方法栈中JNI(Native方法)引用的对象

1
2
3
4
public native void nativeMethod();

// 在Native代码中创建的Java对象引用
// 这些引用在本地方法栈中,也是GC Roots

5. 被同步锁(synchronized)持有的对象

1
2
3
4
5
6
public void synchronizedMethod() {
Object lock = new Object();
synchronized(lock) {
// lock对象作为锁,是GC Root
}
}

6. Java虚拟机内部的引用

  • Class对象
  • 异常对象
  • 系统类加载器等

可达性分析过程

graph TD
    subgraph "GC Roots"
        GR1[Thread Stack]
        GR2[Static Fields]
        GR3[JNI References]
    end

    subgraph "对象引用链"
        A[Object A] --> B[Object B]
        A --> C[Object C]
        B --> D[Object D]
        C --> E[Object E]
        F[Unreachable Object]
    end

    GR1 --> A
    GR2 --> A
    GR3 --> C

    style F fill:#ffcccc
    style A fill:#ccffcc
    style B fill:#ccffcc
    style C fill:#ccffcc
    style D fill:#ccffcc
    style E fill:#ccffcc

分析步骤

  1. 枚举GC Roots:找到所有GC Roots对象
  2. 递归遍历:从GC Roots开始递归遍历引用链
  3. 标记存活对象:将所有可达对象标记为存活
  4. 回收垃圾对象:回收未被标记的对象

GC Roots枚举的实现

1. 准确式GC与保守式GC

准确式GC(Exact GC)

  • 虚拟机明确知道内存中哪些位置是引用
  • 通过OopMap数据结构记录对象引用的位置
  • HotSpot虚拟机采用准确式GC

保守式GC(Conservative GC)

  • 不能准确区分引用和非引用数据
  • 通过启发式算法判断是否为引用
  • 可能存在”假引用”问题

2. 安全点(Safepoint)

由于GC Roots枚举需要在一个能看到一致内存快照的点进行,JVM引入了安全点机制:

1
2
3
4
5
6
public void longRunningMethod() {
for (int i = 0; i < 1000000; i++) {
// 方法调用、循环跳转、异常跳转等位置可以设置安全点
doSomething(); // 这里是安全点
}
}

安全点的选择标准:

  • 方法调用
  • 循环跳转
  • 异常跳转
  • 长时间运行的循环

3. OopMap数据结构

HotSpot使用OopMap来记录栈上和寄存器中的引用信息:

1
2
3
4
5
6
// OopMap的结构示意
class OopMap {
BitMap frame_oops; // 栈帧中的引用位图
BitMap register_oops; // 寄存器中的引用位图
int frame_size; // 栈帧大小
};

二、三色标记法(Tri-color Marking)

三色标记法基本概念

三色标记法是一种用于并发垃圾收集的标记算法,主要用于解决在标记过程中应用程序仍在运行导致的标记不准确问题。

三种颜色含义

  • 白色(White):未被访问的对象,初始状态所有对象都是白色
  • 灰色(Grey):已被访问但其引用的对象尚未全部被访问的对象
  • 黑色(Black):已被访问且其引用的所有对象也已被访问的对象

三色标记动态过程

flowchart TD
    subgraph "初始状态 - 全白"
        START[所有对象白色]
        W1[A⚪] --> W2[B⚪] --> W3[C⚪]
        W4[D⚪] --> W5[E⚪] --> W6[F⚪]
        G[G⚪] --> H[H⚪] --> I[I⚪]
    end

    subgraph "第一阶段 - 标记GC Roots"
        T1[A🔘] --> T2[B⚪] --> T3[C⚪]
        T4[D🔘] --> T5[E⚪] --> T6[F⚪]
        TG[G⚪] --> TH[H⚪] --> TI[I⚪]
        ROOTS[GC Roots: A, D]
    end

    subgraph "第二阶段 - 遍历灰色"
        S1[A⚫] --> S2[B🔘] --> S3[C⚪]
        S4[D⚫] --> S5[E🔘] --> S6[F⚪]
        SG[G⚪] --> SH[H⚪] --> SI[I⚪]
    end

    subgraph "第三阶段 - 继续遍历"
        TH1[A⚫] --> TH2[B⚫] --> TH3[C🔘]
        TH4[D⚫] --> TH5[E⚫] --> TH6[F🔘]
        TG2[G⚪] --> TH2[H⚪] --> TI2[I⚪]
    end

    subgraph "最终状态"
        F1[A⚫] --> F2[B⚫] --> F3[C⚫]
        F4[D⚫] --> F5[E⚫] --> F6[F⚫]
        GARBAGE[G⚪] --> H2[H⚪] --> I2[I⚪]
        COLLECT[将被回收]
    end

    START --> T1
    T1 --> S1
    S1 --> TH1
    TH1 --> F1

    style W1 fill:#f0f0f0
    style W2 fill:#f0f0f0
    style W3 fill:#f0f0f0
    style T1 fill:#808080
    style T4 fill:#808080
    style S1 fill:#000000,color:#fff
    style S4 fill:#000000,color:#fff
    style S2 fill:#808080
    style S5 fill:#808080
    style TH2 fill:#000000,color:#fff
    style TH5 fill:#000000,color:#fff
    style F1 fill:#000000,color:#fff
    style F2 fill:#000000,color:#fff
    style GARBAGE fill:#f0f0f0
    style COLLECT fill:#ff6b6b

颜色状态图例

  • 白色:未被标记的对象(垃圾候选)
  • 🔘 灰色:已标记但引用对象未完全遍历
  • 黑色:已标记且所有引用都已遍历

三色标记工作流程

1
2
3
4
5
6
7
8
9
10
11
12
并发标记时间线 (单位:毫秒):

GC线程: [标记Roots:1000ms][遍历灰色对象:2000ms][继续遍历:2000ms][完成]
应用线程: [================应用运行==================]
: [引用更新:500ms]
写屏障: [SATB处理:500ms]

流程说明:
1. GC线程从GC Roots开始标记
2. 遍历灰色对象,继续标记其引用
3. 应用线程在标记过程中继续执行
4. 写屏障处理并发引用变化

三色不变性(Tri-color Invariant)

为了确保标记的正确性,三色标记法需要满足以下两个不变性之一:

三色不变性对比图

graph TD
    subgraph "强不变性"
        S1[⚫ 黑色对象] -->|不允许直接引用| S2[⚪ 白色对象]
        S1 -->|必须通过灰色对象| S3[🔘 灰色对象] --> S2
    end

    subgraph "弱不变性"
        W1[⚫ 黑色对象] -->|允许直接引用| W2[⚪ 白色对象]
        W3[🔘 灰色对象] -->|必须保护白色对象| W2
    end

    style S1 fill:#000000,color:#fff
    style S2 fill:#f0f0f0
    style S3 fill:#808080
    style W1 fill:#000000,color:#fff
    style W2 fill:#f0f0f0
    style W3 fill:#808080

不变性规则总结

不变性类型 黑→白直接引用 保护机制 适用场景
强不变性 ❌ 不允许 灰色对象截断 CMS GC
弱不变性 ✅ 允许 灰色对象保护 G1 GC

三、三色标记法的漏标问题详解

问题的本质

在并发标记过程中,应用程序线程仍在运行,会修改对象间的引用关系。这可能导致三色标记过程中出现漏标问题,即某些应该被标记为存活的对象被错误地标记为垃圾。

漏标问题的两种类型

1. 对象消失问题(Object Loss)

问题描述
黑色对象断开了对白色对象的引用,导致白色对象被错误回收。

sequenceDiagram
    participant GC as GC线程
    participant App as 应用线程
    participant Black as 黑色对象A
    participant White as 白色对象B

    Note over GC,App: 对象消失问题场景
    GC->>Black: 标记对象A为黑色
    GC->>White: 对象B仍为白色
    Note over GC,App: 此时A引用B,B应该被标记
    App->>Black: A.field = null (断开引用)
    GC->>GC: 完成标记,回收白色对象
    Note over GC: 错误:B被回收,但可能还有其他引用

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 假设对象A已被标记为黑色
class A {
Object ref;
}

class B {
// 一些数据
}

// 并发标记过程中,应用线程执行
A a = getBlackObject(); // a已被标记为黑色
B b = getWhiteObject(); // b仍为白色

a.ref = b; // 初始状态:黑色A引用白色B
a.ref = null; // 应用断开引用,导致B变成浮动对象
// 如果没有其他引用指向B,B会被错误回收

2. 浮动垃圾问题(Floating Garbage)

问题描述
灰色对象断开了对白色对象的引用,导致本应被回收的对象存活到下一轮。

sequenceDiagram
    participant GC as GC线程
    participant App as 应用线程
    participant Gray as 灰色对象C
    participant White as 白色对象D

    Note over GC,App: 浮动垃圾问题场景
    GC->>Gray: 标记对象C为灰色
    GC->>White: 对象D仍为白色,C引用D
    App->>Gray: C.field = null (断开引用)
    GC->>White: D已无引用,但可能仍被标记
    Note over GC: 结果:D存活到下一轮GC

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 假设对象C为灰色,对象D为白色
class C {
Object ref;
}

class D {
// 一些数据
}

// 并发标记过程中
C c = getGrayObject(); // c为灰色
D d = getWhiteObject(); // d为白色

c.ref = d; // 初始状态:灰色C引用白色D
c.ref = null; // 应用断开引用,D应该被回收
// 但D可能已经被标记,成为浮动垃圾

并发标记问题类型对比

graph LR
    subgraph "对象消失问题"
        O1[初始: 黑→白] --> O2[应用: 断开引用]
        O2 --> O3[结果: 白对象不可达但存活]
        O3 --> O4[影响: 内存泄漏风险]
    end

    subgraph "浮动垃圾问题"
        F1[初始: 灰→白] --> F2[应用: 断开引用]
        F2 --> F3[结果: 白对象应回收但存活]
        F3 --> F4[影响: 本轮GC效率降低]
    end

    style O4 fill:#ff6b6b
    style F4 fill:#ffeb3b

漏标问题的严重性

对象消失问题的后果

  • 严重:可能导致程序逻辑错误
  • 不可接受:回收了仍在使用的对象
  • 必须解决:所有并发GC都必须避免

浮动垃圾问题的后果

  • 轻微:只影响GC效率
  • 可接受:下一轮GC会回收
  • 可以容忍:大部分GC都存在浮动垃圾

四、解决方案:写屏障(Write Barrier)

写屏障是在引用更新时执行的代码片段,用于维护三色不变性。它是解决并发标记漏标问题的核心技术。

1. SATB(Snapshot-At-The-Beginning)写屏障

SATB是G1 GC采用的解决方案,它通过记录引用变化前的状态来维护弱不变性。

1
2
3
4
5
6
7
8
// 伪代码:SATB写屏障
void field_write(Object obj, Object new_value) {
// SATB快照:记录旧的引用值
if (obj.field != null) {
satb_mark_queue.add(obj.field); // 将旧引用加入标记队列
}
obj.field = new_value; // 执行实际的写操作
}

SATB工作原理

  1. 快照思想:在并发标记开始时创建对象引用关系的快照
  2. 记录变化:写屏障记录所有被删除的引用
  3. 后续处理:在标记阶段处理记录的引用

SATB处理漏标问题的过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// SATB示例
class Node {
Node next;
}

// 并发标记开始时的快照
Node node1 = new Node(); // 假设被标记为灰色
Node node2 = new Node(); // 假设为白色
node1.next = node2; // 快照中node1引用node2

// 并发标记过程中,应用执行
node1.next = null; // SATB写屏障记录node2到标记队列

// GC后续处理
// SATB标记队列中的node2会被重新标记,避免被错误回收

特点

  • 使用弱不变性
  • 记录引用变化前的状态
  • 适用于G1收集器
  • 产生较少的浮动垃圾

2. 增量更新(Incremental Update)写屏障

增量更新是CMS GC采用的解决方案,它通过记录新添加的引用来维护强不变性。

1
2
3
4
5
6
7
8
// 伪代码:增量更新写屏障
void field_write(Object obj, Object new_value) {
// 如果新引用的对象是白色,将其标记为灰色
if (new_value != null && is_white(new_value)) {
mark_gray(new_value); // 将新引用对象标记为灰色
}
obj.field = new_value; // 执行实际的写操作
}

增量更新工作原理

  1. 即时标记:发现新的白色引用立即标记为灰色
  2. 维护不变性:确保黑色对象不会直接引用白色对象
  3. 避免漏标:通过灰色节点保护新引用的对象

增量更新处理漏标问题的过程

1
2
3
4
5
6
7
8
9
10
11
12
13
// 增量更新示例
class Node {
Node child;
}

// 并发标记过程中
Node parent = new Node(); // 假设parent被标记为黑色
Node child = new Node(); // 假设child仍为白色

// 应用线程执行引用更新
parent.child = child; // 增量更新写屏障立即将child标记为灰色

// 结果:child被标记为灰色,不会在本次GC中被回收

特点

  • 使用强不变性
  • 记录引用变化后的状态
  • 适用于CMS收集器
  • 实现相对简单

3. 两种方案的对比

特性 SATB写屏障 增量更新写屏障
不变性 弱不变性 强不变性
记录时机 引用删除时 引用添加时
处理方式 记录旧引用 立即标记新引用
浮动垃圾 较少 较多
实现复杂度 较高 较低
内存开销 需要标记队列 需要额外的标记操作
适用GC G1 GC CMS GC

五、各垃圾收集器中的三色标记实现

1. Serial GC

实现特点

  • 串行标记,无需三色标记
  • Stop-The-World,无并发问题
  • 简单的标记-清除/标记-整理算法
1
2
3
4
5
6
7
8
9
10
11
12
// Serial GC的标记过程(伪代码)
void serialMark() {
stopTheWorld(); // 暂停所有应用线程

for (Object root : getGCRoots()) {
markObject(root); // 递归标记
}

sweep(); // 清理未标记对象

startTheWorld(); // 恢复应用线程
}

优势

  • 实现简单,无并发复杂性
  • 标记准确,无漏标问题
  • 适合单核环境

劣势

  • STW时间长
  • 无法利用多核优势
  • 不适合大内存应用

2. Parallel GC

实现特点

  • 并行标记,但仍需STW
  • 多线程并行执行标记工作
  • 无需三色标记,因为仍然是STW
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Parallel GC的并行标记过程
void parallelMark() {
stopTheWorld();

List<Object> roots = getGCRoots();
int threadCount = Runtime.getRuntime().availableProcessors();

// 并行标记GC Roots
roots.parallelStream().forEach(this::markObject);

// 并行处理标记队列
while (!markingQueue.isEmpty()) {
List<Object> batch = markingQueue.getBatch(threadCount);
batch.parallelStream().forEach(this::markObject);
}

sweep();
startTheWorld();
}

优势

  • 充分利用多核CPU
  • 标记速度快,吞吐量高
  • 实现相对简单

劣势

  • 仍需STW
  • 无法解决延迟问题
  • 大堆时STW时间较长

3. CMS GC

实现特点

  • 并发标记,使用增量更新写屏障
  • 多次STW:初始标记、重新标记等
  • 复杂的并发控制

CMS的标记阶段

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
31
// CMS GC的并发标记过程
void cmsMarking() {
// 1. 初始标记(STW,短暂)
initialMark();

// 2. 并发标记(与应用并发)
concurrentMark();

// 3. 预清理(与应用并发)
preclean();

// 4. 重新标记(STW,处理并发变化)
remark();
}

// 并发标记阶段
void concurrentMark() {
while (!markingQueue.isEmpty()) {
Object obj = markingQueue.poll();
markObjectAndReferences(obj);
}
}

// 重新标记阶段
void remark() {
// 处理增量更新记录的新引用
processIncrementalUpdates();

// 完成标记
completeMarking();
}

CMS如何解决漏标问题

增量更新写屏障

1
2
3
4
5
6
7
// CMS的写屏障实现
void cmsWriteBarrier(Object oldObj, Object newObj) {
if (newObj != null && isMarkedAsWhite(newObj)) {
// 立即将新引用标记为灰色
markGray(newObj);
}
}

重新标记阶段

  • 处理并发阶段产生的新引用
  • 修正标记错误
  • 确保标记准确性

CMS的优势

  • 低延迟,大部分工作并发执行
  • 适合响应时间敏感的应用
  • 成熟的并发实现

CMS的劣势

  • 产生浮动垃圾
  • 并发模式失败风险
  • 内存碎片问题
  • CPU资源消耗大

4. G1 GC

实现特点

  • 分区化内存管理
  • 并发标记,使用SATB写屏障
  • 可预测停顿时间

G1的标记阶段

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
// G1 GC的并发标记过程
void g1Marking() {
// 1. 初始标记(STW,伴随Young GC)
initialMark();

// 2. 根区域扫描
scanRootRegions();

// 3. 并发标记
concurrentMark();

// 4. 重新标记(STW)
remark();

// 5. 独占清理(STW)
cleanup();

// 6. 并发清理
concurrentCleanup();
}

// SATB写屏障实现
void g1WriteBarrier(Object obj, Object newField) {
// 记录旧的引用值
if (obj.field != null) {
satbMarkQueue.add(obj.field);
}
obj.field = newField;
}

G1如何解决漏标问题

SATB快照机制

  1. 快照创建:在并发标记开始时记录引用关系
  2. 变化记录:写屏障记录所有被删除的引用
  3. 后续处理:在标记过程中处理记录的引用

具体的处理流程

1
2
3
4
5
6
7
8
9
10
11
12
13
// G1的SATB处理
class G1SATBProcessor {
Queue<Object> satbMarkQueue;

void processSATBBuffer() {
while (!satbMarkQueue.isEmpty()) {
Object obj = satbMarkQueue.poll();
if (obj != null) {
markObject(obj); // 重新标记被删除引用的对象
}
}
}
}

G1的优势

  • 可预测的停顿时间
  • 支持大内存堆
  • 增量式回收
  • 较少的浮动垃圾

G1的劣势

  • 实现复杂
  • 需要额外的Remembered Set开销
  • 在小堆上可能不如其他GC

5. ZGC

实现特点

  • 全并发设计
  • 染色指针技术
  • 读屏障而非写屏障
  • 超低延迟

ZGC的标记实现

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
// ZGC的并发标记(概念性代码)
void zgcMarking() {
// 1. 标记根(极短STW)
markRoots();

// 2. 并发标记(全并发)
concurrentMark();

// 3. 处理标记队列(全并发)
processMarkingQueue();

// 4. 迁移根(极短STW)
relocateRoots();

// 5. 并发迁移(全并发)
concurrentRelocate();
}

// ZGC使用读屏障而不是写屏障
Object zgcReadBarrier(Object obj) {
if (isMarking(obj)) {
// 处理正在标记的对象
return handleMarkingObject(obj);
}
if (isRelocating(obj)) {
// 处理正在迁移的对象
return forwardPointer(obj);
}
return obj;
}

ZGC如何解决漏标问题

染色指针和读屏障

  1. 染色指针:在64位指针中编码对象状态
  2. 读屏障:在对象访问时处理状态转换
  3. 并发处理:所有操作都可以并发执行

处理流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ZGC的染色指针
class ColoredPointer {
// 64位指针布局
// [63:42] 对象地址
// [41:38] 颜色位(标记状态)
// [37:0] 页内偏移

static final int MARKED = 0b01;
static final int REMAPPED = 0b10;
static final int FINALIZABLE = 0b11;
}

// 读屏障处理
Object zgcLoadBarrier(Object obj) {
int color = getColor(obj);
switch (color) {
case MARKED:
return obj; // 已标记
case REMAPPED:
return getForwardedObject(obj); // 已迁移
default:
return obj; // 未标记
}
}

ZGC的优势

  • 超低延迟(<1ms STW)
  • 支持TB级内存
  • 全并发处理
  • 无内存碎片

ZGC的劣势

  • 实现极其复杂
  • 读屏障开销
  • 需要64位系统支持
  • 内存占用较高

六、三色标记法的性能影响与优化

性能开销

1. 写屏障开销

1
2
3
4
5
6
7
8
9
10
11
12
13
// 每次引用更新都会触发写屏障
obj.field = newValue; // 隐含调用写屏障

// 开销分析
void writeBarrier(Object oldObj, Object newObj) {
// 条件判断开销
if (oldObj != null && needsRecording(oldObj)) {
// 队列操作开销
satbMarkQueue.add(oldObj);
}
// 实际写操作开销
obj.field = newObj;
}

优化策略

  • 批量处理:减少队列操作的频率
  • 条件优化:减少不必要的判断
  • 缓存友好:优化数据结构布局

2. 标记队列开销

1
2
3
4
5
6
7
8
9
10
11
12
13
// SATB标记队列的优化
class OptimizedSATBQueue {
Object[] buffer;
int index;

// 批量提交,减少锁竞争
void add(Object obj) {
buffer[index++] = obj;
if (index == BUFFER_SIZE) {
flush(); // 批量提交
}
}
}

3. 并发协调开销

  • 内存屏障:确保内存可见性
  • 原子操作:维护数据一致性
  • 线程同步:协调GC线程和应用线程

实际优化案例

案例1:电商应用的GC优化

问题描述
某电商应用在使用CMS GC时,出现频繁的Full GC和响应延迟。

问题分析

1
2
3
4
# GC日志分析
- CMS并发标记时间过长
- 重新标记阶段停顿时间超过目标
- 写屏障开销较大

优化方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 优化写屏障实现
public class OptimizedWriteBarrier {
private static final ThreadLocal<Buffer> localBuffer =
ThreadLocal.withInitial(() -> new Buffer(1024));

public static void writeBarrier(Object oldObj, Object newObj) {
if (oldObj != null) {
Buffer buffer = localBuffer.get();
buffer.add(oldObj);
if (buffer.isFull()) {
globalQueue.addAll(buffer.flush());
}
}
}
}

// JVM参数优化
-XX:+UseCMSInitiatingOccupancyOnly
-XX:CMSInitiatingOccupancyFraction=70
-XX:+CMSParallelRemarkEnabled
-XX:ParallelCMSThreads=4

优化效果

  • 写屏障开销减少40%
  • 重新标记时间缩短60%
  • 应用响应时间改善30%

案例2:金融服务的GC调优

场景
高频交易系统要求极低延迟,选择G1 GC但仍有停顿问题。

深度优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 优化SATB标记队列
class HighPerformanceSATBQueue {
private final Object[] buffer;
private volatile int index;

// 无锁实现,减少竞争
public void add(Object obj) {
int current = index;
if (current < buffer.length) {
buffer[current] = obj;
index = current + 1;
} else {
// 缓冲区满时的处理
flushToGlobalQueue();
}
}
}

// 预分配标记队列,减少GC压力
-XX:G1SATBBufferSize=32K
-XX:G1UpdateBufferSize=32K

结果

  • SATB处理效率提升50%
  • Young GC停顿时间减少20%
  • 整体延迟降低35%

七、监控与诊断

三色标记相关的监控指标

1. 写屏障统计

1
2
3
4
5
6
7
8
9
# 启用写屏障统计
-XX:+PrintGCDetails
-XX:+PrintGCApplicationStoppedTime
-XX:+PrintGCApplicationConcurrentTime

# 关键指标
- 写屏障执行次数
- SATB队列大小
- 标记队列处理时间

2. 并发标记效率

1
2
3
4
5
6
7
8
9
10
11
12
13
// 自定义监控
public class GCMonitor {
private static final AtomicLong writeBarrierCount = new AtomicLong();
private static final AtomicLong satbQueueSize = new AtomicLong();

public static void recordWriteBarrier() {
writeBarrierCount.incrementAndGet();
}

public static void recordSATBQueueSize(int size) {
satbQueueSize.set(size);
}
}

3. 漏标问题检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 开发环境中的漏标检测
class LeakDetector {
private static final Set<Object> trackedObjects = ConcurrentHashMap.newKeySet();

public static void track(Object obj) {
trackedObjects.add(obj);
}

public static void checkLeaks() {
// 在GC后检查被跟踪对象是否存活
for (Object obj : trackedObjects) {
if (!isReachable(obj)) {
System.err.println("Potential leak detected: " + obj);
}
}
}
}

故障排查

1. 标记效率问题

症状

  • 并发标记时间过长
  • 写屏障开销过大
  • 应用响应延迟增加

排查步骤

1
2
3
4
5
6
7
8
9
10
11
# 1. 分析GC日志
grep "concurrent-mark" gc.log

# 2. 检查写屏障统计
jstat -gcutil <pid> 1s

# 3. 分析线程栈
jstack <pid> | grep "GC Thread"

# 4. 检查内存分配模式
jmap -histo <pid>

2. 浮动垃圾问题

症状

  • GC频率过高
  • 内存使用率持续增长
  • 应用性能下降

解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 调整GC参数
-XX:InitiatingHeapOccupancyPercent=45 // G1
-XX:CMSInitiatingOccupancyFraction=70 // CMS

// 优化对象生命周期
public class ObjectPool {
private final Queue<Object> pool = new ConcurrentLinkedQueue<>();

public Object acquire() {
Object obj = pool.poll();
return obj != null ? obj : createNew();
}

public void release(Object obj) {
reset(obj);
pool.offer(obj);
}
}

八、总结与最佳实践

三色标记法的演进历程

  1. 串行标记时代:无并发,简单但低效
  2. 并行标记时代:多核并行,仍需STW
  3. 并发标记时代:CMS引入并发,解决延迟问题
  4. 精确标记时代:G1的SATB,减少浮动垃圾
  5. 全并发时代:ZGC的超低延迟实现

各GC的选择建议

应用场景 推荐GC 标记方案 特点
单核小内存 Serial GC 串行标记 简单可靠
多核批处理 Parallel GC 并行标记 高吞吐量
响应敏感Web应用 CMS GC 增量更新 低延迟
大内存服务 G1 GC SATB 可预测停顿
超低延迟交易 ZGC 染色指针 <1ms停顿

最佳实践

1. 监控关键指标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 监控模板
public class GCMetrics {
public void collectMetrics() {
// 写屏障开销
long barrierCount = getWriteBarrierCount();

// 标记队列大小
int queueSize = getMarkQueueSize();

// 并发标记时间
long concurrentMarkTime = getConcurrentMarkTime();

// 记录到监控系统
metrics.record("gc.barrier.count", barrierCount);
metrics.record("gc.queue.size", queueSize);
metrics.record("gc.concurrent.mark.time", concurrentMarkTime);
}
}

2. 参数调优

1
2
3
4
5
6
7
8
9
10
11
12
# G1 GC推荐参数
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:G1HeapRegionSize=16m
-XX:G1SATBBufferSize=32K
-XX:G1UpdateBufferSize=32K

# CMS GC推荐参数
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=70
-XX:+CMSParallelRemarkEnabled
-XX:ParallelCMSThreads=4

3. 应用优化

1
2
3
4
5
6
7
8
9
10
11
// 减少写屏障开销的对象设计
public class OptimizedObject {
// 使用final字段,减少写操作
private final Object immutableField;

// 批量更新,减少写屏障调用
public void batchUpdate(List<Object> newValues) {
Object[] array = newValues.toArray(new Object[0]);
System.arraycopy(array, 0, this.array, 0, array.length);
}
}

未来发展趋势

  1. 更高效的并发算法:减少写屏障开销
  2. 硬件加速:利用专用硬件支持GC操作
  3. 智能预测:基于机器学习的GC参数自适应
  4. 语言级别优化:编译器和运行时的深度集成

结语

三色标记法及其相关的并发标记技术是现代垃圾收集器的核心,它不仅体现了计算机科学的智慧,也展示了软件工程中在正确性和性能之间寻求平衡的艺术。理解这些原理,不仅有助于我们更好地调优Java应用,也为理解现代编程语言的内存管理提供了重要的理论基础。

随着硬件技术的发展和应用需求的变化,垃圾收集技术仍在不断演进。从串行到并发,从写屏障到读屏障,从简单的标记清除到复杂的染色指针,每一次技术革新都旨在更好地平衡吞吐量、延迟和资源利用率。

作为Java开发者,深入理解这些技术原理,不仅能帮助我们写出更好的代码,也能在面对性能问题时做出更明智的技术选择。

JDK垃圾回收器的演进之路

一、各JDK版本的默认垃圾回收器

JDK 1.3及之前:Serial GC时代

默认回收器:Serial GC(串行回收器)

技术特点

  • 单线程回收:所有垃圾回收工作都在一个线程中完成
  • Stop-The-World:回收时会暂停所有用户线程
  • 简单可靠:实现简单,没有并发复杂性
  • 内存占用小:不需要额外的并发控制数据结构

适用场景

  • 单核CPU环境
  • 内存较小的应用(<100MB)
  • 桌面应用程序、客户端工具

配置参数-XX:+UseSerialGC

回收算法

  • 新生代:复制算法(Copying)
  • 老年代:标记-整理算法(Mark-Compact)

JDK 1.4-8:Parallel GC主导时期

默认回收器:Parallel GC(并行吞吐量优先回收器)

可选回收器

  • CMS(Concurrent Mark Sweep):通过 -XX:+UseConcMarkSweepGC 启用

技术突破

  • 多线程并行:充分利用多核CPU性能
  • 吞吐量优先:以最大化应用程序吞吐量为目标
  • 自适应调节:根据系统负载和硬件自动调整参数

适用场景

  • 多核CPU服务器
  • 批处理任务
  • 科学计算、数据处理
  • 对吞吐量要求高于响应时间的应用

关键配置参数

1
2
3
4
5
-XX:+UseParallelGC          # 启用Parallel GC(JDK 5-8默认)
-XX:+UseParallelOldGC # 启用老年代并行回收
-XX:ParallelGCThreads=8 # 设置GC线程数(通常等于CPU核心数)
-XX:MaxGCPauseMillis=200 # 最大GC停顿时间目标
-XX:GCTimeRatio=99 # GC时间占总时间比例(1/(1+99)=1%)

JDK 9-14:G1 GC成为默认

默认回收器:Garbage-First GC(G1垃圾回收器)

历史意义:JDK 9的重大变革,G1成为默认GC标志着GC技术进入新时代

核心特性

  • 分区化模型:将堆划分为多个独立的Region
  • 可预测停顿:可设定目标停顿时间
  • 增量回收:避免长时间的全堆回收
  • 空间整合:回收的同时进行内存整理

适用场景

  • 大内存应用(>4GB)
  • 要求延迟可控的服务器应用
  • 微服务、Web应用

关键参数

1
2
3
4
-XX:+UseG1GC                      # 启用G1 GC(JDK 9+默认)
-XX:MaxGCPauseMillis=200 # 目标最大停顿时间
-XX:G1HeapRegionSize=16m # Region大小
-XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记的堆占用率

JDK 15-16:ZGC正式可用

新增回收器:Z Garbage Collector(ZGC)

技术特点

  • 超低延迟:目标停顿时间<1ms
  • 超大堆支持:支持TB级内存堆
  • 全并发处理:几乎所有工作都是并发执行
  • 染色指针:革命性的指针标记技术

配置启用-XX:+UseZGC

适用场景

  • 超大内存应用(>8GB)
  • 金融交易、实时竞价
  • 大数据处理、AI训练

JDK 17+:多元化GC选择

默认回收器:G1 GC继续作为默认选择

可选回收器

  • ZGC:生产就绪,稳定可用
  • Shenandoah:另一个低延迟GC选择
  • 分代ZGC:JDK 21引入分代ZGC(-XX:+ZGenerational

选择策略

  • 默认:G1 GC(平衡性能和稳定性)
  • 低延迟:ZGC
  • 超大堆:ZGC或分代ZGC

二、GC演进背后的驱动力与技术突破

GC技术演进全景图

graph TD
    A[Serial GC
JDK 1.3] -->|多核CPU普及| B[Parallel GC
JDK 1.4-8] B -->|互联网应用兴起
响应时间要求高| C[CMS GC
JDK 1.5+] C -->|内存碎片问题
大堆性能差| D[G1 GC
JDK 9+] D -->|金融/大数据应用
极致低延迟需求| E[ZGC/Shenandoah
JDK 15+] A1[单线程
Stop-The-World
适合小内存] --> A B1[多线程并行
吞吐量优先
适合批处理] --> B C1[并发标记清除
低延迟
适合Web应用] --> C D1[分区化
可预测停顿
适合大内存应用] --> D E1[全并发
超低延迟
适合超大内存/金融] --> E style A fill:#ffcccc style B fill:#ccffcc style C fill:#ccccff style D fill:#ffffcc style E fill:#ffccff

Serial → Parallel:拥抱多核时代

时代背景与硬件演进图

graph LR
    subgraph "硬件发展"
        H1[2000年
单核CPU] --> H2[2004年
双核CPU] --> H3[2006年
四核CPU] --> H4[2008年
八核CPU] end subgraph "GC技术演进" G1[Serial GC
单线程] --> G2[Parallel GC
多线程并行] end H3 --> G2 style H1 fill:#ffebcd style H2 fill:#ffebcd style H3 fill:#ffebcd style H4 fill:#ffebcd style G1 fill:#add8e6 style G2 fill:#98fb98

核心问题

  1. 资源浪费:在4核、8核服务器上,单线程GC只能利用1个CPU核心,其他核心闲置
  2. 吞吐量瓶颈:随着应用内存需求增长(从几十MB到几GB),单线程回收时间越来越长
  3. 应用性能下降:GC占用大量CPU时间,导致应用程序吞吐量下降

技术突破对比

graph TD
    subgraph "Serial GC"
        S1[单线程回收] --> S2[顺序处理]
        S3[CPU利用率低] --> S4[吞吐量受限]
    end

    subgraph "Parallel GC"
        P1[多线程并行] --> P2[同时处理]
        P3[CPU利用率高] --> P4[吞吐量大幅提升]
    end

    S1 -->|升级| P1
    S3 -->|解决| P3

    style S1 fill:#ffcccb
    style P1 fill:#90ee90

性能提升效果

1
2
3
4
5
6
7
8
9
10
11
12
8核服务器上的性能对比:
┌─────────────┬─────────────┬─────────────┐
│ GC类型 │ CPU利用率 │ 吞吐量提升 │
├─────────────┼─────────────┼─────────────┤
│ Serial GC │ 12.5% │ 1x (基准) │
│ Parallel GC │ 100% │ 2-4x │
└─────────────┴─────────────┴─────────────┘

实际应用场景:
- 批处理任务:数据处理速度提升3倍
- 科学计算:矩阵运算速度提升2.5倍
- 数据挖掘:算法执行时间缩短60%

Parallel → CMS:追求响应速度

时代需求与应用场景图

graph TD
    subgraph "互联网应用爆发"
        A[电商平台] --> B[在线支付]
        B --> C[社交媒体]
        C --> D[实时游戏]
    end

    subgraph "用户体验要求"
        E[响应时间<100ms] --> F[页面流畅度>60fps]
        F --> G[零容忍长时间卡顿]
    end

    D --> G

    style A fill:#ffebcd
    style B fill:#ffebcd
    style C fill:#ffebcd
    style D fill:#ffebcd
    style E fill:#add8e6
    style F fill:#add8e6
    style G fill:#ff6b6b

用户体验影响链

graph LR
    U[用户点击购买] --> GC[GC停顿3秒]
    GC --> PAGE[页面无响应]
    PAGE --> BAD[用户体验差]
    BAD --> LOSE[用户流失/订单损失]

    style GC fill:#ff6b6b
    style LOSE fill:#ff6b6b

GC工作模式对比

1
2
3
4
5
6
7
8
9
10
11
12
13
时间线对比 (单位:秒):

Parallel GC (传统方式):
[应用运行:3s]---[完全停顿:5s]---[应用运行:3s]
特点:用户在5秒内完全无法使用应用

CMS GC (并发方式):
[应用运行:3s]--[停顿:10ms]--[应用+GC并发:3s]--[停顿:20ms]--[应用运行:3s]
特点:大部分时间用户可以正常使用应用

优势对比:
- Parallel GC: 简单但用户体验差
- CMS GC: 复杂但用户体验好

CMS技术革新架构图

graph TD
    subgraph "CMS并发架构"
        A[应用线程] <-->|并发执行| B[GC线程]
        B --> C[并发标记]
        B --> D[并发清除]
        A --> E[短暂停顿点]
        E --> F[初始标记]
        E --> G[重新标记]
    end

    subgraph "传统GC架构"
        H[应用线程] -.->|完全停止| I[GC线程]
        I --> J[串行标记]
        I --> K[串行清除]
    end

    style A fill:#90ee90
    style B fill:#90ee90
    style C fill:#90ee90
    style D fill:#90ee90
    style H fill:#ffcccb
    style I fill:#ffcccb

技术挑战与解决方案

graph LR
    subgraph "挑战"
        T1[线程协调复杂]
        T2[内存动态变化]
        T3[CPU资源竞争]
    end

    subgraph "解决方案"
        S1[写屏障技术]
        S2[三色标记法]
        S3[自适应调度]
    end

    T1 --> S1
    T2 --> S2
    T3 --> S3

    style T1 fill:#ffcccb
    style T2 fill:#ffcccb
    style T3 fill:#ffcccb
    style S1 fill:#90ee90
    style S2 fill:#90ee90
    style S3 fill:#90ee90

CMS → G1:兼顾延迟与大堆

CMS致命缺陷问题树

graph TD
    A[CMS GC缺陷] --> B[内存碎片化]
    A --> C[并发模式失败]
    A --> D[大堆性能恶化]

    B --> B1[标记不整理]
    B1 --> B2[内存支离破碎]
    B2 --> B3[无法分配大对象]
    B3 --> B4[频繁Full GC]

    C --> C1[标记期间空间不足]
    C1 --> C2[退化到Serial Old]
    C2 --> C3[停顿时间更长]

    D --> D1[4GB堆: 2-3秒]
    D1 --> D2[8GB堆: 5-8秒]
    D2 --> D3[16GB堆: 10-15秒]
    D3 --> D4[基本不可用]

    style A fill:#ff6b6b
    style B4 fill:#ff6b6b
    style C3 fill:#ff6b6b
    style D4 fill:#ff6b6b

内存碎片问题可视化

graph LR
    subgraph "CMS内存碎片"
        M1[已用] --> M2[空闲
100KB] M2 --> M3[已用] --> M4[空闲
50KB] M4 --> M5[已用] --> M6[空闲
200KB] M6 --> M7[已用] end subgraph "分配需求" NEED[需要连续2MB空间] end subgraph "结果" FAIL[分配失败 → Full GC → 长时间停顿] end M6 --> FAIL style NEED fill:#ffeb3b style FAIL fill:#ff6b6b

G1革命性设计架构图

graph TD
    subgraph "传统分代内存"
        T1[固定Eden区]
        T2[固定Survivor区]
        T3[固定老年代]
    end

    subgraph "G1分区内存"
        G1[Region1: Eden]
        G2[Region2: Eden]
        G3[Region3: Survivor]
        G4[Region4: Old]
        G5[Region5: Old]
        G6[Region6: Humongous]
        G7[Region7: Free]
    end

    T3 -->|升级为| G1
    T3 -->|升级为| G2
    T3 -->|升级为| G3

    style T1 fill:#ffcccb
    style T2 fill:#ffcccb
    style T3 fill:#ffcccb
    style G1 fill:#90ee90
    style G2 fill:#90ee90
    style G3 fill:#90ee90
    style G4 fill:#90ee90
    style G5 fill:#90ee90
    style G6 fill:#90ee90
    style G7 fill:#90ee90

G1智能回收决策流程

flowchart TD
    START[GC触发] --> CHECK{检查堆占用率}
    CHECK -->|>45%| MARK[并发标记周期]
    CHECK -->|<45%| YOUNG[年轻代GC]

    MARK --> PREDICT[预测停顿时间]
    PREDICT --> SELECT[选择回收Region]
    SELECT --> CALC{预计停顿时间}

    CALC -->|<200ms| EXECUTE[执行混合GC]
    CALC -->|>200ms| ADJUST[减少Region数量]
    ADJUST --> EXECUTE

    EXECUTE --> END[GC完成]

    style START fill:#e1f5fe
    style EXECUTE fill:#c8e6c9
    style END fill:#c8e6c9

G1性能优势对比表

graph LR
    subgraph "堆大小 vs 停顿时间"
        H1[4GB] --> S1[停顿: 50-100ms]
        H2[8GB] --> S2[停顿: 100-150ms]
        H3[16GB] --> S3[停顿: 150-200ms]
        H4[32GB] --> S4[停顿: 200ms]
    end

    subgraph "适用场景"
        A[微服务架构]
        B[大内存Web应用]
        C[实时数据处理]
    end

    S3 --> A
    S3 --> B
    S4 --> C

    style S1 fill:#c8e6c9
    style S2 fill:#c8e6c9
    style S3 fill:#c8e6c9
    style S4 fill:#c8e6c9

G1 → ZGC:迈向极致延迟

现代应用场景需求图

graph TD
    subgraph "金融交易场景"
        F1[CPU处理: 0.1ms] --> F2[网络传输: 0.5ms]
        F2 --> F3[G1 GC停顿: 50ms]
        F3 --> F4[总延迟: 50.6ms]
        F4 --> F5[GC占比: 98.8%]
    end

    subgraph "大数据AI场景"
        A1[数据集: 100GB] --> A2[模型参数: 50GB]
        A2 --> A3[内存堆: 512GB]
        A3 --> A4[G1停顿: 500ms-2s]
        A4 --> A5[训练效率低下]
    end

    style F3 fill:#ff6b6b
    style F5 fill:#ff6b6b
    style A4 fill:#ff6b6b
    style A5 fill:#ff6b6b

ZGC技术突破架构图

graph TD
    subgraph "ZGC核心技术"
        C1[染色指针技术]
        C2[读屏障机制]
        C3[全并发设计]
        C4[多级内存管理]
    end

    subgraph "实现效果"
        E1[停顿<1ms]
        E2[TB级堆支持]
        E3[并发对象移动]
        E4[内存开销极低]
    end

    C1 --> E1
    C1 --> E4
    C2 --> E1
    C2 --> E3
    C3 --> E1
    C4 --> E2

    style C1 fill:#e1f5fe
    style C2 fill:#e1f5fe
    style C3 fill:#e1f5fe
    style C4 fill:#e1f5fe
    style E1 fill:#c8e6c9
    style E2 fill:#c8e6c9
    style E3 fill:#c8e6c9
    style E4 fill:#c8e6c9

染色指针技术详解

graph LR
    subgraph "传统64位指针"
        T1[63-48: 保留位] --> T2[47-0: 对象地址]
    end

    subgraph "ZGC染色指针"
        Z1[63-42: 对象地址] --> Z2[41-38: 颜色位] --> Z3[37-0: 页内偏移]
    end

    Z2 --> Z2A[00: 未标记]
    Z2 --> Z2B[01: 已标记]
    Z2 --> Z2C[10: 重定位中]
    Z2 --> Z2D[11: 重定位完成]

    style T1 fill:#ffcccb
    style Z1 fill:#c8e6c9
    style Z2 fill:#ffeb3b
    style Z3 fill:#c8e6c9

ZGC全并发工作流程

1
2
3
4
5
6
7
8
9
10
时间线 (单位:毫秒):

应用线程: [================应用运行=================]
:
ZGC线程: [STW:1ms][并发标记:3000ms][并发重定位:4000ms][STW:1ms]

关键点:
- 停顿时间:<1ms (用户几乎无感知)
- 并发工作:99.9%的GC工作与应用并发执行
- 堆大小不影响停顿时间

读屏障工作机制

sequenceDiagram
    participant App as 应用线程
    participant Heap as 堆内存
    participant ZGC as ZGC系统

    App->>Heap: 读取对象引用
    Heap->>ZGC: 检查指针颜色位
    alt 对象正在移动
        ZGC->>Heap: 获取新位置
        Heap->>App: 返回新地址
    else 对象已稳定
        ZGC->>App: 直接返回地址
    end
    App->>App: 继续执行

性能扩展性对比

graph LR
    subgraph "G1 GC"
        G1_8[8GB堆: 100ms] --> G1_32[32GB堆: 400ms]
        G1_32 --> G1_128[128GB堆: 1600ms]
        G1_128 --> G1_512[512GB堆: 6400ms]
    end

    subgraph "ZGC"
        Z1_8[8GB堆: <1ms] --> Z1_32[32GB堆: <1ms]
        Z1_32 --> Z1_128[128GB堆: <1ms]
        Z1_128 --> Z1_512[512GB堆: <1ms]
    end

    style G1_512 fill:#ff6b6b
    style Z1_512 fill:#c8e6c9

最终GC选择决策树

flowchart TD
    START[选择GC] --> HEAP{堆大小}

    HEAP -->|<100MB| SMALL[小内存应用]
    HEAP -->|100MB-4GB| MEDIUM[中等内存应用]
    HEAP -->|4GB-32GB| LARGE[大内存应用]
    HEAP -->|>32GB| XLARGE[超大内存应用]

    SMALL --> CLIENT{应用类型}
    CLIENT -->|客户端| SERIAL[Serial GC]
    CLIENT -->|服务端| PARALLEL[Parallel GC]

    MEDIUM --> REQUIREMENT{性能要求}
    REQUIREMENT -->|吞吐量优先| PARALLEL2[Parallel GC]
    REQUIREMENT -->|延迟敏感| G1_SMALL[G1 GC]

    LARGE --> DELAY{延迟要求}
    DELAY -->|可接受100-200ms| G1_LARGE[G1 GC]
    DELAY -->|要求<10ms| ZGC_LARGE[ZGC]

    XLARGE --> FINANCIAL{金融场景?}
    FINANCIAL -->|是| ZGC_FINAL[ZGC]
    FINANCIAL -->|否| ZGC_BIG[ZGC]

    style SERIAL fill:#add8e6
    style PARALLEL fill:#90ee90
    style G1_SMALL fill:#ffeb3b
    style G1_LARGE fill:#ffeb3b
    style ZGC_LARGE fill:#ff6b6b
    style ZGC_FINAL fill:#ff6b6b
    style ZGC_BIG fill:#ff6b6b

三、垃圾收集的核心技术演进

在了解GC演进历程后,让我们深入理解驱动这些演进的核心技术变革。

3.1 可达性分析算法:从串行到并发

可达性分析基本原理

1
2
3
4
可达性分析原理:
GC Roots(起点) → 引用链 → 对象图遍历 → 存活对象集合
↓ ↓ ↓ ↓
绝对不能回收 通过引用可达 递归查找 标记为存活

不同GC的可达性分析策略

graph TD
    subgraph "串行分析"
        S1[单线程遍历] --> S2[全程STW] --> S3[简单但低效]
    end

    subgraph "并行分析"
        P1[多线程遍历] --> P2[缩短STW] --> P3[提高效率]
    end

    subgraph "并发分析"
        C1[三色标记] --> C2[部分STW] --> C3[复杂但高效]
    end

    style S1 fill:#ffcccb
    style P1 fill:#90ee90
    style C1 fill:#ffeb3b

3.2 三色标记法:并发标记的理论基础

三色标记原理

1
2
3
4
三色状态:
● 白色:未被标记(垃圾对象)
○ 灰色:已标记但引用未完全处理(正在处理)
● 黑色:已标记且引用已处理(存活对象)

标记过程流程

flowchart TD
    START[开始标记] --> SCAN[扫描GC Roots]
    SCAN --> COLOR[将Root对象标记为灰色]
    COLOR --> GRAY_QUEUE[加入灰色队列]

    GRAY_QUEUE --> CHECK{灰色队列是否为空?}
    CHECK -->|否| TAKE_GRAY[取出一个灰色对象]
    TAKE_GRAY --> SCAN_REFS[扫描对象的所有引用]
    SCAN_REFS --> CHECK_REF{引用的对象状态}

    CHECK_REF -->|白色| MARK_GRAY[标记为灰色并加入队列]
    CHECK_REF -->|灰色/黑色| IGNORE[已标记,忽略]

    MARK_GRAY --> GRAY_QUEUE
    IGNORE --> MARK_BLACK[当前对象标记为黑色]
    MARK_BLACK --> CHECK

    CHECK -->|是| COMPLETE[标记完成,白色对象为垃圾]

    style START fill:#e1f5fe
    style COMPLETE fill:#c8e6c9
    style SCAN fill:#bbdefb
    style COLOR fill:#64b5f6
    style MARK_GRAY fill:#42a5f5
    style MARK_BLACK fill:#2196f3

三色标记状态转换图

stateDiagram-v2
    [*] --> 白色: 新创建对象
    白色 --> 灰色: 被灰色对象引用
    灰色 --> 黑色: 引用全部扫描完成
    白色 --> 黑色: 直接被Root引用
    灰色 --> 灰色: 发现新的白色引用

    黑色 --> [*]: 垃圾回收时清除

3.3 写屏障:处理并发过程中的引用变化

写屏障的作用

1
2
3
4
5
6
7
// 伪代码:写屏障示例
void write_barrier(Object obj, Object new_value) {
// 在引用更新前后的处理
pre_write_barrier(obj);
obj.field = new_value; // 实际写操作
post_write_barrier(obj, new_value);
}

不同GC的写屏障策略

  • SATB(Snapshot-At-The-Beginning):G1 GC使用
  • 增量更新(Incremental Update):CMS GC使用
  • 读屏障:ZGC使用(更复杂但效果更好)

3.4 内存回收算法的演进

算法发展历程

graph LR
    subgraph "早期算法"
        A1[标记-清除] --> A2[复制算法] --> A3[标记-整理]
    end

    subgraph "现代算法"
        B1[分区回收] --> B2[增量回收] --> B3[并发回收]
    end

    A3 --> B1

    style A1 fill:#add8e6
    style B3 fill:#90ee90

四、内存管理模型的演进:从分代到分区

G1是垃圾收集器发展的分水岭,它彻底改变了内存管理方式。理解这一变革对掌握现代GC技术至关重要。

4.1 传统分代模型(Serial, Parallel, CMS)

物理内存布局

1
2
3
4
5
6
7
8
9
传统分代内存结构:
┌─────────────────────────────────────────────────┐
│ Java堆内存 │
├─────────────────┬───────────────────────────────┤
│ 新生代 │ 老年代 │
├─────────────────┼───────────────────────────────┤
│ Eden │ S0 │ S1│ 老年代区域 │
│ 80% │ 10% │10%│ 固定大小 │
└───────┴──────┴─────┴─────────────────────────────┘

分代理论基础

  1. 弱分代假说:绝大多数对象都是”朝生夕死”的(生命周期短)
  2. 强分代假说:熬过的GC次数越多,对象越难以消亡
  3. 跨代引用假说:跨代引用相对同代引用极少

回收策略特点

1
2
3
4
5
6
7
8
9
10
11
新生代回收(Minor GC):
- 频率:高(几分钟一次)
- 算法:复制算法
- 停顿:短(几十毫秒)
- 对象:大部分是垃圾

老年代回收(Major GC):
- 频率:低(几小时一次)
- 算法:标记-清除/标记-整理
- 停顿:长(几百毫秒到几秒)
- 对象:大部分存活

固有局限性

  1. 刚性边界:Eden、Survivor、Old区域大小固定,无法动态调整
  2. 内存碎片:CMS的标记-清除算法产生大量碎片
  3. 大对象困境:大对象直接进入老年代,可能触发频繁Full GC
  4. 粗粒度回收:老年代只能全量回收,无法精细化控制

4.2 分区模型(G1, ZGC)

逻辑内存布局

1
2
3
4
5
6
7
8
G1分区内存结构:
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ R1 │ R2 │ R3 │ R4 │ R5 │ R6 │ R7 │
│ Eden │ Eden │Surv │ Old │ Old │Humong│Free │
│ (2M) │ (2M) │(1M) │ (4M) │ (4M)│ (8M) │ (2M) │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┘

每个Region都是独立的"小区间",可以灵活转换角色

核心设计理念

Region的基本特性
  • 统一大小:每个Region大小相等(通常1-32MB,JVM自动选择)
  • 角色可变:同一个Region可以是Eden、Survivor、Old等角色
  • 独立回收:以Region为单位进行垃圾回收
  • 动态调整:根据应用需求动态分配Region角色

2. G1的智能回收策略

1
2
3
4
5
6
7
8
Garbage-First原理:
┌─────────────────────────────────────────────────┐
│ 80%垃圾 │ 90%垃圾 │ 20%垃圾 │ 95%垃圾 │ 10%垃圾 │
└─────────────────────────────────────────────────┘
↑ ↑ ↑
优先级1 优先级2 优先级N

优先回收垃圾最多的Region,效率最高!

3. 停顿时间控制

1
2
3
4
5
目标:每次GC停顿不超过200ms
策略:
- 估算每个Region的回收时间
- 选择能在200ms内完成的Region集合
- 优先选择垃圾比例高的Region

4. 大对象专门处理

  • Humongous Region:超过Region大小50%的对象分配到这里
  • 连续空间:大对象可能占用多个连续Region
  • 特殊回收:大对象通常在Full GC时处理

分区模型的优势

1. 灵活性革命

1
2
3
4
5
6
7
8
9
传统分代:刚性边界,无法调整
┌─────────┬─────────┬─────────┐
│ Eden:固定│ Old:固定 │Surv:固定│
└─────────┴─────────┴─────────┘

分区模型:动态边界,按需分配
┌────┬────┬────┬────┬────┬────┬────┐
│Eden│Eden│Surv│ Old│ Old│Humon│Free│
└────┴────┴────┴────┴────┴────┴────┘

2. 精细化控制

  • 选择性回收:只回收需要的Region
  • 增量式整理:局部整理,避免全局停顿
  • 预测性调度:根据历史数据预测GC时间

3. 大堆友好

  • 线性扩展:堆大小增长不影响单次GC停顿时间
  • TB级支持:支持超大内存堆(理论上无限制)

4.3 ZGC的进一步演进

多级Page设计

1
2
3
4
5
6
ZGC内存管理:
Small Page (2MB) Medium Page (32MB) Large Page (>32MB)
┌───────────────┐ ┌─────────────────┐ ┌─────────────────────┐
│ 小对象<256KB │ │ 中等对象<32MB │ │ 大对象>=32MB │
│ 复制算法 │ │ 复制算法 │ │ 标记清除(不移动) │
└───────────────┘ └─────────────────┘ └─────────────────────┘

全并发特性

  • 无分代设计(JDK 21前):所有对象统一管理
  • 染色指针:对象状态存储在指针中
  • 读屏障:处理并发访问问题

对比总结

1
2
3
4
5
6
7
特性对比          │ G1              │ ZGC
─────────────────┼─────────────────┼─────────────────
分区大小 │ 1-32MB │ 2MB/32MB/Large
分代支持 │ 是 │ 否(JDK 21+支持)
停顿目标 │ 100-200ms │ <1ms
堆大小限制 │ 约100GB │ TB级
实现复杂度 │ 高 | 极高


五、各垃圾回收器的工作原理与关键技术

5.1 Serial GC 串行垃圾回收器

运行步骤

Serial GC - 单线程串行垃圾回收器

新生代回收(Minor GC)

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
31
32
33
34
35
36
工作流程:

1. 暂停应用线程(STW - Stop The World)
- 所有运行的应用线程到达安全点
- 线程状态被保存
- 应用程序完全暂停

2. 识别和标记存活对象
- 从GC Roots开始扫描
- 检查栈中的局部变量、静态变量等根对象
- 标记直接引用的对象为存活
- 递归标记所有可达对象

3. 复制存活对象
- 准备空的Survivor空间
- 将Eden区存活对象复制到Survivor区
- 将Survivor区存活对象复制到另一个Survivor区
- 对象年龄+1
- 达到年龄阈值的对象晋升到老年代

4. 清理内存空间
- 清空Eden区
- 清空From Survivor区
- 重置内存分配指针
- 交换Survivor区角色

5. 恢复应用线程
- 重置GC相关数据结构
- 更新GC统计信息
- 唤醒所有暂停的应用线程
- 应用程序从安全点继续执行

特点:
- 单线程执行,简单可靠
- 复制算法保证无内存碎片
- 停顿时间较长,影响响应性能

老年代回收(Full GC)

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
31
32
33
34
35
工作流程:

1. 暂停应用线程(STW)
- 所有应用线程停止运行
- 获取整个堆的访问权
- 准备进行完整清理

2. 标记存活对象
- 遍历所有GC Roots
- 标记直接引用的老年代对象
- 递归标记所有可达对象
- 构建完整的存活对象图

3. 整理内存空间
- 计算对象的移动目标位置
- 移动存活对象到新位置
- 更新所有引用指向新地址
- 消除内存碎片

4. 清理垃圾对象
- 回收未被标记对象的内存
- 合并连续的空闲内存块
- 更新空闲内存列表
- 优化内存布局

5. 恢复应用线程
- 重置GC数据结构
- 清理标记信息
- 唤醒应用线程
- 应用恢复正常运行

特点:
- 标记-整理算法消除内存碎片
- 停顿时间长,影响用户体验
- 适合单核CPU或小内存应用

关键技术

  • 复制算法:新生代使用,简单高效,无碎片
  • 标记-整理算法:老年代使用,解决碎片问题
  • 单线程执行:所有GC操作都在一个线程中完成

优缺点

优点

  • 实现简单,内存占用小
  • 单线程避免多线程竞争
  • 适合单核CPU环境

缺点

  • STW时间长,影响用户体验
  • 无法利用多核CPU优势
  • 回收效率低

5.2 Parallel GC 并行垃圾回收器

运行步骤

Parallel GC - 多线程并行垃圾回收器

新生代并行回收(Parallel Scavenge)

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
工作流程:

1. 暂停应用线程(STW)
- 所有应用线程到达安全点
- 线程状态保存
- 应用程序暂停

2. 初始化并行GC
- 创建多个GC线程(数量=CPU核心数)
- 分配线程工作队列
- 初始化线程同步机制
- 将内存区域划分为多个部分

3. 并行根扫描
- 多个GC线程同时扫描GC Roots
- 线程1扫描虚拟机栈
- 线程2扫描方法区
- 线程3扫描JNI引用
- 其他线程扫描其他根对象
- 动态负载均衡,避免线程空闲

4. 并行标记与复制
- 多线程同时遍历对象引用图
- 并行处理Eden区和Survivor区的对象
- 复制存活对象到新的Survivor区
- 更新对象引用关系
- 处理对象晋升到老年代

5. 并行清理
- 多线程同时清理内存区域
- 清空Eden区和From Survivor区
- 重置内存分配指针
- 更新内存管理数据结构

6. 自适应参数调整
- 分析GC性能数据
- 动态调整Eden/Survivor比例
- 优化对象晋升年龄阈值
- 调整GC线程数量

7. 恢复应用线程
- 所有GC线程完成工作
- 唤醒应用线程
- 应用程序继续运行

特点:
- 多线程并行执行,充分利用多核CPU
- 工作负载均衡,动态调整任务分配
- 自适应调节参数,优化性能
- 吞吐量优先,适合批处理任务

老年代并行回收(Parallel Old)

Parallel Old GC执行步骤

  1. 初始标记阶段(STW)

    • 暂停应用线程
    • 多线程并行标记GC Roots直接引用的对象
    • 构建初始标记集合
  2. 并行标记阶段

    • 多个GC线程并行遍历对象图
    • 标记所有存活对象
    • 动态负载均衡
  3. 并行整理阶段

    • 多线程并行整理内存空间
    • 移动存活对象,消除内存碎片
    • 更新对象引用
  4. 并行清理阶段

    • 多线程并行回收垃圾对象
    • 合并空闲内存空间
    • 更新内存分配器
  5. 恢复应用线程

    • 重置GC数据结构
    • 唤醒应用线程
    • 恢复正常运行

特点:

  • 多线程并行执行,提高回收效率
  • 适合多核CPU环境
  • 吞吐量优先,适合批处理任务

关键技术

  • 自适应调节:根据运行情况自动调整GC参数
  • 吞吐量优先:优化总计算时间中GC时间的比例
  • 并行处理:充分利用多核CPU能力

优缺点

优点

  • 高吞吐量,适合后台计算
  • 充分利用多核CPU
  • 自适应调节减少手动调优

缺点

  • STW仍然较长
  • 对延迟敏感的应用不友好
  • GC线程可能与应用线程竞争CPU

5.3 CMS GC 并发标记清除垃圾回收器

运行步骤

CMS GC - 并发标记清除垃圾回收器

CMS回收过程(主要针对老年代)

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
1. 初始标记阶段(短暂STW)
- 触发条件:老年代使用率达到68%或System.gc()调用
- 暂停应用线程
- 快速扫描GC Roots直接引用的对象
- 标记为存活对象
- 恢复应用线程运行

2. 并发标记阶段(应用线程正常运行)
- 从初始标记的对象开始遍历
- 并发标记所有可达对象
- 处理应用线程产生的引用变更
- 使用增量更新写屏障记录变化

3. 预清理阶段(并发执行)
- 处理并发标记期间的新引用变更
- 减少重新标记阶段的工作量
- 为最终标记做准备

4. 重新标记阶段(STW)
- 再次暂停应用线程
- 处理写屏障记录的引用变更
- 完成最终标记确认
- 确保标记准确性

5. 并发清除阶段(应用线程正常运行)
- 并发清除未被标记的垃圾对象
- 回收内存空间
- 更新空闲内存列表
- 处理引用队列

6. 并发重置阶段(并发执行)
- 重置CMS数据结构
- 清理标记位图
- 为下次GC做准备

特点:
- 大部分时间并发执行,减少停顿时间
- 适用于响应时间要求高的应用
- 产生内存碎片,需要定期Full GC整理


#### 关键技术
- **并发标记清除**:大部分工作与应用并发执行
- **增量更新写屏障**:处理并发过程中的引用变化
- **卡片表**:加速跨代引用扫描

#### 优缺点
**优点**:
- 并发执行,减少STW时间
- 响应时间好,适合Web应用
- 老年代回收停顿时间短

**缺点**:
- 产生内存碎片
- 并发模式失败可能退化到串行GC
- CPU敏感,需要额外CPU资源
- 浮动垃圾问题

---

### 5.4 G1 GC 垃圾优先垃圾回收器

#### 运行步骤

**G1 GC执行步骤**:

1. 年轻代回收(Young GC)
- Eden区满时触发
- 暂停应用线程(短暂STW)
- 复制存活对象到Survivor区
- 处理对象晋升
- 更新Remembered Set
- 转换Region角色
- 控制停顿时间

2. 并发标记周期
- 堆使用率达到45%时触发
- 初始标记(伴随Young GC)
- 根区域扫描(并发)
- 并发标记(并发)
- 重新标记(STW)
- 独占清理(STW)
- 并发清理(并发)
3. 混合回收(Mixed GC)
- 优先回收垃圾最多的Region
- 同时处理年轻代和老年代
- 控制停顿时间在目标范围内
- 多次执行直到回收足够空间

4. Full GC(最后手段)
- 混合回收无法释放足够空间时触发
- 串行整理整个堆
- 消除内存碎片
- 停顿时间较长

特点:
- 分区管理,灵活控制
- 可预测的停顿时间
- 增量式回收
- 适合大内存应用

#### 关键技术
- **分区模型**:将堆划分为多个Region
- **Remembered Set**:处理跨Region引用
- **SATB写屏障**:保证并发标记准确性
- **停顿预测模型**:控制GC停顿时间
- **增量回收**:逐步回收,避免长时间停顿

#### 优缺点
**优点**:
- 停顿时间可预测且可控
- 支持大内存堆(8GB+)
- 增量回收,减少STW时间
- 内存利用率高,减少碎片

**缺点**:
- 需要额外的记忆集开销
- 在小堆上可能不如其他GC
- 实现复杂,调优难度较高

---

### 5.5 ZGC Z垃圾回收器

#### 运行步骤

**ZGC执行步骤**:

1. 标记根阶段(极短STW,通常<1ms)
- 快速扫描GC Roots
- 使用染色指针标记对象
- 应用几乎无感知

2. 并发标记阶段
- 遍历对象图标记所有存活对象
- 使用读屏障处理引用变更
- 应用线程正常运行

3. 并发转移准备阶段
- 识别需要转移的对象集合
- 选择转移目标页面
- 准备转移空间

4. 转移根阶段(极短STW)
- 更新GC Roots中的引用
- 设置转发指针
- 应用几乎无感知

5. 并发转移阶段
- 并发转移存活对象到新位置
- 使用读屏障处理对象访问
- 应用线程正常运行
特点:
- 极低延迟(停顿时间<1ms)
- 完全并发执行
- 支持超大内存堆(TB级别)
- 适用于延迟敏感的实时应用

#### 关键技术详解

##### 1. 染色指针技术(Colored Pointers)

64位指针布局:
[63-48] [47-42] [41-0]
保留位 颜色位 对象地址

颜色位含义:

  • 00:未标记/未迁移
  • 01:已标记
  • 10:已迁移
  • 11:最终可达
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14

    ##### 2. 读屏障(Load Barrier)
    ```java
    // 伪代码:ZGC读屏障
    Object load(Object* field) {
    Object obj = *field; // 读取引用

    // 检查对象状态并进行相应处理
    if (is_relocating(obj)) {
    obj = forward_pointer(obj); // 更新引用
    }

    return obj;
    }

ZGC的Page结构

1
2
3
4
5
6
7
8
9
10
11
12
Page层级结构:
Small Page:2MB
├── 适合小对象
└── 复制算法

Medium Page:32MB
├── 适合中等大小对象
└── 复制算法

Large Page:>32MB
├── 适合大对象
└── 不移动,原地标记清理

优缺点

优点

  • 超低延迟(<1ms STW)
  • 支持超大内存堆(TB级)
  • 停顿时间不随堆大小增长
  • 全并发,充分利用CPU

缺点

  • 实现极其复杂
  • 读屏障有一定性能开销
  • 需要64位系统支持
  • JDK 15+才稳定可用

六、各垃圾回收器工作流程对比分析

6.1 GC工作流程时间线对比

不同GC的停顿时间对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
时间线对比 (单位:毫秒):

Serial GC:
[应用运行 3000ms]---[STW停顿 2000ms]---[应用运行 3000ms]

Parallel GC:
[应用运行 3000ms]---[并行回收 1000ms]---[应用运行 3000ms]

CMS GC:
[应用运行 3000ms]--[STW 10ms]--[并发标记 3000ms]--[STW 20ms]--[应用运行 3000ms]

G1 GC:
[应用运行 3000ms]--[Young GC 100ms]--[应用运行 3000ms]--[Mixed GC 150ms]--[应用运行]

ZGC:
[应用运行 5000ms]--[STW 1ms]--[应用运行 5000ms]--[STW 1ms]--[应用运行]

6.2 关键性能指标对比

GC类型 典型停顿时间 并发程度 适用场景
Serial 2000-5000ms ❌ 无并发 客户端应用
Parallel 1000-2000ms ❌ 无并发 批处理任务
CMS 10-50ms ✅ 部分并发 Web应用
G1 100-200ms ✅ 部分并发 大内存应用
ZGC <1ms ✅ 全并发 金融/大数据

6.3 GC特性对比矩阵

GC类型 吞吐量 延迟 内存占用 复杂度 稳定性
Serial GC ⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
Parallel GC ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
CMS GC ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐ ⭐⭐
G1 GC ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐
ZGC ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐

特性说明

  • ⭐⭐⭐⭐⭐ = 优秀(高吞吐量/低延迟/低占用等)
  • ⭐⭐⭐⭐ = 良好
  • ⭐⭐⭐ = 中等
  • ⭐⭐ = 较差
  • ⭐ = 差

6.4 关键技术对比表

GC类型 关键技术 并发程度 停顿时间 适用场景
Serial 单线程STW ❌ 无 100-1000ms 客户端应用
Parallel 多线程并行 ❌ 无 50-200ms 批处理任务
CMS 并发标记清除 ✅ 部分 10-50ms Web应用
G1 分区化内存 ✅ 部分 10-100ms 大内存应用
ZGC 染色指针 ✅ 全量 <1ms 金融/大数据

6.5 内存管理策略对比

graph TD
    subgraph "内存模型演进"
        M1[固定分代] --> M2[分区化] --> M3[全并发]
    end

    subgraph "Serial/Parallel"
        F1[Eden + 2Survivor + Old]
        F2[固定边界]
        F3[复制+标记整理]
    end

    subgraph "G1"
        R1[多个Region]
        R2[动态角色]
        R3[增量回收]
    end

    subgraph "ZGC"
        Z1[Small+Medium+Large Page]
        Z2[染色指针]
        Z3[全并发移动]
    end

    M1 --> F1
    M2 --> R1
    M3 --> Z1

    style F1 fill:#add8e6
    style R1 fill:#90ee90
    style Z1 fill:#ff6b6b

6.6 GC性能指标详细对比

Serial GC

  • 吞吐量: ⭐⭐ (单线程限制,适合轻负载)
  • 延迟: ⭐⭐ (STW时间长,不适合交互应用)
  • 内存占用: ⭐⭐⭐⭐⭐ (最简单的实现,内存开销最小)
  • 实现复杂度: ⭐⭐⭐⭐⭐ (最简单,稳定可靠)
  • 稳定性: ⭐⭐⭐⭐⭐ (经过最长时间验证)
Parallel GC
  • 吞吐量: ⭐⭐⭐⭐⭐ (多核并行,吞吐量最高)
  • 延迟: ⭐⭐ (STW仍然较长)
  • 内存占用: ⭐⭐⭐⭐ (中等开销)
  • 实现复杂度: ⭐⭐⭐⭐ (相对简单)
  • 稳定性: ⭐⭐⭐⭐ (成熟稳定)

CMS GC

  • 吞吐量: ⭐⭐⭐ (并发执行,但CPU开销大)
  • 延迟: ⭐⭐⭐⭐ (低延迟,但有波动)
  • 内存占用: ⭐⭐⭐ (需要额外数据结构)
  • 实现复杂度: ⭐⭐ (并发算法复杂)
  • 稳定性: ⭐⭐ (存在并发模式失败风险)

G1 GC

  • 吞吐量: ⭐⭐⭐⭐ (平衡性能,略有开销)
  • 延迟: ⭐⭐⭐⭐ (可预测延迟,可控)
  • 内存占用: ⭐⭐⭐ (Remembered Set开销)
  • 实现复杂度: ⭐⭐ (分区管理复杂)
  • 稳定性: ⭐⭐⭐⭐ (经过长期验证)

ZGC

  • 吞吐量: ⭐⭐⭐⭐ (接近G1,略有读屏障开销)
  • 延迟: ⭐⭐⭐⭐⭐ (超低延迟,<1ms)
  • 内存占用: ⭐⭐ (多映射内存开销较大)
  • 实现复杂度: ⭐ (最复杂的技术实现)
  • 稳定性: ⭐⭐⭐ (相对较新,持续优化)

七、GC选择策略与最佳实践

7.1 GC选择决策流程

flowchart TD
    START[选择GC] --> MEMORY{内存大小}

    MEMORY -->|<100MB| SMALL1[小内存应用]
    MEMORY -->|100MB-2GB| MEDIUM1[中等内存应用]
    MEMORY -->|2GB-16GB| LARGE1[大内存应用]
    MEMORY -->|>16GB| XLARGE1[超大内存应用]

    SMALL1 --> SMALL_TYPE{应用类型}
    SMALL_TYPE -->|客户端工具| SERIAL[Serial GC]
    SMALL_TYPE -->|服务端| PARALLEL[Parallel GC]

    MEDIUM1 --> MEDIUM_REQ{性能要求}
    MEDIUM_REQ -->|吞吐量优先| PARALLEL2[Parallel GC]
    MEDIUM_REQ -->|延迟敏感| CMS[CMS GC]

    LARGE1 --> LARGE_DELAY{延迟要求}
    LARGE_DELAY -->|可接受100ms| G1[G1 GC]
    LARGE_DELAY -->|要求<50ms| G1_LATENCY[优化配置的G1]

    XLARGE1 --> XLARGE_SCENE{应用场景}
    XLARGE_SCENE -->|金融交易| ZGC[ZGC]
    XLARGE_SCENE -->|大数据AI| ZGC_BIG[ZGC]
    XLARGE_SCENE -->|普通应用| G1_BIG[大内存G1]

    style SERIAL fill:#add8e6
    style PARALLEL fill:#90ee90
    style CMS fill:#ffeb3b
    style G1 fill:#90ee90
    style ZGC fill:#ff6b6b

7.2 推荐配置参数

客户端应用(小内存)

1
-XX:+UseSerialGC -Xms128m -Xmx512m

批处理任务(高吞吐量)

1
-XX:+UseParallelGC -Xms2g -Xmx4g -XX:ParallelGCThreads=4

Web应用(平衡性能)

1
-XX:+UseG1GC -Xms4g -Xmx8g -XX:MaxGCPauseMillis=200

大数据应用(低延迟)

1
-XX:+UseZGC -Xms16g -Xmx32g

八、总结与展望

8.1 GC技术演进总结

从JDK 1.3的Serial GC到JDK 17+的ZGC,Java垃圾收集器经历了一个不断优化的演进过程:

  1. 单线程时代(JDK 1.3):简单但低效的串行收集
  2. 并行时代(JDK 1.4-8):充分利用多核的并行收集
  3. 并发时代(JDK 1.5+):追求低延迟的并发收集
  4. 分区时代(JDK 9+):G1的分区化精细管理
  5. 全并发时代(JDK 15+):ZGC的超低延迟全并发

8.2 技术发展趋势

  • 延迟优化:从秒级停顿到毫秒级,再到亚毫秒级
  • 内存扩展:从MB级到GB级,再到TB级支持
  • 并发程度:从串行到并行,再到全并发
  • 智能化:自适应调优和智能参数选择

8.3 选择建议

场景特征 推荐GC 理由
小内存、单核 Serial 简单可靠,资源占用小
多核、高吞吐 Parallel 充分利用CPU,批处理效率高
中等内存、低延迟 G1 平衡性能,停顿可控
大内存、极低延迟 ZGC 支持超大堆,停顿时间极短

8.4 最佳实践

  1. 了解应用特点:内存大小、延迟要求、吞吐量需求
  2. 合理配置参数:根据场景选择合适的JVM参数
  3. 持续监控优化:通过GC日志和监控工具持续调优
  4. 版本升级考虑:新版本JVM通常带来GC性能改进

Java垃圾收集器的演进体现了计算机科学与工程实践的完美结合,通过不断的创新和优化,为不同类型的应用提供了最适合的内存管理解决方案。


简述

​ RAG的主流程其实比较简单,流程前面已经说过了,简单看一下下面的图了解一下就行。

image-20250621160518166

​ 从上面的图,可以看到,RAG在回答用户问题的时候,向量数据库的交互式非常重要的一环,如果我们能提高向量库的召回质量也就能提高整个答案生成的质量。

强化索引

​ 现在我们知道提高向量库的召回质量能提高整个提问答案的生成质量,怎么提高向量库交互的召回质量有成了新的问题。之前我们说过向量库的查询召回是通过向量的比较实现的,与提问越相近的向量被召回的概率越高。现在我们只要提高文档的向量质量,这样才能提高查询召回的准确性和质量。

MRI

​ MRI(Multi-representation Indexing)多表示强化索引,为每个文档构建多个不同视角的向量表示。其实这个很好理解,我们一句话从不同的角度去理解包含了不同的信息,比如“我今天下午在写博客”这句话,第一个角度理解,就是字面意思,我今天下午写博客,换一个角度,我今天下午还活着,不然也不能写博客。从上面的示例,我们从两个角度获得了两个信息,推广一下,一段话、一篇文章也一样,不同的角度的理解能得到不同的信息,也就能根据这些不同的信息生成不同的向量。

​ 不同维度的理解更加丰富了文章的定义,不同维度的理解也催生了多向量多文章的描述,从而使文章的向量定义更加准确,也使我们查询召回的时候更加准确。

image-20250621160518166

​ 在实际应用中,我们多以父子文档的方式去处理MRI,父文档是原始文档,子文档都是对父文档的摘要、标题等多维度理解表达,我们通过将子文档存入向量库,通过提问向量匹配召回后,通过这些理解表达再找回父文档返回。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import uuid
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.storage import InMemoryByteStore
from langchain.retrievers.multi_vector import MultiVectorRetriever

# 假设我们有两个原始文档
raw_docs = [
"LangChain is a framework for developing applications powered by language models.",
"Chroma is a vector database used for similarity search over embeddings."
]

# 为每个原始文档生成多个摘要(手动模拟,也可以用 LLM 自动生成)
summaries = [
["LangChain is a framework for LLM apps.", "It helps chain language model calls."],
["Chroma is a vector store.", "It supports similarity search for embeddings."]
]

# 给每个原始文档生成唯一 ID
doc_ids = [str(uuid.uuid4()) for _ in raw_docs]

# 准备子文档(用于嵌入、向量检索)
summary_docs = []
for i, summary_list in enumerate(summaries):
for s in summary_list:
summary_docs.append(Document(page_content=s, metadata={"doc_id": doc_ids[i]}))

# 向量数据库用于存储子文档(summary)
embedding_function = OpenAIEmbeddings()
vectorstore = Chroma(collection_name="multi_index_demo", embedding_function=embedding_function)

# 用于存储父文档(原文)
byte_store = InMemoryByteStore()
id_key = "doc_id"

# 构建多向量检索器
retriever = MultiVectorRetriever(
vectorstore=vectorstore,
byte_store=byte_store,
id_key=id_key,
)

# 添加子文档到向量库
retriever.vectorstore.add_documents(summary_docs)

# 添加原文(父文档)到字节存储中
retriever.docstore.mset(list(zip(doc_ids, raw_docs)))

# 🔍 测试查询
query = "What is a vector database?"
results = retriever.get_relevant_documents(query, n_results=1)

print("🔍 用户查询:", query)
print("📄 匹配到的原始文档:", results[0].page_content)

RAPTOR

RAPTOR 是一种 高效构建多表示索引(multi-representation indexing)的新方法,上面的MRI相当于多维度广度上的处理,一般有广度处理就有深度处理。刚才是从维度上对文章做的处理,现在我们从深度上做处理。我们可以将一篇文章拆分成多个段落,然后对每个段落进行提炼总结,总结出一个个标题,再对这些标题做呢向量处理,生成出向量,这样我们查询到的是一个个段落,这样我们的查询结果会更加精准。

RAPTOR 的核心思想是:先用结构化方法将文档拆分为多个子块(chunk),再用 LLM 为每个子块生成一个有语义的标题作为向量索引的表示

image-20250621160518166

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 伪代码:chunk → title → embedding
from langchain_core.documents import Document
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import FAISS

llm = ChatOpenAI(model="gpt-4")
embed = OpenAIEmbeddings()

chunks = [...] # 原始文档分块
title_chunks = []

for chunk in chunks:
title = llm.invoke(f"为以下内容生成一个主题标题:\n\n{chunk}")
title_chunks.append(Document(page_content=title, metadata={"chunk": chunk}))

# 把标题作为表示加入向量库
vectorstore = FAISS.from_documents(title_chunks, embed)

# 检索时命中标题 → 返回原 chunk(从 metadata 中获取)

ColBERT

ColBERTColumn-wise BERT改进语义检索质量的深度表示方法,但它的思路和实现非常独特,ColBERT 是一种 细粒度(token-level)语义检索方法,它把文档和查询都表示成 多个 token 向量,然后通过 最大相似度匹配机制 来计算查询与文档的相关性,从而提升精度和效率。和上面的REAPTOR 类似单不同,REAPTOR是拆分子块,然后生成标题,这个ColBERT直接就拆分成token了,可以理解为拆分的更细更碎,且没有提炼生成标题的阶段。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from ragatouille import RAGPretrainedModel
import requests

# 加载 ColBERT 模型
RAG = RAGPretrainedModel.from_pretrained("colbert-ir/colbertv2.0")

# 用 Wikipedia API 获取文章内容
def get_wikipedia_page(title: str):
URL = "https://en.wikipedia.org/w/api.php"
params = {
"action": "query",
"format": "json",
"titles": title,
"prop": "extracts",
"explaintext": True,
}
headers = {"User-Agent": "ColBERT-example/0.0.1 (your_email@example.com)"}
response = requests.get(URL, params=params, headers=headers)
data = response.json()
page = next(iter(data["query"]["pages"].values()))
return page["extract"] if "extract" in page else None

# 获取文章内容
doc_text = get_wikipedia_page("Hayao Miyazaki")

# 用 ColBERT 构建向量索引
RAG.index(
collection=[doc_text],
index_name="miyazaki-demo-index",
max_document_length=180,
split_documents=True # 会将长文分割成 chunks
)

# 查询示例
query = "Which animation studio did Miyazaki found?"
results = RAG.search(query=query, k=3)

# 打印结果
for rank, hit in enumerate(results, 1):
print(f"Rank {rank}:")
print("Score:", hit['score'])
print("Passage:\n", hit['content'])
print("=" * 80)

三者异同

​ 我们来详细对比 MRI(Multi-representation Indexing)RAPTORColBERT 三者的异同点

特性 MRI RAPTOR ColBERT
📌 核心思路 同一段文本生成多个不同视角的表示向量(如主题、意图、关键词等) 将文档划分为语义段(小粒度),为每段生成简洁标题(父文档)作为召回锚点 将文本拆成 token 粒度,用户 query 也拆成 token,然后进行 token 级别的Late Interaction 匹配
🎯 优势 多角度语义增强召回,能捕捉不同用户提问方式 结构化文档、压缩检索空间,提高精确召回率 更细粒度的匹配,可解释性强,适合长文高密度信息检索
🧩 粒度 Chunk/Paragraph 级 Paragraph + Title Token 级别
🧠 表示方式 多向量(multi-head) 父子文档(title-child) Token-level 表示
📦 模型类型 通常是 dense encoder(可多次 encode) 标题生成模型 + Dense encoder 特殊结构模型(支持 Late Interaction),如 ColBERT 模型
🧪 检索方式 向量 ANN 父文召回 + 子文 rerank Token-Level MaxSim 聚合
🧰 代表实现 HyDE-MRI、[NeMo RAG] RAPTOR (Hofstätter et al. 2023) ColBERT, ColBERTv2

​ 每一种方法都有优劣,这时候你应该能很敏感的get到,如果能将三种方法综合在一起应该是个很棒的方案,那这个方案是否存在呢,肯定是存在的。如下流程整合:

  1. 文档分割(RAPTOR)
    • 用 RAPTOR 的方式划分语义段落,为每段生成标题(构建父子结构);
  2. 多表示索引(MRI)
    • 对每段生成多种视角的表示向量(主题向量 + 关键词向量 + 语义向量);
  3. Token-level 检索(ColBERT)
    • 用户 Query 使用 ColBERT 拆成 token 粒度匹配以 rerank 最终结果。
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
from ragatouille import RAGPretrainedModel
from transformers import pipeline
import requests

# 加载 ColBERT 模型(用于 Token-level 检索)
RAG = RAGPretrainedModel.from_pretrained("colbert-ir/colbertv2.0")

# 用标题生成模型(模拟 RAPTOR 的“父文档”)
title_generator = pipeline("text2text-generation", model="google/flan-t5-base")

# 获取 Wikipedia 页面
def get_wikipedia_page(title: str):
URL = "https://en.wikipedia.org/w/api.php"
params = {"action": "query", "format": "json", "titles": title, "prop": "extracts", "explaintext": True}
headers = {"User-Agent": "ColBERT-MRI-RAPTOR/0.0.1 (you@domain.com)"}
resp = requests.get(URL, params=params, headers=headers)
page = next(iter(resp.json()["query"]["pages"].values()))
return page.get("extract", "")

# 文档拆分成段落并生成标题(RAPTOR思路)
def split_and_title(document):
chunks = [chunk for chunk in document.split("\n\n") if len(chunk.strip()) > 100]
titled_chunks = []
for chunk in chunks:
title = title_generator(f"Generate title: {chunk[:200]}", max_new_tokens=12)[0]["generated_text"]
titled_chunks.append({"title": title, "content": chunk})
return titled_chunks

# MRI:多表示(示例:原文 + 关键词抽取 + 问题改写)
def multi_view_representations(titled_chunks):
views = []
for chunk in titled_chunks:
views.append(chunk["content"]) # 原始 chunk
views.append(chunk["title"]) # RAPTOR 的标题
# 关键词表示(模拟 MRI)
views.append("Keywords: " + ", ".join(chunk["content"].split()[:10]))
return views

# 构建索引
def index_document(title):
full_text = get_wikipedia_page(title)
titled_chunks = split_and_title(full_text)
mri_texts = multi_view_representations(titled_chunks)
RAG.index(collection=mri_texts, index_name=f"RAG-MRI-RAPTOR-{title}", max_document_length=180)

# 查询
def search(query, k=3):
results = RAG.search(query=query, k=k)
for i, hit in enumerate(results, 1):
print(f"Rank {i}: [Score: {hit['score']:.2f}]")
print(hit['content'])
print("="*80)

# 示例运行
index_document("Hayao Miyazaki")
search("Which animation studio did Hayao Miyazaki found?")

简述

​ RAG的主流程其实比较简单,流程前面已经说过了,简单看一下下面的图了解一下就行。

image-20250621160518166

​ 从上面的图,可以看到,RAG在回答用户问题的时候,最少做了两次交互:1、与向量数据库的交互,2、与大模型的交互。如果我们对向量数据库和大模型有了细分,比如物理相关文档都放在物理向量数据库,物理大模型也是全部都用物理数据训练的,如果用户提问的是物理问题,那与物理向量数据库和物理大模型交互是不是能得到更好的答案,答案是肯定的,毕竟没有其他数据的污染,会查询生产的更快更准。

路由选择

​ 从上面我们看到了两个地方可以做路由选择:向量数据库、大模型。其实除了这两个地方,还有一个隐形的地方,那就是提示词模版。我们调用大模型的时候是要输入提示词的,提示词都是按模版输入的,如果我们细分做好分类,按不同的问题选择特定的模版,也能提高我们生成更好答案的概率。

​ 说直白一点,就是根据问题的类型,选择相应的向量库、模版和大模型。

向量数据库路由

​ 其实我们是没有办法直接区分用户问题的类型的,但是我们可以让大模型去做区分。拿到区分后的结果,去选择相应的路由。

graph LR
A(开始) --> B[划定分类范围]
B --> C[大模型判断分类]
C --> D[选择向量库]
D --> E(结束)

​ 1、划定分类:根据我们已有的向量数据库设置好分类,规范好分类种类。

​ 2、大模型判断:输入用户问题,和我们设置好的分类,让大模型判断出用户输入问题是分类中的哪一类,或者说与分类中哪一类相关性最高。

​ 3、选择向量库:根据大模型的判断结果,选择已有的向量库。

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
31
32
33
from typing import Literal

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.pydantic_v1 import BaseModel, Field
from langchain_openai import ChatOpenAI

# Data model
class RouteQuery(BaseModel):
"""Route a user query to the most relevant datasource."""

datasource: Literal["python_docs", "js_docs", "golang_docs"] = Field(
...,
description="Given a user question choose which datasource would be most relevant for answering their question",
)

# LLM with function call
llm = ChatOpenAI(model="gpt-3.5-turbo-0125", temperature=0)
structured_llm = llm.with_structured_output(RouteQuery)

# Prompt
system = """You are an expert at routing a user question to the appropriate data source.

Based on the programming language the question is referring to, route it to the relevant data source."""

prompt = ChatPromptTemplate.from_messages(
[
("system", system),
("human", "{question}"),
]
)

# Define router
router = prompt | structured_llm

提示词模版路由

​ 套路显然和上面的向量库的套路一样,用大模型获取到分类,在用分类去获取预设好的提示词模版。代码类同,能拿到对应的分类,找到分类对应的模版即可。

graph LR
A(开始) --> B[划定分类范围]
B --> C[大模型判断分类]
C --> D[选择提示词模版]
D --> E(结束)

大模型路由

​ 套路一样,用大模型的判断结果去选择相应的大模型。代码类同。

graph LR
A(开始) --> B[划定分类范围]
B --> C[大模型判断分类]
C --> D[选择大模型]
D --> E(结束)

其他路由选择方法

​ 除了上述使用大模型判断分类,还有没有其他方法判断分类,肯定是有的。以向量计算相关性,判断出最相关的分类。使用embeddings计算出预设模版的向量,再计算出用户问题的向量,最后找出与问题向量最相关的预设模版。能找出模版也就知道预设模版的分类,使用分类去找向量库和大模型即可。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from langchain.utils.math import cosine_similarity
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

# Two prompts
physics_template = """You are a very smart physics professor. \
You are great at answering questions about physics in a concise and easy to understand manner. \
When you don't know the answer to a question you admit that you don't know.

Here is a question:
{query}"""

math_template = """You are a very good mathematician. You are great at answering math questions. \
You are so good because you are able to break down hard problems into their component parts, \
answer the component parts, and then put them together to answer the broader question.

Here is a question:
{query}"""

# Embed prompts
embeddings = OpenAIEmbeddings()
prompt_templates = [physics_template, math_template]
prompt_embeddings = embeddings.embed_documents(prompt_templates)

# Route question to prompt
def prompt_router(input):
# Embed question
query_embedding = embeddings.embed_query(input["query"])
# Compute similarity
similarity = cosine_similarity([query_embedding], prompt_embeddings)[0]
most_similar = prompt_templates[similarity.argmax()]
# Chosen prompt
print("Using MATH" if most_similar == math_template else "Using PHYSICS")
return PromptTemplate.from_template(most_similar)


chain = (
{"query": RunnablePassthrough()}
| RunnableLambda(prompt_router)
| ChatOpenAI()
| StrOutputParser()
)

print(chain.invoke("What's a black hole"))

简述

​ 这周遇到两个新问题,就是大文件的上传和下载。

场景 之前 问题
大文件上传云存储 文件上传到服务器,在从服务器上传到云存储 1、通过nginx、nacos转发耗时太长 
2、太消耗服务器资源
用户下载视频 从其他端下载视频,再将视频转发给客户端 同上

​ 这两个问题的解决,还得感谢chatgpt,这玩意还是好用的。

大文件上传

​ 之前都是直接将文件上传到服务器,通过服务器将文件上传到云端,拿到链接后保存链接。这样刚开始使用的时候还行,上传的文件比较小,后来要上传大文件,问题就暴露出来了,耗时太长。文件要经过nginx、nacos的转发,一个200M的文件,到服务器后端代码开始处理已经过去十多分钟了。除了耗时,还太消耗资源,nginx、nacos、spring服务这每一项都要转发一下大文件,都要消耗内存流量。

​ 解决方案:1、改长相应时间,2、修改设计方案。第一个解决方案明显不行,没解决根本问题,耗时长,资源消耗大的问题还是存在。那就只能考虑第二个解决方案了。

​ 其实刚开始,我也没啥好的解决方案(见识短浅)。后来问了一下chatgpt,解决方案直接就出来了,生成预链接,让前端通过预链接上传文件。说是业界都用的这种方案,我们都不知道,还是落后呢脱节了啊。我们公司用的是AWS的S3存储,它是支持生成预链接的,除了AWS,其他的云厂商都是支持的(阿里云,腾讯云等)。为了保障链接的有效性,我在后加了一个地址校验,校验一下预链接的地址前端有没有上传文件,防止前端没有上传。代码编写随便百度一下大把。

image-20250705170312520

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
31
32
33
/**
* 生成一个可用于 PUT 上传的预签名 URL
*
* @param bucketName S3桶名
* @param objectKey 上传到 S3 的 key(路径+文件名)
* @param contentType 上传内容类型(如 "image/jpeg", "application/pdf")
* @param validMinutes URL 有效时间(分钟)
* @return 可用的 PUT 请求 URL
*/
public URL generatePresignedPutUrl(String bucketName, String objectKey, String contentType, int validMinutes) {
// 初始化 S3 PreSigner(线程安全,可复用)
try (S3Presigner preSigner = S3Presigner.builder()
.region(REGION)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)
))
.build()) {

PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(objectKey)
.acl(ObjectCannedACL.PUBLIC_READ)
.contentType(contentType)
.build();

PresignedPutObjectRequest preSignedRequest = preSigner.presignPutObject(builder -> builder
.putObjectRequest(putObjectRequest)
.signatureDuration(Duration.ofMinutes(validMinutes)) // 有效期
);
// 可用于前端 PUT 上传
return preSignedRequest.url();
}
}

视频下载

​ 背景:本来可以提供三方的视频链接直接给前端下载的,可以视频链接有反扒校验,预ip绑定,其他ip都打不开链接,所以只能后端服务器下载了传给前端。

​ 初次解决方案:服务器直接下载完,再转发给前端下载,但是这样要与前端保持链接的时间太长了,服务端要下载一遍,前端也要接着下载一遍。同时也消耗服务器资源,带宽流量和服务器内存。

​ 优化方案:通过服务器直接透传给前端,这样服务器不用保存,可以减少内存的压力,同时也减少相应的时间。不过这也有问题,前端没那么稳定,会断,导致整个视频下载不了。

​ 最终方案:分片返回给前端,将一个视频流切成很多片,每次请求一片,这样将一个长连接变成了许多短连接。不会因前端的一次断开,导致整个下载失败。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
@PostMapping("/pronhub/url/download")
public ResponseEntity<StreamingResponseBody> downloadVideoWithRange(
HttpServletRequest request,
HttpServletResponse response,
@RequestBody PronhubUrlDownLoadRequest body) throws IOException {

String videoUrl = body.getUrl();
URL url = new URL(videoUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestProperty("User-Agent", "Mozilla/5.0");

int responseCode = conn.getResponseCode();
if (responseCode != 200) {
return ResponseEntity.status(responseCode)
.contentType(MediaType.TEXT_PLAIN)
.body(out -> out.write(("视频请求失败: " + responseCode).getBytes(StandardCharsets.UTF_8)));
}

String contentType = conn.getContentType();
int fullLength = conn.getContentLength();

String rangeHeader = request.getHeader("Range");
int start = 0;
int end;

if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
String[] ranges = rangeHeader.replace("bytes=", "").split("-");
start = Integer.parseInt(ranges[0].trim());
if (ranges.length > 1 && !ranges[1].isEmpty()) {
end = Integer.parseInt(ranges[1].trim());
} else {
// 如果客户端只给了 start,没有 end,主动限制最多 CHUNK_SIZE
end = Math.min(start + CHUNK_SIZE - 1, fullLength - 1);
}
} else {
// 没有 Range 请求时,默认返回从头开始的一个 5MB 分片
end = Math.min(start + CHUNK_SIZE - 1, fullLength - 1);
}

int rangeLength = end - start + 1;
conn.setRequestProperty("Range", "bytes=" + start + "-" + end);
InputStream inputStream = conn.getInputStream();

final int finalStart = start;
final int finalEnd = end;

StreamingResponseBody stream = outputStream -> {
try (InputStream in = inputStream) {
byte[] buffer = new byte[8192];
int bytesRead;
long totalRead = 0;
long lastFlush = System.currentTimeMillis();

while ((bytesRead = in.read(buffer)) != -1 && totalRead < rangeLength) {
int toWrite = (int) Math.min(bytesRead, rangeLength - totalRead);
outputStream.write(buffer, 0, toWrite);
totalRead += toWrite;

if (System.currentTimeMillis() - lastFlush > 2000) {
outputStream.flush();
lastFlush = System.currentTimeMillis();
}
}

outputStream.flush();
} catch (IOException ex) {
if (ex.getMessage().contains("Broken pipe")) {
log.warn("客户端连接中断: {}", ex.getMessage());
} else {
log.error("视频传输异常: ", ex);
}
}
};

return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.header("Accept-Ranges", "bytes")
.header("Content-Type", contentType != null ? contentType : "video/mp4")
.header("Content-Length", String.valueOf(rangeLength))
.header("Content-Range", String.format("bytes %d-%d/%d", finalStart, finalEnd, fullLength))
.header("Full-Length", String.valueOf(fullLength))
.header("Access-Control-Expose-Headers", "Content-Range,Full-Length")
.header("Content-Disposition", "attachment; filename=video.mp4")
.body(stream);
}

简述

​ RAG的主流程其实比较简单,就是先将用户的提问去向量数据库查询相关文档,再将文档和用户提问组装发送给大模型生成答案,最后将答案返回给用户。其中主要的组件只有向量数据库和大模型,只是两相组合可以玩出花来。

image-20250621160518166

丰富提示词

​ RAG意思是检索增强生成,大模型的生成都是根据提示词生成的,而向量库的存在就是为了丰富我们的提示词,丰富了我们的提示词,大模型才能生成出更理想更丰富合适的答案。那怎么丰富我们的提示词呢?

多版本查询

​ 在我们用问题向量化之后向量去检索向量库得到文档的时候,我们能不能尽可能多的获取相关性的文档,比如说,我的提问是用汉语提问题的,这时候向量化之后一定会带有汉语的向量,导致检索向量库的时候检索到可能都是汉语的文档,其实有些问题的英语文档或者其他语言的文档更全面,如果检索到这些文档,去丰富我们的提示词,生成的答案就会更好。

​ 当然我们在提问的时候不可能每个语言或者其他角度都描述到,这时候我们可以让llm帮我们生成这些版本的问题,去检索到更多更有效的文档。

image-20250621160518166

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
from langchain.prompts import ChatPromptTemplate

# Multi Query: Different Perspectives
template = """You are an AI language model assistant. Your task is to generate five
different versions of the given user question to retrieve relevant documents from a vector
database. By generating multiple perspectives on the user question, your goal is to help
the user overcome some of the limitations of the distance-based similarity search.
Provide these alternative questions separated by newlines. Original question: {question}"""
prompt_perspectives = ChatPromptTemplate.from_template(template)

from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI

generate_queries = (
prompt_perspectives
| ChatOpenAI(temperature=0)
| StrOutputParser()
| (lambda x: x.split("\n"))
)

from langchain.load import dumps, loads

def get_unique_union(documents: list[list]):
""" Unique union of retrieved docs """
# Flatten list of lists, and convert each Document to string
flattened_docs = [dumps(doc) for sublist in documents for doc in sublist]
# Get unique documents
unique_docs = list(set(flattened_docs))
# Return
return [loads(doc) for doc in unique_docs]

# Retrieve
question = "What is task decomposition for LLM agents?"
retrieval_chain = generate_queries | retriever.map() | get_unique_union
docs = retrieval_chain.invoke({"question":question})
len(docs)

from operator import itemgetter
from langchain_openai import ChatOpenAI
from langchain_core.runnables import RunnablePassthrough

# RAG
template = """Answer the following question based on this context:

{context}

Question: {question}
"""

prompt = ChatPromptTemplate.from_template(template)

llm = ChatOpenAI(temperature=0)

final_rag_chain = (
{"context": retrieval_chain,
"question": itemgetter("question")}
| prompt
| llm
| StrOutputParser()
)

final_rag_chain.invoke({"question":question})

结果融合

​ 上面的过程,我们将多版本检索出来的结果都提交给大模型生成答案了,多版本检索出来的多结果肯定有好有坏,坏的结果一起提交给大模型,肯定也会影响大模型生成答案,那我们要怎么做才能让减少坏结果的影响呢?

​ 在我们搜索的多结果中,我们可以根据文档在每个列表中的排名,通过 RRF 算法为每个文档打分,最终返回一个融合后的排序列表,最后从列表中筛选出前几个我们想要的结果即可。

给定多个文档排名列表(多个检索器、多个查询),RRF 的打分公式如下:

$$
\text{Score}(d) = \sum_{i=1}^{n} \frac{1}{\text{rank}_i + k}
$$

其中:

  • ( \text{rank}_i ):文档 ( d ) 在第 ( i ) 个排序列表中的排名(从 0 开始)
  • ( k ):平滑参数(通常取 60)
  • ( n ):总共融合的列表数量(例如不同查询或不同检索器)

文档出现越多、排名越靠前,其 RRF 得分就越高。

image-20250621160518166

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
def reciprocal_rank_fusion(results: list[list], k=60):
""" Reciprocal_rank_fusion that takes multiple lists of ranked documents
and an optional parameter k used in the RRF formula """

# Initialize a dictionary to hold fused scores for each unique document
fused_scores = {}

# Iterate through each list of ranked documents
for docs in results:
# Iterate through each document in the list, with its rank (position in the list)
for rank, doc in enumerate(docs):
# Convert the document to a string format to use as a key (assumes documents can be serialized to JSON)
doc_str = dumps(doc)
# If the document is not yet in the fused_scores dictionary, add it with an initial score of 0
if doc_str not in fused_scores:
fused_scores[doc_str] = 0
# Retrieve the current score of the document, if any
previous_score = fused_scores[doc_str]
# Update the score of the document using the RRF formula: 1 / (rank + k)
fused_scores[doc_str] += 1 / (rank + k)

# Sort the documents based on their fused scores in descending order to get the final reranked results
reranked_results = [
(loads(doc), score)
for doc, score in sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)
]

# Return the reranked results as a list of tuples, each containing the document and its fused score
return reranked_results

提问分解

​ 从版本的角度让我们的提示词更丰富了,但是在语义描述方面,能不能提升一下我们的提示词。在一个复杂困难的问题上我们的提示词可能是不够的,毕竟问题的范围比较大,我们的描述范围有限。如果我们缩小问题的范围,变成小问题,我们的提示词是不是就比较丰富了。在我们经验中,那种复杂的问题,比较好的解决办法就是把它拆分成一个一个的小问题,然后依次去解决这些小问题。在这里我们同样可以套用这种解决方式,把复杂问题甩给大模型让他帮我们拆分成一个一个的小问题,然后我们依次去提问解决这些小问题。正对每一个小问题我们的提示词是不是就丰富了

递归回答

​ 怎么去解决这些小问题,如果这些小问题上下有相关性的话,我们可以使用递归递归回答,就是将上一个问题的答案带入到下一个问题中。

image-20250621160518166

​ 如上图,我们将一个复杂问题拆分成3个小问题,然后依次查询生成解决,将每次解决的结果带入到下一次解决中,最后解决这个复杂问题生成一个完整答案输出。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
from langchain.prompts import ChatPromptTemplate

# Decomposition
template = """You are a helpful assistant that generates multiple sub-questions related to an input question. \n
The goal is to break down the input into a set of sub-problems / sub-questions that can be answers in isolation. \n
Generate multiple search queries related to: {question} \n
Output (3 queries):"""
prompt_decomposition = ChatPromptTemplate.from_template(template)
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser

# LLM
llm = ChatOpenAI(temperature=0)

# Chain
generate_queries_decomposition = ( prompt_decomposition | llm | StrOutputParser() | (lambda x: x.split("\n")))

# Run
question = "What are the main components of an LLM-powered autonomous agent system?"
questions = generate_queries_decomposition.invoke({"question":question})
# Prompt
template = """Here is the question you need to answer:

\n --- \n {question} \n --- \n

Here is any available background question + answer pairs:

\n --- \n {q_a_pairs} \n --- \n

Here is additional context relevant to the question:

\n --- \n {context} \n --- \n

Use the above context and any background question + answer pairs to answer the question: \n {question}
"""

decomposition_prompt = ChatPromptTemplate.from_template(template)
from operator import itemgetter
from langchain_core.output_parsers import StrOutputParser

def format_qa_pair(question, answer):
"""Format Q and A pair"""

formatted_string = ""
formatted_string += f"Question: {question}\nAnswer: {answer}\n\n"
return formatted_string.strip()

# llm
llm = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0)

q_a_pairs = ""
for q in questions:

rag_chain = (
{"context": itemgetter("question") | retriever,
"question": itemgetter("question"),
"q_a_pairs": itemgetter("q_a_pairs")}
| decomposition_prompt
| llm
| StrOutputParser())

answer = rag_chain.invoke({"question":q,"q_a_pairs":q_a_pairs})
q_a_pair = format_qa_pair(q,answer)
q_a_pairs = q_a_pairs + "\n---\n"+ q_a_pair
融合回答

​ 如果这些小问题上下文没有相关性或者说没有依赖性,我们又该怎么做呢?可以将每一个小问题单独的去解决生成答案,最后将所有小问题的答案融合生成复杂问题的汇总答案就行了。

image-20250621160518166

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# Answer each sub-question individually 

from langchain import hub
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI

# RAG prompt
prompt_rag = hub.pull("rlm/rag-prompt")

def retrieve_and_rag(question,prompt_rag,sub_question_generator_chain):
"""RAG on each sub-question"""

# Use our decomposition /
sub_questions = sub_question_generator_chain.invoke({"question":question})

# Initialize a list to hold RAG chain results
rag_results = []

for sub_question in sub_questions:

# Retrieve documents for each sub-question
retrieved_docs = retriever.get_relevant_documents(sub_question)

# Use retrieved documents and sub-question in RAG chain
answer = (prompt_rag | llm | StrOutputParser()).invoke({"context": retrieved_docs,
"question": sub_question})
rag_results.append(answer)

return rag_results,sub_questions

# Wrap the retrieval and RAG process in a RunnableLambda for integration into a chain
answers, questions = retrieve_and_rag(question, prompt_rag, generate_queries_decomposition)

def format_qa_pairs(questions, answers):
"""Format Q and A pairs"""

formatted_string = ""
for i, (question, answer) in enumerate(zip(questions, answers), start=1):
formatted_string += f"Question {i}: {question}\nAnswer {i}: {answer}\n\n"
return formatted_string.strip()

context = format_qa_pairs(questions, answers)

# Prompt
template = """Here is a set of Q+A pairs:

{context}

Use these to synthesize an answer to the question: {question}
"""

prompt = ChatPromptTemplate.from_template(template)

final_rag_chain = (
prompt
| llm
| StrOutputParser()
)

final_rag_chain.invoke({"context":context,"question":question})

后退重复生成

​ 对于复杂问题,除了上面说的拆分成子问题有没有其他的方式让我们的提示词变得更丰富了呢?答案肯定是有的,就是后退重复生成。啥意思呢,就是说,我们第一次的提示词不丰富,那我们让第一次生成的结果加到提示词中,这时候我们的提示词是不是更丰富了,在用这个丰富的提示词再去生成,是不是结果更好了呢,如果不够,我们再将生成的结果加入提示词再生成一次呢,一直到满意为止。

image-20250621160518166

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# Few Shot Examples
from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate
examples = [
{
"input": "Could the members of The Police perform lawful arrests?",
"output": "what can the members of The Police do?",
},
{
"input": "Jan Sindel’s was born in what country?",
"output": "what is Jan Sindel’s personal history?",
},
]
# We now transform these to example messages
example_prompt = ChatPromptTemplate.from_messages(
[
("human", "{input}"),
("ai", "{output}"),
]
)
few_shot_prompt = FewShotChatMessagePromptTemplate(
example_prompt=example_prompt,
examples=examples,
)
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"""You are an expert at world knowledge. Your task is to step back and paraphrase a question to a more generic step-back question, which is easier to answer. Here are a few examples:""",
),
# Few shot examples
few_shot_prompt,
# New question
("user", "{question}"),
]
)
generate_queries_step_back = prompt | ChatOpenAI(temperature=0) | StrOutputParser()
question = "What is task decomposition for LLM agents?"
generate_queries_step_back.invoke({"question": question})
# Response prompt
response_prompt_template = """You are an expert of world knowledge. I am going to ask you a question. Your response should be comprehensive and not contradicted with the following context if they are relevant. Otherwise, ignore them if they are not relevant.

# {normal_context}
# {step_back_context}

# Original Question: {question}
# Answer:"""
response_prompt = ChatPromptTemplate.from_template(response_prompt_template)

chain = (
{
# Retrieve context using the normal question
"normal_context": RunnableLambda(lambda x: x["question"]) | retriever,
# Retrieve context using the step-back question
"step_back_context": generate_queries_step_back | retriever,
# Pass on the question
"question": lambda x: x["question"],
}
| response_prompt
| ChatOpenAI(temperature=0)
| StrOutputParser()
)

chain.invoke({"question": question})

假想文档

​ 如果我们的向量数据库中,没有我们提问的所需要的文档和相关知识怎么办?这时候从向量库的检索是没办法帮我们丰富提示词的,但是我们让llm帮我们生成一个假想文档,这样我们就可以根据这个文档获得跟丰富的提示词从而生成出更丰满的答案了。

image-20250621160518166

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
31
32
33
34
35
36
37
38
39
from langchain.prompts import ChatPromptTemplate

# HyDE document genration
template = """Please write a scientific paper passage to answer the question
Question: {question}
Passage:"""
prompt_hyde = ChatPromptTemplate.from_template(template)

from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI

generate_docs_for_retrieval = (
prompt_hyde | ChatOpenAI(temperature=0) | StrOutputParser()
)

# Run
question = "What is task decomposition for LLM agents?"
generate_docs_for_retrieval.invoke({"question":question})
# Retrieve
retrieval_chain = generate_docs_for_retrieval | retriever
retireved_docs = retrieval_chain.invoke({"question":question})
retireved_docs
# RAG
template = """Answer the following question based on this context:

{context}

Question: {question}
"""

prompt = ChatPromptTemplate.from_template(template)

final_rag_chain = (
prompt
| llm
| StrOutputParser()
)

final_rag_chain.invoke({"context":retireved_docs,"question":question})

简述

25年第一次面试,电话面试,这次面试没有准备好,还没来得及复习。

自我介绍

19年毕业,5年多工作经验,目前正在优美互动这边工作。

定位:高开,之前带领三四个人,目前因为团队规模,各自一摊。

目前团队业务是做一些小应用投放海外市场,赚取一些收益。进入团队快一年的时间,参与了文生图和人名查询等项目。

之前华为团队也工作了三年左右的时间,业务是以洞察、评估、规划和收益四个部分向运营商提供数字化机会点发现。自己也参与了全球数据沙盘项目,到路网算法开始参与项目,然后开始OTN to 楼宇参与业务拓展,到最后的模型收编和原子能力编排重构项目。

业务爱好:打篮球,跑步,看看书,有时间也会写写博客。

项目经验

知识库

文生图

原子能力

redis

什么情况下redis的查询会很慢

两种情况,传输的时候很慢,redis处理的时候很慢

1、传输的时候很慢:网速不好,网络服务器部署太远。

2、redis处理很慢:

​ 1、存在bigkey,hotkey,占用了带宽和处理速度

​ 2、存在复杂查询,复杂度在O(n)

redis中的大key怎么删除

1、分隔:将一个bigkey分隔成多个小key,例如将一个含有上万字段的hash按照一定的策略拆分成多个hash

2、手动清理:redis4.0+可以使用 UNLINK 命令来异步删除一个或者多个指定的key,redis4.0以下可以考虑使用SCAN命令结合DEL命令来份批次删除。

3、采用合适的数据结构:例如二进制数据不使用String保存,使用HyperLogLog统计页面UV

4、开启lazy-free(惰性删除/延迟释放):lazy-free特性式redis4.0开始引进的,指的是让redis采用异步的方式延迟释放key使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。

数据库

怎么优化sql

万变不离其宗,所有查询的优化都是基于底层存储结构。

​ 1、innerbd是以B+树的形式保存的,根据索引和id构建了B+树,所以我们在查询的时候要注意使用索引,这样可以更快的找到数据。但是在使用的时候要注意最左匹配原则和隐式计算。

​ 2、索引B+数据的叶子结点保存的只是数据的id并不是数据的所有值,所以即使根据索引找到了数据的叶子结点,如果叶子结点的字段不够查询需要,还要根据id去找具体数据,这个过程叫做回表,但是如果叶子结点包含的字段值够我们查询需要,就不需要去查询具体数据,减少了回表这个过程。

​ 3、查看执行计划,根据执行计划优化sql。

执行计划怎么查看

image-20250309103934302

https://blog.csdn.net/tannins_/article/details/140007565

比较重要的字段:

select_type:查询类型 simple 简单语句, primary 复杂查询, union 联合查询 等等

table:对应的表

type:索引类型,all < index < range < eq_ref < const < system

possible_key:分析时可能用到的索引

key:实际使用的索引

key_len:使用到索引的长度

ref:哪些列或者常量被用于查找索引列上的值

row:估算查找到满足条件的记录所需要的读取的行数

extra:执行计划的其他信息

线程

线程池的参数

两个线程数:核心线程数和最大线程数

两个停留时间相关:停留时间值和时间单位

三个过程相关:产生的工厂方法,排队的队列,拒绝的策略

线程池参数中的排序队列的注意点

队列的选择

​ 一般分为:直接提交队列,有界任务队列,无界任务队列,优先任务队列,延迟队列。

​ SynchronousQueue:直接提交队列,此队列不存储任何元素,直接将每个插入操作必须等待一个对应的移除操作,反之依然。通常用于创建无界线程池,最大线程数实际取决于系统或JVM的限制

​ ArrayBlockingQueue:有界队列,此队列是一个基于数据结构的有界阻塞队列,新元素插入队列时,如果队列已满,则插入线程会被阻塞直到队列中有空间可用。使用有界队列,可以更好的控制资源的使用防止资源耗尽。

​ LinkedBlockingQueue:此队列按照先进先出的排序规则对元素进行排序,且队列的容量Integer.MAX_VALUE,使用无界队列时,线程池的大小将只受到corePoolSize的约束,因为任务可以在队列中无限期的执行等待,然而,这可能导致资源耗尽。

​ PriorityBlockingQueue:优先队列,此队列根据元素的优先级进行排序,优先级高的元素先被移除,对于按优先级执行任务的应用场景非常有用。

​ DelayQueue:延迟队列,此队列中的元素只有当起指定延迟时间到了才能够从队列中获取到该元素

注意控制队列的长度,避免造成内存溢出

线程池中公平锁的线程是怎么唤醒的

理解有点问题,公平锁是指一个锁,而线程池是一个池,线程池中的排序队列是可以有多种队列,但是队列的本质还是从头部获取第一个元素,所以线程池还是总是从队列中获取第一个元素,只是可能有排序方式的不一样,导致不一定是先入先出。

jvm

cpu飙升怎么排查

1、使用top命令查看系统cpu占用情况,找出占用最高的进程pid

2、使用命令查找进程内占用cpu最高的线程id:ps H -eo pid,tid,%cpu | grep 最高的进程pid

3、根绝线程id查找堆栈信息,注意要将线程id转化成16进制

4、在根据堆栈信息定位问题

简介

首先我们得明白一下概念,dify、ollama、deepseek都是什么?

dify:一个平台,一个简单易用开源的LLMOps(Language Model Operations)平台,我们可以在上面可视化的开发编排AI应用。

ollama:一个开源的大模型服务工具,帮助用户快速在本地运行大模型。

deepseek:国产的大模型。

所以我们要在dify编排大模型应用,就需要有一个大模型,而ollama刚好可以帮我部署运行大模型。所以ollama在这里更像是dify平台和deepseek大模型之间的连接工具。

ollama安装

可以在ollama官网直接下载安装https://ollama.com/。

linux安装命令:curl -fsSL https://ollama.com/install.sh | sh

安装之后可以通过 ollama -v 命令查看是否安装好。

image-20250320165712115

ollama的默认端口是11434,安装完可以通过浏览器打开ip和port界面,可以看到界面提示 ollama is running

image-20250320172632758

有默认端口,就可以改端口,可以通过修改启动文件,从而制定端口

vim /etc/systemd/system/ollama.service

可以看到这里我指定了9876端口

image-20250320172913316

deepseek安装

直接运行ollama run deepseek-r1:7b命令按转,当然也可以安装其他版本或者其他厂商的大模型。

image-20250320170141452

dify安装

这里我是通过docker安装的,docker的安装过程就详细展示,可自行搜索教程安装docker。

dify的安装命令:

1
2
3
4
git clone https://github.com/langgenius/dify.git
cd dify/docker
cp .env.example .env
docker compose up -d

其中dify默认的端口是80,我们要想修改端口的话,可以在 .env 环境配置中修改端口号。可以看到,这里我改成了8080。改完重启dify

image-20250320170830107

dify连接deepseek

通过浏览器打开 http://ip:port 界面 ip为自己主机ip,port为上面自己设置的port。在一系列账号密码注册之后到主页面。在主页面的个人账号下设置里配置大模型。image-20250320171734695

在setting里面,找到模型厂商(Model Provider),在下面的 to be configured 的配置中找到ollama的配置,安装好插件配置,成功安装后如图。

image-20250320172145700

在右下角中的Add model中添加模型。

image-20250320173110641

model name:一定要注意,写的是具体部署的模型型号,我们部署的deepseek-r1:7b,那这里也要填写deepseek-r1:7b,开始我以为可以随意命名,结果搞了半天都没连接上。

base url:填写上面ollama可以在浏览器打开的地址。

连接完就可以使用了,后面再分享怎么构建知识库怎么使用。

简述

实现备份,将源minio数据备份到其他minio上。

先决条件

1、minio版本相同,最好相同,如果版本不同可能会发生未知错误。

2、站点复制需要开启桶级别版本控制(Bucket Versioning),并且会自动开启已创建的Bucket的版本控制。不可以关闭站点复制的MinIO部署上的版本控制功能。

image-20241011222201154

3、源minio中有Bucket,并且有数据。目标minio中没有任何Bucket和Object。

4、源minio和目标minio最好有相同的IDP,IDP可在页面设置,且设置足够的权限。

image-20241011222424095

操作设置

页面上直接添加备份minio,设置好url和账号密码即可。

image-20241011222607409

注意如果已经有备份站点了,需要再添加一个的话,需要保留原有站点,在peer sites栏下面点击加号按钮新加一个,如下图。

image-20241013111252255

0%