【iOS】—— Tagged Pointer
关于Tagged Pointer
为了节省内存和提高执行效率,苹果提出了Tagged Pointer的概念。先看看原有的对象为什么会浪费内存。假设要存储一个NSNumber对象,其值是一个整数。正常情况下,如果这个整数只是一个NSInteger的普通变量,那么它所占用的内存是与CPU的位数有关,在32位CPU下占4个字节,在64位CPU下是占8个字节的。而指针类型的大小通常也是与CPU位数相关,一个指针所占用的内存在32位CPU下为4个字节,在64位CPU下也是8个字节。所以一个普通的iOS程序,如果没有Tagged Pointer对象,从32位机器迁移到64位机器中后,虽然逻辑没有任何变化,但这种NSNumber、NSDate一类的对象所占用的内存会翻倍。
Tagged Pointer的介绍
为了存储和访问一个NSNumber对象,我们需要在堆上为其分配内存,另外还要维护它的引用计数,管理它的生命期。这些都给程序增加了额外的逻辑,造成运行效率上的损失。
对此提出了Tagged Pointer概念,由于NSNumber、NSDate一类的变量本身的值需要占用的内存大小常常不需要8个字节,拿整数来说,4个字节所能表示的有符号整数就可以达到20多亿。所以我们可以将一个对象的指针拆成两部分,一部分直接保存数据,另一部分作为特殊标记,表示这是一个特别的指针,不指向任何一个地址。
简单来理解就是把指针指向的内容,直接放到了指针变量的内存地址中。 于是使用了标签指针这种方式来优化数据的存储方式。在运行时根据实际情况创建。
NSTaggedPointer示例
NSString *string = nil;
NSMutableString *mutableString = [NSMutableString stringWithFormat:@"abcde"];
for (int i = 0; i < 13; i++) {
[mutableString appendString:@"c"];
string = [mutableString copy];
NSLog(@"%@ %p %@", string, string, [string class]);
}
输出结果:
当字符长度在10以内的时候,字符串的类型都是NSTaggedPointer类型,当超过10时,就变成了__NSCFString。
NSTaggedPointer结构
苹果为了安全对其做了编码,runtime内部实现了编码、解码方法,我们看一下:
编码:
static inline void * _Nonnull
_objc_encodeTaggedPointer(uintptr_t ptr)
{
uintptr_t value = (objc_debug_taggedpointer_obfuscator ^ ptr);
#if OBJC_SPLIT_TAGGED_POINTERS
if ((value & _OBJC_TAG_NO_OBFUSCATION_MASK) == _OBJC_TAG_NO_OBFUSCATION_MASK)
return (void *)ptr;
uintptr_t basicTag = (value >> _OBJC_TAG_INDEX_SHIFT) & _OBJC_TAG_INDEX_MASK;
uintptr_t permutedTag = _objc_basicTagToObfuscatedTag(basicTag);
value &= ~(_OBJC_TAG_INDEX_MASK << _OBJC_TAG_INDEX_SHIFT);
value |= permutedTag << _OBJC_TAG_INDEX_SHIFT;
#endif
return (void *)value;
}
我们可以试着打印地址:
NSNumber *number1 = [NSNumber numberWithInt:1];
NSLog(@"number1 pointer is %p", number1);
输出结果:
- Tagged Pointer 标记:x86最后一位是标记位,arm64最高位是标记位。1表示是Tagged Pointer对象,0表示是普通对象。
- Tag:对象类型标记。x86为13位,arm64为02。7表示有扩展信息。
- Extended:x86为411位,arm64为5462。用来扩展更多类型。
- payload:有效负载。存储真正的数据(除了标记位、tag以及extended),不过为了安全苹果做了编码。
Tagged Pointer的特点
- Tagged Pointer专门用来存储小的对象,比如NSNumber,NSDate。
- Tagged Pointer指针指向的不再是内存地址,而是一个真正的值。所以也不是一个对象,而是披着对象外衣的变量。内存也不存储在堆上,不需要使用malloc和free。
- 减少了 64位机器下程序的内存占用,还提高了运行效率,完美地解决了小内存对象在存储和访问效率上的问题。
注意事项
isa指针
Tagged Pointer 的引入也带来了问题,即 Tagged Pointer 并不是真正的对象,而是一个伪对象,所以你如果完全把它当成对象来使用,可能会让它“露马脚”。在上一章中我们写道,所有对象都有isa 指针,而 Tagged Pointer 其实是没有的,因为它不是真正的对象。
isa指针的优化
除了引入Tagged Pointer来优化小的对象,也普通对象的isa指针进行了优化和调整。
在32 位环境下,对象的引用计数都保存在一个外部的表中,每一个对象的 Retain 操作,实际上包括如下 5个步骤:
- 获取全局记录引用计数的哈希表。
- 为了线程安全,给哈希表上锁。
- 找到目标对象的引用计数。
- 将引用计数+1,写回哈希表。
- 给该哈希表解锁。
为了保证线程安全,对引用计数的增减操作都要先锁定这个表,这从性能上看是非常差的。
在64位的情况下,指针也是64位,实际作为指针部分的只有33位,剩下的31位中,19位用于保存引用计数,当引用计数超过了19位时,才会保存到外部表中,这样引用计数的更改效率提高。
与前面的5个步骤对应,在64位环境下,新的 Retain 操作包括如下 5个步骤:
- 检查isa指针是有存在标记位,如果不存在,就执行以前的方法。负责执行的二步。
- 判断当前的对象是否正在释放,如果是,就不用进行操作。
- 增加对象的引用计数,先不写回isa指针中。
- 判断引用计数的位数是否可以被19位表示,如果不能就执行原来的方法,否则执行下一步。
- 进行原子的写操作,将isa的值写回。
接下来看一道题
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
for (int i = 0; i < 1000; i++) {
dispatch_async(queue, ^{
p.name = [NSString stringWithFormat:@"addafghsdddds"];
});
}
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
for (int i = 0; i < 1000; i++) {
dispatch_async(queue, ^{
p.name = [NSString stringWithFormat:@"ad"];
});
}
两段代码唯一的区别就是一个name属性所赋值的字符串长一些长度大于10,另一个长度小一点小于10。我们去运行它,就会发现,第一段代码程序崩溃,第二段没有崩溃。
原因就是:
第一段代码并发访问了共享数据 p.name。在多线程环境下,同时对同一变量进行写操作可能引发竞争条件或数据不一致的问题。要解决它就要给它加上锁。
而第二段因为字符串短,所以被改为了Tagged Pointer对象:Tagged Pointer 指针的值不再是地址了,而是真正的值。