深入了解iOS中的OOM(低内存崩溃)

本文详细探讨了iOS中的OOM(Out-Of-Memory)问题,包括其机制、内存类型(Clean Memory、Dirty Memory、Compressed Memory)、内存警告以及如何确定OOM阈值。介绍了iOS不支持交换空间的原因,并解释了系统如何通过Memory Warning和内存压缩来管理内存。此外,还分析了如何通过源码探究OOM的发生,并讨论了如何判定和处理OOM情况。
摘要由CSDN通过智能技术生成

在iOS开发过程或者用户反馈中,可能会经常看到这样的情况,用着用着就崩溃了,而在后台查看崩溃栈的时候,找不到崩溃日志。其实这大多数的可能是系统产生了低内存崩溃,也就是OOM(还有一种可能是主线程卡死,导致watchdog杀掉了应用),而低内存崩溃的日志,往往都是以JetsamEvent开头的,日志中有内存页大小(pageSize),CPU时间(cpuTime)等字段。

什么是OOM

什么是OOM呢,它是out-of-memory的缩写,字面意思就是内存超过了限制。它是由于 iOS 的 Jetsam机制造成的一种“另类” Crash,它不同于常规的Crash,通过Signal捕获等Crash监控方案无法捕获到OOM事件。

当然还会有FOOM这样的词,代表的是Foreground-out-of-memory,是指App在前台因消耗内存过多引起系统强杀。这也就是本文要讨论的。后台出现OOM不一定都是app本身造成的,大多数是因为当前在前台的App占用内存过大,系统为了保证前台应用正常运行,把后台应用清理掉了。

什么是Jetsam机制

Jetsam机制可以理解为操作系统为了控制内存资源过度使用而采用的一种管理机制。Jetsam是一个独立运行的进程,每一个进程都有一个内存阈值,一旦超过这个阈值Jetsam就会立刻杀掉这个进程。

为什么要设计Jetsam机制

首先设备的内存是有限制的,并不是无限大的,所以内存资源非常重要。系统进程及用户使用的其他app的进程都会争抢这个资源。由于iOS不支持交换空间,一旦触发低内存事件,Jetsam就会尽可能多的释放应用占用的内存,这样在iOS系统上出现系统内存不足时,应用就会被系统终止。

交换空间

物理内存不够使用该怎么办呢?像一些桌面操作系统,会有内存交换空间,在window上称为虚拟内存。它的机制是,在需要时能将物理内存中的一部分交换到硬盘上去,利用硬盘空间扩展内存空间。

iOS不支持交换空间

但iOS并不支持交换空间,大多数移动设备都不支持交换空间。移动设备的大容量存储器通常是闪存,它的读写速度远远小于电脑所使用的硬盘,这就导致在移动设备上就算使用了交换空间,也并不能提升性能。其次,移动设备的容量本身就经常短缺、内存的读写寿命也有限,所以在这种情况下还拿闪存来做内存交换,就有点奢侈了。

需要注意的是,网上有少出文章说iOS没有虚拟内存机制,实际上指的是iOS没有交换空间机制。

典型app内存类型

当内存不足的时候,系统会按照一定策略来腾出更多空间供使用,比较常见的做法是将一部分低优先级的数据挪到磁盘上,这个操作称为Page Out。之后当再次访问到这块数据的时候,系统会负责将它重新搬回内存空间中,这个操作称为Page In

Clean Memory

Clean Memory是指那些可以用以Page Out的内存,只读的内存映射文件,或者是App所用到的frameworks。每个frameworks都有_DATA_CONST段,通常他们都是Clean的,但如果用runtime进行swizzling,那么他们就会变Dirty

Dirty Memory

Dirty Memory是指那些被App写入过数据的内存,包括所有堆区的对象、图像解码缓冲区,同时,类似Clean memory,也包括App所用到的frameworks。每个framework都会有_DATA段和_DATA_DIRTY段,它们的内存是Dirty的。

值得注意的是,在使用framework的过程中会产生Dirty Memory,使用单例或者全局初始化方法是减少Dirty Memory不错的方法,因为单例一旦创建就不会销毁,全局初始化方法会在类加载时执行。

Compressed Memory

由于闪存容量和读写寿命的限制,iOS 上没有交换空间机制,取而代之使用Compressed memory。

