1. 从一次内存抖动说起:为什么要盯着 Glide 的缓存不放

做过 Android 图片加载的朋友大概率都遇到过这样的场景:列表滑得飞快,图片一张接一张地加载,突然 Logcat 里开始刷 GC 日志,界面肉眼可见地卡了一下,然后恢复正常,再滑又卡。你打开 Profiler 一看,内存曲线像心电图一样上下跳,Bitmap 对象频繁创建又回收。这个问题十有八九跟图片缓存的设计有关,而 Glide 作为 Android 生态里用得最广的图片加载库,它的缓存机制值得每一个 Android 开发者认真拆一遍。

Glide 的缓存体系大致分四层: 活动资源缓存(ActiveResources) 、 内存缓存(MemoryCache) 、 磁盘缓存(DiskCache) ,以及 网络或原始数据源 。这四层从快到慢、从贵到便宜,形成了一个完整的资源获取链路。其中活动资源缓存这一层,就是标题里说的"弱引用"发挥作用的地方。很多人第一次看 Glide 源码的时候会疑惑:既然已经有 LruCache 做内存缓存了,为什么还要额外搞一个用弱引用存的活动资源缓存?这不是多此一举吗?

这篇文章就是围绕这个问题展开的。我会从 Glide 缓存的分层结构讲起,把活动资源缓存的设计动机、弱引用的具体用法、引用队列的配合机制、以及这套设计在实际项目中带来的收益和需要注意的坑,一层一层拆开。适合已经用过 Glide、但对内部缓存机制只有模糊印象的 Android 开发者,也适合正在准备中高级面试、想把图片加载这块讲清楚的朋友。读完之后,你应该能回答出"为什么用弱引用"这个问题背后的完整逻辑链,而不只是背一句"防止内存泄漏"。

2. Glide 四级缓存的分工与数据流向

2.1 每一层缓存到底存了什么

先把 Glide 的缓存层次理清楚,不然后面讲弱引用会缺少参照。Glide 在加载一张图片时,会依次经过以下几个查找环节:

缓存层级 存储内容 数据结构 生命周期
活动资源缓存 正在被使用的图片资源 弱引用 + 引用队列 跟随资源使用状态
内存缓存 近期使用过的图片资源 LruCache 跟随应用进程
磁盘缓存 原始文件或转换后的文件 DiskLruCache 跟随磁盘文件
数据源 网络或本地原始数据 无 一次性

活动资源缓存和内存缓存都存在于内存中,但它们的职责完全不同。内存缓存用的是 LRU 策略,容量有限,当新资源进来而容量不够时,会按"最近最少使用"的原则淘汰掉老资源。活动资源缓存则不一样,它存的是 当前正在被界面使用的资源 ,这些资源随时可能被再次请求,所以不能轻易淘汰。

这里有个关键点:一张图片被加载到 ImageView 上之后,它同时存在于活动资源缓存和内存缓存里吗?答案是: 在 Glide 的设计中,一个资源要么在活动资源缓存里,要么在内存缓存里,不会同时存在于两处 。当资源被请求使用时,它从内存缓存移到活动资源缓存;当资源不再被使用时,它从活动资源缓存移回内存缓存。这个"移动"的过程,就是理解弱引用设计的关键。

2.2 资源在缓存之间的流转过程

我用一个具体的加载流程来说明这个流转:

  1. 用户请求加载一张图片,Glide 首先查活动资源缓存,看有没有正在被使用的同一资源。
  2. 如果没有,查内存缓存(LruCache),找到了就把资源从内存缓存移除,放入活动资源缓存,并返回给请求方。
  3. 如果内存缓存也没有,查磁盘缓存,找到了就解码成资源,放入活动资源缓存。
  4. 如果磁盘缓存也没有,走网络请求,拿到数据后解码,放入活动资源缓存。
  5. 当图片不再被任何 ImageView 使用时(比如列表滑出了屏幕),资源从活动资源缓存移除,放回内存缓存。

