做Android性能优化,尤其是线上内存告警那会儿,我第一反应永远是连上adb shell,先把设备当前的系统可用内存、目标App的Java堆内存、VSS虚拟内存以及详细内存分布全部拉一遍,再决定要不要抓hprof做堆快照分析。这套流程看似简单,但里面容易踩的坑不少——比如dumpsys meminfo的输出每一列到底代表什么,am dumpheap抓下来的.hprof文件为什么MAT打不开,top命令里VSS飙到好几个G到底该不该慌。这篇文章就把我用adb shell排查App内存的完整方法、命令和判断逻辑整理出来,适合刚接触Android内存优化的开发、测试,也适合要处理OOM和内存泄漏问题的老手参考。
1. 内容整体设计与思路拆解
1.1 为什么先要分清PSS、RSS和VSS
做内存排查之前,必须先统一一个认知:Android系统里“内存占用”不是一个数,而是一组数。Linux内核给每个进程提供的指标里,最常被提到的就是VSS、RSS、PSS、USS四个维度。
- VSS(Virtual Set Size):进程虚拟地址空间的大小,包含未映射到物理内存的部分,比如代码段、共享库的映射、mmap的匿名内存。只看VSS没有任何物理内存意义,因为大量共享库的映射会被所有进程重复计数。
- RSS(Resident Set Size):进程实际驻留在物理内存中的页面总和,不区分是否与其他进程共享。它的问题是会把共享库重复计算,导致所有进程RSS加起来远远超过物理内存总量。
- PSS(Proportional Set Size):把共享页面按进程数量均摊后再求和。比如一块2MB的共享库被4个进程加载,每个进程的PSS只算0.5MB。这是衡量单App真实物理内存占用的关键指标,dumpsys meminfo就是在PSS框架下做的统计。
- USS(Unique Set Size):只统计进程独占的物理内存,不包含任何共享页面,基本等于进程本身直接占用的“净内存”,适合判断一个进程真正“私有”了多少。
实际排查中,我最关心的其实是PSS,其次是USS。VSS通常只用来排除一些极端异常,比如指针被撑爆、地址空间碎片化。RSS只适合辅助判断,不能单独作为结论。
1.2 Java堆和Native堆是两回事
Android App的内存从分配路径上分成两大块:Java堆和Native堆。
Java堆由ART虚拟机管理,所有new出来的Java对象都在这里分配。我们在Android Studio Memory Profiler里看到的Java memory,指的就是ART堆的使用情况。Java堆有一个特点:虚拟机会根据使用情况自动调整堆大小,但不会无限制增长,超过上限就会抛出OutOfMemoryError。通过dumpsys meminfo里的Java Heap字段能直接看到当前堆的size和allocated。
Native堆则是通过C/C++代码直接分配的内存,常见来源有Bitmap像素数据、Skia图形缓存、OpenGL纹理、WebView内核、系统Binder缓冲等。这块内存不受Java堆上限约束,出了泄漏更隐蔽,表现出来就是RSS持续上涨,但Java Heap维持稳定。
理解了这两个堆的区别,你才会明白为什么线上OOM不一定都是Java堆不够用。很多App的OOM实际上是Native内存吃掉了大量物理内存,导致系统触发lmkd杀进程,表现为用户看到的红屏或闪退。
1.3 整体排查思路怎么安排
我个人的排查顺序一般是这样:
- 先看系统可用内存,判断是设备整体内存紧张,还是单个App异常。
- 再看目标App的总PSS,确认它占用了多少物理内存。
- 接着拆分Java堆、Native堆、Graphics、Stack等分项,锁定异常方向。
- 如果需要进一步定位泄漏,再抓hprof做对象引用分析。
- 如果怀疑Native泄漏,那要用malloc_debug或者外部工具,hprof派不上用场。
这篇文章后面所有的命令、参数和判断方法,都是按这个流程展开的。你可以直接照着操作,不需要额外装任何插件,只要电脑上配置好adb环境、手机打开USB调试就行。
2. 查看系统可用内存:判断设备内存到底紧不紧
2.1 用 /proc/meminfo 看系统内存全貌
查看系统内存最直接的方式是读取内核暴露的内存信息文件:
adb shell cat /proc/meminfo输出里最关键的几项是:
- MemTotal:物理内存总量(不过部分被内核和硬件预留,不是全部可给App用)。
- MemFree:完全空闲的物理内存页数量。
- MemAvailable:估算出来的、新进程可用内存量。它比MemFree更贴近实际,因为它把可回收的页缓存、可回收的Slab算进去了。
- Buffers:块设备缓冲等。
- Cached:页缓存,包括tmpfs、共享内存等,这个在Android上经常被App的匿名共享内存占掉。
- SwapTotal/SwapFree:Android很多设备没有swap分区,一般不用看。
需要提醒的是,Android原生系统里可能没有free命令,但有/proc/meminfo就够用了。有些定制ROM会把部分字段改名或隐藏,但MemTotal和MemAvailable通常都存在。
判断设备内存紧不紧张,我一般用两个数:
- 如果MemAvailable低于系统设置的阈值(通常由内核的watermark决定),lmkd就开始杀后台进程。
- 如果MemAvailable占总内存比例长期低于10%(比如8GB设备只剩不到800MB),那说明整体压力很大,单个App再涨一点就可能触发杀进程。
2.2 free命令和MemAvailable的正确理解
如果设备里有free命令,也可以用:
adb shell free -mAndroid的free输出和Linux服务器略有差异,但核心逻辑一样:第二行是物理内存使用情况,buffers/cache那一列应该被理解成“可用释放候选”,而不是“已被占用”。很多人看到used很高就以为内存爆炸了,其实如果cached很高,说明大部分内存被用作缓存,随时可以回收。
关键就在MemAvailable这个字段的设计意图:它估算的不是“当前空闲多少”,而是“系统在保持足够稳定性、不触发明显回收的前提下,还能分配出多少内存给新进程”。所以当你看到MemFree只有几百MB,但MemAvailable还有1.5GB时,这都是正常情况。反过来,如果MemAvailable也跟着跌到几百MB,那系统是真的紧张了。
2.3 系统内存不足时App会怎样
系统内存不足的后果不是直接OOM,而是由lmkd(Low Memory Killer Daemon)根据每个进程的oom_score_adj值选择性地杀进程。前台App的oom_score_adj通常很低,不容易被杀;后台App、空进程、缓存进程则优先被清理。
所以排查系统内存问题时,除了看数值,还要看目标进程的oom_score_adj:
adb shell cat /proc/<pid>/oom_score_adj前台App这个值一般是0或负值,后台进程可能几百甚至上千。如果你发现App在后台被杀得很频繁,先看这个值是不是被系统标了高可杀优先级,再决定是自己内存太大还是系统太激进。
3. 查看单个App的详细内存状况:dumpsys meminfo 正确读法
3.1 dumpsys meminfo 的输出怎么看
针对单个进程,最常用的命令是:
adb shell dumpsys meminfo <package_name_or_pid>以我们的App为例,截取关键输出:
Applications Memory Usage (in Kilobytes): Uptime: 12345678 Realtime: 12345678 ** MEMINFO in pid 12345 [com.example.app] ** Pss Private Private SwapPss Rss Heap Heap Heap Total Dirty Clean Dirty Total Size Alloc Free ------ ------ ------ ------ ------ ------ ----- ----- ----- Native Heap 10240 10240 0 0 10240 20480 16384 4096 Dalvik Heap 24576 24576 0 0 24576 32768 22016 10752 Dalvik Other 1024 1024 0 0 1024 Stack 128 128 0 0 128 Ashmem 32 32 0 0 32 Gfx dev 4096 4096 0 0 4096 Other dev 128 128 0 0 128 .so mmap 16384 8192 8192 0 20480 .apk mmap 2048 0 2048 0 2048 .ttf mmap 512 0 512 0 512 .dex mmap 4096 0 4096 0 4096 .oat mmap 512 0 512 0 512 Image mmap 2048 0 2048 0 2048 Total 81920 45056 15360? ? 81920 ...这里关键看三部分:
- Native Heap和Dalvik Heap/Java Heap的Size、Alloc、Free。Heap Size代表虚拟机当前给这块堆划分的容量,Alloc是已经分配出去的量,两者差值就是堆内还能继续分配的空间。注意Android 8.0之后显示为“Java Heap”,老版本显示为“Dalvik Heap”,只是命名差异,含义一样。
- Gfx dev:图形缓冲区,包括Surface、OpenGL相关的GPU内存,如果这个值异常大,说明渲染管线有内存问题。
- Total那行,Pss Total就是整个进程的PSS总和,Private Dirty是最重要的私有内存指标,表示进程独占且脏掉的物理内存,不能和别的进程共享回收。
查看时注意单位是KB,不是MB。我习惯除以1024来读。
3.2 判定App内存异常的三个关键信号
拿到dumpsys meminfo输出后,我一般按三个信号判断有没有问题:
第一,Java Heap的Alloc是否持续上涨,而且执行几次GC之后降不下来。比如列表页反复进出,Java Heap从80MB一路涨到150MB不回落,这基本就是Java对象泄漏或者集合类持有大量引用没释放。
第二,Native Heap的Pss是否异常高。正常一个中大型App的Native Heap在几十MB到一两百MB都算合理,如果冲到几百MB甚至1GB以上,优先怀疑Bitmap、WebView、OpenGL没释放。
第三,Gfx dev和Graphics相关字段是否跟着操作场景飙升。比如疯狂切页、视频播放、地图滑动之后,这块内存涨上去没有回落,大概率是图形资源没回收。
还有一种更实用的连续观察法:反复执行几十次dumpsys meminfo,把Java Heap的Alloc记下来,看趋势。这个方法比单次抓取更能暴露问题。
3.3 和 Android Studio Profiler 的关系
有人会问,Android Studio的Memory Profiler那么直观,为什么还要用adb shell?我的经验是,两者定位不同:
- Profiler适合开发阶段,你能看到内存随时间变化的曲线、对象分配记录、直接抓取hprof,交互体验好。
- adb shell适合线上和真机问题复现,很多用户设备没有USB调试环境,但只要你拿到root或者设备允许adb,dumpsys meminfo这种命令行工具不依赖IDE,执行结果还可以批量采集。
另外,adb shell dumpsys meminfo的输出是基于PSS体系的系统级统计,和Profiler的Java堆数据不是一回事。Profiler里看到的是ART堆内部的对象分布,dumpsys meminfo看到的是整个进程在系统视角下的内存账本,两者互为补充。
4. VSS虚拟内存:别被 top 的数字吓到
4.1 VSS来自哪里:/proc/ /status 与 top 的差别
查看进程VSS最直接的方式是读/proc/ /status:
adb shell cat /proc/<pid>/status | grep -E "VmPeak|VmSize|VmRSS|VmData|VmExe|VmLib|VmStk"输出示例:
VmPeak: 16777216 kB VmSize: 16773120 kB VmRSS: 102400 kB VmData: 1048576 kB VmExe: 12 kB VmLib: 20480 kB VmStk: 128 kB- VmSize就是VSS,单位kB。
- VmRSS对应RSS。
- VmPeak表示历史最大VSS。
- VmData是数据段,VmExe是代码段,VmLib是共享库段,VmStk是栈。
top命令里也能看到VSS:
adb shell top -H -p <pid>输出第4列一般就是VIRT也就是VSS,第5列是RSS。注意Android的top是toybox实现的,和Linux完整版的top参数略有差异,但基本够用。
4.2 为什么VSS很大却不一定危险
很多第一次做性能优化的同事会被VSS吓到,看到一个进程VSS 4GB、6GB就觉得内存泄漏了。实际上,VSS很多时候只是一幅“虚假的繁荣”。
举个例子,一个App加载了100个so库,每个so库的代码段都通过mmap映射到进程地址空间,这100个映射段的虚拟地址在VSS里全部计入。但如果这些so库里真正调入物理内存的页面只有一小部分(冷代码往往留在磁盘上,按需调入),那么RSS和PSS并没有增大。
还有一个典型是预分配虚拟地址空间:一些音视频SDK、游戏引擎为了减少分配抖动,会先用mmap预留一块很大的虚拟地址空间,然后在需要时逐步commit物理页面。这种模式下VSS瞬间暴涨到几个GB,但物理内存占用其实很小。
所以看到VSS高,不要急着判死刑。正确做法是再看VmRSS和PSS,如果这两项也高,才说明真的使用了物理内存。
4.3 什么情况下VSS值得关注
VSS不是完全没用,有几种场景它必须警惕:
第一,32位进程的虚拟地址空间只有4GB,其中用户空间通常只有3GB左右。如果VSS接近2.5GB到3GB,说明虚拟地址空间快耗尽了,即使物理内存还有余量,后续的mmap也会失败。这种叫地址空间枯竭,多见于采用32位so的App,尽量升级64位是根治方案。
第二,配合VmData观察,如果VmData持续上涨,说明是数据段膨胀,通常对应Heap或匿名映射在增长,这可能指向Native内存泄漏。
第三,如果两个App的VSS结构差异极大,一个3GB一个1.2GB,前者加载了更重的渲染引擎或WebView内核,这不代表一定异常,但要结合性能数据综合判断。
5. 内存快照 hprof:抓取、转换与泄漏分析
5.1 用 am dumpheap 生成堆快照
当Java堆出现明显异常时,就该抓hprof了。抓取命令:
# 先拿到进程pid adb shell pidof com.example.app # 生成堆快照到设备目录 adb shell am dumpheap <pid> /data/local/tmp/example.hprof # 拉到本地 adb pull /data/local/tmp/example.hprof .有几个关键点必须注意:
第一,am dumpheap需要目标进程可调试。如果是debuggable的App,普通权限就能抓;如果是release包,通常需要root权限,否则会报permission denied或抓下来的文件内容不全。
第二,抓取hprof会暂停目标进程一段时间。这个过程可能引发可见卡顿甚至ANR,线上环境慎用。我一般建议在测试环境复现问题后再抓,或者选择业务低峰期。
第三,抓取前先手动触发GC可以减小文件体积,也能去掉大量可回收的垃圾对象,方便定位真实泄漏。触发方式可以是执行几次GC相关的shell命令,或者让App停留在特定页面。
5.2 hprof文件转换:Android格式与标准格式
这里有个大坑:直接用Android抓下来的.hprof文件,MAT(Eclipse Memory Analyzer)是打不开的,因为Android的hprof是Dalvik/ART格式,不是JVM标准格式。需要先转换。
Android SDK里提供了hprof-conv工具,路径一般在:
$ANDROID_HOME/platform-tools/hprof-conv转换命令:
hprof-conv -z example.hprof example_std.hprof转换后再用MAT打开,或者直接拖进Android Studio的Memory Profiler分析。注意转换过程中如果文件很大,内存建议给足,MAT分析大hprof文件时也要调整JVM的-Xmx参数,不然会OOM或者卡死。
现在的Android Studio已经很智能,从Profiler里直接右键“Export heap dump”保存的文件,通常已经是MAT可识别的标准格式,不需要手动转换。但命令行方式仍然适合自动化脚本批量抓取和分析。
5.3 用MAT分析泄漏对象:Dominator Tree与引用链
拿到可分析的hprof后,我通常按下面几步来定位泄漏:
第一步,看Histogram里的对象总数,找出数量异常大的类。比如一个Activity实例有几十个,说明它没被回收。
第二步,右键类名 -> "Merge Shortest Paths to GC Roots" -> "exclude all phantom/weak/soft etc. references",这样能看到从GC Root到这个对象的最短强引用链。这条链就是定位泄漏的关键线索,通常是某个静态变量持有了Activity引用,或者单例里缓存了View。
第三步,用Dominator Tree看哪个对象“支配”了大块内存。Dominator Tree的本质是回答“如果我回收了这个对象,还能释放多少内存”,对找大对象特别高效。
举一个实际场景:我曾经排查过一个首页图片轮播内存上涨的问题,抓了两次hprof对比,发现ViewHolder里被静态单例缓存了Bitmap,同时ImageLoader的LruCache没有配置正确的maxSize,导致每张轮播图都滞留内存。通过查看GC Root链很快就锁定了ImageLoader单例的引用关系,修复之后Java Heap稳定下降了80MB。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| Java Heap Alloc持续上涨不回落 | Java对象泄漏、集合未清理 | 多次dumpsys meminfo观察趋势,抓hprof查GC Root链 |
| Native Heap很大但Java堆正常 | Bitmap像素、WebView、OpenGL、so库泄漏 | 检查Gfx dev、用malloc_debug或Profiler native memory |
| dumpsys meminfo没有Java Heap字段 | 老版本系统字段叫Dalvik Heap | 兼容处理,读取Dalvik Heap分项 |
| hprof用MAT打不开 | Android hprof格式不是标准JVM格式 | 用hprof-conv -z转换后再打开 |
| am dumpheap报permission denied | 目标App不可调试或没有root | 使用debuggable包或root设备,或配合run-as |
| 系统MemAvailable很低但App不OOM | lmkd优先杀后台进程缓解压力 | 看oom_score_adj,确认自身进程是否高优先级 |
| 进程总PSS很高但Java堆很低 | 可能Graphic/GPU内存或Native内存占用 | 分别看Gfx dev、Native Heap、.so mmap分项 |
| top输出里VSS高达3GB+ | 32位进程虚拟地址空间预分配、mmap映射 | 看VmRSS/PSS是否同步增高,确认物理占用情况 |
6.2 我踩过的几个坑
第一个坑是太依赖单次dumpsys meminfo。系统内存状态是动态的,GC、页面缓存回收、其他进程占用都会影响结果。只看一次数据很容易误判,尤其是判断是否泄漏。正确做法是连续采样,最好在同样的操作路径下间隔10秒到30秒采样3到5次。
第二个坑是忽略SwapPss。高版本Android输出里有SwapPss列,表示进程使用过的交换内存折算,虽然很多设备没开swap,但部分系统上它会影响Total的统计。如果只看Pss Total不看SwapPss,会低估进程对系统的压力。
第三个坑是抓hprof时没有先清理垃圾对象。如果你在App内存里有一堆临时对象没被回收就抓快照,文件会很大,分析时干扰项也很多。我习惯在抓取前先触发GC,或者在代码里做一个Debug-only的清理动作,效果会好很多。
第四个坑是Native泄漏调用了错误的工具链。Java泄漏用hprof就行,但Native问题必须用malloc_debug、AddressSanitizer或者Perfetto的heapprofd。很多人拿着hprof找Native泄漏,纯属浪费时间。Android 10之后可以用heapprofd抓native内存分配,比malloc_debug方便不少。
6.3 一些提效的shell小技巧
最后分享几个脚本化的操作,适合批量排查或者快速定位:
按PSS排序出当前所有进程的占用:
adb shell dumpsys meminfo | grep -E "Total PSS by process" -A 200 | head -60或者更简单,直接看进程PSS排行:
adb shell "dumpsys meminfo | grep 'TOTAL PSS:' | head -30"批量循环抓取某个App的Java Heap,间隔5秒采样10次:
for i in $(seq 1 10); do adb shell dumpsys meminfo com.example.app | grep -E "Java Heap|Dalvik Heap|TOTAL PSS" >> meminfo.log; sleep 5; done查看指定进程的详细条目标签:
adb shell dumpsys meminfo --package <package_name>这个命令会列出该App所有进程,包括主进程、渲染进程、守护进程的单独内存。WebView多进程场景下很有用,因为渲染进程的内存常常独立统计,只在主进程看会漏掉一大块。
平时我还会配合adb logcat一起看,比如抓hprof前后观察有没有GC日志或者lmkd杀进程的记录:
adb logcat -s ActivityManager:V | grep -E "lowmemorykiller|kill|am_kill"这样能确认App是被系统杀还是自己崩溃,避免一股脑扎进内存泄漏的分析里。
最后再补一点个人体会
这套adb shell命令组合我几乎是天天用,从系统可用内存到单进程PSS,再到Java Heap、Native Heap、VSS,最后用hprof做对象级定位,整个链路下来基本能覆盖90%的Android内存问题。工具本身没什么门槛,难的是读懂数据背后的含义。熟练之后你会发现,一个App内存到底有没有问题,往往不用等线上告警,连上设备跑几分钟命令心里就有数了。如果你刚开始接触这块,建议先在自己手机上装上几个常用App,一条一条命令敲着看,找找数值变化的规律,比背命令高效得多。