iOS内存介绍

概览

iOS 启动,系统将App装载进内存。然后将这块区域内部划分成如下部分。

图中与我们相关性较大的是栈区和堆区。栈区是为了实现方法的调用而存在的,内存管理由系统处理。堆区存放开发者创建的对象。通常我们说的内存管理,就是指的堆中的内存管理。

引用计数

iOS中采用引用计数的方式管理内存,每多一个强引用,引用计数就加一,销毁一个强引用,引用计数就减一。引用计数为0时,会触发dealloc方法,释放该对象。下面将要介绍的东西都是在引用技术基础上做的内存管理。

内存相关结构与操作

相关数据结构

引用计数涉及到的数据结构有isa指针,sidetables,sidetable,weaktable等。

目前iOS设备普遍是arm64系统,指针位数为64位,可以寻址2的64次方的内存,目前完全用不到。所以运行时库对isa指针进行了特殊的处理,下面是非指针类型的isa。只有shiftcls代表的33位是表示指针。

这里引用计数相关的有has_sidetable_rcextra_rcextra_rc是用来存储该对象被引用的次数的。但是因为位数有限,能表达的数量很有限,当不够表达时,has_sidetable_rc标记是否存储在sidetable中。

1
2
3
4
5
6
7
8
9
10
11
struct {
uintptr_t indexed : 1;
uintptr_t has_assoc : 1;
uintptr_t has_cxx_dtor : 1;
uintptr_t shiftcls : 33; // MACH_VM_MAX_ADDRESS 0x1000000000
uintptr_t magic : 6;
uintptr_t weakly_referenced : 1;
uintptr_t deallocating : 1;
uintptr_t has_sidetable_rc : 1;
uintptr_t extra_rc : 19;
};

sidetables由64个sidetable组成。通过hash算法定位。因为这些表是全局共享,会频繁的并发读写,如果只有一个表,多个线程同时操作时,要等很久。分表后可以大大减少多个线程同时操作一个表的情况,提高性能。

1
2
3
4
5
6
7
8
struct SideTable {
// 保证原子操作的自旋锁
spinlock_t slock;
// 引用计数的 hash 表
RefcountMap refcnts;
// weak 引用全局 hash 表
weak_table_t weak_table;
};

引用计数表以对象指针为key,以引用计数+两个标记位 为value。因为后两个标记位所以,当引用计数需要加减的时候,是从第三位开始。后面的两个标记位,一个是表示是否正在处于dealloc,一个表示是否有若引用,后面还会看到。

weak表以对象指针为key,以引用地址的数组为value,每增加一个weak引用,就添加到这个数组中。

retain,relase等相关的操作都是针对这些结构的添加修改删除。

操作

retainCount

retainCount比较简单,根据对象地址找到sidetable,然后继续在RefCountmap中找到计数并返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
uintptr_t
objc_object::sidetable_retainCount()
{
SideTable& table = SideTables()[this];

size_t refcnt_result = 1;

table.lock();
RefcountMap::iterator it = table.refcnts.find(this);
if (it != table.refcnts.end()) {
// this is valid for SIDE_TABLE_RC_PINNED too
refcnt_result += it->second >> SIDE_TABLE_RC_SHIFT;
}
table.unlock();
return refcnt_result;
}

retain
retain操作会对引用计数加1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
id
objc_object::sidetable_retain()
{
#if SUPPORT_NONPOINTER_ISA
assert(!isa.indexed);
#endif
SideTable& table = SideTables()[this];

if (table.trylock()) {
size_t& refcntStorage = table.refcnts[this];
if (! (refcntStorage & SIDE_TABLE_RC_PINNED)) {
refcntStorage += SIDE_TABLE_RC_ONE;
}
table.unlock();
return (id)this;
}
return sidetable_retain_slow(table);
}

release

自动引用减一,减到0,会调用SEL_dealloc,触发dealloc。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
uintptr_t 
objc_object::sidetable_release(bool performDealloc)
{
#if SUPPORT_NONPOINTER_ISA
assert(!isa.indexed);
#endif
SideTable& table = SideTables()[this];

bool do_dealloc = false;

if (table.trylock()) {
RefcountMap::iterator it = table.refcnts.find(this);
if (it == table.refcnts.end()) {
//找不到这个值,就销毁对象
do_dealloc = true;
//给这个值设置为,正在销毁标志
table.refcnts[this] = SIDE_TABLE_DEALLOCATING;
} else if (it->second < SIDE_TABLE_DEALLOCATING) {
// SIDE_TABLE_WEAKLY_REFERENCED may be set. Don't change it.
//引用计数已经减到0了,deallocating还没设置
//进行销毁,并且,将销毁标志位置1
do_dealloc = true;
it->second |= SIDE_TABLE_DEALLOCATING;
} else if (! (it->second & SIDE_TABLE_RC_PINNED)) {
//引用计数减一
it->second -= SIDE_TABLE_RC_ONE;
}
table.unlock();
if (do_dealloc && performDealloc) {
((void(*)(objc_object *, SEL))objc_msgSend)(this, SEL_dealloc);
}
return do_dealloc;
}

return sidetable_release_slow(table, performDealloc);
}