这个流转机制的核心诉求是: 正在使用的资源不能被回收,不在使用的资源要尽快变成可回收状态 。活动资源缓存要解决的就是前半句,而弱引用在这里扮演了一个非常巧妙的角色。

2.3 为什么不能只用 LruCache

如果只有 LruCache 一层内存缓存,会有什么问题?假设一个列表里有 20 张图片正在屏幕上显示,它们都在 LruCache 里。这时候用户快速滑动,新的图片不断加载进来,LruCache 容量满了,就会按 LRU 策略淘汰掉一些资源。但问题是,那些正在屏幕上显示的图片,可能因为"最近没有被再次请求"而被判定为"最少使用",从而被淘汰掉。一旦被淘汰,如果用户又滑回来,就得重新从磁盘或网络加载,体验很差。

所以 Glide 需要一个机制来标记"这些资源正在被使用,别动它们"。活动资源缓存就是这个标记机制。它把正在使用的资源单独存一份,让 LruCache 只负责管理那些"暂时不用但可能还会用"的资源。这样 LruCache 的淘汰策略就不会误伤正在显示的图片。

3. 弱引用在活动资源缓存中的具体用法

3.1 弱引用的基本特性回顾

在 Java 和 Android 里,引用分为四种:强引用、软引用、弱引用、虚引用。弱引用的特点是: 只要发生 GC,不管内存是否充足,弱引用指向的对象都会被回收 。这一点和软引用不同,软引用是在内存不足时才回收。

Glide 的活动资源缓存用的就是弱引用。具体来说,它用一个 Map<Key, WeakReference<EngineResource<?>>> 来存储正在使用的资源。Key 是资源的标识(比如图片的 URL、尺寸、变换参数等组合),Value 是资源的弱引用。

你可能会问:用弱引用存,那 GC 一来不就把资源回收了吗?那还怎么保证正在使用的图片不被回收?这个疑问很合理,答案在于 强引用的持有方 。资源被加载到 ImageView 上之后,ImageView 本身持有资源的强引用(通过 Target 机制),所以只要 ImageView 还在显示这张图,资源就不会被 GC 回收。活动资源缓存里的弱引用只是一个"索引",用来快速查找,而不是资源的唯一持有者。

3.2 引用队列如何配合弱引用工作

光有弱引用还不够,因为当资源被 GC 回收后,Map 里会残留一个指向 null 的 WeakReference 条目。如果不清理,这个 Map 会越来越大,造成内存泄漏。Glide 用**引用队列(ReferenceQueue)**来解决这个问题。

具体做法是:创建 WeakReference 的时候,把 ReferenceQueue 传进去。当弱引用指向的对象被 GC 回收时,这个 WeakReference 本身会被加入到 ReferenceQueue 中。Glide 在每次操作活动资源缓存时,会先检查 ReferenceQueue 里有没有待清理的引用,有的话就把对应的条目从 Map 里移除。

// 简化后的逻辑示意
ReferenceQueue<EngineResource<?>> queue = new ReferenceQueue<>();

// 存入时
WeakReference<EngineResource<?>> weakRef = new WeakReference<>(resource, queue);
activeResources.put(key, weakRef);

// 清理时
Reference<? extends EngineResource<?>> ref;
while ((ref = queue.poll()) != null) {
    // 根据 ref 找到对应的 key,从 map 中移除
    activeResources.values().remove(ref);
}

这个机制保证了 Map 里不会残留无效的弱引用条目。实际源码中,Glide 会把 Key 和 WeakReference 包装成一个 Entry 对象,Entry 继承自 WeakReference,这样从队列里取出的 ref 可以直接强转成 Entry,拿到 Key 做清理。

3.3 资源引用计数的配合

除了弱引用和引用队列,活动资源缓存还有一个重要的配合机制: 引用计数 。EngineResource 内部有一个 acquired 变量,记录当前有多少个地方在使用这个资源。每次资源被请求,计数加一;每次资源被释放,计数减一。当计数归零时,资源才会从活动资源缓存移回内存缓存。

