- 本节,进入
内存管理篇章,将从以下几部分讲解:
- 内存布局
- TaggedPointer
- 引用计数(retain、release、dealloc) & SideTables 散列表
- retainCount
准备工作:
- 可编译的
objc4-781源码: //www.greatytc.com/p/45dc31d91000
1. 内存布局
按照地址从高到低排列: 栈区 -> 堆区 -> 全局静态区 -> 常量区 -> 代码区 (内核区和保留部分不在考虑范围内)

补充说明:
内存五大区,实际是指虚拟内存,而不是真实物理内存。(详情可查看👉 本文第3点 虚拟内存与物理内存)iOS系统中,应用的虚拟内存默认分配4G大小,但五大区只占3G,还有1G是五大区之外的内核区
内存五大区的详细功能,可查看👉 内存五大区
2.TaggedPointer
-
TaggedPointer是标记指针。标记的是小对象,可以记录小内容的NSString、NSDate、NSNumber等内容。是内存管理(节省内存开销)的一种有效手段。
例如:使用
NSNumber记录10
【常规操作】
去堆中开辟一个内存,用于存储这个对象,在对象内部记录这个内容10,然后需要一个指针,指向堆中的内存首地址。
(内存开销:指针8字节+堆中对象的空间大小)【
TaggedPointer小对象】
记录一个10,根本不用去堆中开辟内存,直接利用指针拥有的8字节空间即可。
(类似NonPointer_isa非指针型isa,使用union联合体位域,中间shiftcls部分存储类信息。其他部位记录其他有效信息。 相关参考👉 剖析isa)
(内存开销:指针8字节)结论:
占用空间较小的对象,直接使用指针内部空间,节约内存开销
2.1案例分析
@interface ViewController ()
@property (nonatomic, strong) dispatch_queue_t queue;
@property (nonatomic, copy) NSString * name;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.queue = dispatch_queue_create("ht", DISPATCH_QUEUE_CONCURRENT);
for (int i = 0; i < 10000 ; i++) {
dispatch_async(self.queue, ^{
self.name = [NSString stringWithFormat:@"ht"];
NSLog(@"%@ %p %s",self.name, self.name, object_getClassName(self.name));
});
}
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"来了");
for (int i = 0; i < 10000; i++) {
dispatch_async(self.queue, ^{
self.name = [NSString stringWithFormat:@"ht_学习不行,回家种田"]; //setter - 新值retain,旧值release
NSLog(@"%@ %p %s",self.name, self.name, object_getClassName(self.name)); // getter
});
}
}
@end
- 上面
打印结果:
image.png -
点击触发TouchBegin后的打印结果:
image.png
Q: 两次
10000次的循环,都是对name进行赋值,为什么上面不会崩溃,下面会崩溃?A: 因为上面
name存储在栈中,不需要手动释放,而下面name存储在堆中,在setter赋值时会触发retain和release,异步线程中,可能导致指针过度释放,造成了崩溃。
name赋值为ht时,是小对象(NSTaggedPointerString),直接将内容存储在指针内部,指针存储在栈中,由系统负责管理。
name赋值为ht_学习不行,回家种田时,由于内容过多,指针内部空间不够存储,所以去堆中开辟空间,需要管理引用计数了。每次setter都会触发新值retain和旧值release。异步线程中,可能导致retain未完成,但提前release了,导致指针过度释放,造成了崩溃。
底层探索:
clang对ViewController.m文件编译成.cpp文件:clang -x objective-c -rewrite-objc -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk ViewController.m
- 可以看到
name的setter方法实际是调用objc_setProperty,打开objc4源码,搜索objc_setProperty:
image.png- 可以看到
taggedPointer对象不参与引用计数的计算。
2.2 NSTaggedPointerString底层探索
- 沿着
上面看到的isTaggedPointer()的判断,我们进入isTaggedPointer()内部:
image.png
【拓展】:
taggedPointer混淆机制
APP启动时,dyld调用_read_images时,第一步有一个初始化taggedPointer混淆机制
image.png- 内部为:
iOS10.14之后,且打开了小对象混淆机制,就进行小对象混淆:
image.png- 搜索
objc_debug_taggedpointer_obfuscator,可以看到混淆的编码和解码:
image.png- 编码:
原地址进行^异或操作一次,得到编码地址- 解码: 将
编码地址再进行^异或操作一次,得到原地址
- 混淆验证:
// 声明(在其他库中实现)
extern uintptr_t objc_debug_taggedpointer_obfuscator;
// 从objc4源码拷贝解码代码, 入参改为id类型
static inline uintptr_t
_objc_decodeTaggedPointer(id ptr) {
return (uintptr_t)ptr ^ objc_debug_taggedpointer_obfuscator;
}
- (void)demo {
NSString * str1 = [NSString stringWithFormat:@"a"];
NSNumber * num1 = @(1);
NSLog(@"str1: %p-%@", str1, str1);
NSLog(@"num1: %p-%@", num1, num1);
NSLog(@"str1 解码地址: 0x%lx", _objc_decodeTaggedPointer(str1));
NSLog(@"num1 解码地址: 0x%lx", _objc_decodeTaggedPointer(num1));
}