dealloc 注释有说明,如果没有额外处理,就直接free,不然先通过object_dispose处理若引用,关联属性等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
inline void
objc_object::rootDealloc()
{
assert(!UseGC);
if (isTaggedPointer()) return;

//指针型isa && 没被若引用&&没有关联属性&&没有c++创建&&没有用引用计数表
if (isa.indexed &&
!isa.weakly_referenced &&
!isa.has_assoc &&
!isa.has_cxx_dtor &&
!isa.has_sidetable_rc)
{
assert(!sidetable_present());
//直接释放
free(this);
}
else {
object_dispose((id)this);
}
}

dispose会调用destructInstance,这个方法如下,注释有说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void *objc_destructInstance(id obj) 
{
if (obj) {
// Read all of the flags at once for performance.
bool cxx = obj->hasCxxDtor();
bool assoc = !UseGC && obj->hasAssociatedObjects();
bool dealloc = !UseGC;

// This order is important.
//调用c++的销毁方法
if (cxx) object_cxxDestruct(obj);
//移除关联属性
if (assoc) _object_remove_assocations(obj);
//清理引用计数表和weak表
if (dealloc) obj->clearDeallocating();
}

return obj;
}

下面是clearDeallocating,主要是处理引用计数表和弱引用表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
inline void 
objc_object::clearDeallocating()
{
if (!isa.indexed) {
// Slow path for raw pointer isa.
//清理引用计数表
sidetable_clearDeallocating();
}
else if (isa.weakly_referenced || isa.has_sidetable_rc) {
// Slow path for non-pointer isa with weak refs and/or side table data.
clearDeallocating_slow();
}

assert(!sidetable_present());
}

autoreleasepool

大体思路

autoreleasepool通过AutoreleasePoolPage管理对象,每个线程有一个page,存储在TLS中。page内部以栈的方式组织,大小位4096字节,对应操作系统的内存页。每次添加对象就通过page的压栈存入,存满会创建一个新的page继续存储,page与page通过链表的方式存储。push操作会将一个哨兵(nil)压栈,并返回这个位置的地址。pop会查找这个地址,将栈顶到该位置的对象都release。多次的autoreleasepool,会有多个push,记录多个哨兵的位置,然后pop时pop到对应的位置。

autoreleasepool的实现

我们通常使用自动释放池就是使用@autoreleasepool{},这个block对应一个结构体

1
2
3
4
5
struct __AtAutoreleasePool {
__AtAutoreleasePool() {atautoreleasepoolobj = objc_autoreleasePoolPush();}
~__AtAutoreleasePool() {objc_autoreleasePoolPop(atautoreleasepoolobj);}
void * atautoreleasepoolobj;
};

这个结构题会在初始化的时候调用objc_autoreleasePoolPush,在析构时调用objc_autoreleasePoolPop。 我们在objc源码中找到这两个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
void *
objc_autoreleasePoolPush(void)
{
if (UseGC) return nil;
return AutoreleasePoolPage::push();
}

void
objc_autoreleasePoolPop(void *ctxt)
{
if (UseGC) return;
AutoreleasePoolPage::pop(ctxt);
}

这两个方法是AutoreleasePoolPage这个类来实现的。
直接从这两个方法看起

1
2
3
4
5
6
7
8
9
10
11
12
13
//添加哨兵POOL_SENTINEL(值为nil),处理page,返回哨兵对象的地址。
static inline void *push()
{
id *dest;
if (DebugPoolAllocation) {
// Each autorelease pool starts on a new pool page.
dest = autoreleaseNewPage(POOL_SENTINEL);
} else {
dest = autoreleaseFast(POOL_SENTINEL);
}
assert(*dest == POOL_SENTINEL);
return dest;
}

下面看下怎么处理的poolpage

1
2
3
4
5
6
7
8
9
10
11
static inline id *autoreleaseFast(id obj)
{
AutoreleasePoolPage *page = hotPage();
if (page && !page->full()) {
return page->add(obj);
} else if (page) {
return autoreleaseFullPage(obj, page);
} else {
return autoreleaseNoPage(obj);
}
}