Compressed memory是在内存紧张时能够将最近使用过的内存占用压缩至原有大小的一半以下,并且能够在需要时解压复用。它在节省内存的同时提高了系统的响应速度,特点总结起来如下:

  • Shrinks memory usage 减少了不活跃内存占用
  • Improves power efficiency 改善电源效率,通过压缩减少磁盘IO带来的损耗
  • Minimizes CPU usage 压缩/解压十分迅速,能够尽可能减少 CPU 的时间开销
  • Is multicore aware 支持多核操作

例如,当我们使用Dictionary去缓存数据的时候,假设现在已经使用了3页内存,当不访问的时候可能会被压缩为1页,再次使用到时候又会解压成3页。

本质上,Compressed memory也是Dirty memory
因此, memory footprint = dirty size + compressed size ,这也就是我们需要并且能够尝试去减少的内存占用。

Memory Warning

相信对于MemoryWarning并不陌生,每一个UIViewController都会有一个didReceivedMemoryWarning的方法。

当使用的内存是一点点上涨时,而不是一下子直接把内存撑爆。在达到内存临界点之前,系统会给各个正在运行的应用发出内存警告,告知app去清理自己的内存。而内存警告,并不总是由于自身app导致的。

内存压缩技术使得释放内存变得复杂。内存压缩技术在操作系统层面实现,对进程无感知。有趣的是如果当前进程收到了内存警告,进程这时候准备释放大量的误用内存,如果访问到过多的压缩内存,再解压缩内存的时候反而会导致内存压力更大,然后出现OOM,被系统杀掉。

我们对数据进行缓存的目的是想减少 CPU 的压力,但是过多的缓存又会占用过大的内存。在一些需要缓存数据的场景下,可以考虑使用NSCache代替NSDictionaryNSCache分配的内存实际上是Purgeable Memory,可以由系统自动释放。这点在Effective Objective 2.0一书中也有推荐NSCacheNSPureableData的结合使用既能让系统根据情况回收内存,也可以在内存清理的同时移除相关对象。

出现OOM前一定会出现Memory Warning么? 答案是不一定,有可能瞬间申请了大量内存,而恰好此时主线程在忙于其他事情,导致可能没有经历过Memory Warning就发生了OOM。当然即便出现了多次Memory Warning后,也不见得会在最后一次Memory Warning的几秒钟后出现OOM。之前做extension开发的时候,就经常会出现Memory Warnning,但是不会出现OOM,再操作一两分钟后,才出现OOM,而在这一两分钟内,没有再出现过Memory Warning。

当然在内存警告时,处理内存,可以在一定程度上避免出现OOM。

如何确定OOM的阈值

有经验的同学,肯定知道不同设备OOM的阈值是不同的。那我们该如何知道OOM的阈值呢?

方法1

当我们的App被Jetsam机制杀死的时候,在手机中会生成系统日志,在手机系统设置-隐私-分析中,可以得到JetSamEvent开头的日志。这些日志中就可以获取到一些关于App的内存信息,例如我当前用的iPhone8(iOS11.4.1),在日志中的前部分看到了pageSize,而查找per-process-limit一项(并不是所有日志都有,可以找有的),用该项的rpages * pageSize即可得到OOM的阈值。