这个设计解决了一个问题:同一张图片可能同时被多个 ImageView 使用(比如聊天界面里同一张表情包出现多次)。如果只靠弱引用,无法知道资源是否还在被使用。引用计数提供了精确的使用状态跟踪,和弱引用配合,形成了完整的资源生命周期管理。

注意:引用计数归零并不等于资源立即被回收,它只是从活动资源缓存移到内存缓存,变成"可被 LRU 淘汰"的状态。真正回收要等 LruCache 淘汰或进程结束。

4. 弱引用方案背后的设计权衡

4.1 为什么不用软引用或强引用

这是一个很自然的对比问题。软引用的特点是内存不足时才回收,看起来比弱引用更"安全",那为什么 Glide 不用软引用?

原因在于 回收时机的不可控性 。软引用在内存不足时被回收,但"内存不足"这个时机是由 GC 决定的,开发者无法精确控制。在图片加载场景下,如果大量资源用软引用持有,可能导致内存一直处于较高水位,直到触发 GC 才集中回收,造成卡顿。而弱引用的回收更及时,配合引用计数和引用队列,可以做到资源的精细化管理。

强引用就更不行了,强引用持有资源意味着资源永远不会被回收,除非手动移除。在列表快速滑动的场景下,大量图片资源被强引用持有,很快就会 OOM。

所以弱引用的选择,本质上是在"及时回收"和"使用中不被回收"之间找平衡。使用中的资源由 ImageView 强引用持有,不会因为弱引用被回收;不在使用的资源,弱引用允许 GC 及时回收,避免内存堆积。

4.2 弱引用带来的一个潜在问题

弱引用方案并非完美。有一个容易被忽略的问题: 如果资源只被活动资源缓存弱引用持有,而没有任何其他强引用,那么它随时可能被 GC 回收 。在 Glide 的设计中,这种情况理论上不应该发生,因为资源被请求后会被 Target 强引用。但如果代码里出现了资源请求了但没有正确绑定到 Target 的情况,就可能出现资源被提前回收,导致加载失败或闪烁。

这也是为什么 Glide 要求所有资源请求都必须通过 into() 方法绑定到 Target。如果你绕过 Target 直接拿资源,就要自己保证强引用。这个约束在自定义 Target 或做预加载时需要特别注意。

4.3 和 LruCache 的容量配合

活动资源缓存本身没有容量限制,因为它存的是"正在使用"的资源,数量理论上受限于屏幕能显示的内容。但实际上,如果代码里有资源泄漏(比如 Target 没有正确释放),活动资源缓存里的条目会异常增多。虽然弱引用允许 GC 回收,但引用队列的清理是"下次操作时"才触发的,如果长时间不操作缓存,Map 里的无效条目会暂时堆积。

所以实际使用中,如果发现内存异常,除了看 LruCache 的命中率和大小,也要关注活动资源缓存的条目数。可以通过自定义 GlideModule 或者用 Profiler 观察 activeResources 这个 Map 的大小变化。

5. 从源码角度看弱引用的实现细节

5.1 ActiveResources 类的核心结构

Glide 中负责活动资源缓存的类是 ActiveResources 。它的核心字段包括:

  • 一个 Map<Key, ResourceWeakReference> 类型的 activeResources ,用 HashMap 实现。
  • 一个 ReferenceQueue<EngineResource<?>> 类型的 resourceReferenceQueue 。
  • 一个 Handler 或 Executor ,用于在主线程处理引用队列的清理。

ResourceWeakReference 继承自 WeakReference<EngineResource<?>> ,同时持有 Key 和一个 isCacheable 标志。这个标志用来区分资源是否可以被缓存,有些资源(比如某些变换后的临时资源)可能不需要进入内存缓存。

5.2 activate 和 deactivate 的完整流程

资源的激活和去激活是活动资源缓存的两个核心操作:

activate 流程 (资源被请求使用):

  1. 从内存缓存(LruCache)中移除该资源。
  2. 创建 ResourceWeakReference,传入引用队列。
  3. 将弱引用放入 activeResources Map。
  4. 资源引用计数加一。

