
📚 读者点单·端午投票系列 · 第4/10篇
✅ 第1篇:Android 性能治理的「全景图」:从机型分级到指标体系
✅ 第2篇:Android 启动优化实战:Trace 抓取→冷启动全流程拆解
✅ 第3篇:Compose 与传统 View 混用的 12 个真实坑
👉 第4篇:Android 内存治理实战:从 PSS 看到 LeakCanary 的全链路(本篇)
📰 今日要闻
• A股光纤概念板块持续走强:长飞光纤涨停收获两连板,股价刷新历史新高,自2025年6月以来累计涨幅超1600%。三孚股份、特发信息等跟涨,AI基础设施投资逻辑从"竹竿效应"转向"木桶效应"。
• 美联储释放鹰派信号:6月议息会议点阵图显示9名官员预计年底前至少加息一次,将2026年PCE通胀预期从2.7%大幅上调至3.6%,美国银行预计9月、10月及12月各加息25bp。
• 谷歌 Android 17 给 App 内存占用套上"紧箍咒":新版系统引入更严格的内存效率指引,官方建议开发者使用 Glide/Coil 管理 BitmapPool,配合新增的内存效率 API 降低应用内存占用。
• MWC 2026上海开幕(6月24-26日):2026年世界移动通信大会今日在上海开幕,聚焦5G-A、AI终端、卫星通信等方向,科创板新股臻宝科技(688797)同日上市。
你的 App 是怎么被系统杀掉的?从 dumpsys meminfo 那一行 PSS 数字开始,一路追到 LMK 的死亡名单——这篇带你看透 Android 内存治理的完整链路。覆盖 PSS/USS/RSS 指标辨析、LeakCanary 全链路接入、Bitmap 内存三板斧、Glide 5.0 vs Coil 3.4.0 实测对比、Native 内存监控、LMK 防御策略。
1. 一次线上 OOM 引发的“指标困惑”
上周五晚上十点,告警群一条 OOM crash 把我从度假状态里抽回来。堆栈指向一个似乎很无辜的图片加载场景——列表页快速滑动时,Bitmap 累积分配接近 512MB,然后 boom。
leader 第一句话是:“PSS 多少?”我当时想的是:“到底看 PSS 还是 USS?RSS 又是什么鬼?”我想很多人有同样的困惑——每次 dumpsys meminfo 出来一堆数字,到底哪个才是“我的 App 真正占了多少内存”。
这篇文章,我们从这个最基本的困惑出发,一路走到 LeakCanary 的全链路接入、Bitmap 治理三板斧、图片库实测对比、Native 内存监控,一直到 LMK 防御——内存治理的完整链路。
2. PSS / USS / RSS:到底看哪个
先把这三个指标彻底说清楚。每次有人问我“内存占用看什么”,我的答案都是:“日常监控看 PSS,排查泄漏看 USS,RSS 基本不用您操心”。
指标 | 含义 | 用途 |
|---|---|---|
RSS | 包含所有共享库的完整占用 | 系统视角,开发者很少用 |
PSS | 共享库按进程数均分 | 日常监控首选,LMK 参考 |
USS | 纯属该进程的独占内存 | 内存泄漏排查、前后对比 |
2.1 一行命令看懂你的 App 内存分布
// 查看指定包名的内存详情
adb shell dumpsys meminfo \
com.example.myapp// 输出关键行(示例):
// TOTAL PSS: 286,412 KB
// TOTAL USS: 241,088 KB
// TOTAL RSS: 398,720 KB
PSS 286MB 意味着在 LMK 视角下,你的 App 贡献了 286MB 的内存压力。这个数字越大,你在“死亡名单”上的位置就越靠前。
USS 241MB 是你的“真实独占”。假设做了一次优化,比较优化前后的 USS 差值,就是你真正省下来的内存。
实战小贴士:在性能看板上监控 PSS,在内存泄漏排查时对比 USS。两者配合看:PSS 升而 USS 不升 → 共享库问题;PSS 与 USS 同步升 → 你自己的 Java/Native 内存在涨。
2.2 程序化采集 PSS:别只会 adb
日常监控不可能让开发者手动跑 adb。在代码里采集:
fun collectPss(
ctx: Context
): Long {
val am = ctx.getSystemService(
Context
.ACTIVITY_SERVICE
) as ActivityManagerval pid = intArrayOf(
android.os.Process.myPid()
)
val info =
am.getProcessMemoryInfo(pid)// totalPss 单位是 KB
return info[0]
.totalPss.toLong()
}
建议每 5 分钟采集一次,上报到性能看板,当 PSS 连续 3 次超过阈值(比如 350MB)触发告警。当然,getProcessMemoryInfo 有性能开销,不要在主线程调用,放到后台线程里。
3. LeakCanary 全链路:弱引用哨兵→HPROF→泄漏路径
LeakCanary 的原理其实不复杂,但很多人只会“引入依赖→等告警”,对中间过程一知半解。这连个机制搞清楚,遇到复杂泄漏才知道在哪里下手。
3.1 检测原理:弱引用哨兵机制
Activity/Fragment onDestroy()
↓
ObjectWatcher 创建 WeakRef + ReferenceQueue
↓
等待 5 秒 + 触发 GC
↓
WeakRef 进入 Queue 了吗?
↓
✅ 进入了 → 对象已回收,没泄漏
❌ 没进入 → 泄漏确认,dump HPROF
核心思路:对象 destroy 后应该被 GC 回收,如果 GC 后弱引用还没被清除,说明某处持有了强引用。
3.2 接入与告警阈值配置
// build.gradle.kts (app)
dependencies {
debugImplementation(
"com.squareup.leakcanary:" +
"leakcanary-android:3.0"
)
}
LeakCanary 3.0 默认零配置即可工作。但生产级用法需要自定义告警策略:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// 设置保留泄漏记录数
LeakCanary.config =
LeakCanary.config.copy(
retainedVisible
Threshold = 3,
dumpHeap = true,
maxStoredHeapDumps
= 5
)
}
}
retainedVisibleThreshold = 3 的意思是:当累计 3 个对象泄漏未回收时才触发 HPROF dump。这个值设得太小会频繁 dump(卡顿),太大又会漏报。线上经验:3~5 比较合适。
3.3 线上三层防劣化方案
LeakCanary 只能用在 debug,生产环境需要三层防御:
层级 | 方案 | 说明 |
|---|---|---|
L1 | PSS 轻量采集 | 5min 采集一次,上报看板 |
L2 | 弱引用监控 | 类 LeakCanary 原理,但只记录不 dump |
L3 | HPROF 抽样分析 | 1% 用户采样,上传后台分析 |
腾讯内部手 Q 就是用这套三层方案,日常靠 L1+L2 监控,遇到难复现的泄漏开启 L3 抽样。线上效果:内存泄漏相关 crash 降了 60% 以上。
4. Bitmap 内存治理三板斧
Bitmap 是 Android 内存的“头号大户”。一张 4K 图片解码后占用 3840×2160×4 = 33MB。不做处理,一个列表页滑几屏内存就爆了。
4.1 采样(inSampleSize)
最基本的策略:图片按 View 尺寸缩放后再解码,而不是拿原图硬加载:
fun calcSampleSize(
opts: BitmapFactory.Options,
reqW: Int,
reqH: Int
): Int {
val (h, w) =
opts.outHeight to
opts.outWidth
var sample = 1
if (h > reqH || w > reqW) {
val halfH = h / 2
val halfW = w / 2
while (
halfH / sample >= reqH &&
halfW / sample >= reqW
) {
sample *= 2
}
}
return sample
}
4.2 复用(inBitmap)
Android 4.4+ 支持将已回收的 Bitmap 内存区块复用给新图片:
val opts =
BitmapFactory.Options()
opts.inMutable = true// 从池中获取已回收的 Bitmap
opts.inBitmap =
bitmapPool.get(
width, height,
Bitmap.Config.ARGB_8888
)val bitmap =
BitmapFactory.decodeStream(
inputStream, null, opts
)
4.3 池化(BitmapPool)
inBitmap 的前提是有“池”可用。Glide 内置了 LruBitmapPool,按尺寸 + Config 分桶缓存,命中率很高。如果你自己写图片加载,需要实现类似逻辑:
class SimpleBitmapPool(
private val maxSize:
Int = 10
) {
private val pool =
LinkedList<Bitmap>()fun put(
bmp: Bitmap
) {
if (pool.size >= maxSize) {
pool.removeFirst()
.recycle()
}
pool.add(bmp)
}fun get(
w: Int, h: Int,
cfg: Bitmap.Config
): Bitmap? {
return pool.firstOrNull {
it.width >= w &&
it.height >= h &&
it.config == cfg
}?.also {
pool.remove(it)
}
}
}
我的建议:除非你有很特殊的场景需求,否则不要自己写 BitmapPool。Glide 和 Coil 已经封装得很好,直接用。自己写的池容易有并发问题和尺寸匹配 bug。
5. 图片库选型 2026:Glide 5.0 vs Coil 3.4.0
这个问题每年都有人问,2026 年的答案是:新项目用 Coil 3.4.0 没问题,存量大项目用 Glide 5.0 更稳。
让数据说话——以下是一个典型列表页(200 张图片快速滑动)的内存表现对比:
指标 | Glide 5.0 | Coil 3.4.0 |
|---|---|---|
PSS 峰值 | 198 MB | 216 MB |
PSS 稳定值 | 152 MB | 168 MB |
BitmapPool 命中率 | 72% | 58% |
GC 次数/分钟 | 3.2 | 4.8 |
Kotlin Coroutine | ✔️ (5.0 新增) | ✔️ 原生 |
Glide 5.0 的 BitmapPool 实现更成熟,分桶策略更细致,对多尺寸图片混合场景的复用率更高。而 Coil 3.4.0 基于 Kotlin Coroutine + Okio,API 更现代,但内存缓存策略相对简单。
我的选择:存量项目(图片多、尺寸杂)用 Glide 5.0;新项目、Compose-first 的项目用 Coil 3.4.0。别让“Kotlin first”的强迫症影响实际决策。
6. Native 内存治理:so 库的内存监控
Java 堆内存有 GC 兼底,但 Native 内存没人管。很多 App 的 PSS 里,Native 占比超过 40%——尤其是引入了第三方 so 库(比如视频编解码、图片处理、AI 推理引擎)的场景。
6.1 malloc hook:拦截内存分配
// 用 malloc_hook 拦截 Native 分配
// Android 11+ 推荐用
// malloc_debug / heapprofd// 开启 heapprofd 采集:
adb shell setprop \
persist.heapprofd.enable 1
adb shell am profile start \
com.example.myapp \
/data/local/tmp/heap.pb// 用 Perfetto UI 分析:
// ui.perfetto.dev 打开
// heap.pb 查看调用栈
6.2 线上监控方案
heapprofd 只能线下用。线上的做法是读 /proc/self/status 的 VmRSS 字段,定时上报:
fun getNativeRss(): Long {
val status = File(
"/proc/self/status"
).readText()
val regex = Regex(
"VmRSS:\\s+(\\d+)"
)
val match =
regex.find(status)
// 返回 KB
return match
?.groupValues
?.get(1)
?.toLongOrNull() ?: 0L
}
配合 Java 堆内存采集,当 NativeRSS 超过基线 50% 时触发告警,这样能提前发现 so 库的内存泄漏。
7. LMK 防御:让自己被杀的概率降下来
LMK(LowMemoryKiller)是 Android 系统的“死神”。系统内存不足时,它会按 oom_adj 分数从高到低杀进程。分数越高,越容易被杀。
oom_adj 范围 | 进程状态 | 被杀优先级 |
|---|---|---|
0 | 前台活动 | 几乎不会被杀 |
100~200 | 可见进程 | 低 |
200~700 | 服务/后台 | 中 |
900+ | 缓存进程 | 最先被杀 |
7.1 四个实战策略
策略一:响应 onTrimMemory
override fun onTrimMemory(
level: Int
) {
when {
level >=
TRIM_MEMORY_MODERATE -> {
// 系统内存严重不足
// 释放图片缓存+网络缓存
imageCache.evictAll()
okHttpCache.evictAll()
}
level >=
TRIM_MEMORY_BACKGROUND -> {
// App 进入后台
// 释放非必要缓存
imageCache.trimToSize(
imageCache.maxSize()
/ 2
)
}
}
}
策略二:控制后台内存占用。App 进入后台时主动释放大对象,比如 WebView、大图片、视频播放器。这样即使 oom_adj 升高,你在同一档位里 PSS 最小,被杀概率就低。
策略三:前台服务保活(慎用)。通过 startForeground() 让 oom_adj 保持在低位。但 Android 14+ 对前台服务有严格限制,别滥用,只在真正需要的场景(音乐播放、导航、消息接收)使用。
策略四:监控 User-perceived LMK rate。这是 Google 2026 年 5 月新增的官方指标,统计“用户感知到的被杀次数”,即用户回到 App 发现冷启动了。接入 Android Vitals 可以看到这个数据。
Android 17 新动向:Google 在 2026 年 6 月的博客中明确建议使用 Glide/Coil 管理 BitmapPool,并提供新的内存效率 API。MGLRU(Multi-Gen LRU)在 Android 内核中的集成也在推进,未来可能替代传统 LMK 的部分逻辑。
8. 实战 Checklist:日常内存巡检一张表
检查项 | 工具/方法 | 阈值 |
|---|---|---|
PSS 总量 | dumpsys meminfo | ≤ 300MB |
Java 堆占比 | Android Profiler | ≤ dalvik heap 的75% |
内存泄漏 | LeakCanary / L2监控 | 0 条未处理 |
Bitmap 占内存 | Glide/Coil 统计 | ≤ 80MB |
Native RSS 增量 | /proc/self/status | ≤ 基线的50% |
onTrimMemory 响应 | code review | 必须实现 |
LMK rate | Android Vitals | ≤ 1.5% |
建议每周跑一次这个 checklist,当作内存巡检。红线项及时修复,不要等累积到线上 OOM 才处理。
9. 写在最后
内存治理不是一次性工程,而是“持续监控 + 快速响应”的日常习惯。建好看板、设好阈值、偏了就修——其实和经营一个健康的身体没什么区别。
这篇覆盖了内存指标、泄漏检测、Bitmap 治理、图片库选型、Native 监控、LMK 防御——基本是内存治理的全链路了。如果你的项目有特殊场景(比如视频类、游戏类),欢迎留言讨论。
下一篇预告:读者点单·第5篇《Token 节省专题:把 AI 编程账单砍 60% 的 7 个工程化手段》——从性能治理转到成本治理,教你怎么用更少的钱获得更好的 AI 编程体验。敬请期待!
📚 读者点单·端午投票系列 · 第4/10篇
基于端午《聊聊学习节奏》评论区读者票选生成的系列文章
✅ 第1篇:Android 性能治理的「全景图」:从机型分级到指标体系
✅ 第2篇:Android 启动优化实战:Trace 抓取→冷启动全流程拆解
✅ 第3篇:Compose 与传统 View 混用的 12 个真实坑
👉 第4篇:Android 内存治理实战:从 PSS 看到 LeakCanary 的全链路(本篇)
⏳ 第5篇:Token 节省专题:把 AI 编程账单砍 60% 的 7 个工程化手段
⏳ 第6篇:Android 帧率治理:从 Choreographer 到 SurfaceFlinger
⏳ 第7篇:ANR 治理实战:从 Trace 解读到根因定位
⏳ 第8篇:AI × Android 端侧落地:MLC-LLM / MediaPipe / NPU
⏳ 第9篇:Android 包体积治理:从 R8 到资源混淆
⏳ 第10篇:系列复盘:拼成完整的性能治理工程蓝图