JVM垃圾回收算法与收集器面试题详解

一、垃圾回收算法基础

1. 请详细说明主流的垃圾回收算法及其优缺点

问题分析角度:

  • 考察对GC算法理论基础的掌握
  • 考察算法适用场景的判断能力
  • 考察算法演进过程的理解

1.1 标记-清除算法(Mark-Sweep)

算法原理:

  1. 标记阶段: 标记所有需要回收的对象
  2. 清除阶段: 统一回收所有被标记的对象
java 复制代码
// 伪代码示例
void markSweep() {
    // 标记阶段
    for (Object root : gcRoots) {
        mark(root);
    }
    
    // 清除阶段
    for (Object obj : heap) {
        if (!isMarked(obj)) {
            free(obj);
        }
    }
}

void mark(Object obj) {
    if (obj == null || isMarked(obj)) return;
    setMarked(obj);
    for (Object ref : obj.getReferences()) {
        mark(ref);
    }
}

优点:

  • 实现简单
  • 不需要移动对象

缺点:

  1. 效率问题: 标记和清除效率都不高,需要扫描整个堆

  2. 空间问题: 产生大量内存碎片,可能导致大对象无法分配

    回收前: [A][B][C][D][E][F]
    回收后: [A][ ][C][ ][E][ ] // 产生碎片

应用场景: CMS收集器的老年代回收


1.2 标记-复制算法(Mark-Copy)

算法原理:

  1. 将内存分为大小相等的两块
  2. 使用其中一块,满了就将存活对象复制到另一块
  3. 清空已使用的那块内存
java 复制代码
// 伪代码示例
void copyingGC() {
    Object[] newSpace = allocateNewSpace();
    int newIndex = 0;
    
    // 复制所有存活对象
    for (Object root : gcRoots) {
        newIndex = copy(root, newSpace, newIndex);
    }
    
    // 交换空间
    swapSpaces();
}

int copy(Object obj, Object[] newSpace, int index) {
    if (obj == null || obj.forwardingPointer != null) {
        return index;
    }
    
    // 复制对象到新空间
    newSpace[index] = obj.clone();
    obj.forwardingPointer = newSpace[index];
    
    // 更新引用
    for (Object ref : obj.getReferences()) {
        index = copy(ref, newSpace, index + 1);
    }
    
    return index;
}

优点:

  • 实现简单
  • 运行高效
  • 没有内存碎片

缺点:

  • 内存利用率低(只能使用50%)
  • 存活对象多时效率降低

优化方案 - Appel式回收:

复制代码
新生代划分: Eden(80%) + Survivor0(10%) + Survivor1(10%)

[Eden 80%][S0 10%][S1 10%]

工作流程:
1. 对象优先在Eden分配
2. Eden满时,触发Minor GC
3. 存活对象复制到空闲的Survivor
4. 清空Eden和使用过的Survivor

实际效果:

java 复制代码
// 新生代对象存活率通常很低(研究表明<10%)
// 使用8:1:1的比例,实际可用内存达到90%

-XX:SurvivorRatio=8  // Eden/Survivor=8
-XX:+UseAdaptiveSizePolicy  // 动态调整比例

应用场景: 新生代回收(Serial、ParNew、Parallel Scavenge、G1的Young GC)


1.3 标记-整理算法(Mark-Compact)

算法原理:

  1. 标记阶段: 标记所有存活对象
  2. 整理阶段: 将所有存活对象移动到一端,清理边界外的内存
java 复制代码
// 伪代码示例
void markCompact() {
    // 标记阶段
    for (Object root : gcRoots) {
        mark(root);
    }
    
    // 计算新地址
    int newAddress = heapStart;
    for (Object obj : heap) {
        if (isMarked(obj)) {
            obj.forwardingAddress = newAddress;
            newAddress += obj.size();
        }
    }
    
    // 更新引用
    updateReferences();
    
    // 移动对象
    for (Object obj : heap) {
        if (isMarked(obj)) {
            move(obj, obj.forwardingAddress);
        }
    }
}

优点:

  • 没有内存碎片
  • 不浪费空间
  • 适合老年代(对象存活率高)

缺点:

  • 移动对象成本高
  • 需要暂停用户线程(Stop The World)

性能对比:

复制代码
场景: 100MB堆,存活率90%

标记-复制: 需要复制90MB对象
标记-整理: 需要移动对象并更新引用,但不浪费空间

老年代选择标记-整理是因为:
1. 存活率高,复制成本大
2. 不能浪费50%的空间

应用场景: 老年代回收(Serial Old、Parallel Old、G1的Mixed GC)


1.4 分代收集理论

