内存管理之Tagged pointer

内存管理

iOS开发者对引用计数这个名词肯定不陌生,引用计数是苹果为了方便开发者管理内存而引入的一个概念,当引用计数为0时,对象就会被释放。但是,真的是所有对象都是这样吗?

内存分配

iOS将虚拟内存按照地址由低到高划分为如下五个区:

在程序运行时,代码区,常量区以及全局静态区的大小是固定的,会变化的只有栈和堆的大小。而栈的内存是有操作系统自动释放的,我们平常说所的iOS内存引用计数,其实是就堆上的对象来说的。

如何引入tagged pointer

自2013年苹果推出iphone5s之后,iOS的寻址空间扩大到了64位。我们可以用63位来表示一个数字(一位做符号位)。那么这个数字的范围是2^63 ,很明显我们一般不会用到这么大的数字,那么在我们定义一个数字时NSNumber *num = @100,实际上内存中浪费了很多的内存空间。

当然苹果肯定也认识到了这个问题,于是就引入了tagged pointer,tagged pointer是一种特殊的“指针”,其特殊在于,其实它存储的并不是地址,而是真实的数据和一些附加的信息。

我们可以在WWDC2013的《Session 404 Advanced in Objective-C》视频中,看到苹果对于Tagged Pointer特点的介绍:

  • Tagged Pointer专门用来存储小的对象,例如NSNumber, NSDate, NSString。
  • Tagged Pointer指针的值不再是地址了,而是真正的值。所以,实际上它不再是一个对象了,它只是一个披着对象皮的普通变量而已。所以,它的内存并不存储在堆中,也不需要malloc和free。
  • 在内存读取上有着3倍的效率,创建时比以前快106倍。

NSTaggedPointer

我们先看下下面这段代码:

1
2
3
4
5
6
7
8
9
NSMutableString *mutableStr = [NSMutableString string];
NSString *immutable = nil;
#define _OBJC_TAG_MASK (1UL<<63)
char c = 'a';
do {
[mutableStr appendFormat:@"%c", c++];
immutable = [mutableStr copy];
NSLog(@"%p %@ %@", immutable, immutable, immutable.class);
}while(((uintptr_t)immutable & _OBJC_TAG_MASK) == _OBJC_TAG_MASK);

运行结果:

1
2
3
4
5
6
7
8
9
10
2020-08-08 14:15:54.480862+0800 TaggedPointerDemo[55468:2078125] 0xdc5050684e86e57c a NSTaggedPointerString
2020-08-08 14:15:54.481719+0800 TaggedPointerDemo[55468:2078125] 0xdc5050684e80c57f ab NSTaggedPointerString
2020-08-08 14:15:54.482480+0800 TaggedPointerDemo[55468:2078125] 0xdc50506848b0c57e abc NSTaggedPointerString
2020-08-08 14:15:54.483342+0800 TaggedPointerDemo[55468:2078125] 0xdc50506e08b0c579 abcd NSTaggedPointerString
2020-08-08 14:15:54.483950+0800 TaggedPointerDemo[55468:2078125] 0xdc50563e08b0c578 abcde NSTaggedPointerString
2020-08-08 14:15:54.484246+0800 TaggedPointerDemo[55468:2078125] 0xdc56363e08b0c57b abcdef NSTaggedPointerString
2020-08-08 14:15:54.484800+0800 TaggedPointerDemo[55468:2078125] 0xda26363e08b0c57a abcdefg NSTaggedPointerString
2020-08-08 14:15:54.485200+0800 TaggedPointerDemo[55468:2078125] 0xdc527050ee978a35 abcdefgh NSTaggedPointerString
2020-08-08 14:15:54.485644+0800 TaggedPointerDemo[55468:2078125] 0xdcd85e404adcb774 abcdefghi NSTaggedPointerString
2020-08-08 14:15:54.486003+0800 TaggedPointerDemo[55468:2078125] 0x28334c2c0 abcdefghij __NSCFString

上图我们可以看到,当字符串的长度为10个以内时,字符串的类型都是NSTaggedPointerString类型,当超过10个时,字符串的类型才是__NSCFString