拿到当前page,如果能拿到并且,page没有存满,就将obj存入
如果page是满的,就走autoreleaseFullPage
如果没拿到page,走autoreleaseNoPage方法

接着看下hotPage()是怎么处理的。

1
2
3
4
5
6
7
static inline AutoreleasePoolPage *hotPage() 
{
AutoreleasePoolPage *result = (AutoreleasePoolPage *)
tls_get_direct(key);
if (result) result->fastcheck();
return result;
}

这个page是放到线程的存储空间的,所以poolpage是线程相关的,一个线程,一个page链。

没有page时,第一次创建成功会将hotpage存起来,会存到线程中。

1
2
3
4
5
static inline void setHotPage(AutoreleasePoolPage *page) 
{
if (page) page->fastcheck();
tls_set_direct(key, (void *)page);
}

至此push就差不多了,总结下push都干了什么

  • 从线程的存储空间中拿到当前页(hotpage),没有的话,就创建一个放进去
  • 查看page有没有满,没满就将传入的哨兵存入。
  • page满了,向链中寻找最后一个节点,创建一个新的page,parent设置为这个节点,将这个节点设置为hotpage。

接下来看看pop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
static inline void pop(void *token) 
{
AutoreleasePoolPage *page;
id *stop;

page = pageForPointer(token);
stop = (id *)token;
if (DebugPoolAllocation && *stop != POOL_SENTINEL) {
// This check is not valid with DebugPoolAllocation off
// after an autorelease with a pool page but no pool in place.
_objc_fatal("invalid or prematurely-freed autorelease pool %p; ",
token);
}

if (PrintPoolHiwat) printHiwat();

page->releaseUntil(stop);

// memory: delete empty children
if (DebugPoolAllocation && page->empty()) {
// special case: delete everything during page-per-pool debugging
AutoreleasePoolPage *parent = page->parent;
page->kill();
setHotPage(parent);
} else if (DebugMissingPools && page->empty() && !page->parent) {
// special case: delete everything for pop(top)
// when debugging missing autorelease pools
page->kill();
setHotPage(nil);
}
else if (page->child) {
// hysteresis: keep one empty child if page is more than half full
if (page->lessThanHalfFull()) {
page->child->kill();
}
else if (page->child->child) {
page->child->child->kill();
}
}
}

pop操作和push是成对操作,push操作记录的位置,接下来会用来pop。

runtime对autorelease返回值的优化

问题1:为什么要做这个优化?

答:
当返回值被返回之后,紧接着就需要被 retain 的时候,没有必要进行 autorelease + retain,直接什么都不要做就好了。

问题2:如何做的优化?

基本思路:

在返回值身上调用objc_autoreleaseReturnValue方法时,runtime在TLS中做一个标记,然后直接返回这个object(不调用autorelease);同时,在外部接收这个返回值的objc_retainAutoreleasedReturnValue里,发现TLS中有标记,那么直接返回这个object(不调用retain)。
于是乎,调用方和被调方利用TLS做中转,很有默契的免去了对返回值的内存管理。

具体做法:
优化主要是通过两个方法进行实现objc_autoreleaseReturnValueobjc_retainAutoreleasedReturnValue

看第一个方法前,先看个枚举

1
2
3
enum ReturnDisposition : bool {
ReturnAtPlus0 = false, ReturnAtPlus1 = true
};

objc_autoreleaseReturnValue

方法的实现如下,通过注释进行了解释

1
2
3
4
5
6
7
8
// Prepare a value at +1 for return through a +0 autoreleasing convention.
id objc_autoreleaseReturnValue(id obj)
{
//可被优化的场景下,直接返回obj
if (prepareOptimizedReturn(ReturnAtPlus1)) return obj;
//否则还是使用autorelease
return objc_autorelease(obj);
}

对可优化场景的判断,在prepareOptimizedReturn方法中,参数我们根据上边的枚举已经得知
ReturnAtPlus1true,看下这个方法的实现,用注释做了说明。

1
2
3
4
5
6
7
8
9
10
11
12
static ALWAYS_INLINE bool 
prepareOptimizedReturn(ReturnDisposition disposition)
{
//如果调用方符合优化条件,就返回true,表示这次调用可以被优化
if (callerAcceptsOptimizedReturn(__builtin_return_address(0))) {
//设置dispostion,为后续的objc_retainAutoreleasedReturnValue准备
if (disposition) setReturnDisposition(disposition);
return true;
}
//不符合条件,不做优化
return false;
}