核心假说:

  1. 弱分代假说: 绝大多数对象都是朝生夕灭
  2. 强分代假说: 熬过多次GC的对象越难消亡
  3. 跨代引用假说: 跨代引用相对同代引用占极少数

分代设计:

复制代码
堆内存结构:
+----------------------------------+
|         新生代(Young Gen)         |
|  Eden  |  Survivor0  |  Survivor1|
|  (8)   |    (1)      |    (1)    |
+----------------------------------+
|         老年代(Old Gen)           |
|                                  |
+----------------------------------+

对象晋升规则:
1. 大对象直接进入老年代
   -XX:PretenureSizeThreshold=3m
   
2. 长期存活对象进入老年代
   -XX:MaxTenuringThreshold=15
   
3. 动态年龄判定
   Survivor中相同年龄对象总大小 > Survivor空间一半
   则年龄>=该年龄的对象进入老年代
   
4. 空间分配担保
   Minor GC前检查老年代最大可用连续空间
   是否大于新生代所有对象总空间

实战案例:

java 复制代码
public class GenerationExample {
    // 大对象直接进入老年代
    byte[] bigObject = new byte[4 * 1024 * 1024];  // 4MB
    
    public static void main(String[] args) {
        // 短命对象-在新生代回收
        for (int i = 0; i < 1000000; i++) {
            String temp = new String("temp" + i);
        }
        
        // 长命对象-晋升到老年代
        List<String> cache = new ArrayList<>();
        for (int i = 0; i < 1000; i++) {
            cache.add(new String("cache" + i));
        }
        
        // 触发多次Minor GC后,cache对象会晋升到老年代
        for (int i = 0; i < 10; i++) {
            System.gc();
        }
    }
}

二、垃圾收集器详解

2. 请详细对比各种垃圾收集器的特点和适用场景

问题分析角度:

  • 考察对各种收集器的深入理解
  • 考察收集器选择的实战经验
  • 考察新一代收集器的认知

2.1 Serial收集器(串行收集器)

特点:

  • 单线程收集

  • 新生代使用标记-复制算法

  • 老年代使用标记-整理算法

  • 收集时必须Stop The World

    工作流程:
    用户线程: ||||----[STW]----||||
    Serial GC: [GC]

参数配置:

bash 复制代码
-XX:+UseSerialGC              # 启用Serial收集器
-XX:+UseSerialGC -XX:+UseSerialOldGC  # 新生代+老年代都使用Serial

优点:

  • 简单高效
  • 单线程环境下停顿时间最短
  • 内存占用小

缺点:

  • 多核CPU下性能浪费
  • 停顿时间较长

适用场景:

  • Client模式下的默认收集器
  • 单核CPU或内存受限环境
  • 桌面应用程序

2.2 ParNew收集器(并行收集器)

特点:

  • Serial的多线程版本

  • 新生代并行,老年代串行

  • 可与CMS配合使用

    工作流程:
    用户线程: ||||----[STW]----||||
    ParNew GC: [T1][T2][T3]

参数配置:

bash 复制代码
-XX:+UseParNewGC              # 启用ParNew
-XX:ParallelGCThreads=4       # 设置并行线程数(默认=CPU核数)

性能对比:

java 复制代码
// 测试场景: 4核CPU,2GB堆
// Serial: 停顿200ms
// ParNew: 停顿80ms (提升60%)

// 但在单核环境下,ParNew可能慢于Serial
// 因为线程切换开销

适用场景:

  • 多核CPU环境
  • 配合CMS使用(JDK9前唯一选择)

2.3 Parallel Scavenge收集器(吞吐量优先)

特点:

  • 关注吞吐量而非停顿时间
  • 提供自适应调节策略
  • 新生代收集器

核心概念:

复制代码
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + GC时间)

例如: 程序运行100分钟,GC 1分钟
吞吐量 = 99 / 100 = 99%

参数配置:

bash 复制代码
-XX:+UseParallelGC                    # 启用Parallel Scavenge
-XX:MaxGCPauseMillis=100              # 最大停顿时间100ms
-XX:GCTimeRatio=99                    # 吞吐量目标99%
-XX:+UseAdaptiveSizePolicy            # 自适应调节(默认开启)

# 自适应调节会自动调整:
# -Xmn(新生代大小)
# -XX:SurvivorRatio(Eden/Survivor比例)
# -XX:PretenureSizeThreshold(大对象阈值)

吞吐量 vs 停顿时间:

复制代码
高吞吐量场景:
- 后台计算任务
- 批处理作业
- 科学计算

低停顿时间场景:
- Web应用
- 交互式应用
- SLA要求高的服务

