浅谈Android GC回收机制与分代回收策略

162 阅读9分钟

很乐意用一种有趣的方式带你深入理解GC(垃圾回收)的奥秘。我们暂时抛开那些晦涩的术语,从一个故事开始。

一场永不停歇的咖啡馆故事

想象一下,你开了一家名叫  “Android咖啡馆”  的App。

  • 内存(Heap) :就是你的整个咖啡馆大厅。
  • 对象(Object) :就是来到咖啡馆的每一位顾客。
  • 你(开发者) :就是店长,负责招呼客人(new Object()),安排他们坐下(分配内存)。
  • GC(垃圾回收器) :就是你们店里那位勤快又聪明的清洁工小清。他的工作是当客人离开后,及时收拾桌子,让新来的客人有地方坐。

第一部分:为什么需要小清(GC)?

开店之初,你很抠门,没有雇小清。结果呢?

// 糟糕的店长,只招呼客人,不记谁走了
void badManager() {
    Customer customer1 = new Customer(); // 客人A来了,坐1号桌
    Customer customer2 = new Customer(); // 客人B来了,坐2号桌
    customer1 = null; // 客人A悄悄离开了!但店长你还以为1号桌有人
    // 此时,1号桌实际上空着,但你不知道,新客人来了也不敢安排过去
    Customer customer3 = new Customer(); // 想安排3号桌,但店长你以为满了
    // 最终,咖啡馆(内存)很快就“客满”,但实际上很多是空桌,新客人进不来 -> **内存泄漏**
}

很快,咖啡馆就因为“看似客满”而无法接待新客人,这就是 OutOfMemoryError

你意识到必须雇一个清洁工小清,他的核心任务就是:识别出哪些桌子是真正没人的(垃圾对象),然后收拾干净(回收内存)

那么,小清如何判断一个客人已经离开(对象已死)呢?

他使用一个叫做  “可达性分析算法”  的侦探技巧。

故事的比喻:
咖啡馆里有一些非常重要的“锚点”,比如:

  • 前台(静态变量)
  • 正在点单的顾客(活动线程的栈帧中的局部变量)
  • JNI吧台(JNI全局引用)

小清会从这些“根(GC Roots)”开始,像牵线一样,找出所有跟这些根有直接或间接联系的客人。比如,从前台可以找到预订座位的王总,从王总可以找到他带来的李经理...

任何一条线都牵不到的客人,就是已经离开的“垃圾客人”!

// 小清(GC)的工作视角
void gcWork() {
    Set<Object> garbage = findAllObjects(); // 先假设所有对象都是垃圾
    Set<Object> roots = getGCRoots(); // 找到所有根对象(前台、点单顾客等)

    for (Object root : roots) {
        markAsAlive(root); // 从根开始,标记所有能联系到的对象为“存活”
    }

    // 最后,所有没被标记的,就是垃圾
    for (Object obj : garbage) {
        if (!isMarkedAlive(obj)) {
            recycle(obj); // 回收这个对象占用的内存
        }
    }
}

第二部分:分代回收策略 —— 咖啡馆的“座位分区”

经营一段时间后,小清发现客人的行为有规律,于是向你这个店长提出了一个天才的  “分代回收”  座位管理方案。他将咖啡馆分成了三个区域:

+---------------------------------+
|        Android 咖啡馆           |
|  (Java Heap 堆内存)             |
|                                 |
|  +---------+ +-------+ +------+ |
|  |  年轻代  | | 老年代 | |永久代/| |
|  | (Young  | | (Old  | |元空间| |
|  | Generation) | Generation) | | (Meta- | |
|  |         | |       | |Space)| |
|  | +-----+ | +-------+ +------+ |
|  | |Eden | |                   |
|  | +-----+ |    大部分顾客      |
|  | |S0/S1| |    最终会聚集在这里 |
|  | +-----+ |                   |
|  +---------+ +-------+ +------+ |
|                                 |
+---------------------------------+

1. 年轻代 (Young Generation) - “快饮区”

这个区域的特点是:客人来得快,去得也快。大部分新顾客只是进来喝杯咖啡,歇个脚就走。

  • Eden区 (伊甸园)新客接待区。所有新来的客人(new出来的对象)首先被安排在这里。
  • Survivor区 (幸存者区)常客等候区。它有两个,S0S1,任何时候只有一个在使用。

年轻代的GC过程(Minor GC / Young GC),我们称之为 “快饮区大扫除”:

  1. 初始状态:Eden区快满了,S0是空的,S1也是空的。

  2. 触发清扫:小清开始对“快饮区”进行快速清扫。

  3. 标记:小清找出所有在Eden和当前在用的Survivor区(比如S0)中还在世的客人。

  4. 搬运与清理

    • 将Eden区所有在世客人和S0区所有在世客人,一次性搬到另一个Survivor区(S1)。
    • 关键一步:每搬一次,小清就在客人的“会员卡”上盖一个章(年龄+1)。
    • 然后,整个清空Eden区和S0区。这个过程非常快,因为只清理了两个区,而不是一点点擦桌子。
  5. 角色互换:清扫完成后,S1变成“在用”状态,S0变成空闲状态,等待下次清扫时使用。

为什么要有两个Survivor区?
为了解决内存碎片化!如果只有一个Survivor区,每次清扫后,存活对象和空闲位置会交错在一起,就像被撕碎的票根,很难找到一整块足够大的空位给新来的大团体客人。通过“复制算法”到另一个空Survivor区,可以保证存活对象在另一边是紧凑排列的。

晋升 (Promotion)
如果一个客人在“快饮区”经历了多次清扫(比如会员卡上盖满了15个章),小清就会认为他是真正的“常客”,把他请到更安静的“老年代”就坐。

