iOS底层学习——内存管理(内存五大区、TiggedPointer、引用计数)
1.内存布局
内存五大分区: 栈区、堆区、全局区、常量区、代码区
1.内存五大区
-
栈区(
stack
)-
特点
- 栈是系统数据结构,其对应的进程或者线程是唯一的
- 栈是向低地址扩展的数据结构
- 栈是一块连续的内存区域,遵循先进后出(
FILO
)原则 - 栈的地址空间在
iOS
中是以0X7
开头 - 栈区一般在运行时分配
-
存储内容
- 栈区是由编译器自动分配并释放的,主要用来存储局部变量
- 函数的参数,例如函数的隐藏参数(
id self
,SEL _cmd
)
-
优缺点
- 优点:因为栈是由编译器自动分配并释放的,不会产生内存碎片,所以快速高效
- 缺点:栈的内存大小有限制,数据不灵活
iOS
主线程栈大小是1MB
,其他主线程是512KB
,MAC
只有8M
传入函数的参数值、函数体内声明的局部变量等,由编译器自动分配释放,通常在函数执行结束后就释放了。(注意:不包括
static
修饰的变量,static
意味该变量存放在全局/静态区)在Threading Programming Guide中有,内存大小的相关说明,见下图:
-
-
堆区(
heap
)-
特点
- 堆是向高地址扩展的数据结构
- 堆是不连续的内存区域,类似于链表结构(便于增删,不便于查询),遵循先进先出(
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
开头,在程序结束后由系统释放 - 通常是指用来存放程序中已经初始化的全局变量和静态变量的一块内存区域。数据段属于静态内存分配,可以分为只读数据段和读写数据段。字符串常量等,是放在只读数据段中,结束程序时才会被收回。
- 常量区是编译时分配的内存空间,在
-
代码区(
代码段
)- 代码区是编译时分配主要用于存放程序运行时的代码,代码会被编译成二进制存进内存的
- 代码区需要防止在运行时被非法修改,所以只准许读取操作,而不允许写入(修改)操作——它是不可写的。
- 补充 除了以上内存区域外,系统还会保留一些内存区域。
2.内存分区验证
下面通过代码来区分不同的内存区域。
-
栈区
验证代码如下:
- (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
都是局部变量,这些变量都存储在栈区。运行结果: -
堆区
验证代码如下:
- (void)testHeap{ NSLog(@"************堆区************"); // 堆区 NSObject *object1 = [NSObject new]; NSObject *object2 = [NSObject new]; NSObject *object3 = [NSObject new]; NSLog(@"object1 = %@",object1); NSLog(@"object2 = %@",object2); NSLog(@"object3 = %@",object3); // 访问---通过对象->堆区地址->存在栈区的指针 }
上面代码创建了三个变量,这三个变量都存储在栈区,这些变量存储的指针都指向堆区的对象。运行结构见下图:
-
全局区、常量区
案例代码如下:
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小对象
1.何为小对象
我们知道一个对象至少要8
个字节,但是对于一些数据来说是有些浪费的,比如NSNumber
、NSDate
、NSString
(小字符串)。所以64
位环境下,引入了Tagged Pointer
技术,用一个小对象
来存储这些数据。以字符串为例,见下图:
通过上面的案例发现,str1
和str4
的区别,str1
的类型是NSTaggedPointerString
,而str4
是__NSCFString
类型。同时通过控制台输出地址发现,其余堆区的地址也有很大的区别:
2.案例分析
我们通过案例继续分析其区别。
-
案例1
-
案例2
-
运行结果
分别运行上面两个案例,会有怎么样的结果呢?
案例1
会报错案例2
正常运行
调试打开查看汇编,
案例1
运行报错信息,见下图:分析运行报错日志,
坏内存访问
,为什么呢? -
原因分析
set
方法实际就是新值的retain
,旧值的release
。由于nameStr
修饰为nonatomic
所以是线程不安全的。当多条线程同时访问,造成多次release
,所以会出现坏内存访问
。 -
如何解决呢?
修饰改为
atomic
或者加锁
。 -
为什么
案例2
可以正常运行呢?在
案例1
中,设置断点,发现此时nameStr
数据类型为__NSCFString
,见下图:而在
案例2
中,nameStr
数据类型为TiggedPointer
,见下图:正常对象都是指针指向堆内存中的地址,所以
案例1
会因为多线程访问而造成坏内存访问,而TaggedPointer
存储在常量区,不会创建内存。在进行对象释放时,针对TiggedPointer
类型进行了过滤处理,也就说TiggedPointer
类型不会对引用计数进行处理。见下面源码:
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**
就是解码后得到的结果。那么这个地址代表什么意义呢?这是我们需要探索的!!!
-
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
的定义:#if __has_feature(objc_fixed_enum) || __cplusplus >= 201103L 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
和我们的猜想完全一致!小对象类型地址包含了类型。那么值存储在哪里呢?
-
TaggedPointer
值分析同样我们引入一个案例:
在上面的案例中,我们可以发现指针的末尾位表示小对象的长度。那么数值存储在哪呢?
WWDC
的相关说明中提到,如需要获取其内部的数值,需要查看二进制,按位获取对应的数值。分析过程见下图:通过上面可以看出,小对象的指针包含了对象类型,对象的值,对象的长度信息。
-
总结
通过解读源码和案例的分析,我们发下小对象在进行释放操作时会被过滤,不会执行相关的释放流程,其是存储在常量区,并不会进行内存的申请和释放,效率高了很多!
3.引用计数
我们知道内存管理方案分为MRC
和ARC
,但是不管是哪种方案,都是对引用计数的处理,这些方法涉及:alloc
、dealloc
、realease
、retain
、retainCount
、autorealease
等。MRC
环境下,需要我们手动调用这些方法,ARC
环境,系统会自动帮我们调用。那么这些方法的实现原理是怎样的呢?我们逐步分析!
我们首先回顾一下,nonpointer isa
,使用了结构体位域,针对arm64架构
和x86架构
提供了不同的位域设置规则
。其中包括了两个重要的字段:has_sidetable_rc引用计数表
和extra_rc对象引用计数
。
如何去分析他们之间的关系呢,alloc
和retain
方法!我们在前面的章节中,已经分析了alloc
的处理流程,完成isa
的创建,并初始化引用计数为1
。见下图:
retain
也会对对象的引用计数进行操作,下面从retain
方法开始分析。
1.retain方法
找到retain
方法的实现源码:
其调用了rootRetain
方法,查找rootRetain
的实现源码:
通过初步解读,发现红色框区域即为核心代码,下面深入分析该部分的内容。
在方法的一开始就进行了判断,当前对象是否为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
,再找到自己引用计数表的存储空间,对自己的引用计数进行加操作。
如果正在释放isDeallocating
,也就是此时isa
的extra_rc
和has_sidetable_rc
都为0
,则不需要对引用计数进行处理,源码如下:
bool isDeallocating() {
return extra_rc == 0 && has_sidetable_rc == 0;
}
如果以上内容都不满足,则会进行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 (slowpath(transcribeToSideTable)) {
// Copy the other half of the retain counts to the side table.
sidetable_addExtraRC_nolock(RC_HALF);
}
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)
,所以满状态的一半已经有相当大的存储空间了!
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 (performDealloc) {
((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(dealloc));
}
3.dealloc
dealloc
最终会调用rootDealloc
方法,见下面代码:
当对象释放时,也就不难理解,首先判断其是否为小对象,小对象不需要处理,因为系统会自动帮我们释放掉。同时通过对象的isa
判断是否为nonpointer isa
,如果是继续判断其是否能有弱引用、是否存在关联对象、是否存在析构、是否存在散列表。如果不存在上面的内容则会调用free
方法,将对象释放。如果存在,则调用object_dispose
方法。见下面代码:
4.retainCount
获取引用计数最终调用的是rootretainCount
方法,源码实现见下图:
首先判断是否为否为小对象,小对象不做引用计数处理。如果是nonpointer isa
,首先从isa
指针中获取extra_rc
数值,同时判断是否存在散列表,如果存在,则再加上散列表中的数值。如果不是nonpointer isa
,直接获取对象对应SideTable
中的引用计数。
转载自:https://juejin.cn/post/7007664422185828366