iOS OOM治理

01

消失的崩溃

iOS开发的深水区,我们经常会遇到一种诡异的现象:用户反馈App在使用过程中突然"闪退",但打开听云、BuglyCrashlytics等崩溃监控平台,却找不到对应的Crash堆栈。这就是OOMiOS开发者的隐形杀手。

与普通的NSExceptionSignal Crash不同,OOM是操作系统(iOS/iPadOS)为了保护整体系统运行的稳定性,通过Jetsam机制主动杀死的进程。由于是系统内核级(Kernel)的强杀(SIGKILL)App进程在瞬间消亡,根本没有机会捕获堆栈、写入日志或上传崩溃报告。

本文将从操作系统内核原理出发,深入剖析OOM的成因,对比业界主流方案,并手把手带你构建一套包含OOM判定、内存堆栈回溯、大对象监控在内的完整治理体系。

02

iOS内存管理的底层逻辑

要治理OOM,必须先理解iOS是如何管理内存的。这不仅仅是allocdealloc的问题,而是涉及XNU内核、虚拟内存(VM)和物理内存(RAM)的博弈。

▲物理内存 vs 虚拟内存

iOS中,每个进程都拥有独立的虚拟地址空间。对于64位系统,这个空间理论上是巨大的(16EB左右),但受限于物理RAM的大小,我们真正能使用的资源极其有限。

iOS的内存管理基于Page,通常一页为16KB(A7芯片以后)。内存分为三类:

  1. Clean Memory(干净内存) :可以被Page Out的内存。例如加载到内存中的Framework指令代码、只读常量、mmap映射的文件。当内存紧张时,系统可以直接丢弃这些页,需要时再从磁盘读取。

  2. Dirty Memory(脏内存) :无法被Page Out的内存。例如malloc分配的堆内存、图像解码缓冲区、对象实例变量等,这些是OOM的元凶。

  3. Compressed Memory(压缩内存)iOS没有AndroidPC那样的Swap(交换分区)机制,因为它不想频繁擦写闪存。iOS 7引入了内存压缩技术,当内存紧张时,系统会将不活跃的Dirty Memory压缩。访问时再解压。

核心公式:

AppMemoryUsage=DirtyMemory+CompressedMemory

▲Jetsam - 看门狗机制

iOS系统中运行着一个名为com.apple.jetsam的守护进程(在内核中体现为Jetsam线程),它的职责是监控物理内存水位。

这里需要区分两种"看门狗":

CPU Watchdog :这是开发者熟悉的"卡死监控"。如果App主线程阻塞太久,系统会将其强制终结,并生成包含错误码0x8badf00d(Ate Bad Food)的崩溃日志。

Memory Watchdog (Jetsam) :这才是OOM的主角。它不关心App是否卡顿,只关心物理内存是否吃紧。Jetsam就是内存世界的看门狗。

当系统可用内存低于阈值时,Jetsam会触发。它会根据进程的优先级(Priority)列表,从低到高寻找要killAPP

• 后台挂起的进程通常优先级最低,最先被杀。

• 前台App优先级较高,但在内存极度紧张时(如开启相机、运行大型游戏),依然会被杀。

这种被Jetsam杀死的行为,被称为Jetsam Event。系统会生成.jetsam日志,存储在/private/var/logs/下。与CPU Watchdog不同,这种日志通常只有用户能在设置里看到,开发者很难直接获取,导致了OOM常常成为"无声的崩溃"。

#### ▲phys_footprint阈值

了解了Jetsam是如何kill App的,我们必须搞清楚它是依据什么标准来判定的。

很多开发者习惯通过读取task_basic_info中的resident_size来监控内存。这是一个巨大的误区。 resident_size(驻留内存)包含了Clean Memory,而Clean Memory在内存紧张时是可以被系统回收(Page Out)的,并不会直接导致OOM

****Jetsam在计算内存水位时,真正关心的指标是 phys_footprint

XNU内核源码osfmk/mach/task_info.h中,我们可以找到其定义。相比于误导性的 resident_sizephys_footprint才是内核用来衡量App对物理内存造成"不可回收压力"的真实数值:

go 复制代码
struct task_vm_info {
    mach_vm_size_t  virtual_size;       // 虚拟内存大小
    integer_t       region_count;       // 内存区域数量
    integer_t       page_size;
    mach_vm_size_t  resident_size;      // 驻留内存(包含共享库、Clean Memory等,参考价值低)
    // ... 
    mach_vm_size_t  phys_footprint;     // 【关键】物理内存占用,OOM的判决依据
    // ...
};

为什么phys_footprint才是OOM的红线?