实战案例:

java 复制代码
// 场景: 大数据批处理
// 需求: 最大化CPU利用率,可以接受较长的停顿

java -Xmx10g -Xms10g \
     -XX:+UseParallelGC \
     -XX:GCTimeRatio=99 \
     -XX:+UseAdaptiveSizePolicy \
     -jar data-processor.jar

// 结果: 吞吐量从95%提升到99%

适用场景:

  • 后台计算密集型应用
  • 批处理任务
  • 对停顿时间不敏感的场景

2.4 CMS收集器(Concurrent Mark Sweep)

设计目标: 获取最短停顿时间

工作流程(四个阶段):

复制代码
1. 初始标记(Initial Mark) - STW
   ||||----[STW]----||||
   标记GC Roots直接关联的对象(速度很快)

2. 并发标记(Concurrent Mark)
   用户线程: ||||||||||||||||
   CMS线程:  [标记整个引用链]
   与用户线程并发执行

3. 重新标记(Remark) - STW
   ||||----[STW]----||||
   修正并发标记期间变动的对象(停顿时间较长)

4. 并发清除(Concurrent Sweep)
   用户线程: ||||||||||||||||
   CMS线程:  [清除未标记对象]
   与用户线程并发执行

参数配置:

bash 复制代码
-XX:+UseConcMarkSweepGC           # 启用CMS
-XX:CMSInitiatingOccupancyFraction=75  # 老年代使用75%触发CMS
-XX:+UseCMSCompactAtFullCollection     # Full GC后进行碎片整理
-XX:CMSFullGCsBeforeCompaction=0       # 多少次Full GC后整理
-XX:+CMSParallelRemarkEnabled          # 并行重新标记
-XX:+CMSScavengeBeforeRemark          # Remark前先做Minor GC

优点:

  • 并发收集,停顿时间短
  • 适合对响应时间敏感的应用

缺点:

  1. CPU资源敏感
java 复制代码
// CMS默认启动线程数 = (CPU核数 + 3) / 4
// 4核CPU: (4+3)/4 = 1个线程,占用25% CPU
// 2核CPU: (2+3)/4 = 1个线程,占用50% CPU

// 解决方案:
-XX:ParallelCMSThreads=2  // 手动设置CMS线程数
  1. 浮动垃圾(Floating Garbage)
java 复制代码
// 并发清除阶段产生的新垃圾无法回收
// 需要预留空间给用户线程

// 如果预留空间不足,触发"Concurrent Mode Failure"
// 启用后备方案Serial Old(Full GC),停顿时间很长

// 优化:
-XX:CMSInitiatingOccupancyFraction=70  // 降低触发阈值
  1. 内存碎片
java 复制代码
// 使用标记-清除算法,产生大量碎片
// 可能导致大对象无法分配,触发Full GC

// 解决方案:
-XX:+UseCMSCompactAtFullCollection  // Full GC时整理碎片
-XX:CMSFullGCsBeforeCompaction=5    // 5次Full GC后整理

实战案例:

bash 复制代码
# 电商网站配置(8核16G服务器)
JAVA_OPTS="
-Xms8g -Xmx8g
-Xmn3g
-XX:+UseConcMarkSweepGC
-XX:+UseCMSInitiatingOccupancyFraction
-XX:CMSInitiatingOccupancyFraction=70
-XX:+CMSParallelRemarkEnabled
-XX:+CMSScavengeBeforeRemark
-XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction=5
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
"

# 效果:
# GC停顿时间: 50-100ms
# 吞吐量: 95%
# Full GC频率: 每天1-2次

适用场景:

  • 互联网应用
  • Web服务器
  • 对响应时间敏感的系统

2.5 G1收集器(Garbage First)

设计理念:

  • 面向服务端应用
  • 兼顾吞吐量和停顿时间
  • 可预测的停顿时间模型

内存布局革新:

复制代码
传统分代:
+------------------+------------------+
|   新生代(连续)    |   老年代(连续)    |
+------------------+------------------+

G1分代:
+---+---+---+---+---+---+---+---+
| E | E | S | O | O | E | H | O |  // Region混合布局
+---+---+---+---+---+---+---+---+
E=Eden, S=Survivor, O=Old, H=Humongous

每个Region: 1MB-32MB(必须是2的幂)
Humongous: 存储大对象(>= Region的50%)

核心概念 - Remembered Set(记忆集):

java 复制代码
// 问题: 如何避免扫描整个堆来确定对象存活?
// 解决: 每个Region维护一个RSet,记录外部指向本Region的引用

Region A的RSet:
- Region B的对象引用A中的对象
- Region D的对象引用A中的对象