// 模拟年轻代GC
void youngGCDemo() {
    // 大部分新对象都在Eden区创建
    Object object1 = new Object(); // 在Eden区
    Object object2 = new Object(); // 在Eden区
    Object object3 = new Object(); // 在Eden区

    // 假设一次Young GC发生了
    // object1 和 object3 还被引用着(还活着),object2 已经没人引用了(已死)

    // GC之后:
    // object2 被回收
    // object1 和 object3 被复制到Survivor区(比如S0),年龄+1
}

2. 老年代 (Old Generation) - “商务区”

这个区域的特点是:客人待得久,流动性低。他们都是店的忠实顾客或长期包场的客户。

  • 存放对象:从年轻代“晋升”过来的常客,以及一些一开始就很大的对象(比如一个20人的旅行团,快饮区坐不下,直接安排到商务区)。
  • GC类型:发生在老年代的GC称为 Major GC / Full GC。我们称之为  “全场停业大清盘”

老年代的GC过程(通常是“标记-整理”算法):

  1. 触发条件:老年代空间不足时(比如晋升过来的客人太多,或者分配大对象失败)。
  2. 全场停业Full GC通常会触发“Stop-The-World” ,即所有工作线程暂停,咖啡馆暂停营业!这是为了确保在清点客人时,不会有新客人来,也不会有客人移动。
  3. 标记:小清再次使用“可达性分析”,找出所有在老年代的存活客人。
  4. 整理:小清让所有存活的客人都向一边靠拢,挤掉他们之间的空座位。这样,另一边就会腾出一大块连续的空位。
  5. 恢复营业:清理完成,工作线程恢复,咖啡馆继续营业。

为什么Full GC很慢?
因为它要处理的对象数量多,区域大,而且“整理”步骤需要移动对象,是项繁重的体力活。在Android上,频繁的Full GC会导致应用卡顿甚至ANR

// 模拟对象晋升和Full GC
void fullGCDemo() {
    List<Object> longLiveList = new ArrayList<>(); // 这个List本身在年轻代,但它引用的对象可能晋升

    // 疯狂创建对象,让它们晋升
    for (int i = 0; i < 100000; i++) {
        Object longLivedObject = new Object();
        longLiveList.add(longLivedObject);
        // 在循环中,会触发很多次Young GC。
        // 每次GC,longLivedObject的年龄都会增加,最终达到晋升阈值,进入老年代。
    }

    // 当老年代被这些晋升的对象填满时...
    // ...再尝试创建一个需要分配到老年代的大对象
    byte[] hugeObject = new byte[10 * 1024 * 1024]; // 10MB的大数组

    // 如果老年代没空间了,就会触发一次Full GC!
}

3. 永久代/元空间 (Permanent Generation / MetaSpace) - “店规墙”

这块区域不放客人,放的是咖啡馆的规章制度、员工手册、菜单模板(类的元数据信息)

  • 存放内容:类的结构信息、方法信息、常量池、字符串常量池(部分)等。
  • 回收目标:主要回收不再使用的类。比如,你下架了一款咖啡,那么关于这款咖啡的制作教程就成了垃圾。

在JDK 8之前,这块区域叫“永久代”,是堆内存的一部分。之后,它变成了“元空间”,移到了堆外的本地内存,大大降低了OOM的风险。


第三部分:时序图 —— 一次完整的GC是如何发生的?

下面我们用一张时序图,来展示一次由年轻代空间不足触发的、可能导致对象晋升的Young GC的完整调用过程。

deepseek_mermaid_20251010_076ebc.png

流程解读:

  1. 触发:应用线程不断分配对象,直到Eden区快满了。

  2. 请求与暂停:ART虚拟机监控到这一点,向GC守护线程发起请求,并首先暂停所有应用线程(STW) 。这是为了保证在标记过程中,对象引用关系不会变化。

  3. 标记 (Mark) :GC线程开始工作。

    • 初始标记:速度极快,只标记直接从GC Roots直接关联到的对象。
    • 并发标记:从初始标记的对象出发,递归地标记所有可达的对象。在ART中,这部分工作可能并发进行以减少STW时间。
    • 重新标记:由于应用线程在并发标记时可能还在运行,会产生一些新的引用变化,所以需要一个短暂的重新标记阶段来修正。
  4. 复制/清扫 (Copy/Sweep)

    • 将Eden区和Survivor From区(如图中的S0)中所有存活的对象,复制到Survivor To区(S1)。
    • 同时,给这些存活对象的“年龄”+1。
    • 检查年龄,达到阈值(如15)的对象,直接复制到老年代(晋升)。
  5. 重置与恢复

    • 直接清空Eden区和Survivor From区(S0)。因为存活对象已经被移走了,所以清空代价极小。
    • 交换Survivor区角色,下次GC时,S1变成From区,S0变成To区。
    • 恢复所有应用线程,GC结束。

总结与最佳实践

现在,你已经理解了GC和分代回收的精髓。作为Android开发者,记住以下几点,就能写出对“小清”更友好的代码:

  1. 减少不必要的对象创建:尤其是在onDrawgetView等频繁调用的方法中。少制造垃圾,小清就少干活。
  2. 警惕内存泄漏:比如用静态变量引用Activity、未取消的Handler等。这相当于客人走了但你还用绳子把他拴在座位上,小清永远无法回收他。
  3. 关注Logcat:ART会打印GC日志,多观察有助于发现潜在性能问题。
  4. 使用合适的集合SparseArray替代HashMap<Integer, Object>等,可以减少自动装箱产生的垃圾对象。

希望这个“咖啡馆”的故事能让你对Android GC有一个生动而深刻的理解!它是一位默默无闻但至关重要的伙伴,理解它、配合它,你的应用才会运行得更加流畅。