它主要由以下几部分组成:

  1. Dirty Memory :所有malloc分配的堆内存、图像解码缓冲区。

  2. Compressed Memory :被压缩器处理过的脏页(虽然被压缩了,但依然占用物理RAM)。

  3. IOKit Mappings :GPU 纹理、显存映射等(游戏和相机类App的杀手)。

  4. Purgeable (Non-volatile):标记为可清除但尚未被标记为"可丢弃"的内存。

核心公式:

PhysFootprint ≈ Dirty + Compressed + IOKit

当系统的Jetsam机制被触发时,它会检查所有进程的phys_footprint。如果你的App在前台,虽然优先级高,但如果phys_footprint超过了设备单进程的物理内存上限(Memory Limit) ,系统为了保全内核和其他核心服务,Jetsam会触发EXC_RESOURCE异常并发送SIGKILL信号。

不同设备的"死亡阈值",这个阈值并非固定值,而是取决于设备的物理RAM大小:

iPhone 6s (2GB RAM):阈值约为 1.2GB - 1.3GB。

iPhone X (3GB RAM):阈值约为 1.6GB - 1.8GB。

iPhone 14 Pro (6GB RAM):阈值可达 3GB 以上。

注意:这只是"单进程上限"。如果用户开启了大量App导致系统整体内存(Global Memory Status)紧张,Jetsam会降低容忍度,甚至在你的App远未达到单进程上限时就将其终结。

因此,要治理OOM,我们的监控体系必须盯住phys_footprint这一指标,而非其他无关痛痒的数据。

03

技术调研与方案选型

既然系统不给崩溃日志,我们如何治理?目前业界主要有两类解决方案:

#### ▲判定方案(以Facebook为例)

核心思想 :利用排除法。App启动时检查上一次的退出原因,如果不是用户主动杀进程、不是系统升级、不是普通Crash(由PLCrashReporter捕获),且没有收到applicationWillTerminate(后台被杀通常不调用),那么就推断OOM

优点 :实现简单,几乎无性能损耗,能统计OOM(FOOM/BOOM)

缺点 :只能知道"发生了OOM",不知道"为什么发生",我们并不能解决OOM的问题。

#### ▲监控方案(以腾讯Matrix/OOMDetector为例)

核心思想 :在App运行期间,实时监控内存分配,记录大内存分配的堆栈。当检测到内存超标时,主动Dump堆栈信息到磁盘。下次启动上传。

优点:能精准定位代码行数,还原案发现场。

缺点 :性能损耗大(Hook malloc非常重),实现极其复杂,容易引入新的Crash

#### ▲建议:混合架构

为了兼顾性能与深度,应采用分级治理策略

  1. 全量用户 :采用"判定方案",统计OOM率,通过MetricKit获取宏观数据。

  2. 灰度/采样用户 :占比仅1%~5%,开启"监控方案",Hook内存分配器,记录堆栈,抓取大对象。并且可以动态调整采样率,如有严重性能问题则一键关闭。

04

核心技术实现------如何捕获"隐形"的内存

本章将深入代码层面,讲解如何实现一个高可用的OOM监控系统。

#### ▲准确获取内存数据

不要使用mach_task_basic_inforesident_size,这个并不准确,须使用task_vm_info

go 复制代码
#import <mach/mach.h>

+ (int64_t)getAppMemoryUsage {
    struct task_vm_info info;
    mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
    kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&info, &count);
    
    if (kr == KERN_SUCCESS) {
        return (int64_t)info.phys_footprint;
    }
    return 0;
}
#### ▲判定OOM的"排除法"状态机

我们需要维护一个持久化的状态标识。

核心流程:

  1. App启动 :写入标志位AppStatus = Started

  2. App运行中

• 捕获到普通Crash -> 写入AppStatus = Crashed

• 用户主动退出(通过UI操作等)-> 写入AppStatus = Terminated

• 检测到系统版本变化 -> 写入AppStatus = OSUpdate

  1. 下次启动检查

• 如果AppStatus == Started,说明上次是异常退出。

• 进一步检查是否存在Crash日志。若无Crash日志,则判定为OOM

注意:iOS 10之后,后台OOM极为常见,建议区分FOOM (Foreground OOM)BOOM (Background OOM)。通过监听UIApplicationDidEnterBackgroundNotification更新状态即可区分。

#### ▲内存分配监控(Allocation Tracker)

这是OOM治理中最重要的部分。我们要知道是哪个对象占用了内存,就必须Hook内存分配函数。

Hook方案选型:

方案A:Method Swizzling (OC层)