// Minor GC时只需扫描:
// 1. GC Roots
// 2. RSet中记录的引用

工作流程:

复制代码
1. 初始标记(Initial Mark) - STW
   附着在Minor GC中执行,标记GC Roots

2. 并发标记(Concurrent Mark)
   从GC Roots开始对堆中对象进行可达性分析
   与用户线程并发

3. 最终标记(Final Mark) - STW
   处理并发标记阶段遗留的SATB记录

4. 筛选回收(Live Data Counting and Evacuation) - STW
   对各Region回收价值排序
   根据期望停顿时间制定回收计划
   复制存活对象到空Region

参数配置:

bash 复制代码
-XX:+UseG1GC                          # 启用G1
-XX:MaxGCPauseMillis=200              # 期望最大停顿时间200ms
-XX:G1HeapRegionSize=16m              # Region大小16MB
-XX:InitiatingHeapOccupancyPercent=45 # 堆占用45%触发并发标记
-XX:G1NewSizePercent=5                # 新生代最小占比5%
-XX:G1MaxNewSizePercent=60            # 新生代最大占比60%
-XX:G1ReservePercent=10               # 保留空闲Region 10%
-XX:ConcGCThreads=4                   # 并发GC线程数
-XX:ParallelGCThreads=8               # 并行GC线程数

Mixed GC详解:

java 复制代码
// 当老年代占用达到阈值,触发Mixed GC
// Mixed GC会回收部分老年代Region

// G1根据价值优先选择回收的Region:
// 价值 = 回收收益 / 回收时间

回收收益 = Region中垃圾对象占用的空间
回收时间 = 复制存活对象所需时间

// 示例:
Region A: 90%垃圾, 需要10ms
Region B: 50%垃圾, 需要20ms

Region A价值 = 90% / 10ms = 9
Region B价值 = 50% / 20ms = 2.5

// 优先回收Region A

实战调优案例:

bash 复制代码
# 场景: 4GB堆,要求停顿时间<100ms

# 初始配置
-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100

# 问题: Young GC频繁,停顿时间超标

# 分析GC日志:
[GC pause (G1 Evacuation Pause) (young), 0.15 secs]
# 新生代太小,频繁触发GC

# 优化:
-XX:G1NewSizePercent=30      # 增加新生代最小占比
-XX:G1MaxNewSizePercent=50   # 限制新生代最大占比
-XX:MaxGCPauseMillis=80      # 降低目标停顿时间

# 结果:
# Young GC频率降低50%
# 平均停顿时间: 70ms
# 吞吐量提升: 92% -> 96%

对比CMS:

复制代码
优势:
1. 可预测的停顿时间
2. 没有内存碎片(整理算法)
3. 空间整合效率高
4. 适合大堆(6GB+)

劣势:
1. 内存占用高(RSet等额外结构)
2. 小堆(<4GB)性能不如CMS
3. 程序运行时额外负载(维护RSet)

适用场景:

  • 大内存应用(6GB以上)
  • 需要可预测停顿时间
  • 替代CMS的首选方案
  • JDK9+的默认收集器

2.6 ZGC收集器(JDK11+)

设计目标:

  • 停顿时间不超过10ms
  • 支持TB级别堆
  • 对吞吐量影响<15%

核心技术 - 着色指针(Colored Pointer):

复制代码
64位对象引用:
+--------+--------+--------+--------+
| 未使用  | 标记位  | 重映射位 | 对象地址 |
| 16bit  | 4bit   | 1bit   | 42bit  |
+--------+--------+--------+--------+

支持16TB堆空间(2^42 = 4TB, Linux支持4倍)

标记位用途:
- Marked0: 第一次标记
- Marked1: 第二次标记
- Remapped: 是否已重映射
- Finalizable: 是否只被finalizer引用

核心技术 - 读屏障(Load Barrier):

java 复制代码
// 每次从堆中读取对象引用时插入读屏障
Object obj = object.field;  
// 转换为:
Object obj = barrier(object.field);

// 读屏障作用:
// 1. 检查对象是否在重定位集合中
// 2. 如果是,返回新地址
// 3. 更新引用指向新地址

工作流程:

复制代码
1. 并发标记(Concurrent Mark)
   与应用线程并发执行
   使用着色指针标记存活对象

2. 并发预备重分配(Concurrent Prepare for Relocate)
   统计需要回收的Region
   
3. 并发重分配(Concurrent Relocate)
   复制存活对象到新Region
   通过读屏障处理并发访问

4. 并发重映射(Concurrent Remap)
   修正所有指向旧对象的引用
   可与下一次标记阶段合并