打印结果分析:

NSTaggedPointer标志位

1
2
3
4
5
static inline bool 
_objc_isTaggedPointer(const void * _Nullable ptr)
{
return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;
}

上面这个方法我们看到,判断一个对象类型是否为NSTaggedPointerString类型实际上是讲对象的地址与_OBJC_TAG_MASK进行按位与操作,结果在跟_OBJC_TAG_MASK进行对比,我们在看下_OBJC_TAG_MASK的定义:

1
2
3
4
5
#if OBJC_MSB_TAGGED_POINTERS
# define _OBJC_TAG_MASK (1UL<<63)
#else
# define _OBJC_TAG_MASK 1UL
#endif

我们都知道一个对象地址为64位二进制,它表明如果64位数据中,最高位是1的话,则表明当前是一个tagged pointer类型。

那么我们在看下上面打印出的地址,所有NSTaggedPointerString地址都是0xd开头,d转换为二进制1110,根据上面的结论,我们看到首位为1表示为NSTaggedPointerString类型。在这里得到验证。

注意:TaggedPointer类型在iOS和MacOS中标志位是不同的iOS为最高位而MacOS为最低位

对象类型

正常情况下一个对象的类型,是通过这个对象的ISA指针来判断的,那么对于NSTaggedPointer类型我们如何通过地址判断对应数据是什么类型的呢?

objc4-723之前

在objc4-723之前,我们可以通过与判断TaggedPointer标志位一样根据地址来判断,而类型的标志位就是对象地址的61-63位,比如对象地址为0xa开头,那么转换成二进制位1010,那么去掉最高位标志位后,剩余为010,即10进制中的2。

接着我们看下runtime源码objc-internal.h中有关于标志位的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#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_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

那么我们知道2表示的OBJC_TAG_NSString即字符串类型。因为目前已经无法验证这种情况了 所以我们不做其他类型验证。

objc4-750之后
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Returns a pointer to the class's storage in the tagged class arrays.
// Assumes the tag is a valid basic tag.
static Class *
classSlotForBasicTagIndex(objc_tag_index_t tag)
{
uintptr_t tagObfuscator = ((objc_debug_taggedpointer_obfuscator
>> _OBJC_TAG_INDEX_SHIFT)
& _OBJC_TAG_INDEX_MASK);
uintptr_t obfuscatedTag = tag ^ tagObfuscator;
// Array index in objc_tag_classes includes the tagged bit itself
#if SUPPORT_MSB_TAGGED_POINTERS ////高位优先
return &objc_tag_classes[0x8 | obfuscatedTag];
#else
return &objc_tag_classes[(obfuscatedTag << 1) | 1];
#endif
}

classSlotForBasicTagIndex() 函数的主要功能就是根据指定索引 tag 从数组objc_tag_classes中获取类指针,而下标的计算方法发是根据外部传递的索引tag。比如字符串 tag = 2。当然这并不是简单的从数组中获取某条数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 uint16_t NSString_Tag = 2;
uint16_t NSNumber_Tag = 3;
// 3 = 0011
// _OBJC_TAG_INDEX_MASK = 0x7 = 0111
uintptr_t string_tagObfuscator = ((objc_debug_taggedpointer_obfuscator
>> _OBJC_TAG_INDEX_SHIFT)
& _OBJC_TAG_INDEX_MASK);

uintptr_t number_tagObfuscator = ((objc_debug_taggedpointer_obfuscator
>> _OBJC_TAG_INDEX_SHIFT)
& _OBJC_TAG_INDEX_MASK);

// 异或操作 相同返回0 不同返回1
// 2 ^ 3 = 0010 ^ 0011 = 0001
// 3^ 3 = 0011 ^ 0011 = 0000
uintptr_t string_obfuscatedTag = NSString_Tag ^ string_tagObfuscator;
uintptr_t number_obfuscatedTag = NSNumber_Tag ^ number_tagObfuscator;

// 按位或
// 1000 | 0001 = 1001 = 9
// 1000 | 0000 = 1000 = 8
NSLog(@"%@", objc_tag_classes[0x8 | string_obfuscatedTag]);
NSLog(@"%@", objc_tag_classes[0x8 | number_obfuscatedTag]);

