Android内存回收:GC、kswapd 和 mm_vmscan_direct_reclaim概述
摘要:Android系统中的三种内存管理机制。Java/ART GC负责回收Java堆内存,而kswapd是内核后台异步回收线程。当内存不足时,应用线程可能被迫执行direct reclaim(mm_vmscan_direct_reclaim),在内核态同步回收内存。这种现象不同于GC的全局暂停,而是特定线程的阻塞式回收。解释了为何在有GC和kswapd的情况下仍会发生direct reclaim,主要压力源是Native Heap、Bitmap等非Java堆内存。当主线程等待执行direct reclaim的后台线程时,就会导致UI卡顿。三种机制形成分层管理体系,共同维护系统内存平衡。
GC、kswapd 和
mm_vmscan_direct_reclaim解决的是不同层面的内存问题。即使有 Java/ART GC,也即使 kswapd 在后台回收,应用线程仍然可能在申请内存时被迫进入mm_vmscan_direct_reclaim。
并且:
mm_vmscan_direct_reclaim不是发生在 Java 层,也不是图库业务代码内部;它是 Linux/Android 内核的内存回收路径。但它可以发生在应用的某个线程上下文中。此时这个线程会暂停执行自己的用户态任务,转而在内核态同步做内存回收。
1. 为什么有 Java GC,还会发生 direct reclaim?
因为 Java GC 只管理 Java/ART 堆里的对象,而应用的大量内存并不完全在 Java Heap 里。
例如:
Java Heap: 约 49 MB
Native Heap: alloc 约 503 MB
Graphics/GL: 约 376 MB
Bitmap: 约 201 MB
SwapPss: 约 395 MB
这说明应用主要压力来自:
-
Native Heap
-
Bitmap native allocation
-
GL texture / Graphics buffer
-
gralloc / dma-buf
-
图片 decode 中间 buffer
-
native 图片库内存
-
page cache
-
zram/swap
-
文件映射
-
可能还有图像 AI / 编辑 / 缩略图模块
这些内存很多 不是 Java GC 能直接释放的。
1.1 Java GC 只能判断 Java 对象是否可达
ART GC 负责类似这些对象:
ImageView
List
Bitmap wrapper Java object
Gallery item model
Adapter
Activity/Fragment/View
但很多真实大内存在 native/kernel/graphics 层。比如一个 Bitmap,Java 层可能只是一个小对象,真正像素内存可能在 native heap 或 graphics memory 中。
再比如 GL 纹理:
Java 对象可能已经很小
但 GL texture / gralloc buffer / dmabuf 可能占用几十到几百 MB
Java GC 不一定能立即释放这些资源,尤其当:
-
Java 对象仍然被引用;
-
native 层还有引用;
-
GL 资源未显式释放;
-
图形资源等待渲染管线释放;
-
BitmapPool / LruCache 仍持有;
-
native allocator 没有把 free memory 归还给内核;
-
页面只是被释放到 malloc arena 内部,没有
madvise给内核。
所以即使 Java GC 正常工作,系统层仍可能缺页、缺空闲页、缺连续页,触发 direct reclaim。
2. 为什么有 kswapd,还会发生 mm_vmscan_direct_reclaim?
kswapd 是后台回收线程。它的职责是提前回收内存,把各个 zone 的 free pages 维持在一定水位之上。
但它不是万能的。
当某个应用线程申请内存时,如果发现当前可用页不够,或者 zone 水位不足,或者高阶连续页不足,内核就可能让这个申请内存的线程自己进入回收流程。
这个就是:
direct reclaim
大致逻辑是:
应用线程申请内存
↓
内核检查 free pages / watermark / zone / order
↓
发现当前不能满足分配
↓
kswapd 可能已经在跑,但还不够
↓
当前申请线程被迫自己回收内存
↓
进入 mm_vmscan_direct_reclaim
所以:
kswapd = 后台异步回收
direct reclaim = 当前申请内存的线程同步回收
2.1 kswapd 后台异步内存回收跟不上应用所需
14:44:26.315
PSI stat: 12, 22.59, 8.63, 9.88, 3.82
kswapd reclaim amount : 51218 pages
kswapd reclaim efficiency : 335 KB/ms
14:44:27.398
PSI stat: 13, 22.59, 8.63, 9.88, 3.82
kswapd reclaim amount : 44418 pages
kswapd reclaim efficiency : 236 KB/ms
14:44:28.479
PSI stat: 13, 31.35, 10.67, 13.89, 4.74
kswapd reclaim amount : 36186 pages
kswapd reclaim efficiency : 229 KB/ms
这说明:
-
kswapd 确实在大量回收;
-
PSI 仍然在升高;
-
回收效率在下降;
-
系统内存 stall 仍在加重。
也就是说:
不是没有 kswapd,而是 kswapd 已经很忙,但仍然无法及时满足前台应用线程的内存分配需求,于是 前台应用的线程 进入了 direct reclaim。
3. mm_vmscan_direct_reclaim 是发生在应用进程内部吗?
这个问题要分两层说。
3.1 从代码归属看:不是应用进程代码,是内核代码
mm_vmscan_direct_reclaim 属于 Linux kernel 内存管理模块,大概在:
mm/vmscan.c
它不是 应用进程 的 Java/Kotlin/C++ 业务代码。
所以不能说:
应用进程代码里调用了
mm_vmscan_direct_reclaim
更准确是:
应用进程的线程在执行某个内存申请、page fault、buffer 分配或 native allocation 时进入内核,内核为了满足该分配,在该线程上下文中执行了
mm_vmscan_direct_reclaim。
3.2 从线程上下文看:发生在应用线程上下文中
trace 里显示:
应用的线程
mm_vmscan_direct_reclaim
这意味着:
应用线程进入了内核态,并且在它自己的线程上下文里执行 direct reclaim。
所以 CPU trace 会把这段时间归到 应用 线程上。
可以理解为:
应用的线程 本来要做图片加载/解码/分配内存
↓
调用 malloc / mmap / decode / Bitmap allocate / page fault / buffer allocate
↓
陷入内核
↓
内核发现内存分配压力大
↓
让 应用的线程 这个线程帮系统做回收
↓
应用的线程 在内核态跑 mm_vmscan_direct_reclaim
↓
回收完成或失败后才返回用户态继续应用的任务
所以更准确的表述是:
mm_vmscan_direct_reclaim是内核函数,但它发生在应用的线程上下文中。
4. direct reclaim 发生时,会像 GC 一样停止线程任务吗?
对当前线程来说:会。
对整个进程来说:不一定
对整个系统来说:更不是 stop-the-world。
4.1 对当前线程:它会暂停原本任务
当 应用线程 进入 direct reclaim 后,它不能继续执行自己的加载逻辑。
它原本可能在做:
加载图片
解码 Bitmap
申请 native buffer
上传纹理
构建缩略图缓存
通知主线程
但进入 direct reclaim 后,它实际在做:
扫描 LRU page
回收 file cache
回收 slab
尝试回收匿名页
可能触发 swap/zram
等待 IO
等待锁
执行 shrinker
可能参与 compaction
所以从业务角度看:
应用的线程 的业务任务被暂停了。
这就是为什么主线程等不到它的结果。
4.2 但它不像 Java GC 的 Stop-The-World
Java/ART GC,特别是某些阶段,会暂停所有 Java mutator 线程,或者暂停一部分线程。它是运行时层面的对象回收。
而 direct reclaim 是内核内存分配路径上的同步回收。
区别如下:
| 项目 | Java/ART GC | direct reclaim |
|---|---|---|
| 所属层级 | ART Runtime | Linux Kernel |
| 管理对象 | Java Heap 对象 | 系统物理页、page cache、slab、匿名页、swap 等 |
| 触发原因 | Java Heap 需要回收 | 内核分配内存时水位不足 |
| 是否停止当前线程 | 是,可能暂停 Java 线程 | 是,当前申请内存线程会被阻塞在内核 |
| 是否停止整个进程 | 某些 GC 阶段可能 STW | 通常不会停止整个进程 |
| 是否停止其他线程 | GC 可能影响多个 Java 线程 | 只阻塞进入 direct reclaim 的线程,其他线程可继续运行 |
| trace 表现 | GC、suspend、art 等 | mm_vmscan_direct_reclaim 、 shrink_node 、 try_to_free_pages |
| 典型耗时 | 通常 ms 级,严重时几十/上百 ms | 严重内存压力下可达数百 ms 甚至秒级 |
所以 direct reclaim 不是 GC,但对发生它的线程来说,效果很像:
"线程原本任务被迫暂停,去做内核内存回收。"
5. 为什么主线程 Sleeping,而不是主线程自己 direct reclaim?
Android应用的主线程发起
↓
主线程等待 后台线程 返回数据或通知
↓
后台线程 执行线程加载任务
↓
后台线程 申请内存
↓
后台线程 进入 mm_vmscan_direct_reclaim
↓
后台线程 长时间无法完成任务
↓
主线程一直 Sleeping
↓
后台线程 终于完成并唤醒主线程
所以用户看到的是主线程卡,但直接陷入 reclaim 的是 后台线程。
这也是为什么 trace 显示:
主线程 Sleeping
Woken by 后台线程
后台线程 mm_vmscan_direct_reclaim
这条链非常典型。
6. direct reclaim 期间线程可能具体卡在哪里?
mm_vmscan_direct_reclaim 不是一个简单函数,它背后可能包括很多事情。
典型路径可能类似:
__alloc_pages_slowpath
-> try_to_free_pages
-> do_try_to_free_pages
-> shrink_zones
-> shrink_node
-> shrink_lruvec
-> shrink_list
-> shrink_inactive_list
-> shrink_active_list
期间可能发生:
-
扫描大量 LRU page;
-
回收文件页 cache;
-
回收匿名页;
-
把匿名页换出到 zram/swap;
-
等待 page writeback;
-
等待 page lock;
-
执行 slab shrinker;
-
执行 filesystem shrinker;
-
执行 dmabuf / ion / graphics 相关 shrinker;
-
触发 compaction;
-
被更高优先级线程抢占;
-
因 PSI 统计记录 stall。
所以 trace 里看到:
Runnable (Preempted)
mm_vmscan_direct_reclaim
是合理的。
它可能一会儿在内核态扫描,一会儿被抢占,一会儿等待锁或 IO,一会儿继续扫描。
7. GC、kswapd、direct reclaim 三者的关系可以这样理解
可以用一个分层模型理解:
Java/ART GC
负责清理 Java 层不用的对象
↓
Native allocator / Bitmap / GL / Cache
负责释放 native 和图形资源
↓
Kernel kswapd
后台异步回收系统页
↓
Kernel direct reclaim
当某个线程申请内存但系统无法立即满足时,
让该线程同步参与回收
↓
LMKD
如果回收仍无法缓解,可能杀后台进程
所以它们不是互相替代关系,而是不同层次的机制。
Java GC 不足以释放主要压力
↓
图库 Native/Bitmap/GL 占用高
↓
kswapd 后台回收已经很忙
↓
但系统仍然压力大,PSI 升高
↓
LoadMgr-7 申请内存时进入 direct reclaim
↓
主线程等待 LoadMgr-7,导致卡顿
8. 一个非常重要的点:kernel 不知道 Java 对象是否"没用了"
Java GC 能判断对象是否可达:
某个 Bitmap Java 对象是否还有引用
某个 List 是否还被 Activity 持有
某个 ImageView 是否还引用 Drawable
但内核看不到这些语义。
内核看到的是:
这个进程有一些匿名页
这些页是否在 LRU 上
是否 mapped
是否 dirty
是否 locked
是否可 swap
是否可 reclaim
是否 pinned
所以如果应用进程的业务层仍然持有 Bitmap/GL/cache 引用,或者 native 层资源没有释放,那么:
Java GC 不会释放
kernel 也不能随便回收
结果就是系统只能:
-
回收 file cache;
-
swap 匿名页;
-
回收 slab;
-
尝试 shrinker;
-
触发 direct reclaim;
-
甚至最后 LMK kill 进程。
这就是为什么应用进程高 Bitmap/GL/Native 占用非常容易导致内存回收型卡顿。
9. 最准确的解释
应该这样理解:
1. 应用冷启动/恢复后,线程T 开始加载图片、解码、分配 Bitmap/native/GL 资源。
2. 此时系统内存状态不好:
MemFree 低,SwapUsed 高,CmaFree 为 0,
lmkd_mm 中 PSI 升高,kswapd 高强度回收且效率下降。
3. kswapd 已经在后台回收,但赶不上应用线程的内存申请速度,
或者遇到 zone/CMA/碎片/局部 memcg 压力。
4. 线程T 在申请内存时进入内核 slow path,
由内核在 线程T 上下文中执行 mm_vmscan_direct_reclaim。
5. direct reclaim 期间,线程T 不能继续执行加载任务,
等价于这个线程T 被同步阻塞在内核回收里。
6. 主线程正在等待 线程T 的加载结果或通知,
因此主线程 Sleeping 1820ms+。
7. 线程任务T 从 direct reclaim 返回并完成部分任务后,
唤醒主线程,trace 显示 Woken by 线程T。
10. 总结
ART GC 主要负责 Java Heap 对象回收,不能直接解决应用进程中的 Native Heap、Bitmap native allocation、GL/Graphics buffer、dma-buf 等内存压力。kswapd 是后台异步回收线程,但当系统 zone 水位不足、空闲页不足、高阶页不足、CMA/碎片或 memcg 压力存在,且 kswapd 回收无法及时满足当前分配时,申请内存的应用线程会同步进入 direct reclaim。
mm_vmscan_direct_reclaim属于内核内存管理路径,不是应用业务代码;但它可以运行在应用线程上下文中,例如 trace 中的 异步线程任务。当 应用的线程任务 进入该路径后,该线程会暂停原本的加载任务,转而在内核态执行内存扫描和回收。它不像 Java GC 那样全局 Stop-The-World,但对进入 direct reclaim 的线程来说是同步阻塞的。如果主线程正在等待该线程结果,就会表现为主线程长时间 Sleeping,并最终由该线程唤醒。
一句话总结
mm_vmscan_direct_reclaim是内核为了满足当前内存分配,让申请内存的线程同步参与回收;它不是 Java GC,也不是上层应用进程的代码,但会发生在应用进程的线程上下文中。发生时不会像 GC 一样暂停整个进程,但会阻塞进入该路径的线程;如果主线程等待这个线程,就会造成 UI 卡顿。