前言
作为一名iOS开发者,内存的的只是储备是必不可少的,这篇文章会带领我们探索iOS的内存管理,继续往下走吧。
准备工作
- Objc-818.2
- Threading Programming Guide
1. 内存布局
内存五大分区: 栈区、堆区、全局区、常量区、代码区
1.1 内存五大区
-
栈区--stack
- 特点
- 栈是
系统数据结构
,其对应的进程或者线程是唯一
的 - 栈是
向低地址扩展
的数据结构 - 栈是一块
连续的内存区域
,遵循先进后出
(FILO
)原则 - 栈的地址空间在
iOS
中是以0X7
开头 - 栈区一般在
运行时分配
- 栈是
- 存储内容
- 栈区是由
编译器自动分配并释放
的,主要用来存储局部变量
- 函数的
参数
,例如函数的隐藏参数(id self,SEL _cmd
)
- 栈区是由
- 优缺点
-
优点
:因为栈是由编译器自动分配并释放的,不会产生内存碎片
,所以快速高效 -
缺点
:栈的内存大小有限制
,数据不灵活
-
iOS
主线程栈大小是1MB
,其他主线程是512KB
,MAC
只有8M
-
- 特点
注意:传入函数的参数值、函数体内声明的局部变量等,由编译器自动分配释放,通常在函数执行结束后就释放
了(不包括static
修饰的变量,static
意味该变量存放在全局/静态区
)。
在Threading Programming Guide中有相关内存大小的说明,如下:
-
堆区--heep
- 特点
- 堆是
向高地址扩展
的数据结构 - 堆是
不连续的内存区域
,类似于链表结构
(便于增删,不便于查询
), - 遵循
先进先出
(FIFO
)原则 - 堆的地址空间在
iOS
中是以0x6
开头,其空间的分配总是动态
的 - 堆区的分配一般是在
运行时分配
- 堆是
- 存储内容
- 堆区是由程序员
动态分配
和释放
的,如果程序员不释放,程序结束后,可能由操作系统回收 -
OC
中使用alloc
或者使用new
开辟空间创建对象 -
C
语言中使用malloc
、calloc
、realloc
分配的空间,需要free
释放
- 堆区是由程序员
- 优缺点
-
优点
:灵活方便,数据适应面广泛 -
缺点
:需手动管理,速度慢
、容易产生内存碎片
-
- 特点
注意:当需要访问堆中内存时,一般需要先通过对象读取到栈区的指针地址
,然后通过指针地址访问堆区
。因为现在iOS
基本都使用ARC
来管理对象,所以也不需要手动释放
。
-
全局区(静态区)(BSS段)
-
BSS段
(bss segment
)通常是指用来存放程序中未初始化
的或者初始值为0
的全局变量的一块内存区域
。BSS
是英文Block Started by Symbol
的简称。BSS段属于静态内存分配
。 -
数据段
:数据段(data segment
)通常是指用来存放程序中已初始化的全局变量
的一块内存区域,数据段属于静态内存分配
。 - 全局区是
编译时
分配的内存空间,在iOS
中一般以0x1
开头,在程序运行过程中,此内存中的数据一直存在,程序结束后由系统释放
,主要存放-
未初始化的全局变量和静态变量
,即BSS区
(.bss
) -
已初始化的全局变量和静态变量
,即数据区
(.data
)
-
- 由
static
修饰的变量会成为静态变量
,该变量的内存由全局/静态区
在编译阶段
完成分配,且仅分配一次
。 -
static
可以修饰局部变量
也可以修饰全局变量
。
-
-
常量区(数据段)
- 常量区是编译时分配的内存空间,在
iOS
中一般以0x1
开头,在程序结束后由系统释放
- 通常是指用来存放程序中已经初始化的全局变量和静态变量的一块内存区域。数据段属于静态内存分配,可以分为
只读数据段
和读写数据段
。字符串常量等,是放在只读数据段中,结束程序时才会被收回。
- 常量区是编译时分配的内存空间,在
-
代码区(代码段)
- 代码区是编译时分配主要用于存放程序
运行时的代码
,代码会被编译成二进制存进内存
- 代码区需要
防止在运行时被非法修改
,所以只准许读取操作
,而不允许写入(修改)
操作——它是不可写的
。
- 代码区是编译时分配主要用于存放程序
补充:除了以上内存区域外,系统还会保留一些内存区域
。
1.2 内存分区验证
1.2.1 栈区
验证代码如下:
- (void)testStack{
NSLog(@"************栈区************");
// 栈区
int a = 10;
int b = 20;
NSObject *object = [NSObject new];
NSLog(@"a == \t%p",&a);
NSLog(@"b == \t%p",&b);
NSLog(@"object == \t%p",&object);
NSLog(@"%lu",sizeof(&object));
NSLog(@"%lu",sizeof(a));
}
上面代码中,a、b、object
都是局部变量
,这些变量都存储在栈区
。运行结果如下:
1.2.2 堆区
验证代码如下:
- (void)testHeap{
NSLog(@"************堆区************");
// 堆区
NSObject *object1 = [NSObject new];
NSObject *object2 = [NSObject new];
NSObject *object3 = [NSObject new];
NSLog(@"object1 = %@",object1);
NSLog(@"object2 = %@",object2);
NSLog(@"object3 = %@",object3);
// 访问---通过对象->堆区地址->存在栈区的指针
}
代码创建了三个变量,这三个变量都存储在栈区
,这些变量存储的指针都指向堆区的对象
。运行结果如下:
1.2.3 全局区、常量区
验证代码如下:
int clA;
int clB = 10;
static int bssA;
static NSString *bssStr1;
static int bssB = 10;
static NSString *bssStr2 = @"hello";
static NSString *name = @"name";
- (void)viewDidLoad {
[super viewDidLoad];
NSLog(@"************栈区************");
int sa = 10;
NSLog(@"bssA == \t%p",&sa);
NSLog(@"************全局区************");
NSLog(@"clA == \t%p",&clA);
NSLog(@"bssA == \t%p",&bssA);
NSLog(@"bssStr1 == \t%p",&bssStr1);
NSLog(@"clB == \t%p",&clB);
NSLog(@"bssB == \t%p",&bssB);
NSLog(@"bssStr2 == \t%p",&bssStr2);
NSLog(@"bssStr2 == \t%p",&name);
}
通过打印全局区的变量的地址与栈区变量进行对比,运行结果如下:
2. TiggedPointer小对象
2.1 什么是小对象
iOS
开发中,一个对象至少要8
个字节,但是对于一些数据来说是有些浪费
的,比如NSNumber
、NSDate
、NSString
(小字符串
)。所以64
位环境下,引入了Tagged Pointer
技术,用一个小对象来存储这些数据。以字符串为例,见下图:
通过以上案例发现,
str1
和str4
的区别,str1
的类型是NSTaggedPointerString
,而str4
是__NSCFString
类型。同时通过控制台输出地址发现,其余堆区
的地址也有很大的区别,如下:
2.2 案例分析
通过以下的案例,我们继续分析他们之间的区别。
- 案例1
- 案例2
- 运行结果
分别运行上面两个案例,得出的结果分别是-
案例1
出现报错 -
案例2
能够正常运行
-
打开汇编,查看案例1
报错的信息,如下:
分析其报错的原因是
坏内存访问
,这是什么回事呢?
原因分析
set
方法实际就是新值的retain
,旧值的release
。由于nameStr
修饰为nonatomic
所以是线程不安全
的。当多条线程同时访问,造成多次release
,所以会出现坏内存访问
。解决方案
修饰改为atomic
或者加锁
。-
为什么
案例2
可以正常运行呢?
在案例1
中,设置断点,发现此时nameStr
数据类型为__NSCFString
,如下:
而在案例2
中,nameStr
数据类型为TiggedPointer
,如下:
正常对象都是
指针指向堆内存中的地址
,所以案例1
会因为多线程访问而造成坏内存访问
,而TaggedPointer存储在常量区
,不会创建内存
。在进行对象释放时,针对
TiggedPointer
类型进行了过滤处理
,也就说TiggedPointer
类型不会对引用计数进行处理
。-
release
方法源码
2.3 TiggedPointer原理分析
在之前类加载原理分析中的_read_images
方法中已经探索到了TiggedPointer
方面的内容,如下:
通过
initializeTaggedPointerObfuscator
方法,实现TaggedPointer
指针混淆器的初始化,实现源码如下:
也就是说,上面案例中,我们通过
%p
打印TaggedPointer
对象地址时得到的内容,是指针经过混淆器换算后得到的结果
。
接着根据objc_debug_taggedpointer_obfuscator
方法,针对TaggedPointer
对象的指针编码
和解码
算法:,如下:
通过上面的算法可以发现,
编码过程为
:
uintptr_t value = (objc_debug_taggedpointer_obfuscator ^ ptr);
解码过程为
:
value ^ objc_debug_taggedpointer_obfuscator;
找到了编码
和解码
算法,我们可以将小对象输出的地址进行解码,得到他原来的指针内容。见下面处理流程:
0xa000000000000621
就是解码得到的结果,这又有什么意义呢?请继续往下探索。
2.3.1 TaggedPointer
指针类型分析
在TaggedPointer
相关的源码中,找到了下面这个代码:
static inline bool
_objc_isTaggedPointer(const void * _Nullable ptr)
{
return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;
}
#if OBJC_SPLIT_TAGGED_POINTERS
# define _OBJC_TAG_MASK (1UL<<63)
判断一个对象是否为TaggedPointer
类型,通过对象指针&
上_OBJC_TAG_MASK
之后并等于_OBJC_TAG_MASK
自己;而这个mask
是高位为1
,其余都为0
的64
位数值。也就是说如果一个对象的高位地址是1
,则视为小对象
。
引入案例进行分析:
通过上面的案例的输出结构,基本可以确定,高位的
0xa
代表NSString
,0xb
代表NSNumber
,0xe
代表NSDate
。我们来还原一下:
0xa -> 1010
0xb -> 1011
-
0xe -> 1110
可以发现高位都是1,所以这些都是TaggedPointer
类型,也就是小对象。那么如果移除高位的1
,剩下的位就应该是代表tag
,即: -
0xa -> 1010 -> 010
表示NSString
-
0xb -> 1011 -> 011
表示NSNumber
-
0xe -> 1110 -> 110
表示NSDate
那么类型是不是这样子标记的呢?那么我们根据源码继续探索,如下:
在小对象类型进行标记时,传入了
objc_tag_index_t
类型的tag
,查看objc_tag_index_t
的定义,如下:
enum objc_tag_index_t : uint16_t
#else
typedef uint16_t objc_tag_index_t;
enum
#endif
{
// 60-bit payloads
OBJC_TAG_NSAtom = 0,
OBJC_TAG_1 = 1,
OBJC_TAG_NSString = 2,
OBJC_TAG_NSNumber = 3,
OBJC_TAG_NSIndexPath = 4,
OBJC_TAG_NSManagedObjectID = 5,
OBJC_TAG_NSDate = 6,
// 60-bit reserved
OBJC_TAG_RESERVED_7 = 7,
......
}
NSString = 2 -> 010
NSNumber = 3 -> 011
NSDate = 6 -> 110
结论:好明显就是跟我们猜想的完全一致!!!!小对象类型地址包含了类型!
2.3.2 TaggedPointer
值分析
在上面分析中只是找出了小对象的标记为以及小对象类型的tag
,那么值存储在哪里呢?请继续往下走哦!
同样我们引入一个案例:
在上面的案例中,我们可以发现指针的
末尾位表示小对象的长度
。那么数值存储在哪呢?WWDC
的相关说明中提到,如需要获取其内部的数值,需要查看二进制
,按位获取对应的数值
。分析过程如下:
通过上面可以看出,小对象的指针包含了
对象类型
,对象的值
,对象的长度
信息。
2.4 总结
通过解读源码和案例的分析,我们发现小对象在进行释放操作时会被过滤
,不会执行相关的释放流程
,其是存储在常量区
,并不会进行内存的申请和释放
,效率高
更高了。
3. 引用计数
我们知道内存管理方案分为MRC
和ARC
,但是不管是哪种方案,都是对引用计数的处理,这些方法涉及:alloc
、dealloc
、realease
、retain
、retainCount
、autorealease
等。
MRC
环境下,需要我们手动调用
这些方法,ARC
环境,系统会自动
帮我们调用。那么这些方法的实现原理是怎样的呢?请继续往下走。
(isa详解)[https://www.jianshu.com/p/6a295edebf69]文章中详细说明了nonpointer isa
,使用了结构体位域
,针对arm64
架构和x86
架构提供了不同的位域设置规则。其中包括了两个重要的字段:has_sidetable_rc
引用计数表和extra_rc
对象引用计数。
那么它们之间的关系是怎么样子的呢?alloc与retain方法肯定是绕不开的探索方向了,我们在前面的章节中,已经分析了alloc
的处理流程,完成isa
的创建,并初始化引用计数为1
。见下图:
retain
也会对对象的引用计数进行操作,下面从retain
方法开始分析。
3.1 retain方法
找到retain
方法的实现源码,如下:
其调用了
rootRetain
方法,查找rootRetain
的实现源码,如下:
处理好
rootRetain
方法内部的判断逻辑关系,发现do while
是这个方法的核心部分
,我们就重点分析这一部分的内容。
在方法的一开始就进行了判断,当前对象是否为TaggedPointer
类型,也就是小对象
,如果是小对象直接返回不处理
,所以小对象不进行引用计数
方面的处理,也不需要进行内存的开辟和释放
,由系统自动完成
。
在do...while
循环中进行isa
中引用计数相关的操作,在while
判断语句中,调用StoreExclusive
方法完成新老isa
的比对替换操作,成功后跳出循环。
在循环中,首先判断如果不是nonpointer isa
,则处理对象对应的散列表SideTable
的引用计数。见下面代码:
if (slowpath(!newisa.nonpointer)) {
ClearExclusive(&isa.bits);
if (tryRetain) return sidetable_tryRetain() ? (id)this : nil;
else return sidetable_retain(sideTableLocked);
}
id
objc_object::sidetable_retain(bool locked)
{
#if SUPPORT_NONPOINTER_ISA
ASSERT(!isa.nonpointer);
#endif
SideTable& table = SideTables()[this];
if (!locked) table.lock();
size_t& refcntStorage = table.refcnts[this];
if (! (refcntStorage & SIDE_TABLE_RC_PINNED)) {
refcntStorage += SIDE_TABLE_RC_ONE;
}
table.unlock();
return (id)this;
}
流程是:从系统维护的SideTables
中找到自己所在的散列表SideTable
,再找到自己引用计数表
的存储空间,对自己的引用计数进行加操作
。
散列表的相关内容,在分析弱引用的时候已经说明:(iOS weak实现原理和销毁过程)[https://www.jianshu.com/p/1b566137b3fe]
如果以上内容都不满足,则会进行isa
中extra_rc
属性的操作,也就是对引用计数加1
,不同框架下extra_rc
所在isa
的位置不同,所以RC_ONE
位域值也不同。见下面代码:
uintptr_t carry;
newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry); //extra_rc++
carry
用于判断extra_rc
字段是否已经存储满
了,如果已满
,则执行下面的这段代码:
if (slowpath(carry)) {
// newisa.extra_rc++ overflowed
if (variant != RRVariant::Full) {
ClearExclusive(&isa.bits);
return rootRetain_overflow(tryRetain);
}
// Leave half of the retain counts inline and
// prepare to copy the other half to the side table.
if (!tryRetain && !sideTableLocked) sidetable_lock();
sideTableLocked = true;
transcribeToSideTable = true;
newisa.extra_rc = RC_HALF;
newisa.has_sidetable_rc = true;
}
如果extra_rc
已满,会将extra_rc
所能存储的容量的一半
放到,对象对应的散列表
中。见下面这段代码:
if (variant == RRVariant::Full) {
if (slowpath(transcribeToSideTable)) {
// Copy the other half of the retain counts to the side table.
sidetable_addExtraRC_nolock(RC_HALF);
}
....
}
sidetable_addExtraRC_nolock
方法的源码实现如下:
bool
objc_object::sidetable_addExtraRC_nolock(size_t delta_rc)
{
ASSERT(isa.nonpointer);
SideTable& table = SideTables()[this];
size_t& refcntStorage = table.refcnts[this];
size_t oldRefcnt = refcntStorage;
// isa-side bits should not be set here
ASSERT((oldRefcnt & SIDE_TABLE_DEALLOCATING) == 0);
ASSERT((oldRefcnt & SIDE_TABLE_WEAKLY_REFERENCED) == 0);
if (oldRefcnt & SIDE_TABLE_RC_PINNED) return true;
uintptr_t carry;
size_t newRefcnt =
addc(oldRefcnt, delta_rc << SIDE_TABLE_RC_SHIFT, 0, &carry);
if (carry) {
refcntStorage =
SIDE_TABLE_RC_PINNED | (oldRefcnt & SIDE_TABLE_FLAG_MASK);
return true;
}
else {
refcntStorage = newRefcnt;
return false;
}
}
在进行散列表操作时进行了锁
的操作,这样会影响性能
,所以在extra_rc
满状态下,会将其满状态的一半
放到散列表中,避免频繁操作散列表
。同时extra_rc
满状态也不是频繁的出现slowpath(carry)
,所以满状态的一半已经有相当大的存储空间了!
3.2 release方法
release
的处理流程也就很容易理解了,对引用计数的反向操作
。找到release
的实现源码,如下:
释放时也会判断当前的对象是否为小对象
TaggedPointer
,如果是小对象就不需要对引用计数进行处理。如果不是小对象则调用release
方法。继续跟踪代码,最终会调用到rootRelease
方法,如下:
同样其依然会进行判断是否为
nopointerisa
、是否正在释放
,如果不是,则进行extra_rc减1操作
,见下面代码:
uintptr_t carry;
newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry); // extra_rc--
if (slowpath(carry)) {
// don't ClearExclusive()
goto underflow;
}
同时也会设置一个标记位carry
,用于判断extra_rc
是否已经被清空
。如果此时extra_rc
的引用计数值为0
,则会走到underflow
流程中。在underflow
中,首先判断该对象是否存在散列表,如果存在,则从散列表中移除一些引用计数到extra_rc
中,见下面代码:
其中的核心操作代码如下:
// Try to remove some retain counts from the side table.
auto borrow = sidetable_subExtraRC_nolock(RC_HALF);
bool emptySideTable = borrow.remaining == 0; // we'll clear the side table if no refcounts remain there
// 设置extra_rc,并对散列表进行设置,是否清空散列表
newisa.extra_rc = borrow.borrowed - 1; // redo the original decrement too
newisa.has_sidetable_rc = !emptySideTable;
在此过程中,会将散列表中的一部分引用计数赋值到extra_rc
中,同时,根据剩余引用数,来设置散列表是否需要清空。如果此时散列表被设置为emptySideTable
,空,则会调用sidetable_clearExtraRC_nolock
方法将该SideTable从SideTables
中抹除:
if (emptySideTable)
sidetable_clearExtraRC_nolock();
void
objc_object::sidetable_clearExtraRC_nolock()
{
ASSERT(isa.nonpointer);
SideTable& table = SideTables()[this];
RefcountMap::iterator it = table.refcnts.find(this);
table.refcnts.erase(it);
}
当extra_rc
数值为空,散列表
也被清除,则此时处于isDeallocating
状态,会进入deallocate
流程中,发送dealloc
消息,完成对象的释放。
if (slowpath(newisa.isDeallocating()))
goto deallocate;
deallocate:
// Really deallocate.
ASSERT(newisa.isDeallocating());
ASSERT(isa.isDeallocating());
if (slowpath(sideTableLocked)) sidetable_unlock();
__c11_atomic_thread_fence(__ATOMIC_ACQUIRE);
// 发送dealloc消息,完成对象的释放。
if (performDealloc) {
((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(dealloc));
}
return true;
}
3.3 dealloc
dealloc
最终会调用rootDealloc
方法,代码如下:
首先判断其是否为小对象,小对象不需要处理,因为系统会自动帮我们释放掉。同时通过对象的
isa
判断是否为nonpointer isa
,如果是继续判断其是否能有弱引用
、是否存在关联对象
、是否存在析构
、是否存在散列表
。如果不存在
上面的内容则会调用free
方法,将对象释放。如果存在
,则调用object_dispose
方法。见下面代码:
object_dispose
会调用objc_destructInstance
方法,其实现如下:
如果
存在关联对象
,通过_object_remove_assocations
方法对关联对象进行释放。此部分内容在分类的(iOS 类扩展&关联对象)[https://www.jianshu.com/p/4315c2440c56]中已经做了分析,通过调用clearDeallocating
方法,完成散列表
和弱引用表
的释放,此部分内容在(iOS weak实现原理和销毁过程)[https://www.jianshu.com/p/1b566137b3fe]中也做了说明,如下所示:
3.4 retainCount
获取引用计数最终调用的是rootretainCount
方法,源码实现见下图:
首先判断是否为否为小对象,小对象不做引用计数处理。如果是
nonpointer isa
,首先从isa
指针中获取extra_rc
数值,同时判断是否存在散列表
,如果存在,则再加上散列表中的数值。如果不是nonpointer isa
,直接获取对象对应SideTable
中的引用计数。
总结
这篇文章针对iOS内存管理先探索到这里了,内存管理还有很多的内容,我们继续往后探索。继续努力!