控制台输出为:

1
2
TaggedPointer[89420:3027642] NSTaggedPointerString
TaggedPointer[89420:3027642] __NSCFNumber

当我们多次运行时,我们发现实际上每次获取到的string_tagObfuscatornumber_obfuscatedTag都不一样,但是每次从objc_tag_classes中取出的类型均是一致的,因此实际上每次运行objc_tag_classes中的内容也是不断变化的。

如果你想进一步的了解可以参考Objective-C中伪指针Tagged Pointer

NSCFNumber

下面我们在看下NSNumber类型

1
2
3
4
5
6
7
8
9
10
NSNumber *number1 = @(0x1);
NSNumber *number2 = @(0x20);
NSNumber *number3 = @(0x3F);
NSNumber *numberFFFF = @(0xFFFFFFFFFFEFE);
NSNumber *maxNum = @(MAXFLOAT);
NSLog(@"number1 pointer is %p class is %@", number1, number1.class);
NSLog(@"number2 pointer is %p class is %@", number2, number2.class);
NSLog(@"number3 pointer is %p class is %@", number3, number3.class);
NSLog(@"numberffff pointer is %p class is %@", numberFFFF, numberFFFF.class);
NSLog(@"maxNum pointer is %p class is %@", maxNum, maxNum.class);

我们在看下打印结果:

1
2
3
4
5
TaggedPointerDemo[59218:2167895] number1 pointer is 0xf7cb914ffb51479a class is __NSCFNumber
TaggedPointerDemo[59218:2167895] number2 pointer is 0xf7cb914ffb51458a class is __NSCFNumber
TaggedPointerDemo[59218:2167895] number3 pointer is 0xf7cb914ffb51447a class is __NSCFNumber
TaggedPointerDemo[59218:2167895] numberffff pointer is 0xf7346eb004aea86b class is __NSCFNumber
TaggedPointerDemo[59218:2167895] maxNum pointer is 0x28172a0c0 class is __NSCFNumber

我们发现对于NSNumber,我们打印出来的数据类型均为__NSCFNumber,但是我们发现对于MAXFLOAT打印出的地址显然与其他几项不符,上面几个NSNumber的地址以0xf开头,根据字符串地址的经验我们可以看出f = 1111,首位标记位为1,表示这个数据类型属于TaggedPointer。而MAXFLOAT不是。

获取TaggedPointer的值

objc4-723之前

字符串:

从上图的地址中我们就可以看出,从低位到高位分别表示的就是字符串的值(在ASCII码表中的值)

数字:

对于数字来说从地址中也是直接读出存储的值,如上图。

objc4-750之后
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
static inline uintptr_t
_objc_decodeTaggedPointer(const void * _Nullable ptr)
{
return (uintptr_t)ptr ^ objc_debug_taggedpointer_obfuscator;
}

static inline uintptr_t
_objc_getTaggedPointerValue(const void * _Nullable ptr)
{
// assert(_objc_isTaggedPointer(ptr));
uintptr_t value = _objc_decodeTaggedPointer(ptr);
uintptr_t basicTag = (value >> _OBJC_TAG_INDEX_SHIFT) & _OBJC_TAG_INDEX_MASK;
if (basicTag == _OBJC_TAG_INDEX_MASK) {
return (value << _OBJC_TAG_EXT_PAYLOAD_LSHIFT) >> _OBJC_TAG_EXT_PAYLOAD_RSHIFT;
} else {
return (value << _OBJC_TAG_PAYLOAD_LSHIFT) >> _OBJC_TAG_PAYLOAD_RSHIFT;
}
}

