Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help


title: Native 与虚拟内存管理优化 chapter: '23.3' section: '23.3' status: finalized applicable_versions: Android 10 (API 29) - Android 17 (API 37) last_verified: '2026-08-15' last_verified_against: Android 17 / API 37 / AOSP android-17.0.0_r1;Perfetto heapprofd;Android NDK 内存调试、MTE、GWP-ASan、Scudo 与 16 KiB 官方文档 last_review_finalize_at: '2026-08-15T07:14:34+08:00' last_review_finalize_run_id: 20260815-071434-gracker-writing-review confidence: high sources:

  • type: official path: https://source.android.com/docs/core/tests/debug/native-memory
  • type: official path: https://perfetto.dev/docs/data-sources/native-heap-profiler
  • type: official path: https://developer.android.com/ndk/guides/memory-debug
  • type: official path: https://developer.android.com/ndk/guides/arm-mte
  • type: official path: https://source.android.com/docs/security/test/memory-safety/arm-mte
  • type: official path: https://developer.android.com/ndk/guides/gwp-asan
  • type: aosp path: https://android.googlesource.com/platform/bionic/+show/android-17.0.0_r1/libc/memory/malloc_debug/README.md
  • type: aosp path: https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-17.0.0_r1/core/jni/android_os_Debug.cpp
  • type: aosp path: https://android.googlesource.com/platform/system/memory/libmeminfo/+/refs/tags/android-17.0.0_r1/androidprocheaps.cpp
  • type: aosp path: https://android.googlesource.com/platform/system/memory/libmeminfo/+/refs/tags/android-17.0.0_r1/procmeminfo.cpp
  • type: aosp path: https://android.googlesource.com/platform/system/memory/libmemunreachable/+/refs/tags/android-17.0.0_r1/README.md
  • type: official path: https://source.android.com/docs/security/test/scudo
  • type: official path: https://developer.android.com/guide/practices/page-sizes
  • type: blog path: Clippings/Android 性能优化 - Native 内存优化(上):so 库申请的内存优化.md
  • type: aosp path: art/runtime/thread.cc @ android-17.0.0_r1 (FixStackSize, CreateNativeThread)
  • type: aosp path: art/runtime/native/java_lang_Thread.cc @ android-17.0.0_r1
  • type: aosp path: frameworks/base/core/java/android/webkit/WebViewLibraryLoader.java @ android-17.0.0_r1
  • type: aosp path: frameworks/base/native/webview/loader/loader.cpp @ android-17.0.0_r1
  • type: aosp path: art/runtime/gc/heap.cc @ android-17.0.0_r1 (PerformHomogeneousSpaceCompact)
  • type: aosp path: frameworks/base/core/jni/android_os_Debug.cpp @ android-17.0.0_r1 (load_maps)
  • type: official path: https://source.android.com/docs/core/perf/mmd
  • type: official path: https://developer.android.com/training/articles/perf-jni
  • type: aosp path: https://android.googlesource.com/kernel/common/+/refs/tags/android17-6.18-2026-06_r6/Documentation/filesystems/proc.rst
  • type: aosp path: https://android.googlesource.com/kernel/common/+/refs/tags/android17-6.18-2026-06_r6/Documentation/mm/multigen_lru.rst
  • type: blog path: '[结构参考: Clippings/Android 性能优化 - 虚拟内存优化(上):线程+多进程优化.md]'
  • type: blog path: '[结构参考: Clippings/Android 性能优化 - 虚拟内存优化(下):一些"黑科技"优化手段.md]'
  • type: blog path: '[结构参考: Clippings/Android 性能优化 - 原理:掌握 App 运行时的内存模型.md]' tags:
  • native-memory
  • malloc
  • asan
  • hwasan
  • so-memory
  • virtual-memory
  • VSS
  • thread-stack
  • maps-analysis
  • oom-prevention
  • memory-optimization
  • webview-reservation related_chapters:
  • '23.4'
  • '4.1'
  • '10.1'
  • '15.3'
  • '20.5'
  • '4.2'
  • '4.3'
  • '23.5'
  • '5.8' pipeline_stage: finalized last_draft_polish_at: '2026-08-15T07:14:34+08:00' last_draft_polish_run_id: 20260815-071434-gracker-writing task6_state: reviewed task9_state: reviewed task2b_state: fixed consolidated_from:
  • src/part5-app/ch23-memory-practice/08-memory-case-studies.md
  • src/part5-app/ch23-memory-practice/11-scudo-native-heap-allocator.md
  • src/part5-app/ch23-memory-practice/13-virtual-memory-optimization.md
  • src/part5-app/ch23-memory-practice/03-native-memory-management.md
  • src/part5-app/ch23-memory-practice/09-virtual-memory-optimization.md last_consolidated_at: '2026-08-24'

Native 与虚拟内存管理优化

版本基线

平台源码统一以 Android 17 / API 37 / android-17.0.0_r1 为锚点。涉及 /proc、VMA 与内核统计接口时,以 android17-6.18-2026-06_r6 为内核侧基线。旧版本只用于说明能力的引入时间,不作为当前实现依据。

Native 内存治理先找到分配器、对象所有者和释放路径,再进入虚拟地址映射、页驻留、缺页和回收。malloc 数字、RSS 和 PSS 分别描述不同层面,不能互相替代。

分配器、所有权与资源生命周期

原生内存为什么需要单独排查

Java 堆没有持续增长,不代表进程的内存占用稳定。使用 JNI、音视频 SDK、地图 SDK、游戏引擎、图片库或加密库的应用,原生堆(Native Heap)、匿名 mmap、共享库映射和图形缓冲都可能让 PSS 上升。后果可能是后台进程更早被系统回收、前台出现内存压力或原生崩溃。

应用侧要先确认增长属于哪一种系统统计,再按问题类型选择 heapprofd、libmemunreachablemalloc_debug、ASan、HWASan、GWP-ASan 或 MTE。容量问题与非法访问需要不同证据:前者关注分配栈和存活量,后者关注越界、释放后访问等错误现场。内存模型见 4.1 Android 与 Linux 内存管理全景,工具使用见 15.3 内存分析、HPROF 与 Heap Dump 工具,应用进程统计见 10.1 App 内存分析与案例

文中术语含义如下:

术语本文含义
JNI / .so / ELFJNI 是 Java/Kotlin 与 C/C++ 代码之间的调用接口;ELF 是 Android 原生二进制文件采用的格式,.so 是其中的共享库文件。
RSS / PSS / Private DirtyRSS 是驻留物理页总量;PSS 按共享者数量分摊共享页;Private Dirty 是当前进程独占且已写入的页面。Shared Clean 是未被写脏的共享页,Private Clean 是未被写脏的私有页。
VMA / mmapVMA(虚拟内存区域)描述一段连续地址范围及其权限和来源;mmap 用于建立匿名或文件映射。
allocator / arena / chunk / quarantineallocator(内存分配器)管理申请与释放;arena 是其成组管理的内存区域;chunk 是单个分配块;quarantine(隔离区)会延迟复用已释放块。
memtrack / dma-bufmemtrack 是 Android 汇总图形等设备内存的接口;dma-buf 是内核中供设备和进程共享缓冲区的机制。
Graphics / GL / Code / Unknown这些是 dumpsys meminfo 的分类标签:Graphics 和 GL 通常覆盖图形缓冲与 OpenGL 驱动分配,Code 统计映射的程序及安装包文件,Unknown 收纳未归入其他分类的页面;具体边界受平台和驱动实现影响。
UID / SELinux / rootUID 是 Linux 用来区分进程身份的用户编号;SELinux 是 Android 的强制访问控制机制;root 表示系统管理员权限。
user / userdebug / enguser 是量产构建;userdebug 和 eng 提供更多调试权限。debuggableprofileable 是应用清单或构建配置声明的可调试、可分析属性。
Bionic / Scudo / jemallocBionic 是 Android 的 C 标准库;Scudo 和 jemalloc 是系统可采用的原生内存分配器。
sanitizer / instrumentationsanitizer 是检测内存错误的运行时;instrumentation(插桩)是在构建时加入检测代码。
tombstone / fault addresstombstone 是 Android 原生崩溃转储;fault address 是触发异常访问的地址。Build ID 用于匹配二进制和符号,ABI 表示应用二进制接口。
HPROFJava 堆快照的数据格式和分析路径,用于查看 Java 对象,不等同于 heapprofd 记录的原生堆分配。
producer / client在 heapprofd 中,producer 是写入 Perfetto 数据的组件,client 是目标进程内接收配置并记录分配的组件。

原生内存先按系统统计分区,再选择对应工具;分配调用栈、虚拟内存页和图形缓冲不能用同一份数据互相替代。

原生内存包含哪些部分

应用侧讨论原生内存时,容易把所有非 Java 堆的增长都归到 malloc。排查时至少区分四类:

  • 原生堆:C/C++ 代码通过 malloccallocreallocnew 申请的堆内存,常见来源是 JNI 层业务代码、第三方 .so、音视频编解码、图片库和加密库。
  • 匿名 mmap 区域:代码直接用 mmap 申请的私有匿名映射,或者分配器向内核申请的大块 arena。/proc/<pid>/smaps 中可能显示为 [anon:libc_malloc][anon:scudo:*] 或业务自定义名称。
  • .so / ELF 映射.so 文件被动态链接器映射到进程地址空间后,会产生代码段、只读数据、可写数据和重定位相关页面。共享只读页面通常按 PSS 分摊,可写脏页计入当前进程。
  • 图形与硬件缓冲:Bitmap 像素、OpenGL/Vulkan 纹理、Surface buffer(界面缓冲)、dma-buf 等资源可能出现在原生堆、Graphics、GL 或 memtrack 统计中。统计位置受分配方式、驱动和厂商实现影响,不能只根据一个 VMA 名称判断资源类型。图片内存见 23.4 Bitmap 与图片内存优化

Android 17 中,android_os_Debug.cppandroid_os_Debug_getDirtyPagesPid() 调用 libmeminfo 的 ExtractAndroidHeapStats() 取得按 VMA 分类的统计,再把 memtrack 返回的图形数据计入相应字段。VMA 分类规则位于 androidprocheaps.cpp[heap][anon:libc_malloc][anon:scudo:][anon:GWP-ASan] 等名称归入原生堆,.so 归入共享库,.jar.apk 等归入对应的代码分类。Graphics 数值还可能来自 memtrack,不能推断所有 dma-buf 都由 androidprocheaps.cpp 按名称归类。

VMA 分类和总量统计使用不同读取路径。androidprocheaps.cpp 必须扫描详细的 /proc/<pid>/smaps,因为分类依赖每个 VMA 的名字。ProcMemInfo::SmapsOrRollup() 服务于 PSS、RSS 等聚合值;内核提供 smaps_rollup 时读取汇总文件,否则改读 smapssmaps_rollup 没有逐 VMA 名称,不能替代分类路径。

[源码锚点: AOSP android-17.0.0_r1, frameworks/base/core/jni/android_os_Debug.cpp, system/memory/libmeminfo/androidprocheaps.cpp, system/memory/libmeminfo/procmeminfo.cpp]

四类内存对应不同的诊断动作:原生堆看分配栈;.so 映射看装载范围、重定位和脏页;图形缓冲看图像、渲染资源与 memtrack;匿名 mmap 需要追踪调用方或业务设置的 VMA 名称。把它们合成一个“原生内存”数字,会丢失定位所需的信息。

排查入口:先看趋势,再记录调用栈

原生内存问题常见两种曲线:一次性峰值过高,或者存活内存随操作次数持续增长。前者多见于大图、模型、音视频缓冲和一次性解压;后者需要继续检查泄漏、缓存失控或对象池未回收。

这三组命令用于建立第一次快照。dumpsys meminfo 适合先看系统分类;读取 smaps 适合检查 VMA,但在量产设备上可能受到 UID、SELinux 和 /proc 访问限制,需要 root、userdebug 或目标进程具备相应调试条件。

# 1. 看系统口径的进程内存分类
adb shell dumpsys meminfo <package_or_pid>

# 2. 在权限允许时保存 VMA 级明细
adb shell cat /proc/<pid>/smaps > smaps.txt

# 3. 从系统分类中单独观察 Native Heap
adb shell dumpsys meminfo <pid> | grep -A 20 "Native Heap"

这些快照只能说明“哪一类发生变化”,不能直接证明泄漏。应在相同设备、相同构建和相同操作序列下,对比进入场景前、场景稳定后、退出并等待回收后的数据。如果原生堆持续增长,再采 heapprofd;如果 Graphics、GL 或 memtrack 相关值增长,转到图片和渲染资源路径;如果 .so 的私有脏页异常,再检查动态库装载、初始化写入和重定位。

heapprofd:原生堆分配的首选分析入口

heapprofd 适合回答两个问题:哪条调用栈的累计分配量高,哪条调用栈在快照时仍有较多存活分配。它是 Perfetto 的原生堆采样分析器,从 Android 10 开始可用。user 构建只能分析满足系统权限规则的 debuggable 或 profileable 应用;系统进程和更深的诊断通常需要 userdebug 或 eng 环境。

中央服务默认禁用,由采集请求按需启动

Android 17 的 heapprofd.rcheapprofd 服务声明为 disabledpersist.heapprofd.enable=1traced.lazy.heapprofd=1 时,init(Android 启动后负责管理系统服务的进程)才启动服务;两个属性都清空后停止。因此,heapprofd 不是从开机起持续运行的常驻采集器。

服务启动后,heapprofd.cc::StartCentralHeapprofd() 从 init 进程传入的 socket(本地通信端点)取得监听端,建立 HeapprofdProducer,接收目标进程中分析 client 的连接。应用进程使用 malloc_interceptor_bionic_hooks.cc 中的 Bionic MallocDispatch 拦截 mallocfreecallocrealloc 等入口,并通过 AHeapProfile_registerHeap 注册要分析的堆;这条路径不依赖 LD_PRELOAD

这套接口属于 heapprofd 与系统分配器的内部协作面,不能作为应用 SDK 的公共 malloc 拦截 API。Android 17 源码里的 heapprofd_get_malloc_leak_infoheapprofd_write_malloc_leak_info 是显式的空实现,不能把它们解释为 LeakCanary 或其他原生泄漏 SDK 的基础接口。

