EN

Android 中的卡顿丢帧原因概述 - 低内存篇

字数统计: 5.4k阅读时长: 21 min
2019/09/18
loading

在Android 中的卡顿丢帧原因概述 - 系统篇 这篇文章中, 实际案例这里我们有列举一些由于系统低内存导致的卡顿, 由于 Android 低内存对整机性能影响比较大, 所以单独写一篇文章, 来概述系统低内存对整机性能的影响 .

随着 Android 系统版本的更迭, 以及 App 的代码膨胀, Android 系统对内存的需求越来越大, 在写作当年的 2019 年,市面上还存在着大量 4GB 内存及以下的机器, 这部分用户就很容易遇到整机低内存的情况, 尤其是在系统大版本更新、同时运行的应用和服务增多的情况下 .

Android 内存压力可能导致性能问题, 具体表现就是响应慢和卡顿 . 比如启动一个应用要花比平时更长的时间 ; 滑动列表会掉更多帧 ; 后台的进程减少导致冷启动变多 ; 额外回收和重启工作也可能增加功耗,但发热本身不能证明低内存, 下面我会概述发生这些性能问题的原因 . Debug 的方法, 以及可能的优化措施 .

版本说明(2026-10-13 技术复核): 本文保留 2019 年的日志与 Systrace 案例。旧内核 LMK 日志、调参接口与函数名有版本限制;下文补充 Android 10/11 的 PSI/lmkd、Android 16 框架源码和 Linux 6.6 的回收机制。历史截图没有完整原始 Trace 和受控实验条件,修订只校正可见证据,不把它们当成当前设备的复测结果。

低内存的数据特征和行为特征

Meminfo 信息

可以先用 Android 的 dumpsys meminfo 查看系统汇总,再结合进程和时间序列分析:

1
adb shell dumpsys meminfo

以下保留原文历史输出,其中 156,ba480K 已损坏,不应猜成一个有效数值:

1
2
3
4
5
6
7
......
Total RAM: 7,658,060K (status moderate)
Free RAM: 550,200K ( 78,760K cached pss + 156,ba480K cached kernel + 314,960K free)
Used RAM: 7,718,091K (6,118,703K used pss + 1,599,388K kernel)
Lost RAM: -319,863K
ZRAM: 2,608K physical used for 301,256K in swap (4,247,544K total swap)
Tuning: 256 (large 512), oom 322,560K, restore limit 107,520K (high-end-gfx)

这段数据不能直接作为“低内存必然如此”的判据:

  • Android 9 的 AMS 汇总逻辑将 cached PSS、部分内核缓存和实际空闲页合在 Free RAM 中;它不等于 /proc/meminfo 的 MemFree,也不等于 MemAvailable。
  • Used RAM 和 Lost RAM 涉及 PSS、swap PSS、内核及 ZRAM 的不同统计口径。这里 Used RAM 大于 Total RAM、Lost RAM 为负,应回到对应版本和原始采样核查,不能解释成物理内存真的“负剩余”,也不能据此认定泄漏。status moderate 同样是框架状态,不是逐帧压力测量。
  • 2,608K physical used、301,256K in swap 和 4,247,544K total swap 分别描述物理占用、已用交换空间和交换空间容量,不能混算成同一个“ZRAM 使用率”。仅这组数字也不支持原文“ZRAM 使用率非常高”的结论。

缓存占用不是坏事。应观察可用内存是否持续下降、回收和换入是否造成停顿,以及同一时段的前台响应;单次“空闲很少”不足以诊断低内存卡顿。

LMK && kswapd 线程活跃

下面是旧内核 lowmemorykiller 驱动的日志,不是现代用户空间 lmkd 的通用日志格式:

1
2
3
4
5
6
7
8
9
[kswapd0] lowmemorykiller: Killing 'u.mzsyncservice' (15609) (tgid 15609), adj 906,
to free 28864kB on behalf of 'kswapd0' (91) because
cache 258652kB is below limit 261272kB for oom score 906
Free memory is -5540kB above reserved.
Free CMA is 3172kB
Total reserve is 227288kB
Total free pages is 271748kB
Total file cache is 345384kB
GFP mask is 0x14000c0