• 只能监控OC对象,无法监控C/C++内存(如图片解码缓冲、CoreFoundation对象)。

Pass

方案B:Fishhook (PLT Hook)

Hook malloccalloc等符号。

• 可以覆盖C分配,但无法覆盖系统库内部的分配(因为系统库内部调用不走PLT)。

Pass

方案C:malloc_logger (系统级Hook)

• 这是iOS系统提供的私有调试接口,libsystem_malloc.dylib中定义了一个全局函数指针 malloc_logger

• 只要覆盖这个指针,所有的内存分配/释放都会回调我们的函数。

• 腾讯Matrix和阿里某些APM工具均采用此方案

#### ▲malloc_logger实战

首先,定义对应的函数签名:

go 复制代码
typedef void (malloc_logger_t)(uint32_t type, uintptr_t arg1, uintptr_t arg2, uintptr_t arg3, uintptr_t result, uint32_t num_hot_frames_to_skip);

extern malloc_logger_t *malloc_logger;

接着,实现我们的Hook函数:

go 复制代码
// 原始的logger,用于链式调用(防止破坏其他工具或系统的监控)
static malloc_logger_t *orig_malloc_logger = NULL;

void my_malloc_logger(uint32_t type, uintptr_t arg1, uintptr_t arg2, uintptr_t arg3, uintptr_t result, uint32_t num_hot_frames_to_skip) {
    
    // type包含了动作类型:alloc, dealloc, realloc等
    // arg1, arg2, arg3 根据type不同代表不同含义(如size, zone等)
    // result 是分配的内存地址
    
    if (type & MALLOC_OP_ALLOC) {
        size_t size = (size_t)arg1;
        if (size > 10 * 1024 * 1024) { // 比如只关心 > 10MB 的大对象
             recordAllocationStack(size, result);
        }
    } else if (type & MALLOC_OP_FREE) {
        // 处理释放逻辑,移除记录
    }

    // 调用原来的logger
    if (orig_malloc_logger) {
        orig_malloc_logger(type, arg1, arg2, arg3, result, num_hot_frames_to_skip);
    }
}

void installMemoryHook() {
    orig_malloc_logger = malloc_logger;
    malloc_logger = my_malloc_logger;
}
▲高性能堆栈捕获

Hook了分配函数后,每秒可能会被调用成千上万次。如果在回调里调用[NSThread callStackSymbols]App会卡顿到无法使用。

优化策略:

  1. 阈值过滤 :只记录大于特定阈值(如50KB)的内存分配。

  2. Backtrace优化 :直接读取寄存器和栈帧指针(Frame Pointer),获取PC(Program Counter)地址列表,而不是获取符号化的字符串。

• 利用backtrace函数获取void*数组。

• 将void*数组暂存在内存的循环缓冲区(Ring Buffer)中。

• 不要在线符号化,等OOM发生后,下次启动上传PC地址列表,在服务端配合dSYM文件进行还原。

go 复制代码
// 堆栈获取示例
#include <execinfo.h>

#define MAX_STACK_DEPTH 20

void recordAllocationStack(size_t size, uintptr_t address) {
    void *stack[MAX_STACK_DEPTH];
    int count = backtrace(stack, MAX_STACK_DEPTH);
    
    // 将 stack 数组、size、address 写入高性能的内存映射文件 (mmap)
    // 必须使用无锁队列或高性能IO,避免死锁
}

05

技术架构设计

为了保证监控系统的稳定性,我们将架构设计为三层:

#### ▲数据采集层 (Collector Layer)

Memory Sentinel :定时器(如每2秒)轮询phys_footprint

Allocation Hook :基于malloc_logger的分配监控器。

MMap Recorder :利用mmap机制,将采集到的堆栈信息实时写入磁盘文件。

• 为什么用mmap?普通文件写IO有缓冲区,如果App突然被Jetsam杀掉,缓冲区数据来不及落盘。mmap直接映射到物理内存,由内核负责落盘,数据丢失率极低。

#### ▲策略控制层 (Policy Layer)

采样控制:云端下发配置,控制采样的设备比例(如百分之一)。

阈值控制:动态调整大对象阈值(50KB/1MB/10MB)。

熔断机制 :如果监控组件本身导致了Crash(如递归Hook),自动触发熔断,下次启动不再开启。

#### ▲分析展示层 (Analyzer Layer)

符号化服务 :服务端接收客户端上传的PC地址列表,结合dSYM生成可读堆栈。

聚类分析 :将相似堆栈的OOM归类,生成Top榜单。

报警系统 :当OOM率突破红线时,飞书/钉钉报警。

06

