ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android内存泄漏排查实战:从Memory Profiler到LeakCanary

Android内存泄漏排查实战:从Memory Profiler到LeakCanary 1. 内存泄漏这件事为什么总是发生在Activity上做Android开发几年的人基本都遇到过这样的场景应用开久了越来越卡反复进出某个页面后内存持续上涨最后直接OOM崩溃。线上用户反馈打开几个页面就闪退你自己本地测试却怎么都复现不了。这种问题十有八九和内存泄漏有关而且泄漏的对象大概率是Activity。为什么偏偏是Activity因为Activity是Android里生命周期最复杂的组件。它承载了UI、持有Context、关联WindowManager还会被系统各种服务间接引用。理论上Activity退出时onDestroy执行完它所占用的内存就应该被GC回收但如果有别的对象还持有它的引用GC就永远无法回收这块内存。每进出一趟页面就漏掉一坨反复操作后可用内存被吃干榨净OOM自然来了。很多人一提到内存泄漏就想当然觉得是内存不够用其实不对。内存泄漏的本质是本该被回收的对象因为还活着导致GC无法回收。它不像崩溃那样立刻报错而是潜伏在应用里像慢性病一样消耗你的内存预算。Android Studio从很早的版本就内置了Memory Profiler它就是用来干这个的。这篇文章我不会讲那种打开Profiler看一眼内存曲线的肤浅操作而是把从抓取内存快照、分析引用链、定位泄漏根因到配合LeakCanary做自动化监测的完整流程全部走一遍结合我真实排查过的几个案例说说那些官方文档不会告诉你的细节。2. 先搞明白GC和引用链否则后面全是在瞎猜在看内存快照之前必须先建立一个基本认知内存泄漏的根源永远是一条GC无法断开的引用链。Java/Kotlin的内存回收机制很简单粗暴——从GC Roots出发通过引用关系遍历凡是能到达的对象都算活的到不了的就回收。GC Roots包括虚拟机栈中的引用、静态变量、JNI引用、活跃线程等。一个对象哪怕逻辑上已经没用了只要有一条从GC Root出发的链路能走到它它就不会被回收。举个最典型的例子class MainActivity : AppCompatActivity() { companion object { private var sActivity: MainActivity? null } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sActivity this } }这个代码的问题一眼就能看出来MainActivity把自己的实例存在了伴生对象编译后是静态字段里静态字段属于GC Root。就算用户退出ActivityGC沿着静态变量sActivity - MainActivity实例这条链路依然能找到它。锚点GC Root不断Activity就永远活在内存里。那既然原理这么简单为什么排查起来还那么难因为真实项目里的引用链通常很长不是单一的引用而是像毛线团一样缠在一起一个Fragment持有Activity的引用Adapter又持有Fragment的引用某个单例又持有了Adapter单例是GC Root于是整条链全挂在上面。你看到的只是这个Activity没被回收但要把那条最关键的引用路径揪出来才是Profiler最核心的用途。这里还要纠正一个常见误区大家都以为内存泄漏就是对象太多了。其实排查泄漏时真正要关心的不是对象总量而是泄漏对象的数量有没有随着操作次数单调递增。一个对象只泄漏一次不可怕可怕的是每做一次操作就多泄漏一份。这也是后面判断是否泄漏的黄金法则。3. Memory Profiler实操怎么抓一份干净可信的内存快照拿到Android Studio第一件事就是把Memory Profiler跑熟。它的入口在底部工具栏的Profiler标签或者在View - Tool Windows - Profiler里打开。连接设备后选择目标应用进程上方会实时画出内存曲线。3.1 抓取快照前的三个准备动作很多人一上来就点Dump Java heap抓出来的快照又大又乱分析半天找不到重点。这不怪工具是准备工作没做到位。准备动作一确保操作场景可复现。先想清楚你要排查哪个页面的泄漏。比如怀疑聊天页有问题那就不停进出聊天页进入 - 停留几秒 - 退出 - 再进入重复五到十轮。目的是让泄漏对象数量累积到足够明显的程度不然一两份泄漏样本混在几千个正常对象里信噪比太低。准备动作二触发一次强制GC。进入Memory Profiler界面后顶部有一个Force garbage collection按钮垃圾桶图标点几下。这一步是让已经没有引用的对象先被回收掉防止它们混在快照里干扰判断。因为有些对象不是严格意义上的泄漏只是还没来得及被回收它们会在触发GC后消失。准备动作三Dump的时机选择。要等所有该销毁的界面销毁之后再Dump。还拿聊天页举例退出聊天页后先在手机上等两三秒让转场动画和异步任务结束再切回Profiler点Java heap dump。Dump过程中App会短暂卡顿这是正常的文件越大卡顿越明显。3.2 用三分钟快速浏览Heap Dump结果Dump完成后Android Studio会自动打开快照查看器。默认按类名分组展示每一行是一个类的统计信息。拿到这个界面先别慌按这个顺序看第一看列表顶部是否有明显的大头类比如Activity子类、Fragment子类、巨大的Bitmap数组。第二选中某个可疑类右边会出现该类的所有实例列表。第三如果实例数量明显大于正常值比如一种Activity只应该有一个实例但列表里有五个那基本就是泄漏了。这里有个操作上的坑Android Studio的Heap Dump只显示Java堆里的对象Bitmap像素数据在Android 8.0之后有一部分分配到Native堆所以看Bitmap泄漏要结合Native内存的数据。如果发现整体内存涨得厉害但Java堆里没找到可疑对象别只盯着Java堆看。4. 在快照里找到Activity实例顺着引用链挖出元凶4.1 按类名过滤快速锁定可疑对象Heap Dump查看器顶部有个搜索框可以直接输入类名。比如怀疑MainActivity泄漏就输入MainActivity下拉列表里会出现MainActivity - 5之类的统计结果。这里的5就是当前一共有5个MainActivity实例。正常情况应该是1当前的Activity如果出现多个说明之前的Activity没释放掉。这时候点击类名右侧会出现所有实例列表。实例列表里每个对象会显示它的内存地址和分配大小。注意区分Shallow Size和Retained Size简单说Shallow Size是对象本身占的内存Retained Size是包括它引用的所有子对象加起来的整体大小。排查泄漏时主要看保留大小因为它决定了你实际漏掉了多少内存。4.2 最核心的一步右键实例查看引用链选中一个应该被回收但还活着的实例右键 - Show Reference in Java Heap或者直接在实例列表里点开某个实例的详情就能看到这个对象被谁引用、又引用了谁。这是整个排查流程中最有价值的一步。Android Studio会以树状结构展示引用链从GC Root一路指向目标对象。你要找的不是普通的引用而是一条能解释为什么它没被回收的引用路径。举个例子之前排查过一个诡异的问题一个Fragment被replace掉之后它的View居然还留在内存里。顺着引用链一看某个自定义View持有了Fragment的引用而这个自定义View又被一个ViewPager2的RecycledViewPool缓存着。ViewPager2把移除的项放进了回收池回收池是静态的引用了ViewView引用了FragmentFragment就活了下来。光靠猜根本猜不到这种跨模块的引用链只有快照才能把它显形出来。查看引用链时有两种模式From GC Roots和References To/From Selected Instance。推荐先从References to Selected Instance入手看看谁指向了当前对象。如果上面挂着一大堆业务对象逐个顺着往上翻直到找到静态变量或长生命周期对象那个就是泄漏的源头。4.3 判断是否泄漏不要单看一份快照这里要强调一个很关键的排查思路单份快照里类实例多不代表一定泄漏。可能是构造大量对象的合理场景也可能是对象正在被回收的瞬间。判断是否泄漏最好的办法是重复操作并多次Dump。具体做法是在进入聊天页之前Dump一份快照作为基线然后进出页面十次退出后再Dump一份快照。对比两份快照中同一类的实例数量。如果数量基本一致说明没有泄漏如果数量增加了每进出一次就增加一个泄漏实锤。这比我之前见过的看到几个对象就说有泄漏的判断方式要靠谱得多。内存分析最忌讳主观臆断一切以重复实验结果为准。5. 进阶技巧用Heap Dump的Compare to和Allocation定位新增对象5.1 快照对比省掉无数眼力活Android Studio的Heap Dump查看器自带一个对比功能这个功能在iOS或者老版本Android Studio的MAT插件里也有类似的实现。但很多人压根没用过。操作方式抓两份快照后在Profiler左侧的Snapshots区域选中两个快照右键 - Compare Heap Dumps。工具会自动把两份快照按类进行对比列出第二份相对第一份新增的实例数量。这个功能有多好用举个例子之前排查一个视频列表页的泄漏直接在界面里看实例列表根本看不出问题因为那个页面本身就有很多View和Image对象。但用对比功能一看发现一个VideoPlayerManager的实例数量从1涨到了20。这个Manager在页面销毁时应该被清掉的却因为被某处静态集合引用着每次进入页面都新建一个旧的又回收不了。如果没有对比功能在成千上万个对象里找这一个类真的会找到怀疑人生。5.2 Combined Analysis抓分配调用栈Heap Dump的最大短板是它只反映当前有哪些对象活着不反映这些对象是哪里分配的。如果泄漏的对象是被第三方SDK创建的你在代码里根本找不到创建点这时候就需要看分配栈。在实例列表里选中某个可疑实例右侧会显示Object Allocation信息Android Studio会在Dump时记录部分对象的分配调用栈。如果能看到调用栈直接双击栈顶的代码位置可以跳到对应的源码。但注意不是所有对象的分配栈都能被记录到。一方面开启分配记录会增加性能开销新版本Android Studio会把Allocation Recording单独作为一个功能另一方面如果对象是从预编译的SDK里创建出来的分配的调用栈可能只显示到SDK内部。遇到这种情况结合编译后的混淆映射表去反推。5.3 单独聊聊Allocation Recorder如果你能确定泄漏对象是在某个时间段内创建的又想知道确切的创建位置可以用Memory Profiler的Record Allocation按钮。它像录像机一样从你点击开始到点击结束记录这段时间内所有Java堆对象分配。实际操作中这么做先进到目标页面的入口点Record Allocation然后进入页面 - 执行操作 - 退出页面 - 结束Record。之后在记录结果里搜索目标类的实例每个实例都会带上创建时的完整调用栈比Heap Dump给的信息全得多。不过它的副作用也很明显开启期间App会明显掉帧某些动画效果会一卡一卡的所以不要长时间开启只针对怀疑的小时间段录制。6. 代码里的常见泄漏源头别在同一个坑里摔三次工具掌握得再好还是得回到代码本身。根据我这么多年的排查经验大部分内存泄漏都逃不开下面这几类根因。每种我都会给出判断特征和修复思路。6.1 单例持有Activity/Context这是最基础也最常见的。单例的生命周期和应用进程一样长如果它里面存了Activity Context那这个Activity就永远无法回收。object NetworkManager { lateinit var sContext: Context // 千万不要这样 fun init(context: Context) { sContext context.applicationContext // 应该使用applicationContext } }凡是单例需要存Context的一律用applicationContext。不要觉得反正init的时候传的是Activity先用着再说等到泄漏发生再回来改Context涉及的调用点可能已经遍布全项目了。检查方法也很简单Heap Dump里看到某个Activity实例数异常顺着引用链走到一个单例对象基本就是这个原因。6.2 Handler和内部类的隐式引用Kotlin的lambda和匿名对象非常容易让人产生它不会持有外部类的错觉。实际上非static的内部类会隐式持有外部类的引用。class ChatActivity : AppCompatActivity() { private val handler object : Handler(Looper.getMainLooper()) { override fun handleMessage(msg: Message) { // 处理消息逻辑 } } override fun onDestroy() { super.onDestroy() handler.removeCallbacksAndMessages(null) // 退出时一定要清理 } }如果handler对象在消息队列中还有待处理的Message这些Message会持有handlerhandler又持有ActivityActivity就泄漏了。修复的核心是在onDestroy里移除所有回调和消息。如果handler是线程更新的主线程UI建议用生命周期感知组件或者协程替代从根上规避这类问题。6.3 静态集合和缓存业务常用的全局内存缓存最容易埋雷。比如静态的ArrayList、HashMap往里面塞了Activity、Fragment或者带着Activity引用的监听器对象忘记移除。判断这类问题有个通用做法在Heap Dump里搜你缓存容器的类名如果容器对象还活着展开它的元素看里面有没有应该销毁的组件。每次进出页面后元素数量递增而这个容器本身存在的时间很长——典型泄漏。针对这类问题现在的做法是尽量用内存敏感的替代方案使用弱引用WeakReference包装对象但不要为了用而用弱引用取出来的值可能为null一定要判空。为静态缓存设定大小上限比如用LRU缓存替代无限增长的集合。页面销毁时主动从缓存中移除和页面相关的条目。6.4 监听器注册了没反注册系统服务、数据库、网络库的监听回调注册了却不注销等于服务持有了回调接口回调接口如果引用Activity那Activity就泄漏了。这类问题隐蔽性很强因为注册代码往往散落在不同文件里。排查思路是先在代码里全局搜索register和unregister的成对调用看有没有只注册不反注册的。再配合Heap Dump在泄漏的Activity的引用链里搜一下回调类。7. 配合LeakCanary做自动化监测一劳永逸的防漏网手工分析Heap Dump适合问题已经发生我要查根因的场景但线上用户不会告诉你我的应用在第三个页面崩了当时内存泄漏了。所以在开发阶段一套自动化的泄漏检测机制很有必要LeakCanary就是干这个的。7.1 LeakCanary的原理和接入方式LeakCanary的核心原理是它会监测Activity和Fragment的生命周期在它们onDestroy之后启动一个延迟检测默认5秒如果延迟结束后这个对象仍然没有被GC回收就认为它可能泄漏了。接着它会主动触发GC再做一次确认如果还在就自动抓取一份Heap Dump分析引用链最后在通知栏弹出一条包含完整引用链的分析报告。接入方式极其简单在build.gradle中加入依赖debugImplementation com.squareup.leakcanary:leakcanary-android:2.14是的只需要debugImplementation就够了。LeakCanary 2.x会自动在主模块的debug变体里注册ContentProvider完成初始化不需要写任何Application代码。它会自动检测Activity、Fragment、ViewModel、Service等组件。7.2 LeakCanary报告怎么读当LeakCanary发现泄漏手机通知栏会弹出xxxx has leaked的提醒。点开可以看到一条自底向上的引用链从GC Root到泄漏对象的完整路径。这份报告和你在Android Studio里手动分析的引用链是一个东西只是它帮你排好了序、缩短了路径看起来更清晰。报告的核心格式分为几个部分LeakingInstance泄漏对象的类名和大小。Reference Key每个泄漏的唯一标识。GC Root的结构告诉你是从静态字段、线程栈还是JNI引用出发的。Reference Chain从GC Root到泄漏对象经过的每一步。对照之前说的排查思路重点看最后几行哪一个持有了Activity它的类型是不是静态的是不是系统服务这些都是后续修复的入口。7.3 LeakCanary的坑点这里分享几个使用LeakCanary必须知道的坑第一部分泄漏是误报。比如横竖屏切换时系统为了保留状态会稍晚才销毁旧的Activity。有时LeakCanary在延迟检测时间还没到就发现了疑似泄漏结果GC后对象又被回收了这种误报一般会自动过滤。但如果频繁出现可以在LeakCanary的配置里调整WatchDurationMillis和RetainedVisibleThreshold。第二LeakCanary抓Heap Dump的瞬间App会卡顿甚至短暂无响应在低端机上尤其明显。所以线上版本绝对不要开启完整检测只在debug构建里用。即使线上包需要做类似监测也应该放到服务端下发开关并且用采样率控制。第三修复泄漏时不能只盯着引用链里最后一个持有者。有些引用链是多级持有一个单例持有列表列表持有某Activity对象而这个Activity其实早该销毁了。修掉单例持有列表的问题整条链自然就断了。8. 把内存排查变成日常习惯最后聊一个比较务实的部分——怎么把内存泄漏的排查动作融入日常开发而不是等到OOM上了才手忙脚乱。我个人的做法是这样首先是每次发版前至少做一轮页面轮巡测试。手动打开应用把核心操作路径全部走一遍同时开着Memory Profiler记录内存曲线。重点观察曲线的整体斜率——如果某个页面不断进出内存不降反升而且上升趋势是可重复的马上抓一份Heap Dump看看。这个工作习惯听起来很基础但真的能拦截掉一大批回归Bug。其次是在CI里接入简单的泄漏检测任务。LeakCanary本身是运行时的但它也可以输出报告到脚本。更轻量的做法是在验收测试中加入步骤进入页面 - 退出 - 等待 - 调用Runtime.getRuntime().gc() - 检查Activity实例数。如果实例数超过了预期阈值测试用例直接标红。虽然做不到完整的引用链分析但至少能让新增的泄漏在上线前暴露。然后是定期审视常驻内存对象。应用启动时初始化的Manager、全局配置类、缓存容器每过几个版本检查一遍看它们有没有悄悄被塞进不该引用的东西。这种审计式的排查不需要每次都靠Profiler抓快照静态代码里搜一遍new XXX()、静态字段、单例的字段列表就够了。最后要说的是工具永远只是辅助真正的核心是建立对引用的敏感度。写代码的时候多问一句这个对象会被谁持有它的生命周期比当前作用域长还是短持有者是活得更久的对象吗想通了这三个问题很多泄漏在写的时候就能避免根本轮不到上线后排查。Android Studio的Memory Profiler和Heap Dump分析能力在业界同类型工具里已经算好用的了关键是把流程走通复现 - 快照 - 对比 - 引用链 - 修复 - 回归验证。每排查完一个泄漏把当时的引用链截图和修复代码存一份下次遇到类似问题直接翻出来对照效率能提高一大截。我现在排查内存问题的习惯已经固定成了一套组合拳日常开发用LeakCanary兜底发现问题后用Memory Profiler手工抓快照对比定位到引用链后用代码审计配合线上采样确认。这套流程用了几年说不上多高级但足够把绝大多数内存泄漏扼杀在开发阶段。
返回列表