- 可以看到
解码后,从地址就可以直接读出当前内容。所以混淆机制就是为了让小对象从地址上降低识别度。
Q:
解码后的小对象地址,前面a/b是啥意思?
image.png
- 当前系统,为了
新旧兼容,TaggedPointer指针的后四位不再存储值。
taggedpointer类型的优点:
节省内存开销:充分利用指针地址空间。
执行效率高: 不需要retain和release,系统直接管理释放、回收,少执行很多代码,不需要堆空间,直接创建和读取。(官方说内存读取快3倍,创建快106倍)
建议:
当
字符串较长时,主动使用@""创建,而不用stringFormat创建。因为如果不是taggedPointer,反而会更耗时间。
image.png不同
字符串长度的类型:
image.png
3. 引用计数(retain、release、dealloc) & SideTables 散列表
回忆:
在熟悉isa的指针内部结构时(参考 👉 剖析isa),我们有提到:
isa结构图
has_sidetable_rc:当对象引用技术大于10时,则需要借用该变量存储进位
extra_rc:当表示该对象的引用计数值,实际上是引用计数值减 1。
如: 如果对象的引用计数为10,那么extra_rc为 9。如果引用计数大于 10, 则需要使用到has_sidetable_rc。(这只是举例,具体方式,我们下面解析)
-
说到
引用计数,自然就想到了retain、release。上面示例中,如果不是taggedPointer,就会进入obj->retain,我们进入retain内部:
(ps: 为了内容不太分散,我梳理成一张大图,可查看原图,放大观看)
image.png -
哈希表结构如下:
image.png
【reatin总结】
taggedPointer类直接返回原对象操作时,使用新isa拷贝当前isa对象,隔离操作。- 【
do-while】循环,是由于多线程环境下,当前isa可能在变化,只要变化就需要再次操作- 如果
不支持指针优化,直接操作散列表进行计数。- 如果
isa记录了正在释放,就不用retain了引用计数+1,首先尝试在isa的extra_rc中+1:
- 成功:【
do-while】retain操作结束- 失败: 表示
extra_rc存储满了,此时将extra_rc计数减半,has_sidetable_rc(使用散列表)标记true,transcribeToSideTable(转移给散列表)标记true。- 【
do-while】外,判断transcribeToSideTable给散列表添加extra_rc最大容量的一半计数。
- 接着,我们来了解
release:
image.png
【release总结】
taggedPointer类直接返回原对象操作时,使用新isa拷贝当前isa对象,隔离操作。- 【retry】:
(do-while循环,监测多线程环境下,当前isa是否变化)
- 如果不支持指针优化,直接操作散列表进行计数。
-尝试给isa的extra_rc - 1: 如果失败,跳转【underflow】- 【underflow】:
(表示extra_rc计数为空,不可以进行-1,需要去散列表拿计数再操作或直接释放)
- 如果有
散列表(has_sidetable_rc为true):
- 如果散列表
没锁,关锁再重新【retry】- 尝试从散列表
读取RC_HALF(extra_rc的一半容量)计数
- 如果
取到值,尝试更新extra_rc和isa。更新失败再次读取和更新。如果还失败,就将borrowed加回给sidetable没散列表或散列表内没计数,如果正在释放中,就不处理。 否则,将deallocating设为true(去释放),重新【retry】- 如果
实现了dealloc函数,就给发送消息调用dealloc
-
引用计数为0时,会调用dealloc:
image.png
【dealloc总结】
- 是
优化指针,但无弱引用表,无关联对象,无析构函数、无散列表,直接free。
- 其他情况,依次检查:
- 有
析构函数:调用析构函数- 有
关联对象:移除关联对象- 非
指针优化:直接清除散列表- 有
弱引用表:直接清除弱引用表内容- 使用
散列表:移除表内引用计数
-现在,我们已熟悉了alloc->retain->release->dealloc的完整流程。
4. retainCount
- 关于
retainCount,有一个有意思的面试题:
// Q:打印的引用计数为多少,alloc、init改变了引用计数吗?
- (void)demo {
NSObject * objc = [NSObject alloc];
NSLog(@"%ld", CFGetRetainCount((__bridge CFTypeRef) objc));
objc = [objc init];
NSLog(@"%ld", CFGetRetainCount((__bridge CFTypeRef) objc));
}
-
打印结果:
image.png -
进入源码,搜索
_objc_rootRetainCount->rootRetainCount:
image.png
结论
alloc、init是不处理对象引用计数retainCount返回的计数,是读取内存后+1的。 (实际计数是打印值-1)引用计数为0,对象为啥没被释放?
因为ARC会自动将对象加入autoRelasePool中,autoReleasePool中对象的被持有时间,就是代码作用域{ }周期,所以达到延迟释放功能。
下一节,将进行强弱引用分析。

