它记录了在 kswapd0 的回收上下文中选择 PID 15609、adj 906 的进程作为清理对象,并列出 cache 258652kB、阈值 261272kB 等条件。不能把它改读成“所有系统都配置了 adj 900 水位”;-5540kB above reserved 是扣除保留条件后的差值,不是物理空闲页数为负。确切阈值和筛选方式需要该厂商内核源码。

kswapd 是内核后台页回收线程;旧 LMK 驱动和用户空间 lmkd 是不同实现,也不要与 Linux OOM killer 或 AMS 清理缓存/空进程混在一起。看到 kswapd 活跃,不等于已经发生杀进程。

版本更新: AOSP lmkd 文档说明,Android 10 支持用内核 PSI 监视压力,Android 11 的策略进一步考虑内存使用与 page-cache thrashing。是否启用仍取决于内核支持和产品配置,不能只看 Android 版本号。现代排查要读取对应版本的 lmkd 原因和压力指标,不应只搜索旧 lowmemorykiller: 字符串。

proc/meminfo

内核汇总可以这样读取,注意路径前的 /:

1
adb shell cat /proc/meminfo

原始样本如下;它的 MemTotal 与上一个 dumpsys 样本不同,不能当作同一设备、同一时刻的联合快照。

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
MemTotal:        5630104 kB
MemFree: 148928 kB
MemAvailable: 864172 kB
Buffers: 28464 kB
Cached: 1003144 kB
SwapCached: 19844 kB
Active: 1607512 kB
Inactive: 969208 kB
Active(anon): 1187828 kB
Inactive(anon): 426192 kB
Active(file): 419684 kB
Inactive(file): 543016 kB
Unevictable: 62152 kB
Mlocked: 62152 kB
SwapTotal: 2097148 kB
SwapFree: 42576 kB
Dirty: 3604 kB
Writeback: 0 kB
AnonPages: 1602928 kB
Mapped: 996768 kB
Shmem: 7284 kB
Slab: 306440 kB
SReclaimable: 72320 kB
SUnreclaim: 234120 kB
KernelStack: 89776 kB
PageTables: 107572 kB
NFS_Unstable: 0 kB
Bounce: 0 kB
WritebackTmp: 0 kB
CommitLimit: 4912200 kB
Committed_AS: 118487976 kB
VmallocTotal: 263061440 kB
VmallocUsed: 0 kB
VmallocChunk: 0 kB
CmaTotal: 303104 kB
CmaFree: 3924 kB

按照 Linux 6.6 /proc 文档,MemFree 是空闲 RAM,MemAvailable 是考虑可回收缓存及保留水位后、不发生交换时可用于新应用的估计。上面两者分别为 148928 kB 和 864172 kB,不能统一概括为“都几乎为零”。SwapFree 只剩 42576 kB,说明这个快照的交换空间已大量使用,但不证明此刻正在频繁换入换出。Committed_AS 也不是当前物理驻留量。

需要进一步定位时,在设备允许读取、内核提供接口的前提下,可以结合:

1
2
3
4
adb shell cat /proc/vmstat
adb shell cat /proc/pressure/memory
adb shell cat /proc/pressure/io
adb shell cat /proc/swaps

vmstat 应看同一次复现前后的增量,例如回收扫描/回收成功、allocstall*、pswpin/pswpout 和 workingset_refault*,具体字段依内核而异;累计值大不等于刚刚卡顿。PSI 的 some 表示至少部分任务受阻,full 表示所有非空闲任务同时受阻;avg10/60/300 是相应秒数窗口的时间比例,total 为累计微秒。详见 PSI 文档。接口不存在、权限不足或未采集时记为“不可用”,不能补成零压力。

整机卡顿 && 响应慢

内存压力可能表现为启动慢、滑动掉帧和更多冷启动,但这些现象也可能由 CPU、存储、锁或其他负载引起。先把卡顿时间与压力、回收、线程等待、进程退出记录对齐,再判断内存是否在这次场景的关键路径上。

低内存对性能的具体影响

影响主线程 IO 操作

内存压力会增加回收工作,可能挤掉后续还要访问的文件缓存,也可能带来交换相关工作。如果前台线程因此等待页面或存储完成,就会延长响应时间。