deactivate 流程 (资源不再被使用):

  1. 资源引用计数减一。
  2. 如果计数归零,从 activeResources Map 中移除。
  3. 如果资源可缓存,放入内存缓存(LruCache)。
  4. 如果不可缓存,直接回收。

这两个流程保证了资源在"使用中"和"空闲"两种状态之间的正确切换。弱引用在 activate 时创建,在 deactivate 时移除,生命周期和资源的使用状态严格对应。

5.3 清理时机与线程模型

引用队列的清理时机很关键。Glide 在每次调用 get() 、 activate() 、 deactivate() 等操作时,都会先调用一次清理逻辑,检查引用队列里有没有待处理的引用。这种"懒清理"的方式避免了额外的线程开销,但也意味着如果长时间不操作缓存,无效条目会暂时存在。

清理操作本身是在主线程执行的,因为 activeResources Map 不是线程安全的。Glide 通过确保所有对活动资源缓存的访问都在主线程,来避免并发问题。这一点在自定义 Glide 组件时需要注意,不要在其他线程直接操作这个 Map。

// 清理逻辑的简化示意
private void cleanupActiveReference(ResourceWeakReference ref) {
    synchronized (this) {
        activeResources.remove(ref.key);
    }
}

提示:如果你在做 Glide 的性能优化,可以关注 cleanupActiveReference 的调用频率。频繁的清理操作虽然单次开销小,但在高频加载场景下也会累积成可观的耗时。

6. 实际项目中与弱引用缓存相关的坑

6.1 资源闪烁与弱引用提前回收

前面提到过,如果资源没有被正确强引用,弱引用可能被提前回收。实际项目中,这个问题最常见的表现是 图片加载完成后短暂显示,然后突然变成占位图或空白 。排查思路是检查 Target 的生命周期管理,确认 into() 绑定的 Target 是否在资源显示期间一直存活。

有一种情况容易踩坑:在 RecyclerView 的 onBindViewHolder 里手动创建 Target 并加载图片,但没有在 onViewRecycled 里正确清理。这样当 Item 被回收时,Target 可能被置空,导致资源失去强引用,被 GC 回收后图片消失。

6.2 内存缓存命中率异常

如果发现内存缓存命中率很低,除了检查 LruCache 的配置,还要看活动资源缓存是否正常工作。有一种情况是:资源被频繁地 activate 和 deactivate,导致它在活动资源缓存和内存缓存之间反复移动,每次移动都涉及 Map 操作和可能的清理,开销不小。

这种情况通常出现在列表滚动时,图片频繁进出屏幕。Glide 本身对这种情况有优化,但如果自定义了 skipMemoryCache 或者用了 diskCacheStrategy 不当,可能加剧这个问题。建议在列表场景下保持默认的内存缓存策略,让 Glide 自己管理资源的流转。

6.3 弱引用与 Bitmap 池的配合

Android 的 Bitmap 在 3.0 之后把像素数据放在 native 堆,Java 堆里只有一个小对象。弱引用回收的是 Java 对象,但 native 内存的释放依赖于 Bitmap 的 recycle() 或 GC 的 finalizer。这意味着即使弱引用被回收,native 内存可能不会立即释放。

Glide 通过 BitmapPool 来复用 Bitmap,减少 native 内存的分配和释放。活动资源缓存和 BitmapPool 的配合是:资源被回收时,如果 Bitmap 可复用,会放回 BitmapPool;下次需要时从池里取,避免重新分配。这个机制和弱引用是互补的,弱引用管 Java 对象的生命周期,BitmapPool 管 native 内存的复用。

问题现象 可能原因 排查方向
图片显示后消失 资源被提前回收 检查 Target 生命周期
内存缓存命中率低 资源频繁流转 检查缓存策略配置
native 内存不释放 Bitmap 未复用 检查 BitmapPool 配置
活动资源缓存条目异常多 资源泄漏 检查 Target 释放逻辑