[源码锚点: Perfetto android-17.0.0_r1, heapprofd.rc, src/profiling/memory/heapprofd.cc, src/profiling/memory/malloc_interceptor_bionic_hooks.cc]

采集配置与采样语义

这段配置演示如何采集一个指定进程。size_kbdump_interval_ms 是诊断示例值,需要根据复现时长和设备负载调整;sampling_interval_bytes 决定采样密度。

buffers: {
  size_kb: 65536
}
data_sources: {
  config {
    name: "android.heapprofd"
    heapprofd_config {
      process_cmdline: "com.example.app"
      sampling_interval_bytes: 4096
      continuous_dump_config {
        dump_interval_ms: 10000
      }
    }
  }
}

Perfetto 官方文档把 4096 字节作为默认采样间隔。Sampler::SampleSize() 对小于间隔的分配使用按字节推进的概率采样,含义是长期期望每 N 字节产生一个样本;单次分配大于或等于间隔时绕过该概率路径,记录这次分配的原始大小。因而,不能用“采样覆盖率”直接换算某个对象被记录的固定次数,也不能脱离设备、调用栈展开成本和负载给出固定 CPU 百分比。

采集后在 Perfetto UI 的 Heap Profile(堆分配视图)中查看结果;其中的火焰图按调用栈层级汇总分配。分析泄漏时,比较多个时间点仍然存活的分配及其调用栈;分析频繁申请和释放造成的分配抖动时,观察累计分配量与分配频率。间隔越小,小分配的细节越多,事件量与运行开销也越高。应先在目标设备上测量开销,再决定复现窗口和采样间隔。

[源码锚点: Perfetto android-17.0.0_r1, src/profiling/memory/sampler.h, src/profiling/memory/sampler.cc]

原生堆与 Java HPROF 是两条独立路径

Android 17 的 Perfetto 源码为原生 heapprofd 使用 __SIGRTMIN + 4,为 Java HPROF 使用 __SIGRTMIN + 6。Java HPROF producer 在发送信号前还会通过 CanProfile() 检查目标进程是否允许分析。两者使用不同的数据源、信号与权限路径;某一种转储命令能运行,不能推出另一种 Perfetto 数据源也一定可用。

[源码锚点: Perfetto android-17.0.0_r1, src/profiling/memory/heapprofd_producer.cc, src/profiling/memory/java_hprof_producer.cc]

libmemunreachable 与 malloc_debug:本地复现时补充泄漏证据

libmemunreachable 在请求发生时扫描原生分配器中无法从可达根访问的分配块。默认模式不会为每次分配记录回溯,因此运行期开销低,但报告只能提供地址、大小上界和部分内容。启用 malloc_debug backtrace(分配回溯栈)后,报告可以附带分配栈,代价是明显的运行时开销。

Android 17 固定标签下,面向应用的流程位于独立仓库 platform/system/memory/libmemunreachablemalloc_debug 实现则从旧目录 libc/malloc_debug 移到了 Bionic 的 libc/memory/malloc_debug。这组命令用于 userdebug 设备上的单个应用;<process> 要替换为进程名,设置属性后必须终止并重新启动进程。

adb root
adb shell setprop libc.debug.malloc.program app_process
adb shell setprop wrap.<process> "\$\@"
adb shell setprop libc.debug.malloc.options backtrace=4

# 重启目标进程并复现问题后执行
adb shell dumpsys -t 600 meminfo --unreachable <process>

--unreachable 报告的是扫描时无法到达的分配块,不等于语言层意义上的确定泄漏。保守扫描可能把某些整数误认成指针,第三方分配器或自管理内存也可能不在它的观察范围内。应结合可重复的增长趋势、分配栈和资源生命周期判断。

Bionic README 还记录了 Android 12 起的一个 wrap.<APP> 问题。属性未生效时,可在受控测试设备上按 README 设置 dalvik.vm.force-java-zygote-fork-loop=true,再重启 Zygote(应用进程的模板进程);这会影响设备进程启动方式,不应在量产设备上尝试。

完成诊断后要清除三个属性并重启进程,避免后续测试继续承受 backtrace 开销:

adb shell setprop libc.debug.malloc.options "''"
adb shell setprop libc.debug.malloc.program "''"
adb shell setprop wrap.<process> "''"

这些清理命令只撤销本次 malloc_debug 配置,不会修改应用数据。量产 user 设备通常不具备执行该流程所需的 root、属性和 SELinux 权限,因此它应位于可控环境的复现阶段。

怎样选择 ASan、HWASan、GWP-ASan 与 MTE

原生内存问题不只有泄漏。越界写、use-after-free(释放后使用)和 double free(重复释放)可能先表现为偶发崩溃、数据损坏或 UI 异常,再在内存统计里留下噪声。检测工具按使用场景选择:

工具适合场景代价与边界
ASanHWASan 无法使用时,调试旧设备上的越界访问和 use-after-free已弃用且不再积极维护;需要重新编译插桩,内存和运行时开销高
HWASan测试阶段检测 64 位 Arm 设备上的内存错误当前 NDK 文档推荐的测试期首选;需要支持的系统镜像和编译配置
GWP-ASan在较低开销下抽样捕获原生堆 use-after-free(释放后使用)和 heap-buffer-overflow(堆缓冲区越界)抽样检测,不保证每次问题都命中
MTEArm Memory Tagging Extension(内存标记扩展),用硬件标签检测越界和释放后访问依赖硬件、系统版本、应用清单与系统配置;模式不同会影响性能与报错时机

HWASan 适合测试构建,ASan 只在 HWASan 不可用时作为兼容方案。GWP-ASan 和 MTE 可在较低开销下扩大检测样本。它们定位内存安全错误,不替代 heapprofd 的容量分析;heapprofd 能说明哪些调用栈分配得多,sanitizer 能说明哪次访问越界或访问了已释放内存。

应用清单可请求的启用模式是 syncasyncasymm 不是 android:memtagMode 的取值。ASYMM 是 Arm v8.7-A 提供的平台模式:读取按同步方式检查,写入按异步方式检查。设备可以通过每个 CPU 的 mte_tcf_preferred,把应用请求的 ASYNC 静默升级为 ASYMM 或 SYNC。因此,应用记录的是请求模式,设备的有效模式还取决于平台配置。

.so 库内存优化从三个维度入手

.so 库相关内存要拆成装载成本、运行时分配和可写脏页。三者对应不同动作。

装载成本:少装、晚装、按需装

一个 .so 被加载后,代码段、只读数据、重定位表和符号相关页面会进入进程地址空间。只看 APK 体积无法判断运行时内存,排查时要看进程 maps / smaps 中对应 .so 的 RSS、PSS 和 Private Dirty。

可执行动作:

  • 只在测量表明 dlopen 数量或重复元数据造成显著成本时,评估合并小型原生模块;合并后若低频代码被一并加载,也可能增加常驻映射;
  • 把低频功能的 .so 延后到功能入口加载;
  • 清理未使用 ABI、未使用架构和重复打包的 .so,这主要减少安装包与安装占用;只有相关文件原本会被映射时,才会影响运行时 RSS/PSS;
  • 把发布构建的符号文件作为独立产物保存,供 tombstone、Perfetto 和地址回溯使用;从 App 内移除调试段主要减少文件体积,不能直接视为等量的运行时内存收益。