{"bug_type":"298","timestamp":"2020-01-03 04:11:13.65 +0800","os_version":"iPhone OS 11.4.1 (15G77)","incident_id":"2723B2EA-7FB8-49A6-B2FC-49F10C748D8A"}
{
  "crashReporterKey" : "a6ad027ba01b1e66d0b3d8446aaef5dbd75dd732",
  "kernel" : "Darwin Kernel Version 17.7.0: Mon Jun 11 19:06:27 PDT 2018; root:xnu-4570.70.24~3\/RELEASE_ARM64_T8015",
  "product" : "iPhone10,1",
  "incident" : "2723B2EA-7FB8-49A6-B2FC-49F10C748D8A",
  "date" : "2020-01-03 04:11:13.65 +0800",
  "build" : "iPhone OS 11.4.1 (15G77)",
  "timeDelta" : 4,
  "memoryStatus" : {
  "compressorSize" : 39010,
  "compressions" : 2282594,
  "decompressions" : 1071238,
  "zoneMapCap" : 402653184,
  "largestZone" : "APFS_4K_OBJS",
  "largestZoneSize" : 35962880,
  "pageSize" : 16384,
  "uncompressed" : 105360,
  "zoneMapSize" : 118865920,
  "memoryPages" : {
    "active" : 39800,
    "throttled" : 0,
    "fileBacked" : 28778,
    "wired" : 19947,
    "anonymous" : 32084,
    "purgeable" : 543,
    "inactive" : 19877,
    "free" : 2935,
    "speculative" : 1185
  }
},
...
  {
    "uuid" : "a2f9f2db-a110-3896-a0ec-d82c156055ed",
    "states" : [
      "frontmost",
      "resume"
    ],
    "killDelta" : 11351,
    "genCount" : 0,
    "age" : 361742447,
    "purgeable" : 0,
    "fds" : 50,
    "coalition" : 2694,
    "rpages" : 89600,
    "reason" : "per-process-limit",
    "pid" : 2541,
    "cpuTime" : 1.65848,
    "name" : "MemoryTest",
    "lifetimeMax" : 24126
  },
...

那么当前这个MemoryTest的内存阈值就是16384 * 89600 / 1024 / 1024 = 1400MB

方法2

通过Xcode进行DEBUG时,当使用的内存超出限制的时候,系统会抛出 EXC_RESOURCE_EXCEPTION 异常。

//iPhone 8,Custom Keyboard Extension
Thread 1: EXC_RESOURCE RESOURCE_TYPE_MEMORY (limit=53 MB, unused=0x0)
方法3

首先,我们可以通过方法得到当前应用程序占用的内存。代码如下

- (int)usedSizeOfMemory {
    task_vm_info_data_t taskInfo;
    mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT;
    kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount);

    if (kernReturn != KERN_SUCCESS) {
        return 0;
    }
    return (int)(taskInfo.phys_footprint / 1024 / 1024);
}

也有其他一些代码使用过的是taskInfo.resident_size,但该值并不准确。我对比Xcode Debug,发现taskInfo.phys_footprint值基本上与Xcode Debug的值一致。而在XNU的task.c中,也找到了该值是如何计算的。

/*
 * phys_footprint
 *   Physical footprint: This is the sum of:
 *     + (internal - alternate_accounting)
 *     + (internal_compressed - alternate_accounting_compressed)
 *     + iokit_mapped
 *     + purgeable_nonvolatile
 *     + purgeable_nonvolatile_compressed
 *     + page_table
 */
 本地测试了一下:
 iOS11上,phys_footprint值与Xcode DEBUG的值相差不到1M,
 而在iOS13上,phys_footprint值与Xcode DEBUG值完全一致。
 有强迫症的同学可以在iOS11上使用
 ((taskInfo.internal + taskInfo.compressed - taskInfo.purgeable_volatile_pmap))来代替phys_footprint。

那么我们可以得到这个值之后,就可以开一个线程,循环申请1MB的内存,直至到达第一次内存警告,以及OOM。

#import "ViewController.h"
#import <mach/mach.h>

#define kOneMB  1048576

@interface ViewController ()
{
    NSTimer *timer;

    int allocatedMB;
    Byte *p[10000];
    
    int physicalMemorySizeMB;
    int memoryWarningSizeMB;
    int memoryLimitSizeMB;
    BOOL firstMemoryWarningReceived;
}

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    physicalMemorySizeMB = (int)([[NSProcessInfo processInfo] physicalMemory] / kOneMB);
    firstMemoryWarningReceived = YES;
}

- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];
        
    if (firstMemoryWarningReceived == NO) {
        return ;
    }
    memoryWarningSizeMB = [self usedSizeOfMemory];
    firstMemoryWarningReceived = NO;
}

- (IBAction)startTest:(UIButton *)button {
    [timer invalidate];
    timer = [NSTimer scheduledTimerWithTimeInterval:0.01 target:self selector:@selector(allocateMemory) userInfo:nil repeats:YES];
}

- (void)allocateMemory {
    
    p[allocatedMB] = malloc(1048576);
    memset(p[allocatedMB], 0, 1048576);
    allocatedMB += 1;
    
    memoryLimitSizeMB = [self usedSizeOfMemory];
    if (memoryWarningSizeMB && memoryLimitSizeMB) {
        NSLog(@"----- memory warnning:%dMB, memory limit:%dMB", memoryWarningSizeMB, memoryLimitSizeMB);
    }
}

- (int)usedSizeOfMemory {
    task_vm_info_data_t taskInfo;
    mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT;
    kern_return_
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值