iOS之深入解析内存管理Tagged Pointer的底层原理
Posted Forever_wj
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了iOS之深入解析内存管理Tagged Pointer的底层原理相关的知识,希望对你有一定的参考价值。
一、前言
① Tagged Pointer 概念
- ios 开发者对“引用计数”这个名词肯定不陌生,引用计数是苹果为了方便开发者管理内存而引入的一个概念。当引用计数为 0 时,对象就会被释放。但是,真的是所有对象都是这样吗?
- 其实,内存管理方案除了常见的 MRC 和 ARC,还有以下三种:Tagged Pointer、Nonpointer_isa、SideTables。
- 在 2013 年 9 月,苹果推出了 iPhone5s,与此同时,iPhone5s 配备了首个采用 64 位架构的 A7 双核处理器,为了节省内存和提高执行效率,苹果提出了 Tagged Pointer 的概念。
- 对于 64 位程序,引入 Tagged Pointer 后,指针的值不再是堆区地址,而是包含真正的值,相关逻辑能减少一半的内存占用,以及 3 倍的访问速度提升,100 倍的创建和销毁速度的提升,用于优化小对象(NSString,NSNumber,NSDate)的存储。
② Tagged Pointer 特点
- 专门用来优化存储小对象,比如 NSString,NSNumber,NSDate。
- Tagged Pointer 指针的值不再是堆区地址,而是包含真正的值,因此它不会在堆上再开辟空间了,也不需要管理对象的生命周期了。
- 内存读取提升 3 倍,创建比之前快 100 多倍,销毁速度更快。
③ Tagged Pointer 作用
- Tagged Pointer 主要解决两个问题:
-
- 内存资源浪费,堆区需要额外的开辟空间;
-
- 访问效率,每次 set/get 都需要访问堆区,浪费时间,而且需要管理堆区对象的声明周期,降低效率。
- 原有的对象为什么会浪费内存?
-
- 假设要存储一个 NSNumber 对象,其值是一个整数,正常情况下,如果这个整数只是一个 NSInteger 的普通变量,那么它所占用的内存是与 CPU 的位数有关,在 32 位 CPU 下占 4 个字节,在 64 位 CPU 下是占 8 个字节的。而指针类型的大小通常也是与 CPU 位数相关,一个指针所占用的内存在 32 位 CPU 下为 4 个字节,在 64 位 CPU 下也是 8 个字节。
-
- 所以一个普通的 iOS 程序,如果没有 Tagged Pointer 对象,从 32 位机器迁移到 64 位机器中后,虽然逻辑没有任何变化,但这种 NSNumber、NSDate 一类的对象所占用的内存会翻倍。如下图所示:
-
- 再来看看效率上的问题,为了存储和访问一个 NSNumber 对象,需要在堆上为其分配内存,另外还要维护它的引用计数,管理它的生命期,这些都给程序增加了额外的逻辑,造成运行效率上的损失。
- 怎么解决内存和效率的问题呢?
-
- 未使用 Tagged Pointer:NSNumber 等对象需要动态分配内存和维护引用计数等,总共的空间 = 指针空间 + 堆中分配的空间;
- 使用 Tagged Pointer:NSNumber 等对象,只需要分配一个指针即可,这个指针内部会包含这些数据内容,因此总空间 = 指针空间,这是因为不用去用对象的方式管理引用计数,所以省却了 retain,release 操作。
二、NSString 的内存管理
- 我们可以通过 NSString 初始化的两种方式,来探究 NSString 的内存管理:
- WithString + @"" 方式初始化;
- WithFormat 方式初始化。
- 现有如下的代码:
#define YDWLog(_c) NSLog(@"%@ - %p - %@", _c, _c, [_c class]);
- (void)testNSString {
// 初始化方式一:通过 WithString + @""方式
NSString *s1 = @"1";
NSString *s2 = [[NSString alloc] initWithString:@"222"];
NSString *s3 = [NSString stringWithString:@"33"];
YDWLog(s1);
YDWLog(s2);
YDWLog(s3);
// 初始化方式二:通过 WithFormat
// 字符串长度在9以内
NSString *s4 = [NSString stringWithFormat:@"123456789"];
NSString *s5 = [[NSString alloc] initWithFormat:@"123456789"];
YDWLog(s4);
YDWLog(s5);
// 字符串长度大于9
NSString *s6 = [NSString stringWithFormat:@"1234567890"];
NSString *s7 = [[NSString alloc] initWithFormat:@"1234567890"];
YDWLog(s6);
YDWLog(s7);
}
- 运行程序,打印如下:
1 - 0x1054fd020 - __NSCFConstantString
222 - 0x1054fd040 - __NSCFConstantString
33 - 0x1054fd060 - __NSCFConstantString
123456789 - 0xf716dc36e05df113 - NSTaggedPointerString
123456789 - 0xf716dc36e05df113 - NSTaggedPointerString
1234567890 - 0x600002b415c0 - __NSCFString
1234567890 - 0x600002b41980 - __NSCFString
- 分析上面结果可以得出,NSString 的内存管理主要分为 3 种:
- __NSCFConstantString:字符串常量,是一种编译时常量,retainCount 值很大,对其操作,不会引起引用计数变化,存储在字符串常量区;
- __NSCFString:是在运行时创建的 NSString 子类,创建后引用计数会加1,存储在堆上;
- NSTaggedPointerString:标签指针,是苹果在 64 位环境下对NSString、NSNumber 等对象做的优化;
- 对于 NSString 对象来说:
- 当字符串是由数字、英文字母组合且长度小于等于 9 时,会自动成为 NSTaggedPointerString 类型,存储在常量区;
- 当有中文或者其它特殊符号时,会直接成为 __NSCFString 类型,存储在堆区。
三、Tagged Pointer 底层原理
① 引用计数处理分析
- 进入 objc 源码中查看 retain、release 源码中对 Tagged Pointer 小对象的处理,查看 setProperty -> reallySetProperty 源码,其中是对新值 retain,旧值 release,如下所示:
static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy)
{
if (offset == 0) {
object_setClass(self, newValue);
return;
}
id oldValue;
id *slot = (id*) ((char*)self + offset);
if (copy) {
newValue = [newValue copyWithZone:nil];
} else if (mutableCopy) {
newValue = [newValue mutableCopyWithZone:nil];
} else {
if (*slot == newValue) return;
newValue = objc_retain(newValue);
}
if (!atomic) {
oldValue = *slot;
*slot = newValue;
} else {
spinlock_t& slotlock = PropertyLocks[slot];
slotlock.lock();
oldValue = *slot;
*slot = newValue;
slotlock.unlock();
}
objc_release(oldValue);
}
- 进入 objc_retain、objc_release 源码,可以看到判断是否是小对象,如果是小对象,则不会进行 retain 或者 release 操作,直接返回。因此可以得出一个结论:如果对象是小对象,不会进行 retain 和 release,如下:
// objc_retain
__attribute__((aligned(16), flatten, noinline))
id
objc_retain(id obj)
{
if (!obj) return obj;
// 判断是否是小对象,如果是则直接返回对象
if (obj->isTaggedPointer()) return obj;
// 如果不是小对象,则retain
return obj->retain();
}
// objc_release
__attribute__((aligned(16), flatten, noinline))
void
objc_release(id obj)
{
if (!obj) return;
// 如果是小对象,则直接返回
if (obj->isTaggedPointer()) return;
// 如果不是小对象,则release
return obj->release();
}
② 小对象的地址分析
- 以 NSString 为例,对于 NSString 来说:
-
- 一般 NSString 对象指针,都是 string 值 + 指针地址,两者是分开的;
-
- 对于 Tagged Pointer 指针,其指针+值,都能在小对象中体现,因此 Tagged Pointer 既包含指针,也包含值。
- 了解过 iOS 类的加载原理时,其中的 _read_images 源码有一个方法对小对象进行了处理,即 initializeTaggedPointerObfuscator 方法,进入 _read_images -> initializeTaggedPointerObfuscator 源码实现,如下:
static void
initializeTaggedPointerObfuscator(void)
{
if (sdkIsOlderThan(10_14, 12_0, 12_0, 5_0, 3_0) ||
// Set the obfuscator to zero for apps linked against older SDKs,
// in case they're relying on the tagged pointer representation.
DisableTaggedPointerObfuscation) {
objc_debug_taggedpointer_obfuscator = 0;
}
// 在iOS14之后,对小对象进行了混淆,通过与操作+_OBJC_TAG_MASK混淆
else {
// Pull random data into the variable, then shift away all non-payload bits.
arc4random_buf(&objc_debug_taggedpointer_obfuscator,
sizeof(objc_debug_taggedpointer_obfuscator));
objc_debug_taggedpointer_obfuscator &= ~_OBJC_TAG_MASK;
}
}
- 在源码实现中,可以看出,在 iOS14 之后,Tagged Pointer 采用了混淆处理,通过如下也可以验证:
NSString *s1 = [NSString stringWithFormat:@"a"];
NSString *s2 = [NSString stringWithFormat:@"b"];
NSLog(@"%p-%@",s1, s1);
NSLog(@"%p-%@",s2, s2);
// 运行结果
0xb401e40023f4ca35-a
0xb401e40023f4ca05-b
- 我们可以在源码中通过 objc_debug_taggedpointer_obfuscator 查找 taggedPointer 的编码和解码,来查看底层是如何混淆处理的,如下所示:
// 编码
static inline void * _Nonnull
_objc_encodeTaggedPointer(uintptr_t ptr)
{
return (void *)(objc_debug_taggedpointer_obfuscator ^ ptr);
}
// 编码
static inline uintptr_t
_objc_decodeTaggedPointer(const void * _Nullable ptr)
{
return (uintptr_t)ptr ^ objc_debug_taggedpointer_obfuscator;
- 通过实现,我们可以得知,在编码和解码部分经过了两次异或处理,其目的是得到小对象自己,例如以 1010 0001 为例,假设 mask 为 0101 1000,如下所示:
1010 0001
^0101 1000 mask(编码)
1111 1001
^0101 1000 mask(解码)
1010 0001
- 因此在外界为了获取小对象的真实地址,可以将解码的源码拷贝到外面,将 NSString 混淆部分进行解码,如下所示:
NSString *s1 = [NSString stringWithFormat:@"a"];
NSString *s2 = [NSString stringWithFormat:@"b"];
NSLog(@"%p-%@",s1, s1);
NSLog(@"%p-%@",s2, s2);
NSLog(@"0x@lx", _objc_decodeTaggedPointer_(s2));
// 运行结果
0xb401e40023f4ca35-a
0xb401e40023f4ca05-b
0xa000000000000621 //(解码后打印)
- 观察解码后的小对象地址,其中的 62 表示 b 的 ASCII 码,再以 NSNumber 为例,同样可以看出,1 就是实际的值:
NSNumber *number1 = @1;
NSLog(@"0x@lx", _objc_decodeTaggedPointer_(number1));
// 运行结果
0xb00000000000012
- 至此,可以验证小对象指针地址中确实存储了值,那么小对象地址高位其中的 0xa、0xb 又是什么含义呢?
// NSString
0xa000000000000621
// NSNumber
0xb000000000000012
- 我们继续去源码中查看 _objc_isTaggedPointer 源码,主要是通过保留最高位的值(即 64 位的值),判断是否等于 _OBJC_TAG_MASK(即2^63),来判断是否是小对象:
static inline bool
_objc_isTaggedPointer(const void * _Nullable ptr)
{
// 等价于 ptr & 1左移63,即2^63,相当于除了64位,其他位都为0,即只是保留了最高位的值
return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;
}
- 因此 0xa、0xb 主要是用于判断是否是小对象 tagged pointer,即判断条件,判断第 64 位上是否为1(tagged pointer 指针地址即表示指针地址,也表示值)。
-
- 0xa 转换成二进制为 1010(64 位为1,63~61 后三位表示 tagType 类型 - 2),表示 NSString 类型;
-
- 0xb 转换为二进制为 1011(64 位为1,63~61 后三位表示 tagType 类型 - 3),表示 NSNumber 类型;
-
- 需要注意,如果 NSNumber 的值是 -1,其地址中的值是用补码表示的。
- 通过 _objc_makeTaggedPointer 方法的参数 tag 类型 objc_tag_index_t 进入其枚举,其中 2 表示 NSString,3 表示 NSNumber,如下所示:
#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,
// 52-bit payloads
OBJC_TAG_Photos_1 = 8,
OBJC_TAG_Photos_2 = 9,
OBJC_TAG_Photos_3 = 10,
OBJC_TAG_Photos_4 = 11,
OBJC_TAG_XPC_1 = 12,
OBJC_TAG_XPC_2 = 13,
OBJC_TAG_XPC_3 = 14,
OBJC_TAG_XPC_4 = 15,
OBJC_TAG_NSColor = 16,
OBJC_TAG_UIColor = 17,
OBJC_TAG_CGColor = 18,
OBJC_TAG_NSIndexSet = 19,
OBJC_TAG_First60BitPayload = 0,
OBJC_TAG_Last60BitPayload = 6,
OBJC_TAG_First52BitPayload = 8,
OBJC_TAG_Last52BitPayload = 263,
OBJC_TAG_RESERVED_264 = 264
};
#if __has_feature(objc_fixed_enum) && !defined(__cplusplus)
typedef enum objc_tag_index_t objc_tag_index_t;
#endif
- 同理,我们还可以定义一个 NSDate 对象,来验证其 tagType 是否为6。通过打印结果,其地址高位是 0xe,转换为二进制为 1110,排除 64 位的 1,剩余的 3 位正好转换为十进制是 6,正好符合上面的枚举值。如下:
NSDate *date = [NSDate date];
NSLog(@"%@ - 0x@lx", object_getClass(date), _objc_decodeTaggedPointer_(date));
// 运行结果
__NSTaggeDate - 0xe2d2b6aa325583d8
- 在 iOS 真机上判断是否 Tagged Pointer,可以直接看指针的最高一个比特位是否为1。
KEY | (MacOS 或 TARGET_OS_IOSMAC) 且 x86_64 | 其它情况 |
---|---|---|
OBJC_MSB_TAGGED_POINTERS | 0 | 1 |
_OBJC_TAG_MASK | 1UL | 1UL<<63 |
_OBJC_TAG_INDEX_SHIFT | 1 | 60 |
_OBJC_TAG_SLOT_SHIFT | 0 | 60 |
_OBJC_TAG_PAYLOAD_LSHIFT | 0 | 4 |
_OBJC_TAG_PAYLOAD_RSHIFT | 4 | 4 |
_OBJC_TAG_EXT_MASK | 0xfUL | (0xfUL<<60) |
_OBJC_TAG_EXT_INDEX_SHIFT | 4 | 52 |
_OBJC_TAG_EXT_SLOT_SHIFT | 4 | 52 |
_OBJC_TAG_EXT_PAYLOAD_LSHIFT | 0 | 12 |
_OBJC_TAG_EXT_PAYLOAD_RSHIFT | 12 | 12 |
③ 总结
- Tagged Pointer 小对象类型(用于存储 NSNumber、NSDate、NSString),小对象指针不再是简单的地址,而是地址 + 值,即真正的值,所以实际上它不再是一个对象了,实质是一个普通变量而已,可以直接进行读取。因此它的优点是占用空间小、节省内存。
- Tagged Pointer 小对象不会进入 retain 和 release,而是直接返回,意味着不需要 ARC 进行管理,所以可以直接被系统自主的释放和回收;
- Tagged Pointer 的内存并不存储在堆中,而是在常量区中,也不需要 malloc 和 free,可以直接读取,相比存储在堆区的数据读取,效率上升 3 倍左右,创建的效率相比堆区快了近 100 倍左右;
- 综合来说,Tagged Pointer 的内存管理方案,比常规的内存管理,要快很多;
- Tagged Pointer 的 64 位地址中,前 4 位代表类型,后 4 位主要适用于系统做一些处理,中间 56 位用于存储值;
- 对于 NSString 来说,当字符串较小时,建议直接通过 @"" 初始化,因为存储在常量区,可以直接进行读取。这样一来,会比 WithFormat 初始化方式更加快速。
四、Tagged Pointer 面试题分析
- 现有以下代码,运行会有什么问题吗?
@property (nonatomic, strong) dispatch_queue_t queue;
@property (nonatomic, copy) NSString *name;
- (void)taggedPointerDemo {
self.queue = dispatch_queue_create("com.ydw.cn", DISPATCH_QUEUE_CONCURRENT);
for (int i = 0; i < 10000; i++) {
dispatch_async(self.queue, ^{
// alloc 堆 iOS优化 - tagged pointer
self.name = [NSString stringWithFormat:@"YDW"];
NSLog(@"%@",self.name);
});
}
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
for (int i = 0; i < 10000; i++) {
dispatch_async(self.queue, ^{
self.name = [NSString stringWithFormat:@"越努力,越幸运"];
NSLog(@"%@",self.name);
});
}
}
- 运行以上代码,可以看到 taggedPointerDemo 单独运行没有问题,当触发 touchesBegan 方法后,程序会出现崩溃,这是为什么呢?
- 其实,崩溃的原因是多条线程同时对一个对象进行释放,导致了过渡释放,所以产生了崩溃,其根本原因是 name 在底层的类型不一致导致的。
- 调试程序,如下:
- 通过结果,可以看出:
-
- taggedPointerDemo 方法中,name 是由数字、英文字母组合且长度小于等于 9 ,经过 Xcode 中 iOS 内存管理优化,自动变成为 NSTaggedPointerString 类型,存储在常量区,因此 name 类型是 NSTaggedPointerString。
-
- touchesBegan 方法中,name 含有中文或者其它特殊符号,因此它的类型是 NSCFString 类型,存储在堆上。
以上是关于iOS之深入解析内存管理Tagged Pointer的底层原理的主要内容,如果未能解决你的问题,请参考以下文章
iOS之深入解析内存管理的引用计数retainCount的底层原理