核心流程与难点

#### ▲内存快照的生成时机

我们不能把所有分配都记下来,那样磁盘会爆炸。我们需要记录的是大内存。

这就涉及到一个映射问题:malloc必须配对free

我们在mmap文件中维护一个哈希表:

• Key:内存地址

• Value:堆栈信息ID

malloc时,写入记录。

free时,从表中移除记录。

但在高并发下,维护这个哈希表极其耗费CPU

优化方案

只记录,不移除。利用**内存快照(Memory Dump)**策略。

当内存占用达到危险阈值(如总内存的90%)时,暂停所有线程(Suspend Threads),快速遍历堆内存,或者简单地将最近记录的N条大对象分配记录Dump到磁盘,认为是嫌疑对象。

#### ▲解决malloc_logger的死锁与递归

如果我们在my_malloc_logger中调用了NSLog,而NSLog内部又调用了malloc,就会无限递归导致栈溢出。

如果我们在my_malloc_logger中使用了锁,而持有锁的线程在malloc过程中被挂起,可能导致死锁。

解决方案:

  1. Thread Local Storage (TLS) :使用pthread_setspecific设置一个标志位,进入Hook函数前检查标志位,防止递归调用。

  2. No Allocation :在Hook函数内部,严禁调用任何可能触发内存分配的函数(包括Objective-C方法调用)。只做纯C的指针操作和内存拷贝。

07

OOM治理实战

有了监控工具,我们就能抓出导致OOM的真凶。以下是我们在实战中发现的典型OOM场景:

#### ▲图片是一切的万恶之源

很多时候,一张4000x3000的图片直接加载到内存中,即使显示区域只有100x100

原理 :图片解码后的内存 = 宽 * 高 * 4字节(RGBA)。一张1200万像素图占用约48MBDirty Memory

治理 :使用ImageIO的缩略图API(kCGImageSourceCreateThumbnailWithTransform)进行Downsampling(降采样),只解码显示所需的大小。
##### #### ▲内存碎片(Fragmentation)

有时候phys_footprint还没满,但依然OOM了。这可能是因为大量的碎片化小内存分配导致没有足够大的连续空间来存放新对象,导致虚拟内存地址耗尽(32位常见)或内核页表压力过大。

治理:优化数据结构,减少碎片化分配。

▲循环引用与各种Leak

这是老生常谈,但依然是内存问题的主力。

治理 :集成MLeaksFinder在开发阶段拦截。线上利用OOM监控抓取长时间驻留的VC堆栈。
##### #### ▲autoreleasepool 的滥用

在由于大循环中不断生成临时对象,导致内存峰值暴涨。

治理 :在循环内部显式添加@autoreleasepool

08

官方解决方案 - MetricKit

在很长一段时间里,iOS开发者都在用"排除法"猜测OOM。在iOS 14引入了MetricKit ,可以准确提供发生OOM以及OOM的类型。

▲准确的OOM数据

MetricKit通过MXMetricManager将操作系统内核收集的性能数据聚合后回调给App。与我们自研的监控相比,它有两个无法比拟的优势:

零性能损耗 :数据由系统在独立进程中收集,完全不占用App的主线程或内存资源。

绝对权威 :数据直接源自XNU内核统计,能够精准区分是OOM看门狗杀(Watchdog)还是正常退出,彻底解决了误判问题。

#### ▲解读 ExitMetrics

MXMetricPayload中,我们最关注的是applicationExitMetrics。它详细记录了App在后台和前台的各种退出原因:

go 复制代码
#import <MetricKit/MetricKit.h>

- (void)didReceiveMetricPayloads:(NSArray<MXMetricPayload *> *)payloads {
    for (MXMetricPayload *payload in payloads) {
        if (!payload.applicationExitMetrics) continue;
        
        MXMetric *exitMetrics = payload.applicationExitMetrics;
        
        // 前台 OOM 次数 (我们最关注的指标)
        NSUInteger foregroundOOM = exitMetrics.foregroundExitData.cumulativeMemoryResourceLimitExitCount;
        
        // 后台 OOM 次数
        NSUInteger backgroundOOM = exitMetrics.backgroundExitData.cumulativeMemoryResourceLimitExitCount;
        
        // 各种其他异常退出 (用于排除干扰)
        NSUInteger watchdogCount = exitMetrics.foregroundExitData.cumulativeAppWatchdogExitCount; // 卡死被杀
        NSUInteger badAccessCount = exitMetrics.foregroundExitData.cumulativeBadAccessExitCount; // 野指针Crash
        
        NSLog(@"[MetricKit] Yesterday: FOOM=%lu, BOOM=%lu", (unsigned long)foregroundOOM, (unsigned long)backgroundOOM);
        
        // TODO: 上报到数据平台
    }
}