7. 自定义缓存策略时的注意事项

7.1 什么时候需要动活动资源缓存

大多数情况下,不需要自定义活动资源缓存。Glide 的默认实现已经覆盖了绝大多数场景。但有两种情况可能需要关注:

第一种是 超高频加载场景 ,比如短视频应用的封面图列表,图片切换极快。这时候活动资源缓存的 activate/deactivate 频率很高,可以考虑调整内存缓存大小或使用自定义的缓存策略来减少流转开销。

第二种是 大图预览场景 ,比如图片查看器,单张图很大,占用内存多。这时候要确保大图不被 LruCache 长期持有,可以通过 skipMemoryCache(true) 让大图只走活动资源缓存和磁盘缓存,减少内存压力。

7.2 自定义 Target 时的强引用管理

如果自定义 Target,必须保证在资源使用期间持有强引用。常见的做法是在 Target 的 onResourceReady 里把资源保存到成员变量,在 onLoadCleared 里置空。这样只要 Target 存活,资源就不会被弱引用机制影响。

public class CustomTarget extends CustomViewTarget<ImageView, Drawable> {
    private Drawable currentResource;

    @Override
    public void onResourceReady(Drawable resource, Transition<? super Drawable> transition) {
        currentResource = resource; // 强引用
        view.setImageDrawable(resource);
    }

    @Override
    public void onLoadCleared(Drawable placeholder) {
        currentResource = null; // 释放强引用
        view.setImageDrawable(placeholder);
    }
}

这个模式看起来简单,但实际写的时候容易漏掉 onLoadCleared 的清理,导致资源被 Target 长期强引用,反而造成内存泄漏。所以强引用的管理是双向的:既要保证使用期间有强引用,也要保证释放后及时断开。

7.3 监控与调优建议

如果你想验证活动资源缓存的工作状态,可以通过以下方式:

  • 用 Android Studio 的 Memory Profiler 观察 ActiveResources 实例的 retained size 和对象数量。
  • 在 Debug 模式下打印 activeResources Map 的大小,观察列表滚动时的变化。
  • 关注 GC 日志中 WeakReference 的回收频率,如果异常频繁,说明资源流转过快。

调优的核心思路是: 让正在使用的资源稳定停留在活动资源缓存,让不用的资源尽快回到内存缓存或释放 。如果发现资源在两层缓存之间频繁移动,就要检查是不是有代码在反复请求和释放同一资源。

8. 弱引用机制给我们的通用启示

Glide 用弱引用做活动资源缓存,这个设计思路其实可以迁移到很多其他场景。核心思想是: 用弱引用做索引,用强引用做持有,用引用队列做清理 。这三者配合,既能快速查找资源,又能保证资源不被误回收,还能避免内存泄漏。

我在做其他缓存系统的时候也借鉴过这个模式。比如做一个图片编辑器的图层缓存,正在编辑的图层用强引用持有,缓存索引用弱引用,图层关闭时通过引用队列清理索引。效果比单纯用 LruCache 好很多,因为 LruCache 的淘汰策略在"正在使用"这个维度上是盲的。

另一个启示是 引用计数和弱引用的配合 。弱引用解决"对象是否还存在"的问题,引用计数解决"对象是否还在被使用"的问题,两者结合才能完整描述资源的生命周期。单独用任何一个都有缺陷:只用弱引用,无法知道资源是否还在使用;只用引用计数,无法感知资源被 GC 回收的情况。

最后说一个实际体会:理解 Glide 的缓存机制,最好的方式不是死记源码,而是自己动手写一个简化版的图片加载器,把活动资源缓存、LruCache、引用队列这几个组件串起来。写一遍之后,再回头看 Glide 的源码,很多设计决策就自然明白了。我在带新人的时候,通常会让他们先实现一个只支持内存缓存和磁盘缓存的版本,然后再加入活动资源缓存和弱引用,对比前后的内存表现和加载速度,效果比单纯讲原理好得多。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