在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
代替NSDictionary
,NSCache
分配的内存实际上是Purgeable Memory
,可以由系统自动释放。这点在Effective Objective 2.0一书中也有推荐NSCache
与NSPureableData
的结合使用既能让系统根据情况回收内存,也可以在内存清理的同时移除相关对象。
出现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_