注意:cumulativeMemoryResourceLimitExitCount这个指标,就是内核明确告诉你:"是因为内存超标(High Watermark)所以我杀了你"。这不仅包含了Dirty Memory超标,也包含了显存(Metal/OpenGL)超标的情况。

#### ▲诊断能力的进化 (iOS 15+)

除了统计数据,iOS 15后的MetricKit还引入了诊断负载 (Diagnostic Payloads) 。虽然OOM这种 SIGKILL通常来不及生成完整的堆栈快照(Crash log),但MetricKit提供了 MXDiskWriteExceptionDiagnostic(磁盘写入异常)和MXCPUExceptionDiagnostic(CPU高占用)。

这对于治理OOM同样有参考意义:很多OOM的前兆是CPU满载(死循环创建对象)或 磁盘疯狂写入(日志失控) 。通过关联分析,我们可以找到OOM的诱因。

#### ▲MetricKit 的局限与互补策略

既然有了MetricKit,我们还需要自研监控吗?必须需要。

MetricKit存在两个核心痛点:

  1. 滞后性:它通常每24小时才回调一次(当设备空闲且充电时)。这意味着你无法实时报警,只能做"T+1"的趋势分析。

  2. 缺乏上下文 :它告诉你昨天发生了10次OOM,但它无法告诉你这10次OOM发生时,用户在哪个页面、内存里存了什么对象。

最佳实践:双轨制治理

建议建立一套 "校准体系"

宏观校准 :以MetricKit的数据为基准(Ground Truth)

微观归因 :以自研的PLCrashReporter/Matrix方案为解决问题的分析依据

公式:

监控准确率 = 自研方案捕获的OOM数/MetricKit统计的OOM数

如果你的自研方案统计出昨天有100OOM,而MetricKit说是150次,说明你的监控漏报了33%(可能是启动阶段的Crash没抓到,或者后台挂起时的瞬间死亡);如果自研统计出200次,说明你的判定逻辑太激进,把用户手动杀进程误判为了OOM

通过MetricKit不断修正自研方案的判定阈值,最终实现既能实时报警 ,又能精准还原的完美监控。

09

总结与展望

OOM治理是一场持久战。从最初的两眼一抹黑,到利用phys_footprint建立监控,再到Hook malloc_logger还原堆栈,我们不断深入系统的黑盒内部。

核心沉淀:

  1. 数据 :相信phys_footprint,而非resident_size

  2. 监控:线上采样 + 离线符号化,平衡性能与深度。

  3. 架构:分级治理,结合 MetricKit 和自研 APM。

未来的OOM治理将更趋向于精细化预测性 。例如利用机器学习模型,根据用户当前的内存增长斜率,预测是否即将OOM,并主动释放缓存(如释放非必要VC),从而实现规避OOM问题。

参考文献:

  1. Apple Open Source: XNU Kernel (osfmk/kern/)

  2. WWDC 2018: iOS Memory Deep Dive

  3. Tencent Matrix Source Code

  4. Facebook: Reducing FOOMs in the Facebook iOS app


相关推荐
莫桐2 小时前
微信小程序-ios环境下webview打开的h5页面replace跳转方式不生效问题
ios·微信小程序·小程序
2501_915909062 小时前
在无需越狱的前提下如何对 iOS 设备进行文件管理与数据导出
android·macos·ios·小程序·uni-app·cocoa·iphone
普马萨特2 小时前
如何从安卓系统中获取扫描到的 Wi‑Fi 的 MAC 地址和 RSSI?
android·macos
@大迁世界3 小时前
Swift、Flutter 还是 React Native:2026 年你该学哪个
开发语言·flutter·react native·ios·swift
boss-dog3 小时前
Record3D 获取iphone RGBD 和 pose
ios·iphone·record3d
@大迁世界3 小时前
“围墙花园”的终结?iOS 26.3 带来的三大生态系统巨变
macos·ios·objective-c·cocoa
游戏开发爱好者83 小时前
iPhone 网络调试的过程,请求是否发出,是否经过系统代理,app 绕过代理获取数据
android·网络·ios·小程序·uni-app·iphone·webview
王者鳜錸3 小时前
短语音多语种混合识别-GUI界面开发
ide·macos·xcode
游戏开发爱好者84 小时前
在 Linux 环境通过命令行上传 IPA 到 App Store,iOS自动化构建与发布
android·linux·ios·小程序·uni-app·自动化·iphone