运行时分配:把大块分配变成可解释事件

第三方 .so 分配异常时,单靠 dumpsys meminfo 只能看到原生堆变大。heapprofd 或 malloc 拦截能把分配归到调用栈;如果符号文件保留完整,还能还原到函数名和源码行。

工程上可以为可控的原生大对象建立统一分配入口,例如模型缓冲、音视频帧缓冲、解码输出池和压缩临时缓冲。封装层记录大小、用途、生命周期与调用位置,生产环境只上报有界的聚合数据;本地再使用 heapprofd 或 malloc_debug 取得完整分配栈。对于不可改动的第三方库,以分析工具的证据为准,避免仅凭库名归因。

可写脏页:少改共享映射,少做启动期全量初始化

.so 的只读页面可以在多个进程间共享;页面一旦被当前进程写脏,就会变成私有成本。常见来源包括全局可变数据、启动期大表初始化、懒加载缓存写入和重定位后的可写段。

排查时对比同一 .soShared CleanPrivate DirtyPrivate Clean。如果某个库的 Private Dirty 远高于同类模块,要检查它是否在启动期写入大块全局状态,或者把可延迟的数据提前初始化了。

原生内存监控方案

生产监控不应照搬本地分析工具。用户设备上的目标是发现趋势、定位版本和场景,不能长期记录完整调用栈。

本节只定义 Native Heap、映射和内存安全错误的原生侧信号;跨 Java、Native、图形与系统回收的统一监控闭环见 23.7 内存监控与线上治理

一套可控方案可以分三层:

  • 基础指标层:定时采集 PSS、RSS、Native Heap Alloc(原生堆已分配量)、Graphics、GL、线程数、文件描述符(FD)数量,并带上页面、业务场景、前后台状态和设备可用内存分组。采集频率按场景设定,避免常驻高频轮询。
  • 异常判定层:在同一会话内观察增长斜率,例如进入页面前后、按确定脚本重复操作后、播放或上传结束后。单点阈值容易把合理的高占用当成异常。
  • 诊断触发层:命中抽样诊断规则后,对少量 debuggable 或 profileable 包触发 heapprofd、系统 meminfo 快照或业务侧原生分配摘要。普通发布包只上报聚合指标和场景标签。

指标上报要区分“占用高”和“泄漏”。缓存命中带来的短时原生堆增长不一定是问题;退出场景、收到 onTrimMemory() 或完成任务后仍不回落,只能构成进一步分析的信号,还需要分配栈与对象生命周期证明原因。分配器可能保留空闲页,PSS 没有立刻下降也不能单独证明对象仍然存活。

Scudo 与原生堆的统计边界

Android 应用通常不直接选择系统分配器,但分配器会影响碎片、页归还、错误检测和 smaps 命名。Android 11 起常规设备使用 Scudo,低内存设备仍可能使用 jemalloc;最终实现应根据设备构建、VMA 名称和 tombstone 确认。

四种数值不要互相替代

口径数据来源适合回答的问题覆盖不到的部分
Debug.getNativeHeapAllocatedSize()mallinfo().uordblks分配器当前记为已分配的字节任意 mmap()、线程栈、共享库、Graphics
dumpsys meminfo 的 Native Heap原生堆 VMA 的 PSS/RSS 分类原生堆对物理内存的贡献具体分配调用栈
/proc/$pid/smaps / showmap内核 VMA 与页统计匿名映射、文件映射、栈和 .so 分别占多少每次 malloc() 的调用者
heapprofd / Native Allocations录制窗口内的分配、释放与栈哪些路径分配、哪些样本仍存活录制前分配和绕过分配器的映射

Android 17 的 android_os_Debug.cpp 直接把 mallinfo().uordblks 返回为 getNativeHeapAllocatedSize()libmeminfo 则把 [heap][anon:libc_malloc][anon:scudo:*][anon:GWP-ASan*] 等 VMA 归到 Native Heap,并按页面计算 PSS/RSS。两条统计路径不同,所以分配器记录的已分配量下降后,原生堆 PSS 不要求同步下降。

free() 后 RSS 没回落不等于泄漏

业务调用 free() 后,chunk 已不能再访问,但分配器可以把它放进 quarantine、线程缓存或空闲结构,等待复用或批量归还操作系统。Scudo 的 quarantine 会延迟复用已释放块,release interval 是批量向系统归还页面的间隔;两者会影响安全检查、锁竞争、复用速度与 RSS 回落。普通应用不应通过 SCUDO_OPTIONS__scudo_default_options 把这类系统配置当作常规省内存开关。

泄漏判断仍需同时满足可重复增长和对象归因:固定场景中已分配量持续上升,heapprofd 的未释放样本集中在稳定调用栈,业务缓存过期后仍不回落,并且 Graphics、线程栈、文件映射等其他分区不能解释增量。

Scudo ERROR:发现错误的位置不等于破坏内存的位置

Scudo 会在发现 corrupted chunk headerinvalid chunk statemisaligned pointerallocation type mismatchinvalid sized delete 等异常时终止进程。这些是日志中的原始错误标签,其中 chunk header 是分配器保存大小、状态等信息的元数据。若线程 B 在 free() 时发现 header 已损坏,越界写可能早已发生在线程 A。排查必须保留完整 tombstone、错误文本、fault address、Build ID、ABI、相关线程和匹配的未剥离符号,再用 GWP-ASan、MTE 或 HWASan 补充分配、释放与访问位置的证据。

GWP-ASan 是抽样检测,Recoverable(可恢复上报)模式写出 tombstone 后继续运行也不代表进程已经恢复正确状态。MTE 的 SYNC、ASYNC 和平台 ASYMM 模式在定位精度与成本上不同,应用清单请求还受设备硬件和系统配置约束。这些工具定位内存安全错误,不能替代 heapprofd 的容量归因。

16 KiB 页与三方 so

16 KiB 页会改变 ELF 加载段对齐、APK 中未压缩 .so 的 ZIP 起始位置对齐、mmap() 参数约束以及分配器管理区域的页面粒度。它不会让每个小对象都占用独立 16 KiB,也不能从 PSS 增量直接反推出 malloc() 对象数。含原生代码的应用应检查所有 ABI,运行时通过 getconf PAGE_SIZE 获取实际页大小,清理动态链接器和第三方库中写死的 4096 字节假设。

Android 15 起支持 16 KiB 页面设备。按 2026-08-15 的 Google Play 规则,面向 Android 15(API 35)及以上的应用更新需要在 64 位设备上支持 16 KiB 页面;从 2027-02-01 起,不兼容的更新将无法发布。构建兼容性仍需同时检查 ELF 加载段和 APK 内未压缩 .so 的 ZIP 对齐,不能只在 4 KiB 设备上运行一次就判定通过。

第三方 .so 的报告至少保存 SDK 版本、.so Build ID、ABI、输入与并发、设备页大小、heapprofd 样本和相同场景的 meminfo。缺少 Build ID 时,同名 .so 可能来自不同二进制,聚合后的调用栈没有可比性。