Trace 上要区分 Uninterruptible Sleep 与带 Block I/O 的分类,并查看阻塞调用点。D 状态或黄色本身不是磁盘 I/O 的充分证据;wait_on_page_bit_killable 表示等待相关页状态,不独证低内存、具体文件或存储设备饱和。缓存命中时,读文件也不一定触发这种等待,加载 View 更不必然发生磁盘读取。

这张 SystemUI 主线程图的可见阻塞点是 io_schedule,支持继续追查 I/O 等待,但没有给出同一时刻的内存压力值:

SystemUI 主线程的 Block I/O 状态与 io_schedule 阻塞点

下一张是 OPScreenRecord 的 bindApplication,包含 APK/OAT 加载区间和线程状态;它提示启动阶段存在等待,不能只凭颜色确定每一段等待的原因。

OPScreenRecord 的 bindApplication 与 APK/OAT 加载区间

出现 CPU 竞争

后台回收、压缩/解压以及重新加载内容会消耗 CPU。当这些工作与前台争用运行机会时,可能增加 Runnable 等待;要结合调度轨道确认,而不能只看有同名线程出现。

下图能看到 kswapd0 多次在 CPU 7 上运行。原文将设备描述为骁龙 855,但这张裁剪图没有足够的拓扑和数值频率信息,不能单凭 CPU 编号证明“超大核满频”,也没有测出功耗或某个前台线程被它延误的时间。

CPU 7 上多次出现 kswapd0,频率轨道未显示可读数值

HeapTaskDaemon 执行 ART 堆任务;它的活动不等于整段都是 GC,更不等于整段停世界。是否与系统压力有关,需要具体 GC/堆任务和应用分配证据,而不是从线程名推导。

进程频繁查杀和重启

