1. 内存泄漏的本质与JVM内存模型
1.1 为什么Android开发者最该先搞懂JVM内存结构
先抛个观点:你写的每行Java/Kotlin代码,最终都会落到JVM的内存布局里,内存泄漏不是玄学,而是你对这块布局的理解出现了盲区。
JVM在运行一个Java程序时,会把它管理的内存划分成几个区域。Android虽然用的是Google深度定制的ART虚拟机(早期是Dalvik,5.0之后全面切换ART),但底层的内存管理思路和Java标准规范基本同源。搞懂JVM的内存模型,Android里的性能问题就已经解决了一大半。
运行时数据区通常被分为线程私有和线程共享两大部分。线程私有的有程序计数器、Java虚拟机栈、本地方法栈;线程共享的则是堆(Heap)和方法区/元空间。我们聊内存泄漏,最关心的就是堆。
堆是所有线程共享的内存区域,放的是对象实例,也就是你用new或者Kotlin里没有特殊关键字创建的绝大多数对象都在这里分配。GC(垃圾回收器)回收内存的主要战场也是堆。JVM规范里对于堆没有强制要求连续内存,也不要求固定大小,所以你可以通过参数调节。
虚拟机栈则是线程私有的,每个方法调用都会对应一个栈帧的入栈和出栈,栈帧里保存了局部变量表、操作数栈、动态连接、返回地址等信息。栈溢出(StackOverflowError)和堆溢出(OutOfMemoryError)是两个完全不同的东西,很多人调试的时候会搞混,后面会讲。
这里有个经典的类比:把堆理解成一个大的共享仓库,所有部门(线程)都能往里放东西、取东西;把栈理解成每个员工自己的工作台,工作台上的东西用完就扔,进入下一个任务了。内存泄漏的核心问题基本都出在仓库里——某些东西明明没人用了,却被一直占着货架,导致新货进不来,最后仓库爆了。
1.2 可达性分析与强引用:判断垃圾的底层规则
JVM判定一个对象是不是垃圾,用的是可达性分析(Reachability Analysis),而不是简单的引用计数。
引用计数的思路是每个对象维护一个计数器,被引用一次就加一,失去引用就减一,计数器归零就认为是垃圾。听起来很简单,但Java没有采用这个方案,因为解决不了循环引用问题——两个对象互相引用,但外部已经没有任何引用指向它们,引用计数器永远不为零,内存就永远释放不了。
可达性分析的做法是:从一组名为GCRoots的根对象出发,沿着引用链往下找,能到达的对象就标记为存活,不能到达的对象就是垃圾。在JVM里,GCRoots包括:
- 虚拟机栈中局部变量表里引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
- 活跃线程对象等
到这里你就明白了,内存泄漏的本质,不是JVM没能力回收,而是GCRoots把不该活的对象一直链着,让GC判断它“还活着”。
而引用类型又分四种:强引用、软引用、弱引用、虚引用。实际开发中90%以上的泄漏都是强引用造成的。
- 强引用:
Object obj = new Object(),只要强引用还在,GC就永远不会回收该对象。 - 软引用:内存够用就不回收,内存不够时会被回收,适合做缓存。
- 弱引用:下一次GC时不管内存够不够都会被回收,典型应用是
WeakHashMap、某些框架里的handler持有。 - 虚引用:完全不影响生命周期,主要用来跟踪对象被回收的状态,配合
ReferenceQueue用。
记住一个判断标准:**如果一个对象的生命周期理论上应该短于它被某个长生命周期对象持有的时间,那么这个持有关系就可能是泄漏源。**这会在后面Android实战部分反复用到。
2. Android世界里最容易踩的泄漏模式
2.1 非静态内部类:隐形的外部类持有者
Android开发里最常见的泄漏类型,排行第一一定是非静态内部类。
非静态内部类(包括匿名内部类)在Java语言层面有一个隐蔽的设计:每个非静态内部类对象,都会隐式持有外部类对象的引用。不信你可以用javap反编译看看,内部类的构造函数里会多出一个外部类类型的参数,并赋值给一个this$0字段。
这个特性本身不是bug,但在Android特定的生命周期里就成了大坑。最典型的就是Handler:
public class MainActivity extends AppCompatActivity { private final Handler handler = new Handler() { @Override public void handleMessage(Message msg) { // 更新UI } }; }这段代码很日常对吧?但问题大了。这个匿名Handler对象持有MainActivity的引用。当Activity正在执行onDestroy时,MessageQueue里如果还有待处理的消息(或者消息正在被处理),Message对象的target字段指向的就是这个Handler,于是链路变成了:
MessageQueue → Message → Handler → Activity
这个链路的源头是MessageQueue,它属于主线程,主线程在整个App生命周期里都是存活的。结果就是:Activity明明已经销毁了,但因为被Handler这条引用链连着,GC无法回收它,整个Activity的View树、成员变量、Bitmap等所有与之关联的对象全部泄漏。
以前面试时经常被问:为什么用static修饰Handler就可以避免Activity泄漏?现在原理就清楚了——静态内部类不持有外部类引用,断开了Handler → Activity这条链路。但只改成static还不够,通常还要配合WeakReference<T>来持有Activity,避免在使用时出现NPE。
我在实际项目里给团队的规矩是:能用Lifecycle/协程解决的问题,绝不裸写Handler;必须用Handler的,一律static + WeakReference,并且removeCallbacks。
2.2 Activity/Context错用:getApplicationContext才是保命符
Context的错用是另一个高发泄漏点。Context有三种常见类型:Activity、Service、Application。它们的生命周期依次变长:Activity和Service的生命周期跟组件绑定,Application是进程级的。
很多开发者在写工具类、单例、或者给Dialog/Toast使用Context时,顺手就传了Activity进来。如果这个Activity已经finish了,但长生命周期的对象还持有着它,就会泄漏。
举个例子,单例模式的网络加载框架:
public class NetManager { private static NetManager instance; private Context context; private NetManager(Context context) { this.context = context; } public static NetManager getInstance(Context context) { if (instance == null) { instance = new NetManager(context.getApplicationContext()); } return instance; } }这里有个关键决策:构造函数里保存Context时,强制使用getApplicationContext()做一次降级,而不是直接用传入的context。这样即使外部传进来的是Activity,单例持有的也只是Application,Activity可以正常回收。
我在代码评审阶段特别爱盯这几个点:
- 单例、静态字段里是否存了Activity/View
- 内部类、匿名类是否有被长生命周期对象持有的可能
- Dialog、Toast、PopupWindow是否在Activity销毁时已经dismiss
2.3 注册监听与回调:忘记注销等于给泄漏开了后门
很多系统服务、广播、传感器、EventBus、RxJava订阅,都需要在恰当的时机注册,然后在不使用时注销。Android系统设计的本意是让开发者在onResume/onPause、onStart/onStop、onDestroy里做对称操作。
但项目一大了就容易漏掉,尤其是有多条生命周期路径的时候。漏掉注册注销的监听,就等于在GCRoots里挂了一条指向当前界面的引用链。
实际案例:某项目里SensorManager.registerListener,结果在某个版本里有个页面在退出时走了异常分支,没有执行unregisterListener。传感器服务是系统进程的,它持有这个回调,回调又持有了注册时的Context(恰好是Activity),于是一整个界面泄漏。最恶心的现象是,这个问题只在特定场景下出现,偶发且极难复现。
现在的标准解法很成熟:能用Lifecycle-Aware组件(比如androidx.lifecycle包下的一系列组件)就尽量用,LifecycleOwner会在生命周期事件发生时自动帮你清理。不能用的时候,比如手写第三方SDK的注册逻辑,就老老实实在onDestroy里做对称注销。
2.4 资源未关闭与Bitmap:看不见的泄漏往往最致命
资源泄漏在严格意义上和对象泄漏有区别,但最终表现都是内存只增不减。
Cursor、FileInputStream、SQLiteOpenHelper等资源对象,底层往往持有操作系统的文件句柄或直接内存(Direct Buffer)。Java层面的包装对象很小看不出问题,但底层占用的可能就是数百MB的Native内存。如果只关掉Java对象而没有调用close(),这部分的释放就不可控。
Bitmap是Android里最需要小心谨慎处理的资源。Bitmap在Android 3.0之前像素数据是存在Native堆的,3.0到7.x之间挪到了Java堆,8.0之后又改回了Native堆(通过libhwui渲染管线)。这也是为什么同一个App在不同系统版本上的内存表现差异很大——你在8.0的设备上观察到的Bitmap内存可能不在Java堆里,Java堆占用一直很低但Native内存暴涨。
处理Bitmap的正规姿势:
- 加载前用
BitmapFactory.Options的inSampleSize做采样压缩,不要直接加载原图 - 使用
inJustDecodeBounds提前读取宽高 - 能用
BitmapFactory.Options.inBitmap复用的就复用(Android 3.0+ 支持) - 和图片加载库(Glide/Fresco)配合时,注意它们内部都有缓存,不是你用完dispose就立刻释放的
还是那句话:先搞清楚底层占的是什么内存,再去排查为什么内存没有降下来。
3. 内存泄漏排查:从Profiler到LeakCanary的完整打法
3.1 用Memory Profiler判断“是不是泄漏”而不是“看着像泄漏”
排查内存泄漏最容易犯的错,是一看到内存继续涨就觉得是泄漏。但内存增长也可能是正常现象——比如图片缓存、协程任务、动画等,只要这些内存后来能被回收,就不算泄漏。
正确的判断步骤分三阶段。
第一阶段:观察。打开Android Studio自带的Memory Profiler(或者不要叫我用AS了,新版叫App Inspection),跑一遍功能路径后返回主界面,然后反复执行“进入页面→退出页面”的操作,观察Java堆内存。
第二阶段:对比。执行完上述操作后紧接着触发一次GC(Profiler面板里有Force GC按钮),如果堆内存有明显回落,说明多数对象是可以被回收的,大概率不算泄漏;如果GC之后内存高位横盘,那就要警惕了。
第三阶段:验证。这一步一般在Profiler里Dump Java Heap,保存一份hprof文件,然后分析具体是哪些对象留存。在Android Studio里可以直接打开hprof,自动展示按类分组的实例数和Shallow Size/Retained Size。也可以切换到Heap Dump视图用MAT(Memory Analyzer Toolkit)做更资深的分析。
一个小技巧:打开hprof后,重点看那些Retained Size特别大的类——它们往往是你整个泄漏链的核心。然后右键某个实例,选Instances查看它的引用链,从对象本身一路往上追,看能不能在GCRoots里找到合理的“存活解释”。找不到,就基本实锤是泄漏了。
3.2 LeakCanary:把“人工找泄漏”变成“自动报泄漏”
LeakCanary这个库在排查泄漏里属于“作弊级”工具,因为它把上面的分析过程自动化了。
原理一句话解释:它内部注册了一个Application.ActivityLifecycleCallbacks,监听每个Activity的onDestroy。当某个Activity被销毁后,它会用WeakReference引用这个Activity,然后主动触发一次GC。如果GC后这个Activity还在,就说明有强引用链阻止了回收——它就通过hprof分析自动把这条引用链挖出来,并展示在通知栏里。
接入方法不复杂,项目级build.gradle里加依赖,然后在Application里初始化:
class App : Application() { override fun onCreate() { super.onCreate() if (LeakCanary.isInAnalyzerProcess == false) { LeakCanary.showLeakTrace("default") } } }需要注意,LeakCanary本身也是有一定成本的(会监控Activity并触发额外GC分析),所以只在Debug构建里集成就够。用release构建走正式包时,一般不会保留这个库,避免性能影响和hprof文件泄露用户隐私。
我在大大小小十几家公司都见过统一的集成方式:Debug模式开,Release模式关,甚至可以在gradle里用debugImplementation来控制依赖,从源头避免异常引入。
3.3 hprof分析实操:从堆转储文件里“抓元凶”
说一个不太被重视但我觉得很实用的能力——手工分析hprof文件。LeakCanary能自动识别Activity泄漏,但很多非Activity对象的泄漏(比如长生命周期集合、线程池、缓存等)它不见得都能自动精准定位。这时候手工分析比自动工具更可靠。
hprof文件可以通过Android Studio导出,也可以用命令行adb shell am dumpheap来导出:
adb shell am dumpheap -n <package_name> /data/local/tmp/dump.hprof adb pull /data/local/tmp/dump.hprof .导出的hprof默认是Android格式,用MAT分析之前还得转一次格式。Android SDK的platform-tools里没有自带转换器,但可以用hprof-conv(SDK的cmdline-tools里有带):
hprof-conv dump.hprof converted.hprof然后用MAT打开转换后的文件,选择Leak Suspects Report,它会自动列出疑似泄漏的对象链。我个人经常用Histogram视图,按Shallow Heap排序,过滤自己的包名(com.yourcompany.*),看有哪些类实例数远远超出合理范围——比如一个Activity页面实例有几十个,那就是被反复创建从未释放的典型特征。
很多人会觉得MAT难看,但它的Dominator Tree(支配树)功能非常强:从某个大对象出发,你能很直观地看到这棵“支配树”里挂了多少子对象,价值直接拉满。
3.4 内存抖动的辨别:是泄漏还是频繁创建对象
排查内存问题还要注意另一个概念:内存抖动(Memory Churn)。它不算严格意义的泄漏,但表现和泄漏有点像——内存频繁上升和GC频繁触发,App卡顿、掉帧,严重时还是OOM。
内存抖动的典型场景:在onDraw或者列表滚动过程中创建了大量临时对象,导致短生命周期对象数量激增,触发较频繁的Young GC。GC一多,主线程就要暂停,UI就卡了。
怎么区分泄漏和抖动?看Profiler里的堆内存曲线形态——泄漏的曲线是阶梯状上行且长期不下行;抖动的曲线是锯齿状高频摆动,每次下降都伴随一次GC。
定位抖动的思路有两个:一个是在Profiler的Record里录制方法分配,然后排序找出分配调用次数最多的方法;另一个是在代码层面做审计,把循环、高频回调里的对象创建全部提出来。我处理过的几个实际案例里,抖动最普遍的原因是字符串拼接和自动装箱(boxing),特别是Kotlin里用==比较两个String却意外触发了toString等操作时。
4. 从JVM调优到ART:Android性能底层的那些事
4.1 JVM调优面试题背后:参数与GC选型的关系
JVM调优这个话题,面试题里十个有八个都在聊堆大小和GC选型。虽然Android日常开发不直接允许你像服务端一样通过命令行设-Xmx、-XX:MaxPermSize(在Android上这些参数大部分被ART的GC策略接管),但理解原理能帮你更好地理解ART的Oops,以及为什么有些代码在本地跑得好好的,一到低端机上就OOM。
在标准JVM里,堆内存可以拆成新生代(Young Generation)和老年代(Old Generation),新生代里又分为Eden区和两个Survivor区(From/To)。大多数对象刚创建时在Eden区,经过几次Minor GC后仍存活的对象会被挪到Survivor区,再经过几轮GC后会晋升到老年代。到了老年代存活的对象,通常就是生命周期较长的对象,它们的回收要依赖Major GC/Full GC,代价高得多。
配置参数时,常见的几个命令:
-Xms256m -Xmx512m # 初始堆和最大堆 -XX:NewRatio=2 # 新生代/老年代比值为1:2 -XX:SurvivorRatio=8 # Eden/Survivor比值为8:1 -XX:MaxMetaspaceSize=256m选GC也是根据场景来定。单线程小堆用Serial;看重吞吐量的服务端用Parallel Scavenge;低停顿优先的互联网后台用CMS;追求极低停顿和Region化管理的用G1。G1在JDK 9之后成了默认GC,它的核心特点就是把堆划分为多个Region,不要求物理连续,通过维护一个优先列表来判断哪个Region的回收收益最大,优先回收收益高的Region,这就是G1所谓“可预测停顿模型”的由来。
JVM调优本质不是背参数,而是先明确三件事:目标的停顿时间是多少,目标吞吐量是多少,可用物理内存是多少。先量化,再定参数,再通过压测验证。
4.2 ART与JVM的差异:Android里的“JVM”不完全一样
Android的ART和标准JVM的差异是很多人没认真搞清楚的。ART从Android 5.0(Lollipop)开始替代Dalvik,它的核心特色是AOT(Ahead-Of-Time)编译,在安装时或者设备空闲时把DEX编译成机器码,省去了Dalvik时代的JIT解释执行开销。到了Android 7.0,Google引入了混合编译模式,应用首次安装时先走解释/快速模式,然后在设备充电和空闲时做profile-guided JIT/AOT编译。
ART的GC同样分代,但不完全套用JVM的新生代老年代模型。ART有Concurrent、Sticky、Partial、Full等多种GC类型。ART整体倾向于低延迟,所以引入了一个Concurrent GC机制,在垃圾回收时尽可能和应用线程并发执行。但低端机内存紧张时仍然会出现Concurrent GC频繁触发的情况,表现在用户的直观感受上就是列表滑动卡顿、冷启动变慢。
还有一个差异是ART对对象头的压缩处理。ART为了降低内存占用,对对象头做了裁剪,把某些字段(比如class指针)偏移等做了优化,这也解释了为什么同样的一份Java/Kotlin代码在不同设备/系统上的占用量不一样,排查泄漏时不要轻易拿两台差异极大的机器去对比绝对值,没有意义。
4.3 低内存设备上的生存之道:从GC日志反推分配问题
想在Android上确认GC发生了什么,可以在Manifest里加上android:debuggable="true"后,通过adb命令日志观察:
adb shell setprop dalvik.vm.heapgrowthlimit 256m adb logcat | grep -E "GC|dalvik|ART"在开发阶段我们可以在build.gradle里在debug模式下开启严格模式:
if (BuildConfig.DEBUG) { StrictMode.setVmPolicy( new StrictMode.VmPolicy.Builder() .detectLeakedClosableObjects() .detectLeakedSqlLiteObjects() .detectActivityLeaks() .penaltyLog() .build() ); }StrictMode比LeakCanary更早地暴露一批资源类泄漏,尤其对于Closable(文件流、Socket等)未关闭的问题,StrictMode往往在API调用链上就能直接打log,非常高效。但StrictMode也有局限——它默认只检测VM策略和Thread策略里的部分场景,对于跨层的复杂引用链无能为力。所以我在项目里通常的做法是StrictMode + LeakCanary + 定期的Profiler复核,三层防线缺一不可。
5. 实战避坑指南:修复案例与编码规范
5.1 实战修复一:Handler导致的Activity泄漏改造
先看一个我实际处理过的代码片段。某App的消息页,进入后可以轮询拉取最新消息列表,用的是Handler + 定时发送空消息的方式:
public class MessageActivity extends AppCompatActivity { private Handler refreshHandler = new Handler() { @Override public void handleMessage(Message msg) { loadLatestList(); sendEmptyMessageDelayed(MSG_REFRESH, 5000); } }; @Override protected void onDestroy() { super.onDestroy(); // 这里漏了 removeCallbacksAndMessages } }每次进入页面,这个Handler都会把自身注入到主线程的MessageQueue里,循环触发。即使出了页面,只要定时消息没被移除,Activity就被Message引用链拽着不放,页面反复进出几次,堆里的MessageActivity实例数量就肉眼可见地增加。
修复方案分两步走。
第一步,把Inner Handler改为static并持有外部Activity的弱引用:
private static class RefreshHandler extends Handler { private final WeakReference<MessageActivity> activityRef; RefreshHandler(MessageActivity activity) { activityRef = new WeakReference<>(activity); } @Override public void handleMessage(Message msg) { MessageActivity activity = activityRef.get(); if (activity == null) return; activity.loadLatestList(); activity.sendEmptyMessageDelayed(MSG_REFRESH, 5000); } }第二步,在onDestroy里把消息全部清空:
@Override protected void onDestroy() { refreshHandler.removeCallbacksAndMessages(null); super.onDestroy(); }我踩过的一个教训:只把Handler改static但忘了remove,相当于把“内部类持外类引用”断开了,但MessageQueue里的定时消息照样一直执行,逻辑上会产生野调用的风险(activity可能已经被finish了,还在执行UI操作)。所以static + WeakReference + remove 三件套要一起上,缺一个都是隐患。
5.2 实战修复二:单例缓存引发的内存暴涨
另一个典型案例是全局缓存过大。某项目里有个单例的图片加载缓存,用的是LinkedHashMap,代码大致如下:
public class ImageCache { private static ImageCache INSTANCE; private LinkedHashMap<String, Bitmap> cache = new LinkedHashMap<>(); public static ImageCache getInstance() { ... } public void put(String key, Bitmap bitmap) { cache.put(key, bitmap); } }业务上每加载一张图片就put一次,缓存不设上限。结果就是页面不断浏览图片,缓存越攒越大,最终在低端机上频繁OOM。
修法其实很简单,就是做容量限制 + LRU策略。Android自带的LruCache就是为此设计的:
private LruCache<String, Bitmap> cache; // 初始化时设定 maxSize = (int) (maxMemory / 8) cache = new LruCache<>(maxSize) { @Override protected int sizeOf(String key, Bitmap value) { return value.getByteCount(); } };LruCache内部维护一个LinkedHashMap,每次访问或写入都会把条目移动到队尾,当缓存容量超出maxSize后,会从头开始移除最久没被使用的条目。这个方案从源码上保证了缓存不会无限膨胀。
但这里有个细节:**LruCache只保证Java堆上的图片缓存有界,并不保证Bitmap的Native内存也有界。**因为sizeOf返回的是Java侧的估算尺寸(getByteCount也算的是像素缓冲理论大小),如果Bitmap因为硬件加速或者异步分配在Native层,那么最终的内存管理还要靠图片加载库的内部策略。所以项目里引入了Glide/Fresco之后,最好复用它们自带的缓存机制,不要再额外套一层LruCache,否则会出现双重缓存互相抢内存的问题。
5.3 实战修复三:生命周期异步回调引发的跨层泄漏
协程普及之后,把异步任务泄漏的风险降低了很多——CoroutineScope能跟随生命周期取消。但如果写法不当,照样有泄漏缝隙。最典型的是:
class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 没有绑定Lifecycle,任务无法因页面销毁而取消 CoroutineScope(Dispatchers.Main).launch { // 长时间任务后更新UI } } }这里CoroutineScope(Dispatchers.Main)的Scope是你临时new出来的,既没有和Lifecycle绑定,也没有持有任何取消入口。页面销毁后,协程依然运行到结束才停止——如果任务持有了Activity引用(lambda里访问了成员变量),Activity就被这个协程拽住了。
标准做法是用系统提供的lifecycleScope:
class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { // 当生命周期到达DESTROYED时,自动取消 } } }lifecycleScope在使用destroy时会把内部的Job取消,从而断掉所有子协程对上下文的引用。类似地,ViewModel里使用viewModelScope,再配合Flow或LiveData,基本能把绝大多数异步泄漏堵住。团队里如果还是老Java代码,可以改装RxJava,用AutoDispose绑定生命周期,原理都是“在销毁时主动断链”。
5.4 编码规范:把“避免泄漏”变成肌肉记忆
工具只帮你找问题,规范才帮你防问题。我在团队里订过几条铁律,效果不错,分享出来可以参考。
第一,统一Context存取规则:组件内需要长生命周期Context时,一律用getApplicationContext()。Activity的Context只能在View相关的临时场景里用。
第二,内部类一律静态化:能声明为static的内部类就不要写非静态的。非静态内部类的默认引用链条是外部类被隐式持有的,如果你根本不需要访问外部类成员,用静态类可以省掉这一层引用。事件回调、监听器同理。
第三,生命周期对称检查:凡是在onCreate/onResume/onStart里注册的监听、回调、传感器,必须在onDestroy/onPause/onStop里成对注销或解绑。
第四,图片缓存设置上限:不管用哪个图片加载库,都要明确配置磁盘缓存和内存缓存的大小,不要依赖框架默认值,因为默认值在不同版本上差别很大。
第五,大对象局部使用完及时置空:像Bitmap、大型byte[]这类对象,用完没后续用途就早点让引用失效,哪怕只是bitmap.recycle()配合null赋值,也能有效降低你在临界内存状态下触发OOM的概率。
管理上我还有个土办法:**每次发版本前,用Profiler跑一遍核心路径的内存基线,记录页面退出后堆内存的回落数据。**只要这个数据没有逐渐变差,心里就有底。内存优化不是一次性的工程,而是一个持续对抗熵增的过程。
6. 常见问题与排查技巧实录
6.1 内存问题的典型现象速查表
在实际工作中,我总结了一张速查表,当遇到内存类问题时先对号入座,能节省大量定位时间。这里列出来:
| 表现现象 | 可能原因 | 优先排查方向 |
|---|---|---|
| 页面退出后堆内存不降 | Activity/View被长生命周期对象持有 | LeakCanary抓引用链,确认是否存在匿名内部类、静态变量 |
| 内存高频振荡,UI卡顿 | 内存抖动,频繁创建大量临时对象 | Profiler录制方法分配,找高频分配点 |
| 多个OOM集中爆发 | 并发大图加载 / Native内存泄漏 | 检查Bitmap尺寸、是否用了采样压缩,检查NDK侧是否未释放 |
| 内存持续上涨但Profiler显示Java堆不高 | Native堆泄漏,或线程/句柄泄漏 | 结合/proc/pid/status看内存,或使用malloc_debug分析Native |
| 偶发且只有特定路径泄漏 | 生命周期异常分支未注销监听 | 审计onPause/onDestroy里所有注册-注销的对称性 |
| 低端机能跑但内存一直涨到被杀 | 老年代内存持续堆积 | 用MAT看Dominator Tree,定位占有最大的对象 |
这张表不是万能药,但能让你的第一反应从“不知所措”变成“先查哪里”。
6.2 从一次线上OOM到彻底根治的过程记录
分享一个比较典型的线上OOM复盘案例。
当时产品在本地功能测试完全正常,但一上线上灰度,部分机型(多为1GB/2GB内存的低端机)频繁闪退。后台看Crash日志全部是OutOfMemoryError,有的在BitmapFactory.decodeStream位置,有的在Canvas.drawBitmap。
一开始大家觉得是图片太大,但把所有的图片请求都加了采样压缩依旧有闪退。后来我把一台真机连上Profiler,专门跑“用户首屏→进入商品详情→返回”这个路径,连续重复几次后看dump,发现商品详情页的Activity只泄漏了一个实例,但它的View树里挂着一整张轮播图Bitmap,占了非常高的Retained Size。由于每进一次页面都重新创建了一套轮播图,旧页面没释放,叠加几次后内存直接打满。
根因是轮播组件里的一个ViewPager adapter持有了当前页的所有ImageView,而adapter是被一个static的单例框架持有的。代码里为了轮播状态同步,把adapter做成了全局可见,结果页面销毁时adapter没被解绑。修法是把adapter从全局框架中挪出,改到页面级持有;同时在onDestroy里把viewPager.adapter = null,把所有子View清空。修完之后Profiler反复跑几十轮,内存曲线都稳如老狗。
这个case给我的教训是:**线上OOM往往不是单一功能的问题,而是某个全局状态把不该活的页面“粘”住了。**排查时必须从“静态持有”的视角去看整个App的布局图。
6.3 避坑:LeakCanary不是测不出就没泄漏
最后说一个容易产生误判的点。
很多人会以为LeakCanary没有弹通知就等于“内存没问题”。LeakCanary只监听Activity的泄漏,对Fragment、View、非Activity组件(比如Service、ContentProvider、BroadcastReceiver中持有的对象)不会自动触发分析。你在LeakCanary里看不到问题,不代表内存就是健康的。
另外,LeakCanary默认的分析时机是在onDestroy后触发一次GC并判断Activity是否被回收。如果某些对象本身在Java堆里会延迟回收(比如弱引用恰好被GC忽略了、或者某些异步线程在Activity销毁后仍在短暂存活),它偶尔也会给出“疑似泄漏”的误报。看分析结果的时候,重点看引用链里每一个强引用的持有者,判断它的生命周期是不是真的合理。
在把LeakCanary集成进大型项目时,还有个坑:如果项目本身在用各种ARouter、DI框架、动态代理,可能在onDestroy之后框架内部还会长时间持有Activity的引用。这种“框架持有导致误报”的情况,建议先用MAT手动验证引用链,不要一看到通知就去改框架源码。多数时候这不是框架的锅,而是应用层在使用框架的方式上不符合生命周期规范。
7. 最后想说的一些实在话
这篇文章从JVM内存模型一直讲到ART、从理论拆到实战修复,我自己在写的过程中也重新走了一遍这几年踩过的坑。内存泄漏这个事,说难它确实不难——万物皆为引用链,GC的判断逻辑有章可循;但说简单它也不简单,因为现实项目里泄漏往往藏在多层抽象、异步回调、第三方SDK的夹缝里,不是一眼能看穿的。
我个人最有效的学习方式,不是背多少面试题,而是坚持做一件事:**每次遇到一个新的泄漏Case,就把引用链手动画出来,从GC Roots到目标对象,逐段标注每一层引用的类型和生命周期。**画得多了,你对哪些代码会导致泄漏、哪些地方的引用是多余的,会形成一种近乎本能的警觉。这种警觉比任何工具都值钱。
如果你刚接触内存优化,先从LeakCanary和Profiler入手,把工具跑熟;如果你已经在做一些深度性能优化,强烈建议把MAT的Dominator Tree和引用链分析吃透。工具只是放大镜,真正决定你能不能根治问题的,还是对JVM/ART内存模型的理解深度。
最后再分享一个小技巧:在项目里给每个Activity的onDestroy加一条Log,打印当前实例的hashCode()。线上出问题的时候,对比Log里同一页面是否反复打印出不同hashCode,就能快速判断是不是页面被反复创建且从未释放。这个方法我用了好几年,朴实但极其有效。