static inline intptr_t
_objc_getTaggedPointerSignedValue(const void * _Nullable ptr)
{
// assert(_objc_isTaggedPointer(ptr));
uintptr_t value = _objc_decodeTaggedPointer(ptr);
uintptr_t basicTag = (value >> _OBJC_TAG_INDEX_SHIFT) & _OBJC_TAG_INDEX_MASK;
if (basicTag == _OBJC_TAG_INDEX_MASK) {
return ((intptr_t)value << _OBJC_TAG_EXT_PAYLOAD_LSHIFT) >> _OBJC_TAG_EXT_PAYLOAD_RSHIFT;
} else {
return ((intptr_t)value << _OBJC_TAG_PAYLOAD_LSHIFT) >> _OBJC_TAG_PAYLOAD_RSHIFT;
}
}

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
NSString *str1 = [NSString stringWithFormat:@"1"];
NSString *str11 = [NSString stringWithFormat:@"11"];
NSString *str2 = [NSString stringWithFormat:@"2"];
NSString *str22 = [NSString stringWithFormat:@"22"];


// 0x31 1 0x32 1
uintptr_t value1 = objc_getTaggedPointerValue((__bridge void *)str1);
uintptr_t value2 = objc_getTaggedPointerValue((__bridge void *)str2);
uintptr_t value11 = objc_getTaggedPointerValue((__bridge void *)str11);
uintptr_t value22 = objc_getTaggedPointerValue((__bridge void *)str22);
// 以16进制形式输出
NSLog(@"%lx", value1);
NSLog(@"%lx", value11);
NSLog(@"%lx", value2);
NSLog(@"%lx", value22);

控制台输出:

1
2
3
4
TaggedPointer[89535:3033433] 311
TaggedPointer[89535:3033433] 31312
TaggedPointer[89535:3033433] 321
TaggedPointer[89535:3033433] 32322

即 “1” = 0x31 1,最后一位表示长度,在ASCII码表中31表示的就是字符1。而且从字符串“11”的结果我们也可以验证上面的说法。

isa 指针(NONPOINTER_ISA)

上面我们说了,对于一个对象的存储,苹果做了优化,那么对于ISA指针呢?

对象的isa指针,用来表明对象所属的类类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
union isa_t {
isa_t() { }
isa_t(uintptr_t value) : bits(value) { }

Class cls;
uintptr_t bits;
#if defined(ISA_BITFIELD)
struct {
ISA_BITFIELD; // defined in isa.h
};
#endif
};

同时结合下图,我们可以更清晰的了解isa指针的作用以及类对象的概念。

从图中可以看出,我们所谓的isa指针,最后实际上落脚于isa_t的联合类型。那么何为联合类型呢?
联合类型是C语言中的一种类型,是一种n选1的关系,联合的作用在于,用更少的空间,表示了更多的可能的类型,虽然这些类型是不能够共存的。比如isa_t 中包含有clsbitsstruct三个变量,它们的内存空间是重叠的。在实际使用时,仅能够使用它们中的一种,你把它当做cls,就不能当bits访问,你把它当bits,就不能用cls来访问。

对于isa_t联合类型,主要包含了两个构造函数isa_t(),isa_t(uintptr_t value)和三个变量cls,bits,struct,而uintptr_t的定义为typedef unsigned long

当isa_t作为Class cls使用时,这符合了我们之前一贯的认知:isa是一个指向对象所属Class类型的指针。然而,仅让一个64位的指针表示一个类型,显然不划算。

因此,绝大多数情况下,苹果采用了优化的isa策略,即,isa_t类型并不等同而Class cls, 而是struct

struct

下面我们先来看下struct的结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ISA_BITFIELD定义如下
# if __arm64__
# define ISA_MASK 0x0000000ffffffff8ULL
# define ISA_MAGIC_MASK 0x000003f000000001ULL
# define ISA_MAGIC_VALUE 0x000001a000000001ULL
# define ISA_BITFIELD \
uintptr_t nonpointer : 1; \
uintptr_t has_assoc : 1; \
uintptr_t has_cxx_dtor : 1; \
uintptr_t shiftcls : 33; /*MACH_VM_MAX_ADDRESS 0x1000000000*/ \
uintptr_t magic : 6; \
uintptr_t weakly_referenced : 1; \
uintptr_t deallocating : 1; \
uintptr_t has_sidetable_rc : 1; \
uintptr_t extra_rc : 19
# define RC_ONE (1ULL<<45)
# define RC_HALF (1ULL<<18)

注意:成员后面的:表明了该成员占用几个bit
而每个成员的意义如下表

