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_POINTERS01
_OBJC_TAG_MASK1UL1UL<<63
_OBJC_TAG_INDEX_SHIFT160
_OBJC_TAG_SLOT_SHIFT060
_OBJC_TAG_PAYLOAD_LSHIFT04
_OBJC_TAG_PAYLOAD_RSHIFT44
_OBJC_TAG_EXT_MASK0xfUL(0xfUL<<60)
_OBJC_TAG_EXT_INDEX_SHIFT452
_OBJC_TAG_EXT_SLOT_SHIFT452
_OBJC_TAG_EXT_PAYLOAD_LSHIFT012
_OBJC_TAG_EXT_PAYLOAD_RSHIFT1212
③ 总结
  • 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之深入解析内存管理MRC与ARC机制

iOS之深入解析内存管理NSTimer的强引用问题

iOS之深入解析内存管理的引用计数retainCount的底层原理

iOS之深入解析Memory内存

iOS之深入解析内存管理散列表SideTables和弱引用表weak_table的底层原理

iOS内存管理机制解析之MRC手动引用计数机制