[源码锚点: AOSP android-17.0.0_r1, frameworks/base/core/jni/android_os_Debug.cpp, system/memory/libmeminfo/androidprocheaps.cpp, platform/bionic/libc/bionic/malloc_common.cpp, platform/external/scudo]

排查清单

  • dumpsys meminfo 中增长的是 Native Heap、Graphics/GL、Code,还是 Unknown?
  • 使用确定的操作脚本重复场景后,内存曲线是趋于稳定、随次数增长,还是只出现一次峰值?
  • heapprofd 的存活分配火焰图中,最大路径是否来自业务 JNI、第三方 SDK、图片库或音视频库?
  • 原生崩溃是否带有 ASan、HWASan、GWP-ASan、MTE 相关标记?
  • 相关 .so 是否有符号文件可用于地址还原?
  • smaps 里同一 .soPrivate Dirty 是否异常偏高?
  • 生产环境是否只采集聚合指标,避免在用户设备上长期开启高开销调试能力?

分配器与原生堆小结

原生内存调查应先用系统分类确认增长属于分配器、映射、代码还是图形缓冲,再用 heapprofd 或本地检测工具取得对应证据。容量增长、内存安全错误和 free 后 RSS 暂未下降是三类不同问题,不能共用一个“泄漏”结论。

mmap、页驻留、缺页与回收

对象释放解决逻辑所有权,虚拟内存分析继续检查地址空间、文件映射、匿名页和 page fault。已 free 的内存也可能暂时留在进程 RSS。

虚拟内存问题经常与 Java heap OOM(ART 托管对象堆耗尽)、native heap(C/C++ 分配使用的堆)和线程资源耗尽混在一起。VMA(virtual memory area,虚拟内存区域)是内核记录的一段连续地址范围;同一 VMA 具有一致的权限和映射来源。排查时要先确认失败来自地址空间、物理内存、VMA 数量还是线程资源。只看一个很大的 VSS 数字,容易把正常的地址空间预留误判成泄漏。对象持有关系可参阅 23.2 内存泄漏检测与治理,Native Heap 的分配与所有权则见本文前一部分。

平台锚点为 Android 17 / API 37 / android-17.0.0_r1;涉及内核 /proc 与 VMA 语义时,以 android17-6.18-2026-06_r6 为内核锚点。Android 10—16 的历史行为只用于解释存量设备,实际诊断仍以目标设备为准。

1. VSS 先看语义,再看数值

1.1 四个指标回答不同问题

指标常见来源回答的问题不能单独证明什么
VSS(Virtual Set Size)/ VmSize/proc/<pid>/status所有 VMA 长度之和,也就是进程映射了多少虚拟地址已消耗多少 RAM、是否正在泄漏
RSS(Resident Set Size)/ VmRSS/proc/<pid>/statussmaps_rollup当前驻留在 RAM 的页有多少共享页应全部归属于该进程
PSS(Proportional Set Size)dumpsys meminfosmaps共享页按使用进程数分摊后的物理内存地址空间是否碎片化
Private Dirty / Private Cleandumpsys meminfosmaps进程独占且已修改或仍可从文件恢复的页单次快照中的增长原因

地址空间预留(reservation)会先占用一段虚拟地址,常用 PROT_NONE 禁止访问,等后续加载或提交时再改变映射。它与文件映射、尚未访问的匿名页都会计入 VSS,其中一部分还没有对应的物理页。64 位进程出现数 GiB VSS 很常见;结合 ABI、地址空间空洞、线程数、VMA 数量和失败现场后,VSS 才能参与判断。

1.2 32 位与 64 位的风险差异

32 位进程的用户态地址空间小于 4 GiB,具体布局受内核、ABI(应用二进制接口,决定指令集和数据布局)、ASLR(地址空间布局随机化)、链接器和厂商配置影响。不能把所有设备统一写成“3 GiB 用户态 + 1 GiB 内核态”。连续空洞不足时,即使未映射地址的总量看起来还够,大块 mmap()(建立内存映射的系统调用)仍可能返回 ENOMEM(无法满足内存或地址空间请求)。

64 位进程的可用用户态地址范围由架构和内核配置决定,无需固定写成 128 GiB、256 GiB 或 256 TiB;可从目标设备的 maps 边界和内核配置确认。64 位地址耗尽很少见,但 VMA 数量、物理内存、commit charge(内核对匿名内存可兑现容量的提交记账)、资源限制和错误的超大地址空间预留仍可能让映射失败。

常见故障信号如下:

信号优先检查
pthread_create (... stack) failed线程数、每线程栈、native 内存、进程资源限制、VMA 数量
mmap failed: ENOMEMABI、请求长度、最大连续空洞、VMA 数量、RLIMIT_AS(进程虚拟地址空间资源上限)、系统内存压力
std::bad_alloc / native OOMnative allocator(C/C++ 内存分配器)、RSS/PSS、碎片、申请尺寸;VSS 只作辅助
Java OutOfMemoryErrorART 托管堆上限、对象存活关系与 GC;同时确认错误消息是否指向线程创建
LMKD 杀进程PSS/RSS、进程状态和系统压力;VSS 不是 LMKD(low memory killer daemon,低内存终止守护进程)的直接排序指标

错误类型决定排查入口:线程创建失败先看线程和栈,映射失败先看连续地址与资源限制,进程被终止则先看物理内存压力。VSS 不能替代这些现场信号。

1.3 VmSwap 不能当成另一份 VSS

内核 proc.rstVmSwap 定义为匿名私有数据使用的 swap(换出空间),shared memory(共享内存)的换出量不包含在内。页被换出后,原虚拟地址仍在 VMA 中,所以该页仍计入 VmSizeVmSwap 不是可以再加到 VSS 上的独立地址空间。

Android 17 及以上支持内存管理守护进程 mmdmmd_setup 负责配置 ZRAM(在 RAM 中保存压缩换出页的块设备),mmd 再执行重压缩和可选的 writeback(把冷页写到后备存储)。应用从 VmSwap 只能看到按页核算的换出量,不能反推出 ZRAM 中压缩后的字节数,也不能判断页面此刻位于 ZRAM 还是后备存储。分析卡顿时,应同时查看 VmSwap 增长、major fault(需要存储 I/O 才能完成的主缺页)、PSI(Pressure Stall Information,资源压力导致的任务停顿统计)、lmkd 事件和业务时间线。

android17-6.18-2026-06_r6 还包含 Multi-Gen LRU(按访问时间把可回收页划分为多代的 LRU 实现)。它按访问新旧程度参与页回收选择,但不会改变 VSS、RSS、PSS 的定义;目标设备是否启用仍取决于内核与产品配置。应用侧应减少实际工作集并改善访问局部性,不能据此认定某个 VMA 可以由应用手动解除。

2. 建立可复现的地址空间快照

2.1 设备侧基础采集

这段脚本在同一轮中依次采集页大小、VSS、RSS、线程数和 maps。调用时显式传入包名与输出目录,避免把示例包名误用于现场:

#!/usr/bin/env bash
set -euo pipefail