频繁退出和重启会增加分配、代码加载及 I/O 工作,但要先区分谁杀了进程、为什么重启。下面保留当时 Monkey 测试后的原始日志:

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
07-23 14:32:16.932  1435  1510 I am_proc_start: [0,30387,10145,com.tencent.mobileqq,restart,com.tencent.mobileqq]
07-23 14:32:16.969 1435 3420 I am_proc_bound: [0,30387,com.tencent.mobileqq]
07-23 14:32:16.979 1435 3420 I am_kill : [0,30387,com.tencent.mobileqq,901,empty #3]
07-23 14:32:16.996 1435 3420 I am_proc_died: [0,30387,com.tencent.mobileqq,901,18]
07-23 14:32:17.028 1435 1510 I am_proc_start: [0,30400,10145,com.tencent.mobileqq,restart,com.tencent.mobileqq]
07-23 14:32:17.054 1435 3420 I am_proc_bound: [0,30400,com.tencent.mobileqq]
07-23 14:32:17.064 1435 3420 I am_kill : [0,30400,com.tencent.mobileqq,901,empty #3]
07-23 14:32:17.082 1435 3420 I am_proc_died: [0,30400,com.tencent.mobileqq,901,18]
07-23 14:32:17.114 1435 1510 I am_proc_start: [0,30413,10145,com.tencent.mobileqq,restart,com.tencent.mobileqq]
07-23 14:32:17.139 1435 3420 I am_proc_bound: [0,30413,com.tencent.mobileqq]
07-23 14:32:17.149 1435 3420 I am_kill : [0,30413,com.tencent.mobileqq,901,empty #3]
07-23 14:32:17.166 1435 3420 I am_proc_died: [0,30413,com.tencent.mobileqq,901,18]
07-23 14:32:17.202 1435 1510 I am_proc_start: [0,30427,10145,com.tencent.mobileqq,restart,com.tencent.mobileqq]
07-23 14:32:17.216 1435 3420 I am_proc_bound: [0,30427,com.tencent.mobileqq]
07-23 14:32:17.226 1435 3420 I am_kill : [0,30427,com.tencent.mobileqq,901,empty #3]
07-23 14:32:17.249 1435 3420 I am_proc_died: [0,30427,com.tencent.mobileqq,901,18]
07-23 14:32:17.278 1435 1510 I am_proc_start: [0,30440,10145,com.tencent.mobileqq,restart,com.tencent.mobileqq]
07-23 14:32:17.299 1435 3420 I am_proc_bound: [0,30440,com.tencent.mobileqq]
07-23 14:32:17.309 1435 3420 I am_kill : [0,30440,com.tencent.mobileqq,901,empty #3]
07-23 14:32:17.329 1435 2116 I am_proc_died: [0,30440,com.tencent.mobileqq,901,18]
07-23 14:32:17.362 1435 1510 I am_proc_start: [0,30453,10145,com.tencent.mobileqq,restart,com.tencent.mobileqq]
07-23 14:32:17.387 1435 2116 I am_proc_bound: [0,30453,com.tencent.mobileqq]
07-23 14:32:17.398 1435 2116 I am_kill : [0,30453,com.tencent.mobileqq,901,empty #3]
07-23 14:32:17.420 1435 2116 I am_proc_died: [0,30453,com.tencent.mobileqq,901,18]
07-23 14:32:17.447 1435 1510 I am_proc_start: [0,30466,10145,com.tencent.mobileqq,restart,com.tencent.mobileqq]
07-23 14:32:17.474 1435 2116 I am_proc_bound: [0,30466,com.tencent.mobileqq]
07-23 14:32:17.484 1435 2116 I am_kill : [0,30466,com.tencent.mobileqq,901,empty #3]
07-23 14:32:17.507 1435 2116 I am_proc_died: [0,30466,com.tencent.mobileqq,901,18]
07-23 14:32:17.533 1435 1510 I am_proc_start: [0,30479,10145,com.tencent.mobileqq,restart,com.tencent.mobileqq]
07-23 14:32:17.556 1435 2116 I am_proc_bound: [0,30479,com.tencent.mobileqq]
07-23 14:32:17.566 1435 2116 I am_kill : [0,30479,com.tencent.mobileqq,901,empty #3]
07-23 14:32:17.587 1435 2116 I am_proc_died: [0,30479,com.tencent.mobileqq,901,18]
07-23 14:32:17.613 1435 1510 I am_proc_start: [0,30492,10145,com.tencent.mobileqq,restart,com.tencent.mobileqq]
07-23 14:32:17.636 1435 2116 I am_proc_bound: [0,30492,com.tencent.mobileqq]
07-23 14:32:17.646 1435 2116 I am_kill : [0,30492,com.tencent.mobileqq,901,empty #3]
07-23 14:32:17.667 1435 2116 I am_proc_died: [0,30492,com.tencent.mobileqq,901,18]

这里九轮 am_kill 的原因都是 empty #3。在 AOSP Android 9 AMS中,空进程数量超过限额会以 empty # 加计数的形式清理;Android 16 的 OomAdjuster仍有对应的 too many empty 路径。因此,这组日志支持“框架反复清理空进程,随后又发生重启”,不能作为 LMK 直接杀 QQ 的证据。restart 标签也不能独自证明是父进程拉活,需要继续追组件、服务重启或其他启动请求。

对应 system_server 图中可见重复的启动和 killProcessGroup 活动;这不是单一杀进程原因的专用图形:

system_server 中反复出现启动与 killProcessGroup

下图显示 CPU 上密集的运行切片,但缺少完整线程统计,不能把全部 CPU 消耗归到 QQ 或 LMK。这里也不评价这些应用的当前版本。

历史 Trace 的密集 CPU 运行切片

影响内存分配和触发 IO

手机用了很长时间,再打开一个之前用过的应用,反而没有第一次快,还要等它加载一会儿——这也是我关注低内存卡顿的一个场景。不过,光凭这个现象还不能判断原因。往下查时,需要区分几个环节,而不是把它们都叫作“回收”:

  • 分配快路径尝试从已有空闲页满足请求;不应称为“fast path 回收”。不足时,慢路径可能唤醒 kswapd、进行直接回收或内存规整,具体取决于分配标志和条件。直接回收在请求分配的线程上下文中执行,可能直接影响前台延迟。参见 Linux 6.6 page_alloc.c。
  • 回收文件页不一定写盘。干净、可回收的文件缓存可被丢弃,之后再访问才可能重新读入;脏页通常涉及写回,不能把所有 file-backed 页都描述成“先写回再清空”。
  • 匿名页与 swap也不等于存储读写。ZRAM 在 RAM 中压缩存储,仍占物理内存和 CPU;配置了 backing device/writeback 时又可能涉及实际存储。/proc/swaps 和设备配置决定了具体路径,不能假设所有手机 swap 都是纯 RAM。参见 Linux 6.6 ZRAM 文档。
  • 缺页不一定需要读盘;已在页缓存/swap cache 中的数据、首次映射等情况与需要存储 I/O 的缺页不同。重新打开应用变慢也可能是进程已被杀而冷启动,不要只归为 swap-in。

旧内核中的 do_page_fault、lock_page_or_retry、wait_on_page_bit_killable 是分析入口示例,不是所有架构和版本都相同的固定调用链。Linux 6.6 的文件缓存路径已大量使用 folio 接口。PG_locked 与 PG_writeback 是不同状态;对应源码中的 folio_unlock() 与 folio_end_writeback()分别处理解锁和写回结束。原文“页锁只在开始回写或读 I/O 完成时清除”的概括不成立,killable 等待还可能受致命信号影响。

预读过大可能增加无用 I/O 和缓存挤占,过小也可能减少顺序访问收益。是否需要调小,应该由访问模式、命中率和延迟决定。

实例

下面是当时抓的两份启动截图,我分别标为低内存和正常内存。箭头选择的是 bindApplication 到首帧附近,不是从用户点击开始的完整启动耗时,也没有独立的实际呈现时间证明。两次的温度、缓存、后台任务等条件未完整提供,不能当作只改变内存这一因素的受控对照。

低内存的启动情况

上方箭头为 2.044 s,下方 Thread Timeslices 统计范围为 2053.451 ms,两者略有不同。按下方表格逐行读取:Running 是 682.137 ms;带 Block I/O 的两项合计 647.450 ms,全部四项 Uninterruptible 状态合计 783.385 ms。原文“750ms 左右”只概括了部分行,不能替代完整分类。

低内存样本的 2.044s 箭头与 2053.451ms 线程状态统计

CPU Slices 列表可见 HeapTaskDaemon、多个 kworker、kswapd0,但线程名汇总可能合并不同进程的同名线程,且时间跨 CPU 累计,不等于主线程等待时间或整段墙钟时间。

低内存样本 CPU Slices 中的 HeapTaskDaemon、kworker 和 kswapd0

正常内存情况下

正常样本上方箭头为 1.223 s,下方线程统计范围为 1227.232 ms。Running 为 624.278 ms,全部三项 Uninterruptible 合计 139.417 ms,其中带 Block I/O 的两项为 33.097 ms。

正常样本的 1.223s 箭头与 1227.232ms 线程状态统计

两张截图的线程状态表整理如下,单位均为毫秒;不将箭头范围与表格范围混算:

项目 低内存样本 正常样本
统计范围 2053.451 1227.232
Running 682.137 624.278
Runnable 63.060 43.902
Sleeping 524.869 419.635
Uninterruptible(全部子类合计) 783.385 139.417
其中带 Block I/O 的子类 647.450 33.097

最后一行已经包含在前一行,不能再相加。低内存样本增加最多的是 Uninterruptible 状态,但其中没有 Block I/O 标记的部分不能全部算成磁盘 I/O;这些截图也不足以把差值全部归因于低内存。

正常样本的 CPU 列表里仍有 HeapTaskDaemon 和其他工作线程。低内存列表显示 kswapd0,正常截图的可见行没显示它;“没在截图排名中出现”不等于该时段完全没有回收。两次选区长度不同,不能只比较总 CPU 毫秒数就认定某个线程工作强度更高。

正常样本可见 CPU Slices 排名,不代表未显示线程的耗时为零

可能的优化方案 (来自实际的经验和大佬分享的经验)

下面这些方向来自我自己的经验和同行分享,具体问题还是要具体分析。平台调参属于系统/OEM 工作;普通应用应先管理自己的分配、缓存和后台任务。 有常驻通知不等于一定占用大量内存,也不是无条件清理进程的理由。减少进程数量可能增加冷启动、丢失工作和后台功能中断,目标应是前台响应与功能可靠性,而不是“空闲 RAM 越大越好”。

  1. 水位调整: extra_free_kbytes 是历史 Android 扩展接口,不能假设所有内核都存在。AOSP Android 16 的适配脚本会检查该节点,缺失时计算 watermark_scale_factor;两者对 high 水位的影响并不相同,不能逐值替换。调高可能减少分配突发时的直接回收,也可能增加后台回收、减少可用的缓存空间。
  2. 存储: UFS 3.0 是当年的硬件选型例子,不是软件修复或充分性能保证。看实际随机 I/O、队列、尾延迟和热状态,手机也不能按桌面电脑的方式简单“换固态硬盘”。
  3. 预读: 根据顺序/随机访问和缓存命中率调整 read_ahead_kb,不提供统一的越小越好数值。
  4. 后台 I/O: 根据设备采用的 cgroup 版本和控制器做隔离;v1 的 blkio 与 v2 的 io.max 等接口不同。限流过度也可能延误前台依赖的后台工作,见 Linux 6.6 cgroup v2 文档。
  5. 提前回收: 可以在已验证的空闲阶段试验,但要计入 CPU、压缩、写回和之后 refault 的成本;不要以周期性清缓存代替容量管理。
  6. 减少无效退出/重启: 先分清 lmkd、AMS 数量限制、崩溃与应用启动请求,修具体循环;“杀得更多”不等于效率更高。
  7. kswapd: 围绕水位与分配突发调节,观察 allocstall* 等数据;它不是按固定周期必定回收到更多 high 水位的通用策略。
  8. swappiness: 在 Linux 6.6 中是 swap 与文件分页的相对 I/O 成本偏好,范围 0–200,不是“内存使用达到多少百分比才换出”。ZRAM 场景也不应机械设为 0;选择值要实测。
  9. 低 RAM 产品策略: 在同等场景下比较前台尾延迟、冷启动率、退出率、功耗和后台功能成功率,再决定缓存/进程保留策略;不要仅按 RAM 容量复制另一台设备的参数。
  10. 应用侧: 修泄漏,给位图/对象缓存设容量边界,减少无用分配和不必要的后台任务,将允许异步的 I/O 移出主线程并避免主线程等待其结果。Android 14 / API 34 起,普通应用不能用 killBackgroundProcesses() 清理其他包,见桌面被杀案例的版本更新。
  11. 无法恢复的异常: 重启可以是用户选择的临时恢复手段,不能算根因修复;在可行时保留异常日志、压力和进程数据,继续查泄漏或异常负载。

这些参数没有通用最优值。验证优化时应固定设备/构建、负载、缓存和热状态,重复采集并看分布,避免把一次截图的改善当作已解决问题。

参考资料

当前机制与版本依据已在正文就近链接。以下保留原文历史参考,不作为当前版本行为的唯一依据:

  1. 历史参考一
  2. 历史参考二

本文知乎地址

由于博客留言交流不方便,点赞或者交流,可以移步本文的知乎界面
知乎 - Android 中的卡顿丢帧原因概述 - 低内存篇

关于我 && 博客

下面是个人的介绍和相关的链接,期望与同行的各位多多交流,三人行,则必有我师!

  1. 博主个人介绍 :里面有个人的微信和微信群链接。
  2. 本博客内容导航 :个人博客内容的一个导航。
  3. 个人整理和搜集的优秀博客文章 - Android 性能优化必知必会 :欢迎大家自荐和推荐 (微信私聊即可)
  4. Android性能优化知识星球:付费社群已于 2026 年停止运营,不再招收新成员;此处保留历史介绍。

一个人可以走的更快 , 一群人可以走的更远

微信扫一扫

CATALOG
  1. 1. 低内存的数据特征和行为特征
    1. 1.1. Meminfo 信息
    2. 1.2. LMK && kswapd 线程活跃
    3. 1.3. proc/meminfo
    4. 1.4. 整机卡顿 && 响应慢
  2. 2. 低内存对性能的具体影响
    1. 2.1. 影响主线程 IO 操作
    2. 2.2. 出现 CPU 竞争
    3. 2.3. 进程频繁查杀和重启
    4. 2.4. 影响内存分配和触发 IO
  3. 3. 实例
    1. 3.1. 低内存的启动情况
    2. 3.2. 正常内存情况下
  4. 4. 可能的优化方案 (来自实际的经验和大佬分享的经验)
  5. 5. 参考资料
  6. 6. 本文知乎地址
  7. 7. 关于我 && 博客