setReturnDisposition是在TLS中存入标记,后续的objc_retainAutoreleasedReturnValue会从TLS中读取来判断,之前是否已经做过了优化。这里比较复杂的方法是callerAcceptsOptimizedReturn判断调用方是否接受一个优化的结果。方法的实现比较难以理解,但是注释说明的比较清楚。

Callee looks for mov rax, rdi followed by a call or
jump instruction to objc_retainAutoreleasedReturnValue or
objc_unsafeClaimAutoreleasedReturnValue.

接收方为上述的两种情况时,调用方就符合优化条件。这个条件其实是判断,是否MRC和ARC混编,如果调用方和被调方一个使用MRC一个使用ARC,就不能做这个优化了。

objc_retainAutoreleasedReturnValue

这个方法相对简单,就是判断之前是否已经做了优化(通过TLS中的RETURN_DISPOSITION_KEY

1
2
3
4
5
6
7
8
// Accept a value returned through a +0 autoreleasing convention for use at +1.
id objc_retainAutoreleasedReturnValue(id obj)
{ //从TLS中获取RETURN_DISPOSITION_KEY对应的值,为true,就直接返回obj。
//读取完之后,重置为false
if (acceptOptimizedReturn() == ReturnAtPlus1) return obj;
//没有优化,就走正常的retain流程
return objc_retain(obj);
}

这两个方法成对使用,就可以省去将对象添加到autoreleasepool中的操作。

一个对象的内存布局

内存分配&访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct ListNode {
int val;
struct ListNode *next;
};

struct ListNode *node1 = malloc(sizeof(struct ListNode));
struct ListNode *node2 = malloc(sizeof(struct ListNode));
node1->next = node2;
node1->val = 4;

//长度
printf("length of ListNode *: %lu \n", sizeof(struct ListNode *));
printf("length of int: %lu \n", sizeof(int));
printf("length of ListNode: %lu \n", sizeof(struct ListNode));
//输出
length of ListNode *: 8
length of int: 4
length of ListNode: 16

变量的类型和顺序决定这个对象的内部空间分配。以ListNode为例,第一个区域是val,因为是int类型,占4个字节。第二个区域是next,因为是指针类型,占用8个字节。因为内存对齐的缘故,中间会有一些空白区域。后面对内存对齐进行介绍。

有了这个结构,变量的访问逻辑就比较简单了。访问val的地址与ListNode的实例是一样的,*next是ListNode的地址+8。

内存对齐

一个对象占用的内存,并不是所有变量相加出来的结果。中间会有一些空位补0,来对齐。目的是为了提高内存的访问效率以及平台移植。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct StructOne {
char a; //1字节
double b; //8字节
int c; //4字节
short d; //2字节
} MyStruct1;

struct StructTwo {
double b; //8字节
char a; //1字节
short d; //2字节
int c; //4字节
} MyStruct2;
NSLog(@"%lu---%lu--", sizeof(MyStruct1), sizeof(MyStruct2));
//24,16

两个结构体中的组成一样,内存占用却不一样。先看下原则

内存对齐原则:

  • 对于结构体的各个成员,第一个成员的偏移量是0,排列在后面的成员其当前偏移量必须是当前成员类型的整数倍
  • 结构体内所有数据成员各自内存对齐后,结构体本身还要进行一次内存对齐,保证整个结构体占用内存大小是结构体内最大数据成员的最小整数倍
  • 如程序中有#pragma pack(n)预编译指令,则所有成员对齐以n字节为准(即偏移量是n的整数倍),不再考虑当前类型以及最大结构体内类型

在不设置pragma pack的情况下,我们用前两条原则,对上面两个结构体进行分析。
StructOne

  • a字段从0起,占一个字节,offset1
  • b字段不能从2起,按照原则1,要从8开始,占8个字节,offset 16
  • c字段从16起,没有问题,占4个字节,offset 20
  • d字段从20起,没有问题,占2个字节,offset 22
  • 根据原则2,structOne的占位应该是double类型的整数倍,22最近的8的倍数,即24。

应用

Xcode 工具

zombie

为了debug野指针问题(EXC_BAD_ACCESS)。开启zombie模式后,当对象引用计数为0,出发dealloc时,会走特殊的zombie_dealloc。会正常做销毁工作,但是不会释放内存,而是将该isa指针指向一个自定义的zombie_xxx类。在此对这个对象发消息时,会到这个自定义的类。这个类会打印debug。

生成过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//1、获取到即将deallocted对象所属类(Class)
Class cls = object_getClass(self);

//2、获取类名
const char *clsName = class_getName(cls)

//3、生成僵尸对象类名
const char *zombieClsName = "_NSZombie_" + clsName;

//4、查看是否存在相同的僵尸对象类名,不存在则创建
Class zombieCls = objc_lookUpClass(zombieClsName);
if (!zombieCls) {
//5、获取僵尸对象类 _NSZombie_
Class baseZombieCls = objc_lookUpClass(“_NSZombie_");

//6、创建 zombieClsName 类
zombieCls = objc_duplicateClass(baseZombieCls, zombieClsName, 0);
}
//7、在对象内存未被释放的情况下销毁对象的成员变量及关联引用。
objc_destructInstance(self);

//8、修改对象的 isa 指针,令其指向特殊的僵尸类
objc_setClass(self, zombieCls);

触发过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//1、获取对象class
Class cls = object_getClass(self);

//2、获取对象类名
const char *clsName = class_getName(cls);

//3、检测是否带有前缀_NSZombie_
if (string_has_prefix(clsName, "_NSZombie_")) {
//4、获取被野指针对象类名
const char *originalClsName = substring_from(clsName, 10);

 //5、获取当前调用方法名
 const char *selectorName = sel_getName(_cmd);
  
 //6、输出日志
 Log(''*** - [%s %s]: message sent to deallocated instance %p", originalClsName, selectorName, self);

 //7、结束进程
 abort();

address sanitizer

address sanitizer 比 zombie 多了一些功能,除了检查已经被释放了的对象,还可以检查一些越界问题。

实现

开启 address sanitizer 系统的mallocfree操作会被替换成另一种实现。malloc除了会请求指定大小的内存空间,还会在周围申请一些额外空间,并标记为off-limitsfree会将整个区域标记为off-limits。然后添加到quarantine(隔离,检疫)队列。这个队列会使这块区域延后释放。之后内存的访问也会有些变化。

1
2
3
4
5
6
7
8
// 开启前
*address = ...; // or: ... = *address;

// 开启后
if (IsMarkedAsOffLimits(address)) {
ReportError(address);
}
*address = ...; // or: ... = *address;

开启设置后,对off-limits的访问,会报错。

性能影响

开启设置后,CPU性能会下降2到5倍,内存使用增加2到3倍。遇到问题后,开启进行复习,还是可以接受的。

局限

1、只能检查执行的代码,必须复现出来。

2、不能坚持内存泄漏,未初始化的内存,以及Int类型的溢出。

instruments

instruments关于内存的有 allocation 和 leaks。用于检查内存分配和泄漏,用法比较简单,遇到内存涨幅很大的情况,可以用instrument查看,是那些逻辑导致的,以及查看是否是因为内存泄漏导致的内存只增不减。

一些第三方库

因为Xcode提供的工具多是在遇到问题之后才会开启,没办法在开发中实时监测,所以一些开发者写了一些用于在开发期间检查内存问题的库,主要用来检查内存分配和内存泄漏。

MLeaksFinder

假定一个controller被pop出去,或者dismiss掉之后,不久就要被释放。hook navigationController的pop相关方法,以及ViewController的dismiss方法,对即将要释放的Controller开启一个定时器,两秒后触发,如果释放了,就结束了。如果没有释放,两秒后就会对这个控制器进行循环引用检查。

延伸一下,当Controller即将释放时,也会调用属性的类似逻辑,让属性触发自检。循环应用检查,是使用的下面要说的库,FBRetainCycle。

FBAllocationTracker

这个库主要是追踪分配的对象。有两种模式,1、只追踪分配和释放的数量。2、追踪分配的对象。

首先hook对象的分配,即allocWithZonedealloc方法。

第一种模式的实现很简单,在自己的alloc和dealloc中进行加减计数。

第二种模式会额外的将创建的对象加到字典中,dealloc之后移除。这个库在这里比其他类似的库多做了一点操作,就是分代存储。类似instrument中的mark操作,这个库也有一个mark操作,每次mark,就会创建一个新的字典,用于存储这之后的对象,这个字典会放到一个集合中,表示从开启到目前为止的所以分配对象。

FBRetainCycle

FBRetainCycleDetector 对可疑对象进行深度优先搜索,查找可能的循环引用,并将循环引用链打印出来。

思路比较简单

  • 获取可疑对象的所有strong属性
  • 遍历所有属性,对属性继续进行这两步
  • 直到没有属性,或者,达到最大深度
  • 当有属性是之前遍历过的,说明有循环引用,记录下来

MLeaksFinder找到的只是可疑对象,并不一定是真的泄漏对象。而FBRetainCycle则只能对特定对象进行扫描。两者搭配使用,效果很好。

补充:

NSNumber,NSDate 使用taggedPointer技术,将指针与值保存在一起。看似对象,其实是基础类型。

待了解:
iOS Memory Deep Dive

参考文档:

黑幕背后的Autorelease