标志位说明

成员 bit位 说明
nonpointer 1bit 标志位。1(奇数)表示开启了isa优化,0(偶数)表示没有启用isa优化。所以,我们可以通过判断isa是否为奇数来判断对象是否启用了isa优化
has_assoc 1bit 标志位。表明对象是否有关联对象。没有关联对象的对象释放的更快。
has_cxx_dtor 1bit 标志位。表明对象是否有C++或ARC析构函数。没有析构函数的对象释放的更快
shiftcls 33bit 类指针的非零位。
magic 6bit 固定为0x1a,用于在调试时区分对象是否已经初始化。
weakly_referenced 1bit 标志位。用于表示该对象是否被别的对象弱引用。没有被弱引用的对象释放的更快。
deallocating 1bit 标志位。用于表示该对象是否正在被释放。
has_sidetable_rc 1bit 标志位。用于标识是否当前的引用计数过大,无法在isa中存储,而需要借用sidetable来存储。(这种情况大多不会发生)
extra_rc 19bit 对象的引用计数减1。比如,一个object对象的引用计数为7,则此时extra_rc的值为6。

从上表我们发现,extra_rchas_sidetable_rc是和引用计数相关的标志位,当extra_rc 不够用时,还会借助sidetable来存储计数值,这时,has_sidetable_rc会被标志为1。

接下来我们来验证下,这些标志位是否真的如表中介绍那样。

引用计数

我们先来看下面这段代码

1
2
3
4
- (void)testisa {
NSObject *obj = [[NSObject alloc] init];
NSLog(@"1. obj isa_t = %p", *(void **)(__bridge void*)obj);
}

控制台输出结果

1
TaggedPointerDemo[59983:2185591] 1. obj isa_t = 0x1a1f335beb1

我们将地址0x1a1f335beb1转换过后:

我们看到这时候 对象是nonpointer开启了isa优化,且当前的引用计数器为 extra_rc = 0 + 1 = 1;

下面我们接着测试

1
2
3
4
5
NSObject *obj = [[NSObject alloc] init];
NSLog(@"1. obj isa_t = %p", *(void **)(__bridge void*)obj);
_obj1 = obj;
NSObject *tmpObj = obj;
NSLog(@"2. obj isa_t = %p", *(void **)(__bridge void*)obj);

控制台输出为

1
2
TaggedPointerDemo[63235:2266690] 1. obj isa_t = 0x1a1f335beb1
TaggedPointerDemo[63235:2266690] 2. obj isa_t = 0x41a1f335beb1

我们将地址0x41a1f335beb1转换过后:

我们看到这时候,我们将obj强引用之后,又实用了一个局部变量对其进行引用,所以这时的引用计数应该为2,当然从图中我们也可以验证这一点。

weakly_referenced

我们这次添加一个弱引用来验证

1
2
 _weakRefObj = _obj1;
NSLog(@"3. obj isa_t = %p", *(void **)(__bridge void*)_obj1);

控制台输出为

1
TaggedPointerDemo[63235:2266690] 3. obj isa_t = 0x45a1f335beb1

这时候我们仅仅通过地址进行判断 当添加了_obj2 = _obj1后,地址变为0x61a1f335beb1与之前地址0x41a1f335beb1对比

上图我们可以看到weakly_referenced标志位被置为1.表示这个对象有被弱引用。

has_assoc

然后我们在添加一个关联属性

1
2
3
NSObject *attachObj = [[NSObject alloc] init];
objc_setAssociatedObject(_obj1, "attachKey", attachObj, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
NSLog(@"4. obj isa_t = %p", *(void **)(__bridge void*)_obj1);

控制台输出为:

1
TaggedPointerDemo[63235:2266690] 4. obj isa_t = 0x45a1f335beb3

从上图中我们看到has_assoc标志位被置为1.

总结

截止到这里,我们通过观察NSTaggedPointer,相关标志位我们基本了解了NSTaggedPointer是如何存储数据以及标志位的作用。

参考文章

Objective-C中伪指针Tagged Pointer

Friday Q&A 2015-07-31: Tagged Pointer Strings