OC底层原理 学习大纲
- 本节,进入
内存管理
篇章,将从以下几部分讲解:
- 内存布局
- TaggedPointer
- 引用计数(retain、release、dealloc) & SideTables 散列表
- retainCount
准备工作:
- 可编译的
objc4-781
源码: https://www.jianshu.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 *)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
- 上面
打印结果
:
-
点击
触发TouchBegin
后的打印结果
:
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
:
- 可以看到
taggedPointer
对象不
参与引用计数
的计算
。
2.2 NSTaggedPointerString
底层探索
- 沿着
上面
看到的isTaggedPointer()
的判断,我们进入isTaggedPointer()
内部:
【拓展】:
taggedPointer
混淆机制
APP启动
时,dyld
调用_read_images
时,第一步
有一个初始化
taggedPointer混淆机制
- 内部为:
iOS10.14
之后,且打开了小对象混淆机制
,就进行小对象混淆
:
- 搜索
objc_debug_taggedpointer_obfuscator
,可以看到混淆
的编码
和解码
:
- 编码:
原地址
进行^异或
操作一次
,得到编码地址
- 解码: 将
编码地址
再进行^异或
操作一次
,得到原地址
- 混淆验证:
// 声明(在其他库中实现)
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
是啥意思?
- 当前系统,为了
新旧兼容
,TaggedPointer
指针的后四位不
再存储值
。
taggedpointer
类型的优点:
节省内存开销
:充分利用指针地址空间
。
执行效率高
: 不需要retain
和release
,系统
直接管理释放、回收
,少执行
很多代码
,不需要堆空间
,直接创建
和读取
。(官方说内存读取
快3倍
,创建
快106倍
)
建议:
当
字符串较长
时,主动使用@""
创建,而不用stringFormat
创建。因为如果不是taggedPointer
,反而会更耗时间
。
不同
字符串长度
的类型
:
3. 引用计数(retain、release、dealloc) & SideTables 散列表
回忆:
在熟悉isa
的指针
内部结构
时(参考 剖析isa),我们有提到:
has_sidetable_rc
:当对象引用技术大于10
时,则需要借用该变量存储进位
extra_rc
:当表示该对象的引用计数
值,实际上是引用计数值减 1。
如: 如果对象的引用计数
为10
,那么extra_rc
为 9。如果引用计数大于 10
, 则需要使用到has_sidetable_rc
。(这只是举例,具体方式,我们下面解析
)
-
说到
引用计数
,自然就想到了retain
、release
。上面示例中,如果不是taggedPointer
,就会进入obj->retain
,我们进入retain
内部:
(ps: 为了内容不太分散
,我梳理
成一张大图
,可查看原图
,放大
观看)
-
哈希表结构如下:
【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
:
【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
:
【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));
}
-
打印结果:
-
进入源码,搜索
_objc_rootRetainCount
->rootRetainCount
:
结论
alloc
、init
是不处理
对象引用计数
retainCount
返回的计数
,是读取内存
后+1
的。 (实际计数
是打印值-1
)引用计数
为0
,对象
为啥没
被释放
?
因为ARC
会自动
将对象加入autoRelasePool
中,autoReleasePool
中对象
的被持有时间
,就是代码作用域{ }
周期,所以达到延迟释放
功能。
下一节,将进行强弱引用
分析。