Android 使用 JVM 语言(Java/Kotlin)开发,内存管理采用自动垃圾回收(GC)机制。理论上,开发者无需手动释放内存。但 GC 有触发条件和性能开销,若编程不规范,就会出现内存泄漏、内存抖动等内存问题,甚至引发 ANR 和 OOM 崩溃,严重影响用户体验和应用稳定性。
一
常见内存问题
1
内存泄漏(Memory Leaks)
这是导致其他内存问题的根本原因。GC 通过可达性分析,从 GC Roots(静态变量、线程栈局部变量等)出发标记可达对象,回收不可达对象。GC 在内存不足或系统主动触发时执行。当对象不再需要但仍被引用时,该对象仍然可达,无法被垃圾回收,导致内存泄漏。
2
内存溢出(OutOfMemoryError, OOM)
OOM 是指应用申请内存时,超过了系统当前可分配的上限,导致应用崩溃。
OOM 的常见原因包括:
内存泄漏:对象无法被回收,内存占用持续增长(最常见原因)。GC 后可用内存仍极少(如 <1% of heap free after GC),系统会主动拒绝分配以防止崩溃内存抖动:频繁创建/销毁对象,导致内存碎片化。即使总可用内存充足,也可能因无法找到连续内存块而触发 OOM(错误信息通常包含 fragmentation 关键字)一次性分配过大对象:如加载超大图片、处理大文件应用正常内存需求大:图片/视频处理、大数据计算等业务场景设备内存限制:低端设备可用内存较小下面是一个内存碎片化(Memory Fragmentation)问题的崩溃信息:
Fatal Exception: java.lang.OutOfMemoryError:Falled to allocate a 8211 byte allocation with 100663296 free bytes and 253MB until OOM, target footprint 37135872, growth limit536870912; failed due to malloc_space fragmentation (largest possible contiguous allocation 2688 bytes, space in use 60513320 bytes, capacity = 60596224)日志解读:尝试分配8211 字节(约 8KB),当前可用内存有100663296 字节(约 96MB),但是最大连续块仅 2688 字节(约 2.6KB);失败原因:malloc_space fragmentation(内存碎片化)
下面是一个内存不足导致的崩溃信息:
Fatal Exception: java.lang.OutOfMemoryError:Failed to allocate a 8211 byte allocation with 3057352 free bytes and 2985KB until OOM, target footprint 536870912, growth limit 536870912; giving up on allocation because <1% of heap free after GC.日志解读:尝试分配8211 字节(约 8KB),当前可用内存3057352 字节(约 2.9MB);失败原因:giving up on allocation because <1% of heap free after GC(GC 后堆内存仍不足 1%,系统判断继续分配风险过高,主动拒绝)
3
卡顿(Jank)
某些 GC 操作需要阻塞主线程等待完成。当 GC 阻塞主线程时,会延迟绘制流程,导致掉帧和界面卡顿。
下面是一个 GC 阻塞导致卡顿的日志信息:
2022-08-18 13:44:08.392 26965-26965/com.lianjia.beike I/m.lianjia.beik: Waiting for a blocking GC ClassLinker2022-08-18 13:44:08.443 26965-26974/com.lianjia.beike I/m.lianjia.beik: Background concurrent copying GC freed 366836(9699KB) AllocSpace objects, 10(328KB) LOS objects, 49% free, 11MB/22MB, paused 219us,186us total 204.520ms2022-08-18 13:44:08.444 26965-26965/com.lianjia.beike I/m.lianjia.beik: WaitForGcToComplete blocked ClassLinker on Background for 51.421ms日志解读:主线程被阻塞了 51.421ms。在 60fps 下,一帧时间为 16.67ms,51.421ms 的阻塞会导致约 3 帧丢失,用户会感知到明显的卡顿。
4
ANR(Application Not Responding)
内存压力会导致 GC 频繁,主线程阻塞,可能触发 ANR
5
应用存活时间
Android 会优先清理内存占用高的后台进程。内存占用高的应用更容易被系统杀死。
二
常见内存泄漏方式
在Android系统中,大多数内存泄漏与对象生命周期管理有关。核心原则:确保在对象生命周期结束时,所有对该对象的引用都被正确清理。
1
静态变量持有 Activity/Context 引用
这是最常见的一种。静态变量生命周期与 Application 一致,持有 Activity 引用会导致 Activity 无法被回收。
2
Fragment 生命周期
场景1:Fragment 加入返回栈(addToBackStack)时,未在 onDestroyView() 中清空 View 字段,导致 Fragment 的 View 持有 Activity 引用。
场景2:父 Fragment 持有子 Fragment 引用。FragmentManager 的 mFragments 字段会缓存 Fragment 实例。如果父 Fragment 持有 FragmentManager,子 Fragment 即使销毁也无法被回收。
3
订阅未取消注册
注册了监听器、广播接收器或 RxJava 订阅,但在生命周期结束时未取消注册,导致持有对象引用。
4
Handler/Message 导致的内存泄漏
Handler 持有 Activity 引用,Message 持有 Handler 引用。如果 Message 在 MessageQueue 中未处理完,会导致 Activity 无法回收。
5
线程持有对象引用
线程中持有 Activity 或其他生命周期对象的引用,线程未结束时对象无法被回收。
6
动画未取消
无限循环或长时间运行的动画持有 View 引用,View 又持有 Context,导致内存泄漏。
7
Dialog 未正确释放
Dialog 持有 Activity 引用,如果 Dialog 被静态变量或其他长生命周期对象持有,会导致 Activity 泄漏。
8
单例模式持有 Context 引用
单例持有 Activity Context 而非 Application Context,导致 Activity 无法回收。
9
内部类持有外部类引用
非静态内部类(包括匿名内部类)会隐式持有外部类引用,如果内部类被长生命周期对象持有,会导致外部类无法回收。
10
配置变更导致的内存泄漏
在配置变更(如屏幕旋转)时,Activity 会被重建,如果对象持有旧 Activity 实例的引用,会导致泄漏。
三
hprof文件
hprof 的全称是 Heap Profile(堆配置文件)。这是 Java 虚拟机(JVM)和 Android 运行时使用的堆转储文件格式,用于记录应用程序在某个时间点的堆内存快照,也称内存镜像。包含:
所有对象及其引用关系类的定义信息线程和栈信息GC 根对象hprof 文件是内存监控的核心数据源。通过分析 hprof 文件能够帮助定位内存泄漏问题的根源,提供识别大对象、分析内存分布、发现重复对象等内存使用优化思路,从而提升应用稳定性和性能。
四
线下监控工具
线下监控指的是在APP的开发和测试阶段,apk使用的是debug版本。可以直连测试手机或者问题设备进行调试以及日志获取。
1
Profiler
Profiler 是 Android Studio 的工具窗口,包含多个功能模块:
- System Trace(基于 Perfetto 技术,用于系统级性能跟踪)
- Heap Dump(基于hprof文件,用于 Java 内存泄漏分析)
- CPU Profiler(用于方法级 CPU 分析)
- Network Profiler(用于网络分析)
Profiler既支持实时监控和录制:
连接设备实时监控 CPU、内存、网络实时录制 System Trace(Perfetto trace)也支持离线文件分析:
导入 hprof 文件 → Heap Dump 模块分析导入 perfetto-trace 文件 → System Trace 模块分析1.1
实时监控
这里需要注意的是:
1)apk包仅仅是debuggable还不行。从 Android 10 (API 29) 起,Android Profiler 需要在 AndroidManifest.xml 的 <application> 中显式声明android:profileable="true"。
2)通过adb命令在设备上启动traced服务。
完成这两步之后,我们就能连接上设备了:
选中目标进程后就进入了实时监测状态:
录制结束后可以进行Heap Dump的分析:
1.2
Heap Dump分析
1
加载hprof 文件
在 Android Studio 中打开 Profiler(View → Tool Windows → Profiler)
点击左上角的 “+” → “Load from file”,选择你的 .hprof 文件
2
在 Heap Dump 视图中选择可疑实例
顶部第三个筛选项选择:Show activity/fragment Leaks:显示 Activity/Fragment 泄漏。
其中:
Shallow Size:对象本身大小
Retained Size:对象及其引用链的总大小
关注 Retained Size 较大的类,点击可疑类(如 SecondHouseDetailActivity24)
3
分析引用链
在 Instance List 面板选中一个实例,在Instance Details面板中切换到 References 标签,勾选 “Show nearest GC root only” 查看最短引用链。在Instance Details 面板中,引用链是从 GC Root 到目标对象(泄漏的对象)的引用路径,以树形/层级结构,从根到目标逐层展示。
以上图示例为例进行分析,发现引用链:
GC Root → WeakHashMap$Entry → TXCVodVideoView.mContext → SecondHouseDetailActivity24
从而可以得出结论:TXCVodVideoView 通过 mContext 持有 Activity,导致泄漏。
修复方案:检查 TXCVodVideoView 的生命周期管理,确保在 Activity 销毁时释放引用。
2
Perfetto UI
网址为:https://ui.perfetto.dev/
与手机设备的连接过程可能没那么顺利,当右下角“Connect new device”的结果都显示绿色时,才能开始启动录制“Start tracing”。
开始录制前,需要首先设置下录制结束的条件:默认Buffer是 64M,Max duration是 10s。可以根据需要进行Probes中CPU、Memory等采集项的配置。
分析内存泄漏时,因为需要较长时间观察内存变化趋势,或者重复执行可能存在泄漏的操作,所以建议将两项配置都设置为最大,这样可以灵活地进行手动停止。
结束录制后,会自动跳转到trace文件分析页面:
顶部是时间轴,面板左侧是CPU、Memory以及各进程等信息。如何快速识别内存异常?常用方法有:
01
观察趋势图
在 Memory → Meminfo 中,每个指标旁都有小趋势图。关注这些模式:
内存泄漏:持续上升,不回落重点关注:AnonPages、Active(anon)、Committed_AS判断:应用运行过程中持续增长且不下降内存抖动:频繁大幅波动重点关注:Cached、Active(file)判断:短时间内大幅波动,锯齿状内存峰值:突然大幅上升判断:短时间内增长超过正常范围02
使用时间线分析
操作步骤:在时间线上选择时间段(点击并拖拽)
观察选中时间段内内存指标的变化
关联 CPU 调度和进程活动,找出内存增长的原因
3
LeakCanary
LeakCanary可以称为是最流行的内存泄漏检测库,其集成和使用都很简单。
官方文档:https://square.github.io/leakcanary/
框架地址:https://github.com/square/leakcanary
工程主要组成:
其中核心类有:
RetainedObjectTracker / ObjectWatcher功能:对象追踪器。ObjectWatcher使用弱引用监控对象,检测是否被 GC 回收;推荐使用 RetainedObjectTracker。HeapDumper功能:堆转储接口,会调用 Debug.dumpHprofData() 生成堆转储。HeapGraph (通过 HprofHeapGraph 构建)功能:堆图数据结构,提供堆对象的查询和导航。LeakingObjectFinder功能:查找和定位哪些对象泄漏。HeapAnalyzer功能:Shark 引擎的核心,分析堆转储并查找泄漏路径。LeakTracer功能:追踪从泄漏对象到 GC Roots 的引用路径。这是接口,实际实现是 RealLeakTracerFactory。 ShortestPathFinder功能:使用图算法找到最短泄漏路径。这是接口,实际实现是 PrioritizingShortestPathFinder。3.1
监控效果
如图所示,这是包含了LeakCanary功能的debug包在运行时的一个内存泄漏检测结果页面,日志解读:泄漏对象为ResblockInfoChartFragment,泄漏原因是ResblockInfoParentItemCard 持有 FragmentManager,导致 Fragment 无法被回收。再结合具体业务代码,我司平台同学最终定位到原因是:MyHomeFragment 通过childFragmentManager.mFragments 持有MyHomeListFragment引用,导致MyHomeListFragment即使onDestroy() 被调用,引用依然被持有,从而导致MyHomeListFragment被泄漏。
3.2
监控机制
LeakCanary 的完整流程包括:
检测保留对象:监听 Activity、Fragment 等生命周期转储堆:达到阈值条件时自动生成 hprof分析堆:使用 Shark 解析 hprof,定位泄漏对象分类泄漏:区分应用泄漏和库泄漏本文只对第一步的检测进行展开分析。LeakCanary 默认自动监控:Activity 实例、Fragment 和Fragment View 实例、ViewModel 实例、Service 实例、RootView 实例。可通过 AppWatcher.objectWatcher.watch() 手动监控任意对象。
检测保留对象的详细流程为:
1
创建弱引用并关联 ReferenceQueue
KeyedWeakReference(activity, key, description, time, queue)
2
以Activity为例:当退出Activity时,如果Activity没有其他强引用,正常情况下会变为弱可达
3
JVM 检测到对象变为弱可达
4
JVM 自动将 KeyedWeakReference 放入 ReferenceQueue
5
LeakCanary 调用 removeWeaklyReachableObjects()
6
通过 queue.poll() 获取已入队的弱引用
7
从 watchedObjects 中移除(表示对象已被回收)
// 3.2.1 监控时机
Activity 实例
通过 Application.ActivityLifecycleCallbacks 监听生命周期
Fragment 和Fragment View实例
通过 FragmentManager.FragmentLifecycleCallbacks 监听 Fragment 生命周期。在onFragmentDestroyed时将Fragment实例添加到ObjectWatcher中;在onFragmentViewDestroyed时将Fragment View 实例添加到ObjectWatcher中。
ViewModel 实例
这里的ViewModel指的是MVVM设计框架中的VM。它是 Android Architecture Components 中的 androidx.lifecycle.ViewModel,用于存储和管理 UI 相关的数据,生命周期独立于 Activity/Fragment。
LeakCanary利用 ViewModelStoreOwner 机制(FragmentActivity 和 Fragment 都实现了该接口,各自拥有独立的 ViewModelStore)。在 Activity 创建时(onActivityCreated)和 Fragment 创建时(onFragmentCreated),通过 ViewModelProvider 注入一个 ViewModelClearedWatcher 作为间谍 ViewModel。
间谍 ViewModel 在构造函数中通过反射获取并保存 ViewModelStore 中的所有 ViewModel。当 ViewModelStore 被清理时(Activity/Fragment 销毁),所有 ViewModel 的 onCleared() 会被调用,间谍 ViewModel 在 onCleared() 中将其他所有 ViewModel 交给 ObjectWatcher 监控。
Service实例
通过反射 hook ActivityThread 的 Handler 和 ActivityManager 来监听 Service 生命周期。在 STOP_SERVICE 消息时记录 Service,当 serviceDoneExecuting() 被调用时(Service 已收到 onDestroy() 回调,理论上应被销毁),将 Service 实例添加到 ObjectWatcher 中。
RootView 监控
通过 Curtains 库监听根视图的添加和移除。当根视图被添加时,为符合条件的根视图(如 Dialog、Toast、Tooltip 等)添加 OnAttachStateChangeListener。当根视图收到 onDetachedFromWindow() 回调时(从窗口分离,理论上应被销毁),将根视图实例添加到 ObjectWatcher 中。
// 3.2.2 核心原理
上面提到过,LeakCanary 在对象被添加到 ObjectWatcher 时,会创建一个KeyedWeakReference(弱引用):
class KeyedWeakReference(referent: Any,val key: String,val description: String,val watchUptimeMillis: Long,referenceQueue: ReferenceQueue<Any>) : WeakReference<Any>(referent, referenceQueue) {}这里首先介绍下参数 `referenceQueue`。`ReferenceQueue` 是 Java 标准库中的类(`java.lang.ref.ReferenceQueue`),用于与弱引用、软引用、虚引用配合使用。当被引用的对象(referent)变为弱可达时,JVM 会自动将对应的 `WeakReference` 对象放入关联的 `ReferenceQueue` 中。这个过程发生在对象(referent)实际被 GC 回收之前,甚至早于 finalization。而且`ReferenceQueue` 是线程安全的,可以通过 `poll()` 或 `remove()` 方法获取已入队的引用对象。这种 JVM 自动通知机制为应用程序提供了观察对象可达性变化的回调,使得监控工作高效且及时,无需轮询检查。
为什么选择弱引用呢?让我们来回顾下Java四种引用的特点:
引用类型
回收时机
能否获取对象
强引用(Strong Reference)
默认引用类型,不会被 GC 回收
可以
软引用(SoftReference)
内存不足时才会被回收
可以
弱引用(WeakReference)
下次 GC 时就会被回收
可以
虚引用(PhantomReference)
对象被回收后才会进入队列,且无法通过 get() 获取对象
无法获取
所以不难看出,选择弱引用的好处:
弱引用不会阻止对象被 GC监控不干扰对象的正常生命周期,不会影响对象的正常回收。回收行为可预测一旦发生 GC,弱引用就会被回收。LeakCanary 通过手动触发 GC,在固定时间窗口(如 5 秒)后判断是否泄漏。及时性有了 ReferenceQueue 的加持,对象变为弱可达时即入队,早于 finalization,无需轮询检查,也避免了调用 get() 可能创建临时强引用的问题。五
线上监控-KOOM
鉴于文章篇幅关系,线上监控本文只介绍KOOM。KOOM (Kwai OOM, Kill OOM) 是快手团队开发的移动端 OOM 监控解决方案,与LeakCanary相比,KOOM不仅支持 Java 堆泄漏监控,还支持Native 堆泄漏、线程泄漏,而且是面向线上环境,性能开销更低。
框架地址:https://github.com/KwaiAppTeam/KOOM
工程主要组成:
1
Java堆泄漏监控
与LeakCanary相比:
维度
LeakCanary
KOOM
监控时机
生命周期回调时立即监控(实时)
周期性检测系统资源,一旦资源超阈值则进行dump并统一分析(批量)
监控方式
每个对象有独立的监控时间线和检测流程,互不影响
全量扫描,所有对象在同一时间点被统一分析
检测机制
WeakReference + ReferenceQueue
反射读取对象字段
触发条件
对象应该被回收但未回收
系统资源超过阈值
性能开销
较低(逐个监控,延迟检测)
较高(全量扫描,但只在 dump 时)
使用场景
开发/测试环境
线上环境
1.1
如何循环检测
使用 HandlerThread 创建后台线程,线程优先级为THREAD_PRIORITY_BACKGROUND
,避免阻塞主线程。循环间隔:默认 15 秒。首次启动需要手动调用 OOMMonitor.startLoop()
1.2
资源阈值
阈值类型
默认值
说明
堆内存阈值
80%/85%/90%
百分比含义:堆内存使用率 = 已使用的堆内存 / 最大堆内存
根据最大堆内存动态计算
线程阈值
450/750
根据 ROM 和系统版本
文件描述符阈值
1000
固定值
设备内存阈值
5%
设备可用内存占比
VSS 阈值
3.56 GB
仅 32 位设备
堆内存最大阈值
90%
固定值
堆内存增量阈值
342 MB
短时间内增长量
连续超过阈值次数
3 次
连续超过才触发
检测间隔
15 秒
周期性检测间隔
分析次数限制
5 次
每个版本最多分析次数
分析周期
15 天
每个版本的分析周期
堆内存阈值
其中堆内存阈值选择,代码实现:
private val DEFAULT_HEAP_THRESHOLD by lazy {val maxMem = SizeUnit.BYTE.toMB(Runtime.getRuntime().maxMemory())when {maxMem >= 512 - 10 -> 0.8fmaxMem >= 256 - 10 -> 0.85felse -> 0.9f}}选择逻辑:
最大堆内存 ≥ 502MB(512-10)→ 使用 80%
最大堆内存 ≥ 246MB(256-10)→ 使用 85%
其他情况 → 使用 90%
已使用的内存如何计算:
javaHeap = JavaHeap()javaHeap.max = Runtime.getRuntime().maxMemory()javaHeap.total = Runtime.getRuntime().totalMemory()javaHeap.free = Runtime.getRuntime().freeMemory()javaHeap.used = javaHeap.total - javaHeap.freejavaHeap.rate = 1.0f * javaHeap.used / javaHeap.max这里要注意Runtime这几个方法的概念和关系,不要被方法名误导。
1. maxMemory()
含义:JVM 最大可用堆内存(由 -Xmx 或系统限制决定)
特点:固定值,不会变化
示例:512MB、1024MB 等
2. totalMemory()
含义:当前 JVM 已分配的堆内存大小
特点:会动态增长,但不会超过 maxMemory()
说明:GC 后可能减少,但通常不会低于初始值
3. freeMemory()
含义:totalMemory() 中当前空闲的内存大小
特点:会动态变化
说明:GC 后可能增加
4. 已使用内存 (used)
计算:totalMemory() - freeMemory()
含义:当前已分配的堆内存中,实际被对象占用的部分
线程数阈值
KOOM是通过读取 /proc/self/status 文件的方式来获取当前线程数的,与其他方法对比:
方法
优点
缺点
/proc/self/status
准确、实时、系统级
需要文件 I/O
Thread.getAllStackTraces()
Java API,简单
只统计 Java 线程,可能不准确
Thread.activeCount()
Java API,简单
只统计当前线程组,不准确
KOOM中线程数最大值是个固定值:
private val DEFAULT_THREAD_THRESHOLD by lazy {if (MonitorBuildConfig.ROM == "EMUI" && Build.VERSION.SDK_INT <= Build.VERSION_CODES.O) {450} else {750}}文件描述符阈值
KOOM是通过读取 /proc/self/fd 目录的方式来统计文件数量的。
/proc/self/fd 是 Linux/Android 的虚拟文件系统目录,该目录包含当前进程所有打开的文件描述符的符号链接,每个文件描述符对应一个文件(以数字命名,如 0, 1, 2, 3...)。目录结构如下:
/proc/self/fd/├── 0 → /dev/null├── 1 → /dev/log/main├── 2 → /dev/log/main├── 3 → socket:[12345]├── 4 → /data/data/com.example.app/files/test.txt├── 5 → anon_inode:[eventpoll]└── ...这种方式的优点也是:准确、实时、系统级。
设备内存阈值
KOOM通过读取 /proc/meminfo 文件,提取关键信息:
MemTotal:系统总内存
MemAvailable:系统可用内存(包括 MemFree + 可回收的缓存和缓冲区)
MemFree:系统空闲内存(完全未被使用的内存)
计算:可用内存占比 = MemAvailable / MemTotal
/proc/meminfo 是 Linux/Android 系统内存信息文件,获取的是整个设备的内存状态。与堆内存阈值监控方式相比,Runtime.getRuntime() 返回的是当前进程的 JVM 实例,每个 Java 进程有独立的 JVM 和堆内存,所以前者监控的是单个进程的Java 堆内存的使用情况,而当前的设备内存阈值的方式监控的是整个设备的系统物理内存的状态。
VSS阈值
VSS 是进程的虚拟内存大小,KOOM框架中VSS 阈值未用于触发 dump,但 VSS 值会被记录到分析报告中。VSS的值从 /proc/self/status 的 VmSize 字段获取。
1.3
dump触发
1
每次循环检测 (每 15 秒)
2
遍历所有 Tracker
├─ HeapOOMTracker.track()
│ └─ 堆内存 连续3次超过阈值 → 返回 true
├─ ThreadOOMTracker.track()
│ └─ 线程数 连续3次超过阈值 → 返回 true
├─ FdOOMTracker.track()
│ └─ 文件描述符 连续3次超过阈值 → 返回 true
├─ FastHugeMemoryOOMTracker.track()
│ └─ 堆内存占比超过90%(长期高位)或者 堆内存增量超过阈值(突然增长) → 立即返回 true
└─ PhysicalMemoryOOMTracker.track()
└─ 检查系统可用内存是否< 5% 总是返回 false(不触发)
3
如果任何一个 Tracker 返回 true
4
检查限制条件
├─ enableHprofDumpAnalysis = true?
├─ 未超过分析周期(15天)?
└─ 未超过分析次数(5次)?
5
触发 dump
KOOM的Java堆内存泄漏监控的最终产物是HeapReport JSON 文件和hprof文件。
2
Native堆泄漏监控
用于监控应用的 Native 内存泄漏问题,它的核心实现如下:
Hook 机制:使用xhook库hook malloc/free 等内存分配器方法,记录 Native 内存分配元数据「大小、堆栈、地址等」Mark-and-Sweep:周期性利用系统的 libmemunreachable 进行可达性分析,获取不可达的内存块信息「地址、大小」堆栈获取:使用FP Unwind(Frame Pointer Unwind)获取堆栈分配标签:通过 Activity 生命周期绑定泄漏来源KOOM的Native堆泄漏监控的最终产物是LeakRecord 列表,通过 LeakListener 回调给用户,包含泄漏内存地址、大小、分配堆栈等。
阈值类型
默认值
说明
monitorThreshold
16 字节
监控的最小内存分配大小
nativeHeapAllocatedThreshold
0
Native 堆分配阈值(0=不限制)
循环检测间隔
300,000 毫秒(300 秒/5 分钟)
周期性检测间隔
2.1
xhook
PLT Hook 机制通过修改 GOT 表项,将外部函数调用重定向到自定义的 Hook 函数,从而在运行时拦截和修改函数行为。xhook 实现了这一机制,使 KOOM 能够在不修改源码的情况下监控 malloc、pthread_create 等系统函数。
Hook 方式
原理
优点
缺点
PLT Hook (xhook)
修改 GOT 表项
无需修改源码,支持多库
只能 Hook 外部函数
Inline Hook
修改函数入口代码
可 Hook 内部函数
需要处理指令对齐,复杂
GOT Hook
修改 GOT 表项
简单直接
与 PLT Hook 类似
其中的名词解释:
PLT(Procedure Linkage Table,过程链接表)作用:用于间接调用外部函数(如 malloc、pthread_create)位置:ELF 文件的 .plt 段特点:每个外部函数都有一个 PLT 条目GOT(Global Offset Table,全局偏移表)作用:存储外部函数的实际地址位置:ELF 文件的 .got.plt 段特点:PLT 通过 GOT 获取函数地址Hook 的工作流程:
1
应用调用 malloc(size)
2
xhook 拦截调用
3
重定向到包装函数 mallocMonitor(size)
4
包装函数执行:
├─ 调用原始 malloc(size) 分配内存
├─ 记录分配元数据(地址、大小、堆栈、线程名)
└─ 返回分配的内存指针
5
应用继续使用分配的内存
2.2
libmemunreachable
libmemunreachable 是Android系统提供的动态链接库模块,支持Android N+及以上系统。它提供 C/C++ 函数接口,用于检测 Native 内存泄漏。
KOOM 的 mark-and-sweep(标记-清除算法) 实现:
1
初始化 MemoryAnalyzer
2
动态加载 libmemunreachable.so
3
获取 GetUnreachableMemoryString 函数指针
4
调用 CollectUnreachableMem()
5
设置进程为可 dump 状态
prctl(PR_SET_DUMPABLE, 1)
6
调用系统函数 get_unreachable_fn_(false, 1024)进行 mark-and-sweep
7
系统内部执行:
├─ Mark 阶段:从 GC Root 开始标记所有可达内存
└─ Sweep 阶段:扫描整个 Native Heap,找出未标记的内存块
8
返回不可达内存块列表(字符串格式)
"1024 bytes unreachable at 0x7f8a12345678"
9
解析字符串,提取地址和大小
10
恢复进程 dumpable 状态
prctl(PR_SET_DUMPABLE, origin_dumpable)
11
返回不可达内存块列表
[(address, size), ...]
12
与已分配但未释放的内存块匹配
13
生成泄漏记录(包含分配堆栈)
这样既复用了系统实现,又降低了维护成本。
3
线程泄漏监控
KOOM的koom-thread-leak模块主要用于监控应用的线程泄漏问题,它的核心实现:
使用xhook对pthread_create/pthread_exit 等线程方法进行了hook,用于记录线程的生命周期和创建堆栈,名称等信息当发现一个joinable的线程在没有detach或者join的情况下,执行了pthread_exit,则记录下泄露线程信息当线程泄露时间到达配置设置的延迟期限的时候,上报线程泄露信息最终产物是ThreadLeakRecord 列表,通过 ThreadLeakListener.onReport() 回调给用户,格式是已符号化的可读的堆栈信息包含线程 ID、创建时间、堆栈等。
4
kwai-unwind
KOOM 的这个模块主要用于 Native 层堆栈信息的获取,它由三部分组成:
fast_unwind - 快速堆栈展开(Frame Pointer Unwind)使用 Frame Pointer(帧指针)链式遍历:通过 __builtin_frame_address(0) 获取当前帧指针,然后通过 frame_record 结构体(包含 next_frame 和 return_addr)链式向上遍历所有栈帧,收集每层的返回地址。libunwindstack - 完整堆栈展开基于 DWARF 调试信息进行堆栈展开,支持解析 .debug_frame、.eh_frame 等调试信息,能够准确获取函数名、文件名、源码位置等详细信息。libbacktrace - 堆栈回溯封装层提供统一的 Backtrace API 接口层,内部使用 libunwindstack 实现堆栈展开,支持本地进程和远程进程(ptrace)的堆栈获取。在 Android 系统中,libunwindstack 和 libbacktrace 是独立的系统库。但在 KOOM 并没有动态加载这两个 so,而是以源码方式进行了依赖,其优点是:避免依赖系统库版本差异和可用性问题,保证在不同 Android 版本上的稳定性和一致性;同时可以进行针对性的编译优化和功能定制,将源码编译进 `libkwai-unwind.so` 中,形成一个自包含的堆栈展开库。
堆栈展开(Stack Unwinding)是指从当前函数调用点开始,向上遍历调用栈,获取每一层的函数调用信息,形成完整的调用链(Stack Trace)。在 KOOM 中,线程泄漏监控时,会先使用 FastUnwind 获取 PC 地址数组,然后再用完整堆栈展开(UnwinderFromPid::BuildFrameFromPcOnly)将每个 PC 地址转换为可读信息(函数名、文件名、偏移量等)。Native 堆泄漏监控时,主要使用快速堆栈展开(FastUnwind)获取内存分配时的堆栈。
特性
快速堆栈展开
完整堆栈展开
核心机制
Frame Pointer 链式遍历
DWARF 调试信息解析
数据来源
栈内存中的帧指针
ELF 文件中的调试信息
速度
快(微秒级)
慢(毫秒级)
准确性
中等(可能断裂)
高(基于调试信息)
依赖
编译器选项(-fno-omit-frame-pointer)
ELF 文件中的调试信息
适用场景
性能敏感场景(Native 泄漏监控)
需要准确性的场景(崩溃分析)
其中ELF 全称是 Executable and Linkable Format(可执行可链接格式)。它是 Native 库(.so 文件)的二进制格式。DWARF 全称是 Debugging With Attributed Record Formats(带属性记录格式的调试),它是一种调试信息格式,包含堆栈展开、符号、类型等信息。存储在 ELF 文件的特定节中。
5
为什么适合线上监控
KOOM 适合线上监控的主要原因:
dump 在子进程,分析在独立服务中,不阻塞主进程KOOM实现了一套完整的 fork 子进程功能。采用镜像采集的思路,在Native层采用`当前进程虚拟机supend -> fork子进程 -> 当前进程虚拟机resume -> 子进程dump内存镜像`的策略,将采集镜像的耗时转移到子进程,与LeakCanary相比,主进程冻结时间从 20s+降至 < 20ms。hprof 分析在独立的 IntentService 中异步执行,分析完成后通过 System.exit(0) 退出服务进程,避免长时间占用主进程资源。Hprof 裁剪与LeakCanary全量保存hprof文件相比,KOOM在dump过程中通过hook write系统调用,实时裁剪了:- Zygote Space 和 Image Space(系统共享内存,所有应用进程共享,对泄漏分析无意义)- 基本类型数组的数据内容(如 byte[]、int[] 等,通常占用大量空间但对泄漏分析价值有限)裁剪后保留了类信息、实例信息、对象数组、对象引用关系等泄漏分析所需的关键信息。裁剪后文件大小通常会减少 50%-80%,降低存储和传输成本,同时保留泄漏分析所需的关键信息。资源阈值监控(而非生命周期驱动)与LeakCanary基于Activity/Fragment生命周期监控不同,KOOM采用资源阈值监控机制:- 监控堆内存使用率、线程数、文件描述符数、系统可用内存等多个维度- 不依赖特定对象的生命周期,适合复杂线上场景- 支持连续多次检测确认,避免误报采样和限制机制支持远程开关、次数/周期限制(默认每个版本最多5次,15天周期),更灵活地控制监控频率和成本。这些设计使 KOOM 能在线上环境稳定运行,对用户体验影响小,同时有效捕获内存问题。
六
总结
内存监控与优化在实际工作中相辅相成。在软件开发生命周期中,内存监控应该作为性能测试环节的必要内容,并建立持续监控机制。在内存优化方面,可以重点针对图片和大对象进行优化,通过合理缓存策略、对象池化复用等方式,减少频繁的内存分配与释放,从而降低内存峰值和 GC 压力,提升应用稳定性。
转载请注明来自海坡下载,本文标题:《android内存优化(Android内存监控常用工具)》
京公网安备11000000000001号
京ICP备11000001号
还没有评论,来说两句吧...