参数配置:

bash 复制代码
-XX:+UseZGC                           # 启用ZGC
-Xmx16g                               # 最大堆16GB
-XX:ConcGCThreads=4                   # 并发GC线程数
-XX:ZCollectionInterval=120           # GC间隔120秒
-XX:ZAllocationSpikeTolerance=2       # 分配峰值容忍度

性能数据:

复制代码
测试环境: 128GB堆,40核CPU

ZGC:
- 停顿时间: 1-3ms (99.9分位)
- 吞吐量: 92-95%

G1:
- 停顿时间: 50-200ms
- 吞吐量: 95-98%

CMS:
- 停顿时间: 100-500ms
- 吞吐量: 93-96%

适用场景:

  • 超大堆内存应用(16GB+)
  • 对延迟极度敏感(如金融交易)
  • 实时系统

2.7 Shenandoah GC(JDK12+)

设计理念: 与ZGC类似,但实现不同

核心技术 - 转发指针(Forwarding Pointer):

java 复制代码
// 在对象头中添加转发指针
Object Header:
+----------------+------------------+
| Mark Word     | Forwarding Ptr   |
| (已有)         | (新增)            |
+----------------+------------------+

// 并发移动时:
// 1. 在对象头设置转发指针
// 2. 读写屏障检查转发指针
// 3. 重定向到新位置

核心技术 - 读写屏障:

java 复制代码
// ZGC只有读屏障
// Shenandoah有读写屏障

// 写屏障示例:
object.field = value;
// 转换为:
barrier_write(object, field, value);

// 作用: 保证并发移动时的正确性

参数配置:

bash 复制代码
-XX:+UseShenandoahGC                  # 启用Shenandoah
-Xmx16g
-XX:ShenandoahGCHeuristics=adaptive   # 自适应启发式
-XX:ShenandoahGCMode=normal           # GC模式

对比ZGC:

复制代码
相同点:
- 目标停顿时间都在10ms以内
- 都支持TB级堆
- 都使用并发整理

不同点:
1. ZGC使用着色指针,Shenandoah使用转发指针
2. ZGC只有读屏障,Shenandoah有读写屏障
3. ZGC需要Linux特定支持,Shenandoah跨平台性更好

三、垃圾收集器选择策略

3. 如何为应用选择合适的垃圾收集器?

决策树:

复制代码
应用类型?
│
├─ 客户端应用/小内存(<100MB)
│  └─> Serial GC
│
├─ 服务端应用
│  │
│  ├─ 堆内存 < 4GB
│  │  │
│  │  ├─ 吞吐量优先
│  │  │  └─> Parallel GC
│  │  │
│  │  └─ 响应时间优先
│  │     └─> CMS GC (JDK8)
│  │         G1 GC (JDK9+)
│  │
│  ├─ 堆内存 4-32GB
│  │  └─> G1 GC
│  │
│  └─ 堆内存 > 32GB
│     │
│     ├─ 停顿时间要求极低(<10ms)
│     │  └─> ZGC / Shenandoah
│     │
│     └─ 停顿时间要求一般
│        └─> G1 GC

实战选择建议:

bash 复制代码
# 1. 电商网站(8GB堆,响应时间<100ms)
-XX:+UseG1GC -XX:MaxGCPauseMillis=100

# 2. 批处理系统(16GB堆,吞吐量优先)
-XX:+UseParallelGC -XX:GCTimeRatio=99

# 3. 实时交易系统(64GB堆,延迟<10ms)
-XX:+UseZGC
相关推荐
cyforkk3 小时前
01、Java基础入门:JDK、JRE、JVM关系详解及开发流程
java·开发语言·jvm
时艰.3 小时前
JVM 基础入门
jvm
蜂蜜黄油呀土豆3 小时前
深入解析 Java 虚拟机内存模型
jvm·内存管理·垃圾回收·java 性能优化
chilavert3183 小时前
技术演进中的开发沉思-330 : 虚拟机命令行工具
java·jvm
小北方城市网1 天前
Spring Boot 接口开发实战:RESTful 规范、参数校验与全局异常处理
java·jvm·数据库·spring boot·后端·python·mysql
chilavert3181 天前
技术演进中的开发沉思-328 JVM:垃圾回收(上)
java·开发语言·jvm
橙露1 天前
CGO性能深度剖析:成因、评估与优化全指南
java·jvm·myeclipse
chilavert3181 天前
技术演进中的开发沉思-329 JVM:垃圾回收(中)
java·jvm·算法
tqs_123451 天前
Java关键字、GC回收器与JVM调优详解
jvm·测试工具