if (( $# != 2 )); then
  echo "usage: $0 <package-name> <output-directory>" >&2
  exit 2
fi

package_name="$1"
output_directory="$2"
pid="$(adb shell pidof -s "$package_name" | tr -d '\r')"

if [[ -z "$pid" ]]; then
  echo "process not found: $package_name" >&2
  exit 1
fi

mkdir -p "$output_directory"
adb shell getconf PAGE_SIZE | tr -d '\r' > "$output_directory/page-size.txt"
adb shell cat "/proc/$pid/status" > "$output_directory/status.txt"
adb shell "ls /proc/$pid/task | wc -l" > "$output_directory/task-count.txt"
adb shell "wc -l /proc/$pid/maps" > "$output_directory/vma-count.txt"
adb shell cat "/proc/$pid/maps" > "$output_directory/maps.txt"
adb shell dumpsys meminfo "$package_name" > "$output_directory/meminfo.txt"

grep -E '^(Name|VmPeak|VmSize|VmRSS|RssAnon|RssFile|VmSwap|Threads):' \
  "$output_directory/status.txt"

VmSize 应与 maps 中各 VMA 长度之和接近,它不是最低地址到最高地址的跨度;两段 VMA 之间的空洞不计入 VmSizeThreads 应与 /proc/<pid>/task 数量一致。脚本中的命令顺序执行,并非原子快照;进程变化较快时要缩短采集间隔或连续采两轮验证。pidof -s 只选择一个 PID,多进程应用需要对每个目标进程分别采集。dumpsys meminfo 补充 PSS、RSS 和堆分类。部分量产设备会限制 showmapsmaps,权限失败要记录为观测缺口。

采集点至少覆盖:

  • 冷启动完成;
  • 进入目标业务前;
  • 业务稳定运行;
  • 业务退出并等待缓存回收;
  • 异常前后。

只有一张峰值快照时,无法区分一次性预留、可复用缓存和持续泄漏。

2.2 /proc/<pid>/maps 怎样读

这一行展示 maps 的字段布局:

70000000-78000000 ---p 00000000 00:00 0  [anon:libwebview reservation]

地址范围给出 VMA 起止位置;rwx 是读、写、执行权限;第四个字符 ps 表示 private(私有映射)或 shared(共享映射);其后依次是文件偏移、设备号、inode(文件系统中的对象编号)与可选名称。---p 说明当前没有读、写、执行权限,仍不能据此认定该区域可由应用释放。

Android 17 的 android_os_Debug.cpp 通过 meminfo 解析器读取进程映射并按堆类型汇总。应用自建分类器不能假设标签在所有厂商版本上都完全一致。

这段离线脚本按 pathname(maps 末列的映射名称或文件路径)汇总 VSS,并统计没有 pathname 的匿名 VMA。参数是设备侧脚本生成的 maps.txt

from collections import defaultdict
from pathlib import Path
import sys

if len(sys.argv) != 2:
    raise SystemExit(f"usage: {sys.argv[0]} <maps-file>")

groups = defaultdict(lambda: {"bytes": 0, "vmas": 0})
maps_path = Path(sys.argv[1])

for line in maps_path.read_text(encoding="utf-8").splitlines():
    fields = line.split(maxsplit=5)
    if len(fields) < 5:
        continue
    start, end = (int(value, 16) for value in fields[0].split("-", 1))
    name = fields[5] if len(fields) == 6 else "[anonymous]"
    key = name if name.startswith("[") else Path(name).name
    groups[key]["bytes"] += end - start
    groups[key]["vmas"] += 1

for name, stat in sorted(
        groups.items(), key=lambda item: item[1]["bytes"], reverse=True):
    mib = stat["bytes"] / 1024 / 1024
    print(f"{mib:10.1f} MiB  {stat['vmas']:6d} VMAs  {name}")

汇总结果适合查找大块地址空间预留、线程栈、重复 .so 库和 VMA 数量异常。脚本按文件名而非完整路径聚合,同名但不同路径的库会被合并;同一路径的多个 segment(映射分段)也可能分别承载代码、只读数据和可写数据。汇总值表示虚拟地址长度,不能直接当成私有物理内存。

2.3 16 KiB 页设备

getconf PAGE_SIZE 返回 16384 时,VMA 边界、guard page(用于捕获越界访问的不可访问保护页)和 ART 对齐都以 16 KiB 页大小计算。小对象仍可由分配器在页内切分;maps 只展示页级映射。

自研 native 组件需要遵守运行时页大小,Android 的 16 KiB 页适配指南 要求清理写死的 40960x1000 或 4 KiB 对齐。解析 start/end 的十六进制差值不受页大小影响;调用 mmap()mprotect()munmap() 时,则要分别遵守接口对地址、offset(偏移量)和 length(长度)的约束。

每个系统调用的对齐要求不同:文件映射的 mmap() offset 必须按页对齐,内核会把实际映射范围向上取整到包含 length 的完整页;MAP_FIXED 地址、mprotect() 起始地址和 munmap() 起始地址也有页对齐要求。“所有参数都必须页对齐”并不是统一规则,具体要看调用使用的 flags(控制映射行为的标志)和接口文档。

3. 线程栈:先控制线程数量

3.1 Android 17 的 Java 线程栈计算

Thread::FixStackSize() 按以下顺序计算栈预留大小:

  1. 传入 0 时改用 ART 的默认栈大小;
  2. 增加 1 MiB,以兼容依赖 Dalvik 较大 native stack 的应用;
  3. 启用内存检测工具的构建保证至少 2 MiB;
  4. 保证不小于 PTHREAD_STACK_MIN
  5. 根据隐式栈溢出检查配置,增加不可访问保护区和运行时保留区,或只增加保留区;
  6. 向运行时页大小取整;
  7. 把结果交给 pthread_attr_setstacksize()pthread_create()

“每个 Java 线程固定占 1 MiB”并不准确。最终地址空间预留还受 ART 默认值、保护区、保留区、页对齐和构建配置影响。maps 中的 [anon:stack_and_tls:<tid>] 是 Bionic(Android 的 C 标准库与系统基础库)为线程栈与 TLS 合并映射使用的标签;线程 dump(线程转储)可以把线程名与 TID(线程 ID)对应起来,映射大小仍应从 maps 读取。

线程栈通常按需分配物理页,因此 VSS 增长会早于 RSS 增长。32 位进程中,大量线程还会增加 VMA 数量、调度开销、TLS(thread-local storage,线程局部存储)和运行时线程元数据。

3.2 有界线程池

这个 Java 工厂方法展示有界队列、固定工作线程数和线程命名。调用方必须传入经过压测的工作线程数与队列容量,示例不内置设备无关的固定阈值:

static ThreadPoolExecutor newBoundedExecutor(
        String threadNamePrefix,
        int workerCount,
        int queueCapacity,
        RejectedExecutionHandler rejectedExecutionHandler) {
    if (workerCount <= 0 || queueCapacity <= 0) {
        throw new IllegalArgumentException("workerCount and queueCapacity must be positive");
    }
    Objects.requireNonNull(threadNamePrefix);
    Objects.requireNonNull(rejectedExecutionHandler);

    AtomicInteger sequence = new AtomicInteger();
    ThreadFactory factory = runnable -> {
        Thread thread = new Thread(runnable);
        thread.setName(threadNamePrefix + "-" + sequence.incrementAndGet());
        return thread;
    };

    return new ThreadPoolExecutor(
            workerCount,
            workerCount,
            0L,
            TimeUnit.MILLISECONDS,
            new ArrayBlockingQueue<>(queueCapacity),
            factory,
            rejectedExecutionHandler);
}

固定上限可以阻止突发任务无限扩张线程。workerCount 要结合 CPU/I/O 比例和任务驻留内存确定,queueCapacity 要结合可接受的排队时延确定。拒绝策略也由调用方显式选择:CallerRunsPolicy 会让提交线程执行任务;提交方可能是主线程时,应改用合并、拒绝或异步重试等符合业务语义的处理。

线程数量常从这些位置失控:

  • Executors.newCachedThreadPool() 的无限最大线程数;
  • 每个 SDK 各建一套线程池;
  • 定时任务每次创建新线程;
  • 协程调度器或 RxJava Scheduler 配置不当;
  • native SDK 内部的 pthread_create()
  • 线程退出后仍被 ThreadLocal、HandlerThread 或线程池引用。

CPU 密集任务的并发度可从 CPU 核数起步,I/O 任务没有通用的“64—128 线程”答案。应根据服务端并发限制、文件描述符、尾延迟和内存预算决定。

3.3 栈大小与函数拦截的边界

Thread(ThreadGroup, Runnable, String, long) 把 stack size(期望栈大小)定义为平台相关的建议值。Android 17 的 Thread.java 会原样保存该构造器传入的负数,JNI 桥接再把 jlong 交给接收 size_tThread::CreateNativeThread()。因此,特定负数可能在无符号转换和增加 1 MiB 时回绕成较小值;例如 -512 KiB 会在增加 1 MiB 后得到 512 KiB,再叠加 ART 保留区并按页取整。Thread.Builder.OfPlatform.stackSize() 则明确拒绝负数。这条回绕路径属于实现细节,构建变化后可能得到超大栈、pthread_attr_setstacksize() 失败或进程异常,不能进入生产方案。

pthread_create() 做 PLT hook(通过 Procedure Linkage Table 拦截动态库函数调用)也不能覆盖所有线程来源,并会改变系统库与三方库的栈假设。诊断时可以在可调试构建中记录调用栈和 pthread_attr_t 属性;修改栈大小则必须按 ABI、4/16 KiB 页、递归深度、JNI 栈帧大小和极端调用链做压力测试。生产环境仍应优先减少线程数量。

3.4 Android 17 的虚拟线程

android-17.0.0_r1 的 libcore 已包含 Thread.ofVirtual()startVirtualThread()VirtualThread 实现,但 api/current.txt 给创建入口标记了 com.android.libcore.virtual_thread_api_v1 FlaggedApi,表示公开可用性受功能标志控制。ThreadBuilders.newVirtualThread() 会检查 ContinuationSupport.isSupported():支持时创建由 continuation(可暂停并恢复执行状态的运行时机制)驱动的 VirtualThread;不支持且调用方没有指定自定义 scheduler(任务调度器)时,创建一对一绑定平台线程的 BoundVirtualThread

因此不能把“Android 17 虚拟线程一定不创建 pthread、每个任务都省下 1 MiB”当成通用结论。产品采用前需要确认目标镜像是否开放该 API、运行时是否支持 continuation,以及 pinning(虚拟线程因特定阻塞或同步操作而无法暂时脱离承载线程)的行为、调试工具和关键库兼容性。面向 Android 10—17 的通用实现仍应以协程、有界线程池和结构化取消为主。

更完整的线程泄漏边界参阅 20.8 线程与协程泄漏治理

4. WebView 地址空间预留:可观测,不手动解除

4.1 固定标签中的预留

Android 17 的 WebViewLibraryLoader.reserveAddressSpaceInZygote() 选择以下地址空间大小:

进程 ABI地址空间预留
64 位1 GiB
32 位 ARM130 MiB
其他 32 位 ABI190 MiB

loader.cpp::DoReserveAddressSpace() 使用 mmap(PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS) 创建地址空间预留,并让全局变量 gReservedAddressgReservedSize 记录其地址和大小。maps 中的名称是 [anon:libwebview reservation]

这块预留会增加 VSS,但未访问的 PROT_NONE 地址不会按预留大小消耗 RSS。64 位进程看到 1 GiB VSS 增量时,不能写成“WebView 已消耗 1 GiB RAM”。表中的常量解释的是 Android 17 固定标签源码,其他系统版本仍应按对应标签核对。

4.2 为什么不能 munmap

WebView 加载器随后把 gReservedAddressgReservedSize 交给 android_dlopen_ext(),在这段固定预留中加载 provider(提供 WebView 实现的系统组件)原生库和共享 RELRO(重定位完成后设为只读、可供进程共享的数据)。应用私自调用 munmap() 后,加载器的全局状态没有同步清零,该地址还可能被其他映射占用。稍后任何 WebView 初始化都可能加载失败或破坏进程地址空间。

通过函数拦截取得 android_dlopen_ext() 的地址、反射隐藏的 nativeLoadWithRelroFile(),或按 maps 标签解除映射,都依赖隐藏实现。即使当前进程暂时不用 WebView,广告、登录、支付、帮助页、SDK 和系统组件也可能在后续触发 provider。

安全策略只有两类:

  • 业务不需要 WebView 时,避免初始化 provider 和相关 SDK,接受从 Zygote(用于孵化应用进程的模板进程)继承的预留仍计入 VSS;
  • 需要隔离 WebView 时,按产品架构放入受控进程,管理该进程生命周期,并测量总 PSS、启动时延和 Binder(Android 进程间通信机制)代价。

把 WebView Activity 放到子进程不会自动移除主进程继承的预留;它的收益主要来自已提交页、WebView 对象与故障边界的隔离。

5. ART 托管堆:应用不能释放运行时内存区域

5.1 Homogeneous Space Compact 的适用条件

HSC(Homogeneous Space Compact,同构空间压缩)把主分配空间中的存活对象复制到备用空间,以整理碎片。Android 17 的 Heap::SupportHomogeneousSpaceCompactAndCollectorTransitions() 要求同时存在 main_space_backup_main_space_,且前台垃圾回收器为 CMS(Concurrent Mark Sweep,并发标记清扫)。PerformHomogeneousSpaceCompact() 还会在 moving GC(会移动存活对象的垃圾回收)已被禁用、当前回收器本身会移动对象,或主空间不允许移动对象时拒绝执行。

同一文件的 Heap 构造路径会对 CC(Concurrent Copying,并发复制)和 CMC(Concurrent Mark Compact,并发标记压缩)关闭 use_homogeneous_space_compaction_for_oom_。HSC 是受回收器与 Space(ART 管理的一类内存区域)布局约束的兼容路径,并非 Android 17 应用普遍执行的内存整理流程。它也不能推出“每个 Android 17 应用都有两块固定 512 MiB MainSpace”。ART 会按回收器、heap growth limit(托管堆允许增长到的上限)、设备配置和进程类型建立不同 Space。RegionSpace 用分区支持移动回收,zygote space 保存进程孵化前已有的对象,large object space 管理大对象,image space 映射启动镜像;JIT 代码映射则保存即时编译生成的机器码,各自的用途和生命周期不同。

5.2 JNI 临界区必须成对释放

Android JNI tipsGetPrimitiveArrayCritical() 的约束是:ART 可以返回指向数组的直接指针,也可以返回副本。取得指针到执行 ReleasePrimitiveArrayCritical() 之间称为 JNI 临界区;期间 native 代码不能长时间阻塞,也不能任意调用 JNI。完成访问后必须成对释放。

长期保留临界区指针可能延迟或限制 GC,具体影响取决于 ART 返回直接指针还是副本,以及当前垃圾回收器的实现。两种路径都要求临界区尽快结束,否则可能增加分配等待和线程停顿。对任一 dalvik-* 区域调用 munmap() 还会破坏 ART 的分配器、card table(记录哪些堆区域可能包含跨区引用的表)、对象位图和引用关系。“禁用 moving GC 后释放备用 Space”不是安全的应用方案。

应用侧能控制的是对象生命周期和分配形态:

  • 取消不再需要的任务与回调;
  • 对缓存设置容量和生命周期;
  • 释放 Bitmap、媒体缓冲区、DirectByteBuffer 与 native peer(Java 对象对应的原生侧实例);
  • 用 heap dump(堆转储)、Perfetto、heapprofd(Android 原生堆采样器)和对象分配记录找到长期持有者;
  • 避免在内存压力回调中同步制造大量临时对象。

6. 多进程:改变故障边界,也增加固定成本

每个 Android 进程都有独立的虚拟地址空间、ART 运行时、Binder 状态和原生内存分配器。把模块移入子进程会降低主进程中的已提交页与对象数量,但整个应用的总 PSS 可能上升。进程隔离的完整取舍可参阅 23.5 大内存与多进程策略

这段 AndroidManifest.xml 配置展示私有进程声明:

<activity
    android:name=".WebContainerActivity"
    android:process=":web" />

<service
    android:name=".CodecService"
    android:process=":codec"
    android:exported="false" />

冒号前缀创建应用私有进程。是否值得拆分,要同时测主进程 PSS、子进程 PSS、启动时延、Binder 流量、冷启动次数和低内存下的恢复体验。

适合评估隔离的模块通常具备这些特征:

  • 生命周期清晰,结束后允许整个进程退出;
  • 原生代码崩溃风险较高,需要限制影响范围;
  • 已提交内存大,主进程长期不需要保留;
  • IPC(inter-process communication,进程间通信)接口较少,避免高频传输大对象;
  • 被 LMKD 回收后可以恢复。

较大的数据载荷(payload)可考虑通过 SharedMemory、文件描述符或流式接口传输;仍要定义所有权、校验长度并及时关闭 FD(file descriptor,文件描述符)。多进程不能用来规避应用整体内存预算。

7. 建立面向原因的监控

7.1 采样字段

一条可解释的虚拟内存快照至少包含:

  • 时间、业务场景、进程名、PID、ABI 和页大小;
  • VmSizeVmPeakVmRSSRssAnonRssFileVmSwap
  • 线程数、VMA 数量和 FD 数量;
  • Java HeapNative HeapGraphicsCodeStackdumpsys meminfo 分类的 PSS;
  • 占用较大的 VSS 分类及其 VMA 数量,按报告容量保留排名靠前的若干类别;
  • 最近一次 mmappthread_create 或内存分配器失败信息。

采样频率按风险控制。/proc/self/status 成本较低,可在场景边界采样;读取并解析 maps、记录每个 VMA 物理页明细的 smaps,或抓取堆转储,应由异常触发,避免高频磁盘读取和主线程阻塞。

7.2 阈值按设备分组

“五分钟增长 100 MiB”或“接近 2 GiB 就报警”缺少 ABI、业务和基线条件。阈值应按以下维度分别统计分位数:

  • 32/64 位 ABI;
  • Android 版本与页大小;
  • 设备内存档位;
  • 进程角色;
  • 冷启动、稳定态、退出后;
  • 线程数与 VMA 数量。

告警应组合 VmSize 增量、RSS/PSS 增量、线程/VMA 增量和错误信号。VSS 上升后 RSS 不变、业务退出后保持稳定,通常来自地址空间预留或可复用映射;VSS、RSS、线程数一起持续上升时,调查优先级更高。

8. 常见现场如何定位

8.1 pthread_create 失败

  1. 保存 ART 错误中的 requested stack size(请求的栈大小)和 errno(native 调用返回的错误号);
  2. 记录 /proc/<pid>/statusThreadsVmSizeVmRSS
  3. 按线程名、创建调用栈和持有者聚合;
  4. 检查缓存线程池、HandlerThread、SDK 与 native 线程;
  5. 先减少线程,再评估受控线程的栈大小。

8.2 32 位进程 mmap 失败

  1. 记录申请长度、flags(映射标志)、errno 和调用栈;
  2. 保存完整 maps;
  3. 计算最大连续空洞,不能只用地址总范围减 VSS;
  4. 检查大块地址空间预留、重复库、线程栈和 VMA 数量;
  5. 优先提供 64 位 ABI,并减少不必要映射的创建来源。

8.3 VSS 很大但设备没有内存压力

检查大区域是否为 PROT_NONE、文件映射、ART 预留或 WebView 地址空间预留,再看 RSS/PSS 与 page fault(访问尚未驻留页面时产生的缺页事件)。没有失败信号时,不为缩小面板数字去解除系统映射。

8.4 多进程后主进程变小、整机更卡

把所有进程的 PSS 相加,并检查进程反复冷启动、Binder 数据载荷、共享页分摊和 LMKD 回收。主进程单项下降无法证明方案节省了整机内存。

9. 优化顺序

表中的 P0、P1、P2 表示实施优先级:P0 是先完成的基础治理,P1 需要按设备或架构验证,P2 只用于受控诊断;“禁止”项会破坏运行时所有权。

顺序动作风险
P0记录 ABI、页大小、VSS/RSS/PSS、线程和 VMA 的同轮快照
P0明确线程持有者,使用有界线程池,清理失控的 HandlerThread/SDK 线程
P0修复对象、原生缓冲区、Bitmap、DirectByteBuffer 和映射生命周期
P1评估 64 位 ABI 覆盖,针对 32 位碎片做专项测试
P1按总 PSS 和生命周期评估进程隔离
P2在可调试构建中拦截 mmap/pthread_create 做取证
禁止对 WebView 地址空间预留或 ART 托管堆 Space 调用 munmap
禁止永久持有 JNI 临界区指针来阻止 moving GC
禁止用负数栈大小依赖无符号回绕

虚拟内存优化要让每段地址空间都能对应到创建来源和生命周期。32 位进程优先检查连续空洞、线程栈和 VMA 数量;64 位进程优先区分地址空间预留与物理页,并把 PSS/RSS、线程和失败信号放在同一份报告中。如果只有主进程 VSS 下降,而总 PSS 和故障数据没有改善,就不能把改动记为有效优化。

10. 虚拟内存源码锚点

全文小结

Native 与虚拟内存优化要把逻辑对象、分配器记录、VMA 和驻留页分开观察。先按故障类型和系统内存分区选择证据,再修复所有权、线程或映射来源;不要用固定 VSS 阈值,也不要通过手动解除系统预留或 ART 空间来制造“下降”。最终效果应同时体现在 PSS/RSS、失败信号和业务场景稳定性上。

参考资料