背景
oom(out-of-memory)顾名思义内存超出了操作系统允许的使用限制。
治理iOS oom,一方面减少内存泄漏;其次可以使用扩充内存技术。都是为了避免达到内存阈值,前者是优化内存,后者是提升内存阈值。
以xcode14本地操作为例,虚拟内存扩容增加Extended Virtual Addressing 配置,物理内存扩容增加Increased Memory Limit配置,xcode->TARGETS->Signing&Capabilities->+Capability->搜索关键字双击即可添加
添加完后,进行内存加压测试,在不同手机上原来必现crash的阈值都会有提升,系统侧是如何工作的,接下来我们分析一下。
物理内存OOM的直接原因是iOS的 Jetsam 机制造成的,Jetsam是一个独立运行的进程,对于前台进程来说都有一个内存阈值,一旦超过这个阈值,Jetsam将立即杀死该进程。接下来我们分析下默认怎么运行,扩容又是怎么影响的。
以较新xnu-7195.121.3版本XNU(iOS16的xnu尚未开源)来分析 kern_memorystatus 文件分析
/* The value -1 is an alias to JETSAM_PRIORITY_DEFAULT */
每个进程都有优先级,闲置(IDLE)进程<后台(BACKGROUND)进程<Apple App低优进程<前台(FOREGROUND)进程<Apple App高优进程,优先级越低内存告警时越容易被杀掉。
/*
* Checking the p_memstat_state almost always requires the proc_list_lock
* because the jetsam thread could be on the other core changing the state.
*
* App -- almost always managed by a system process. Always have dirty tracking OFF. Can include extensions too.
* System Processes -- not managed by anybody. Always have dirty tracking ON. Can include extensions (here) too.
*/
typedef struct memstat_bucket {
TAILQ_HEAD(, proc) list;
int count;
int relaunch_high_count;
} memstat_bucket_t;
extern memstat_bucket_t memstat_bucket[MEMSTAT_BUCKET_COUNT];
管理进程以优先级队列方式,memstat_bucket数组是管理进程的数组,每个优先级都对应一个memstat_bucket_t 结构体,list是当前优先级的进程链表,count是当前优先级进程的数目。且APP级别进程都受系统进程管理,而系统级别进程则不受任何管理。
触发原因有13种,后面介绍主要触发逻辑。
/* For logging clarity */
static const char *memorystatus_kill_cause_name[] = {
"", /* kMemorystatusInvalid */
"jettisoned", /* kMemorystatusKilled */
"highwater", /* kMemorystatusKilledHiwat */
"vnode-limit", /* kMemorystatusKilledVnodes */
"vm-pageshortage", /* kMemorystatusKilledVMPageShortage */
"proc-thrashing", /* kMemorystatusKilledProcThrashing */
"fc-thrashing", /* kMemorystatusKilledFCThrashing */
"per-process-limit", /* kMemorystatusKilledPerProcessLimit */
"disk-space-shortage", /* kMemorystatusKilledDiskSpaceShortage */
"idle-exit", /* kMemorystatusKilledIdleExit */
"zone-map-exhaustion", /* kMemorystatusKilledZoneMapExhaustion */
"vm-compressor-thrashing", /* kMemorystatusKilledVMCompressorThrashing */
"vm-compressor-space-shortage", /* kMemorystatusKilledVMCompressorSpaceShortage */
};
Jetsam专门有一个线程管理内存状态,默认挂起,当收到内存压力时会唤醒线程,并通过while循环来巡检杀死部分进程后是否还存在问题,当出现问题时就会获取当前上面提的memstat_bucket进程优先级数组,优先杀掉低优进程,如果还不好使就干掉当前前台进程。
static uint32_t kill_under_pressure_cause = 0;
unsigned int memorystatus_available_pages = (unsigned int)-1;
unsigned int memorystatus_available_pages_pressure = 0;
static boolean_t
memorystatus_action_needed(void)
{
return is_reason_thrashing(kill_under_pressure_cause) ||
is_reason_zone_map_exhaustion(kill_under_pressure_cause) ||
memorystatus_available_pages <= memorystatus_available_pages_pressure;
return is_reason_thrashing(kill_under_pressure_cause) ||
is_reason_zone_map_exhaustion(kill_under_pressure_cause);
}
/* Does cause indicate vm or fc thrashing? */
static boolean_t
is_reason_thrashing(unsigned cause)
{
switch (cause) {
case kMemorystatusKilledFCThrashing:
case kMemorystatusKilledVMCompressorThrashing:
case kMemorystatusKilledVMCompressorSpaceShortage:
return TRUE;
default:
return FALSE;
}
}
/* Is the zone map almost full? */
static boolean_t
is_reason_zone_map_exhaustion(unsigned cause)
{
if (cause == kMemorystatusKilledZoneMapExhaustion) {
return TRUE;
}
return FALSE;
}
kill_under_pressure_cause 作为整体内存压力问题原因标记。
2.is_reason_zone_map_exhaustionkMemorystatusKilledZoneMapExhaustion:内存区间zone分布达到上限时,必然会进入while循环内部。但这几种情况更多的是手机app进程多开导致,前台app进程测试很难复现。
if CONFIG_JETSAM
extern void memorystatus_pages_update(unsigned int pages_avail);
memorystatus_pages_update( \
vm_page_pageable_external_count + \
vm_page_free_count + \
VM_PAGE_SECLUDED_COUNT_OVER_TARGET() + \
(VM_DYNAMIC_PAGING_ENABLED() ? 0 : vm_page_purgeable_count) \
); \
} while(0)
extern uint64_t max_mem_actual, max_mem;
unsigned long pressure_threshold_percentage = 15;
static void
memorystatus_update_levels_locked(boolean_t critical_only)
{
memorystatus_available_pages_critical = memorystatus_available_pages_critical_base;
/*
* If there's an entry in the first bucket, we have idle processes.
*/
memstat_bucket_t *first_bucket = &memstat_bucket[JETSAM_PRIORITY_IDLE];
if (first_bucket->count) {
memorystatus_available_pages_critical += memorystatus_available_pages_critical_idle_offset;
if (memorystatus_available_pages_critical > memorystatus_available_pages_pressure) {
/*
* The critical threshold must never exceed the pressure threshold
*/
memorystatus_available_pages_critical = memorystatus_available_pages_pressure;
}
}
if (memorystatus_jetsam_policy & kPolicyMoreFree) {
memorystatus_available_pages_critical += memorystatus_policy_more_free_offset_pages;
}
if (critical_only) {
return;
}
memorystatus_available_pages_pressure = (int32_t)(pressure_threshold_percentage * (atop_64(max_mem) / 100));
}
max_mem为全局变量,并在arm_vm_init文件中的初始化,是全局物理内存字节数(The number of bytes of physical memory in the system)。
atop_64 函数是字节数转物理内存页数,>> 14 运算与除(1024*16 )一致。在A9处理器(iphone6s)之后为16物理内存的分页大小16KB,之前为4KB。
pressure_threshold_percentage默认值是15,但是在memorystatus_init中会从device tree(设备树)中获取key为kern.jetsam_pressure_threshold的设备硬件参数更新,怀疑不同设备有自定义值,但这块线下都被苹果禁用了,目前只能推算。
PE_get_default("kern.jetsam_pressure_threshold", &pressure_threshold_percentage, sizeof(pressure_threshold_percentage));
按照pressure_threshold_percentage按照15来算的话,当可用内存页小于系统整体15%时,则会进入while循环内部。
在杀死一批(最低优)或者一个进程后,系统会在while循环的判断条件中判断当前是否还处在内存压力环境下,如果内存得到改善,memorystatus_thread将会挂起,这个设计避免了过渡杀戮,毕竟热启率对于用户体验来说也至关重要。
上面介绍了memorystatus_thread触发方式,但是最常见的超出单一进程内存限制(kMemorystatusKilledPerProcessLimit)没有出现,搜索此类触发条件,可以确认只有进程任务类task.c中才有调用,且是同步触发,就是不经过线程优先级判断,直接杀死超出限值的进程。
extern void memorystatus_on_ledger_footprint_exceeded(int warning, boolean_t memlimit_is_active, boolean_t memlimit_is_fatal);
还有就是虚拟内存节点达到阈值(kMemorystatusKilledVnodes)同样是直接同步触发,在虚拟文件系统管理vfs_subr中,任何内存操作都是由虚拟内存开始,所以系统虚拟内存紧张时不能异步,只能同步。
我们app在前台运行,在可用内存紧张时经过了memorystatus_thread方式释放一些进程,依然内存不够用时,一种是高优进程占据了大量内存(属于系统bug),其次就是我们app也达到了很高的内存水位,此时就会被直接kill,可以把这个内存水位值看作是物理内存阈值。通过内存加压方式(误差10M内,同一RAM因系统版本不同也会有所不同,下面会介绍)可以得出主要机型OOM阈值
机型 | RAM(GB) | 物理内存阈值(MB) |
iPhone5-6p | 1 | 645 |
iPhone6s-7、8 | 2 | 1396 |
iPhone7p、8p、X | 3 | 1820 |
iPhoneXs、11、12 | 4 | 2090 |
iPhone12pro、13 | 6 | 3060 |
static inline void
proc_increased_memory_limit_entitled(proc_t p, task_t task)
{
static const char kIncreasedMemoryLimitEntitlement[] = "com.apple.developer.kernel.increased-memory-limit";
bool entitled = false;
entitled = IOTaskHasEntitlement(task, kIncreasedMemoryLimitEntitlement);
if (entitled) {
memorystatus_act_on_entitled_task_limit(p);
}
}
proc_increased_memory_limit_entitled方法字如其意,就是进程增加内存限值。源码中有三种扩容方式,流程如下:
posix_spawn是操作系统启动子进程函数,我们app进程启动时都会走这一步,进而检查进程环境配置是否有增加内存上限的配置,并最终走到task_set_phys_footprint_limit_internal改变进程内部阈值与告警level。
legacy_footprint:进程环境配置key是com.apple.private.memory.legacy_footprint,是apple私有配置,iOS11开始有这个策略,用于记录各app内存开销。
/*
* Flags to control the behavior of
* the legacy footprint entitlement.
*/
int legacy_footprint_bonus_mb = 50; /* This value was chosen after looking at the top 30 apps
* that needed the additional room in their footprint when
* the 'correct' accounting methods were applied to them.
*/
其次实验未必命中了
机型&系统版本 | RAM(GB) | 物理内存阈值(MB) |
iPhone6&11.2.1 | 1 | 645 |
iPhone6&12.4.7 | 1 | 647 |
void
memorystatus_act_on_ios13extended_footprint_entitlement(proc_t p)
{
if (max_mem < 1500ULL * 1024 * 1024 ||
max_mem > 2ULL * 1024 * 1024 * 1024) {
/* ios13extended_footprint is only for 2GB devices */
return;
}
/* limit to "almost 2GB" */
proc_list_lock();
memorystatus_raise_memlimit(p, 1800, 1800);
proc_list_unlock();
}
以iPhone8(2G RAM)为例,测试内存阈值(误差10M内),对比iOS12可以看出iOS13之后内存阈值确实有所增加。
机型&系统 | RAM(GB) | 物理内存阈值(MB) |
iPhone8&12.1.4 | 2 | 1496 |
iPhone8&14.2 | 2 | 1547 |
increased-memory-limit:进程环境配置key是com.apple.developer.kernel.increased-memory-limit,iOS15及以后大部分设备都生效(举个高端机例外:iPhoneX R就不行)。
void
memorystatus_act_on_entitled_task_limit(proc_t p)
{
if (memorystatus_entitled_max_task_footprint_mb == 0) {
// Entitlement is not supported on this device.
return;
}
proc_list_lock();
memorystatus_raise_memlimit(p, memorystatus_entitled_max_task_footprint_mb, memorystatus_entitled_max_task_footprint_mb);
proc_list_unlock();
}
memorystatus_entitled_max_task_footprint_mb是不像上面ios13extended_footprint方式是个定值,首先会通过PE_parse_boot_argn优先获取key为entitled_max_task_pmem的iOS系统启动参数,boot-args(启动参数)中查询不到的话,就会从device tree(设备树)中获取key为kern.entitled_max_task_pmem的设备硬件参数,因为可以判断,扩容的核心在于系统为进程设置的使用上限,而不是硬件设备参数决定。
if (!PE_parse_boot_argn("entitled_max_task_pmem", &memorystatus_entitled_max_task_footprint_mb,
sizeof(memorystatus_entitled_max_task_footprint_mb))) {
if (!PE_get_default("kern.entitled_max_task_pmem", &memorystatus_entitled_max_task_footprint_mb,
sizeof(memorystatus_entitled_max_task_footprint_mb))) {
// entitled_max_task_pmem is not supported on this system.
memorystatus_entitled_max_task_footprint_mb = 0;
}
}
// Warn tasks when they hit 80% of their memory limit.
unsigned int max_task_footprint_warning_level = 0; /* Per-task limit warningpercentage */
PHYS_FOOTPRINT_WARNING_LEVEL是固定值为80%作为基础告警水位,max_task_footprint_warning_level则在task_init进程任务初始化中动态设定:
/*
* Configure the per-task memory limit warning level.
* This is computed as a percentage.
*/
max_task_footprint_warning_level = 0;
if (max_mem < 0x40000000) {
/*
* On devices with < 1GB of memory:
* -- set warnings to 50MB below the per-task limit.
*/
if (max_task_footprint_mb > 50) {
max_task_footprint_warning_level = ((max_task_footprint_mb - 50) * 100) / max_task_footprint_mb;
}
} else {
/*
* On devices with >= 1GB of memory:
* -- set warnings to 100MB below the per-task limit.
*/
if (max_task_footprint_mb > 100) {
max_task_footprint_warning_level = ((max_task_footprint_mb - 100) * 100) / max_task_footprint_mb;
}
}
/*
* Never allow warning level to land below the default.
*/
if (max_task_footprint_warning_level < PHYS_FOOTPRINT_WARNING_LEVEL) {
max_task_footprint_warning_level = PHYS_FOOTPRINT_WARNING_LEVEL;
}
在小于内存1GB手机上,当在离阈值50M时触发,在大于1GB也就是绝大多数设备下,会在离阈值100M时触发,且最低为80%。
通过os_proc_available_memory 可以时时获取当前可用内存,当可用内存值尽可能大(手机不开其他app,或者通过加压方式让系统杀死低优进程)时同一设备(iPhone11&iOS16.1)测试,不然测试进程会在低内存水位发生告警,在使用扩容方案increased-memory-limit前后数据对比得出的数据与源码一致,系统降低了告警水位,这样可以更好的服务扩容服务。
iPhone11&iOS16.1 | 物理内存阈值 | 告警阈值 | 告警水位 |
内存扩容前阈值 | 2090 | 1990 | 95% |
内存扩容后阈值 | 2340 | 1870 | 80% |
随着iPhone硬件基础条件越来越好,系统层面不断优化,apple也会尽可能的让app使用更多的内存,以充分利用资源,提升稳定性与用户体验。同一设备在不同iOS系统版本上内存阈值不定,但扩容后内存阈值都有所增长,在6GB手机上甚者增加1G多。即便如此,app也应该尽可能的让app不要过渡使用内存,因为也可能对系统性能造成影响。
机型 | RAM(GB) | 物理内存阈值(MB) | 扩容后阈值(MB) | 同比 |
iPhoneX | 3 | 1820 | 2090 | 270(+14.8%) |
iPhone11 | 4 | 2090 | 2340 | 250(+12.0%) |
iPhone12pro | 6 | 3060 | 4095 | 1035(+33.8%) |
虚拟内存为什么会出现oom,按说iPhone早就进入了64位时代,指针字长更长,可使用虚拟内存更大(32位受限4G寻址空间,64位理论上可达16EB),但看如下crash统计,确实有out_of_memory问题,这是在没有大型活动时候的统计数据,如果有大型活动这个问题会更凸显。
for (size_t i = 0; i < SIZE_T_MAX; i++) {
void *a = malloc(128*1024);
if (a == NULL) {
NSLog(@"SMALL error count: %lu", i);
break;
}
}
由此可见,虚拟内存并不是无限制使用,在不加扩容前提下,iPhone11作为高端机也就能使用6G多,强行分配就只返回NULL了,在主进程强行使用NULL虚拟内存指针,以memset方式为例会报EXC_BAD_ACCESS (code=1, address=0x0)错误。
加上扩展虚拟内存配置后,就可以使用约54G的虚拟内存了。
扩容方案是如何工作的呢?搜索com.apple.developer.kernel.extended-virtual-addressing可以在内核进程处理文件kern_exec.c中找到切入点。
/*
* Processes with certain entitlements are granted a jumbo-size VM map.
*/
static inline void
proc_apply_jit_and_jumbo_va_policies(proc_t p, task_t task)
{
bool jit_entitled;
jit_entitled = (mac_proc_check_map_anon(p, 0, 0, 0, MAP_JIT, NULL) == 0);
if (jit_entitled || (IOTaskHasEntitlement(task,
"com.apple.developer.kernel.extended-virtual-addressing"))) {
vm_map_set_jumbo(get_task_map(task));
if (jit_entitled) {
vm_map_set_jit_entitled(get_task_map(task));
}
}
}
jit_entitled是mac进程相关,可以忽略不看,剩下iOS相关主要流程如下
posix_spawn上面有介绍是系统启动子进程,也就是启动我们的app进程时,进而检查进程环境配置是否有增加扩展虚拟内存地址的配置,如果包含这个配置,则会进入jumbo模式,pmap_max_64bit_offset则在系统boot-arg(启动参数)不特殊配置的情况下会得到最的最大地址空间64GB,核心实现如下
shared_region.h文件
pmap.c文件
#define ARM64_MIN_MAX_ADDRESS (SHARED_REGION_BASE_ARM64 + SHARED_REGION_SIZE_ARM64 + 0x20000000) // end of shared region + 512MB for various purposes
const vm_map_offset_t min_max_offset = ARM64_MIN_MAX_ADDRESS; // end of shared region + 512MB for various purposes
if (option == ARM_PMAP_MAX_OFFSET_DEFAULT) {
max_offset_ret = arm64_pmap_max_offset_default;
} else if (option == ARM_PMAP_MAX_OFFSET_MIN) {
max_offset_ret = min_max_offset;
} else if (option == ARM_PMAP_MAX_OFFSET_MAX) {
max_offset_ret = MACH_VM_MAX_ADDRESS;
} else if (option == ARM_PMAP_MAX_OFFSET_DEVICE) {
if (arm64_pmap_max_offset_default) {
max_offset_ret = arm64_pmap_max_offset_default;
} else if (max_mem > 0xC0000000) {
max_offset_ret = min_max_offset + 0x138000000; // Max offset is 13.375GB for devices with > 3GB of memory
} else if (max_mem > 0x40000000) {
max_offset_ret = min_max_offset + 0x38000000; // Max offset is 9.375GB for devices with > 1GB and <= 3GB of memory
} else {
max_offset_ret = min_max_offset;
}
} else if (option == ARM_PMAP_MAX_OFFSET_JUMBO) {
if (arm64_pmap_max_offset_default) {
// Allow the boot-arg to override jumbo size
max_offset_ret = arm64_pmap_max_offset_default;
} else {
max_offset_ret = MACH_VM_MAX_ADDRESS; // Max offset is 64GB for pmaps with special "jumbo" blessing
}
}
max_offset_ret是最大地址空间,不同设备在虚拟内存阈值上亦有区别,根据上述源码可以计算不同设备最大地址空间。当然不同xnu版本,在定义SHARED_REGION_BASE_ARM64等值上亦有区别,按iOS12 and Later数据来看,有国外大佬总结计算:
RAM(GB) | ||||
>3 | 15.375 | 7.375 | 64 | 56 |
>1 | 11.375 | 3.375 | 64 | 56 |
<=1 | 10.5 | 2.5 | 64 | 56 |
计算公式Address Space - 8 = Usable;在arm64位环境下,有8G地址空间不可以被进程使用。一方面Mach-O文件在装载进虚拟内存时,会预留__PAGEZERO段,固定大小是0x100000000,也就是4G空间;其次共享缓存SHARED_REGION_SIZE_ARM64本身会占据4G空间,剩下的才是进程可用,再算上ASLR、Header、Load commands、__TEXT、__DATA占据的虚拟内存,所以我们测试的虚拟内存阈值一定比Usable要小一些的。
在虚拟内存寻址空间增大后,上线后两个月,查看最新数据,虚拟内存OOM已经没有了。
虚拟寻址空间提升,有助于解决 Memory mapping huge files 和 Sparse memory allocators 类的内存问题。具体场景更多是 WebKit 和 JSC 的bmalloc。在官方文档也有举例游戏资源加载进GPU时,更大的地址空间可以提高运行效率。
支付宝在使用完内存扩容后,真实物理内存oom降低了不少,虚拟内存oom几乎降没了,效果还是很明显。
细想这么好的东西apple为什么不像ios13extended_footprint一样默认设置呢?猜测apple也是又爱又恨,优点很明确,缺点同样存在:如果开发者没有节制大量使用内存,挑战apple的底线,一方面会对系统性能造成影响,其次在多开后台进程环境下会降低其他应用热启率。为啥强调后台进程,因为并行的前台进程例如webContent进程,通过实验可以得出主进程无论在任何内存水位上都不影响前台webContent进程内存使用,wkcrash阈值不受影响,apple已经平衡好。
把手机比作江湖,把app比作剑客,内存扩容有点像辟邪剑谱(倒不至于自宫),别人练了若疯狂使用内存会更容易杀掉在后台的我们,所以我们也要练,不是为了杀别人,而是在前台可以更稳健的明哲保身。
总之,合理使用内存,减少内存泄漏,依然是客户端在内存优化上最正确的方向。
如对本文有任何建议或问题,请关注我们的微信公众号,我们将私信回复 ❤️