1 背景
iPhone5s 是首个采用 64 位架构的 A7 双核处理器的手机,为了节省内存和提高执行效率,苹果提出了 Tagged Pointer 的概念。对于 64 位程序,引入 Tagged Pointer 后,相关逻辑能减少一半以上的内存占用,以及 3 倍的访问速度提升,100 倍的创建、销毁速度提升。
本文将带我们来理解这个概念 是怎么节省内存和提高执行效率的。(注:本篇文章所用系统 皆为 64 位系统)
2 不使用 Tagged Pointer 的情况
以 NSNumber *a = @(1);为例,在不使用 Tagged Pointer 的情况下,我们看下在内存上和访问效率上都是什么情况。
在内存上:
如下图所示, 1 个小对象 需要至少使用 24 字节(指针 8 字节 + 对象 16 字节 )
栈:在栈上,占 1 个指针 8 字节,里面存储的是堆内存的地址 0x600001a92920。
堆:在堆上,占 16 个字节,isa 指针占 8 个字节,1 为 int 类型,占 4 个字节,但由于内存对齐机制(ios 内存对齐 为 16 字节),堆需要 16 个字节的内存。
在效率上:
NSNumber 对象需要动态分配内存、维护引用计数、管理它的生命周期等
方法调用 需要 objc_msgSend 的执行流程(消息发送、动态方法解析、消息转发)
3 使用 Tagged Pointer 的情况
3.1 苹果对 Tagged Pointer 的介绍
苹果对 Tagged Pointer 的介绍主要有三点:
Tagged Pointer 被设计的目的是用来存储较小的对象,例如 NSNumber、NSDate、NSString 等;
Tagged Pointer 的值不再表示地址,而是真正的值;
在内存读取上有着 3 倍的效率,创建时比以前快 106 倍 ;
3.2 Tagged Pointer 实质
Tagged Pointer 实质是一个伪指针,对象的指针中存储的数据变成了 Tag+Data 形式:
Tag 为特殊标记,用于区分是否是 Tagged Pointer 指针 以及区分 NSNumber、NSDate、NSString 等对象类型;
Data 为对象对应存储的值。
在内存上:只占一个指针的大小 8 字节,节省了很多内存开销;
在效率上:objc_msgSend 先识别 是否为 Tagged Pointer,若是,直接返回 不进行其他流程;若不是,进行其他流程(消息发送、动态方法解析、消息转发) 。从而节省了调用开销。
3.3 现状
一般我们在存放 NsNumber 和 NSDate 这一类变量的时候本身占用的内存大小常常不需要 8 个字节。4 字节带符号的整数可以达到 2^31=2147483648,99.999%的情况都能满足了。所以大部分都可以用 Tagged Pointer 类型,不满足的则申请堆内存。
4 Tagged Pointer 原理分析
4.1 设置环境变量
设置环境变量 OBJC_DISABLE_TAG_OBFUSCATION 为 YES, 为关闭 Tagged Pointer 的数据混淆;
设置环境变量 OBJC_DISABLE_TAGGED_POINTERS 为 YES, 来禁用 Tagged Pointer(目前不生效)在以前的版本,设置 OBJC_DISABLE_TAGGED_POINTERS 为 YES 会导致程序崩溃,是 runtime 中进行了判断,调用_objc_fatal()导致的程序崩溃。
4.2 举例分析
我们设置环境变量 OBJC_DISABLE_TAG_OBFUSCATION 为 YES,关闭了数据混淆可以看出:number1 的内存为 0xb000000000000012、number2 的内存为 0xb000000000000022、number3 的内存为 0xb000000000000032。并且 number1 的值为 1、number2 的值为 2、number3 的值为 3。
通过观察发现,对象的值 1、2、3 都存储在了对应的指针中,对应 0xb000000000000012 中的 1、0xb000000000000022 中的 2、0xb000000000000032 中的 3。(混淆为苹果对于数据的保护)而 numberFFFF 的值 0xFFFFFFFFFFFFFFFF,由于数据过大,导致无法 1 个指针 8 个字节的内存根本存不下,而申请了堆内存。
我们都知道所有的 oc 对象都有 isa 指针,那么判断一个指针是否是伪指针最重要的证据是其 isa 指针了,我们看下他们对应的 isa 指针,如下图:
由上图我们可以看出,number1、number2、number3 指针为 Tagged Pointer 类型,为伪指针,isa 指针为 nil。numberFFFF 的 isa 指针真实存在,在堆内存中分配了空间,不是 Tagged Pointer 类型。
以上例子从内存值 和 isa 两方面来验证了 Tagged Pointer 的定义,结合例子我们做下总结:
Tagged Pointer 为 Tag+Data 形式,其中 Data 为内存地址中的 1、2、3 (红色),为存储对应着对象的值。(例:0xb000000000000012 中的 1)
但是内存地址: 0xb000000000000012 中对应的“b” 和 “2”,代表什么?
4.3 解析
我们先看结果,再分析。
4.3.1 解析结果
以上面例子中的 0xb000000000000012 为例,指针中的 b 代表什么?
b 的二进制为 1011,其中第一位 1 是 Tagged Pointer 标识位,代表这个指针是 Tagged Pointer;后面的 011 是类标识位,对应十进制为 3,表示 NSNumber 类。
指针中的 2 代表什么?
2 代表数据类型(NSNumber 为 short、 int、 long 、 float 、 double 等。NSString 为 string 长度)。
以 iOS 中 NSNumber 为例,我们看下图按照位域操作,Tag 和 Data 分别显示在什么位置、代表什么。
Tagged Pointer 的 Tag 标记,为最高 4 位。其余为 NSNumber 数据。下面会分别对标识位、类标识、数据类型做代码验证。
4.3.2 Tagged Pointer 标识位
如何判断为 Tagged Pointer?
在源码 objc_internal.h 中可以找到判断 Tagged Pointer 标识位的方法,如下代码:
将一个指针与 _OBJC_TAG_MASK 掩码 进行按位与操作。这个掩码_OBJC_TAG_MASK 的源码同样在 objc_internal.h 中可以找到:
根据源码得知:
MacOS 下采用 LSB(Least Significant Bit,即最低有效位)为 Tagged Pointer 标识位;(define _OBJC_TAG_MASK 1UL)
iOS 下则采用 MSB(Most Significant Bit,即最高有效位)为 Tagged Pointer 标识位。(define _OBJC_TAG_MASK (1UL<<63))< span="">
如下图,以 NSNumber 为例:
在 iOS 中,1 个指针 8 个字节,64 位,最高位为 1,则为 Tagged Pointer。
同理在上面 4.3.1 Tag 解析结果一节中,以 0xb000000000000012 为例:
0xb000000000000012 为 16 进制指针中的最高位 b 的二进制为 1011,最高位为 1,则代表这个指针是 Tagged Pointer。
且_objc_isTaggedPointer 判断 Tagged Pointer 标识位是处处优先判断的。如下面源码(下面源码只展示相关部分)所示:
在源码 objc_object.h 中可以找到的 objc_object::rootRetain 方法,该方法为引用计数+1 的方法,在这个方法中,优先判断是否是 Tagged Pointer,Tagged Pointer 为伪指针,不需要记录引用计数。
在源码 objc_object.h 中可以找到的 objc_object::rootRelease 方法,该方法为引用计数-1 的方法,在这个方法中,优先判断是否是 Tagged Pointer,Tagged Pointer 为伪指针,不需要记录引用计数。
objc_msgSend 为汇编代码,但其实里面也优先做了 Tagged Pointer 标识位判断。如果不是 Tagged Pointer 则进行消息转发等流程。
Tagged Pointer 的判断是如此的简单,只是二进制的与运算。
4.3.3 Tagged Pointer 类标识
从苹果官方介绍来看, Tagged Pointer 被设计的目的是用来存储较小的对象,例如 NSNumber、NSDate、NSString 等;那么 Tagged Pointer 只是一个伪指针,一个 64 位的二进制,如何来区分是 NSNumber 呢?还是 NSString 等呢?
在源码 objc_internal.h 中可以查看到 NSNumber、NSDate、NSString 等类的标识位,这里只展示我们关心的类型,全面的在 4.4 里有介绍。
下面让我们举例验证,不同的类型,输出一下看看地址:
根据输出我们可以看到:
NSNumber 指针 0xb000000000000012,b 的二进制为 1011,后面的 011 是类标识位,对应十进制为 3,表示 NSNumber 类;
NSString 指针 0xa000000000000611, a 的二进制为 1010,后面的 010 是类标识位,对应十进制为 2,表示 NSString 类。
如图,类标识位置如下:
4.3.4 Tagged Pointer 数据类型
我们知道了以 NSNumber 为例的地址 0xb000000000000012 的数据数值、Tagged Pointer 标识位、Tagged Pointer 类标识。那么最后一位 2 代表的是什么呢?
16 进制的最后一位(即 2 进制的最后四位)表示数据类型。同样我们举例验证:
可以看到,我们都用 NSNumber 类,用不同数据类型做测试,内存地址 16 进制只有最后一位发生了变化。其对应的数据类型分别为:
NSString、NSDate 的二进制最后四位 都是数据类型么?你可以自己去验证一下~
如图,数据类型位置如下:
至此我们就把 Tagged Pointer 实质 Tag+Data 完整地解析了一遍。
4.4 Tagged pointer 注释
在源码 objc-runtime-new.mm 中有一段注释对 Tagged pointer objects 进行了解释,原文如下:
对应注释翻译:
Tagged pointer 指针对象将 class 和对象数据存储在对象指针中;指针实际上不指向任何东西。
Tagged pointer 当前使用此表示形式:
(LSB)(macOS)64 位分布如下:
1 bit 标记是 Tagged Pointer
3 bits 标记类型
60 bits 负载数据容量,(存储对象数据)
(MSB)(iOS)64 位分布如下:
tag index 表示对象所属的 class
负载格式由对象的 class 定义
如果 tag index 是 0b111(7), tagged pointer 对象使用 “扩展” 表示形式
允许更多类,但 有效载荷 更小
(LSB)(macOS)(带有扩展内容)64 位分布如下:
1 bit 标记是 Tagged Pointer
3 bits 是 0b111
8 bits 扩展标记格式
52 bits 负载数据容量,(存储对象数据)
在这些表示中,某些体系结构反转了 MSB 和 LSB。
从注释中我们得知:
Tagged pointer 存储对象数据目前 分为 60bits 负载容量和 52bits 负载容量。
类标识允许使用扩展形式。
那么如何判断负载容量?类标识的扩展类型有那些?我们来看下全面的 objc_tag_index_t 源码:
小结:
区分什么位置为负载内容位
MacOS 下采用 LSB 即 OBJC_TAG_First60BitPayload、OBJC_TAG_First52BitPayload。
iOS 下则采用 MSB 即 OBJC_TAG_Last60BitPayload、OBJC_TAG_Last52BitPayload。
区分负载数据容量
当类标识为 0-6 时,负载数据容量为 60bits。
当类标识为 7 时(对应二进制为 0b111),负载数据容量为 52bits。
类标识的扩展类型有哪些?
如果 tag index 是 0b111(7), tagged pointer 对象使用 “扩展” 表示形式
类标识的扩展类型为上面 OBJC_TAG_Photos_1 ~OBJC_TAG_NSIndexSet。
类标识与负载数据容量对应关系
当类标识为 0-6 时,负载数据容量为 60bits。即 OBJC_TAG_First60BitPayload 和 OBJC_TAG_Last60BitPayload,负载数据容量 的取值区间也为 0 - 6。
当类标识为 7 时,负载数据容量为 52bits。即 OBJC_TAG_First52BitPayload 和 OBJC_TAG_Last52BitPayload,负载数据容量的取值区间为 8 - 263。
你品,你细品这里。只要一个 tag,既可以区分负载数据容量,也可以区分类标识,就是这么滴强大~
5 创建 Tagged Pointer
我们知道了 Tagged Pointer 的实质 Tag+Data,知道了 Tag 对应什么,Data 对应什么。那么为什么 NSNumber、NSDate、NSString 会转成为伪指针呢?其他的为什么不会呢?NSNumber、NSDate、NSString 是如何生成 Tagged Pointer 的?下面让我们继续探索 Tagged Pointer。
5.1 Tagged Pointer 初始化
5.1.1 初始变量设置
在_read_images()方法中,有两处关键代码如下:
上面方法主要分两部分:
disableTaggedPointers():禁用 Tagged Pointer,与环境变量 OBJC_DISABLE_TAGGED_POINTERS 相关。这里就不详述了~
initializeTaggedPointerObfuscator():初始化 TaggedPointer 混淆器:用于保护 Tagged Pointer 上的数据。我们看下这个方法的源码:
上面方法主要分三部分:
objc_debug_taggedpointer_obfuscator 是一个 unsigned long 类型的全局变量。objc_debug_taggedpointer_obfuscator 的用处,下面有用到~
对于一些旧版本 和 环境变量(OBJC_DISABLE_TAG_OBFUSCATION),禁用 tagged pointers 混淆。设置 objc_debug_taggedpointer_obfuscator 为 0,不混淆。
获得 objc_debug_taggedpointer_obfuscator 的值:
将随机数据放入变量中,然后移走所有非有效位。
和 ~_OBJC_TAG_MASK 作一次与操作。
5.1.2 Tagged Pointer 注册校验
为什么 NSNumber、NSDate、NSString 会转成为伪指针呢?其他的为什么不会呢?
加载程序时,从 dyld 库的_dyld_start()函数开始,经历了多般步骤,开始调用_objc_registerTaggedPointerClass() 函数。下面我们来看下在源码 objc-runtime-new.mm 中该方法的实现:
方法主要分为以下三部分:
判断是否禁用 Tagged Pointer,若禁用,则终止程序。
根据指定 tag 获取类指针。若 tag 被用于两个不同的类,则终止程序。
判断负载数据容量如果是 52bits 进行特殊处理,在 OBJC_TAG_RESERVED_7 处存储占位类 OBJC_CLASS_$___NSUnrecognizedTaggedPointer。
其实这个方法 起的名字是注册,在我看来,应该叫校验。校验在全局数组(以 tag 进行位操作 为索引,类为 value,的全局数组)中,用 tag 取出来的类指针 与 注册的类是否相符。
这里我们主要关注下_objc_registerTaggedPointerClass()方法的精髓第二点、根据指定 tag 获取类指针。我们看下 classSlotForTagIndex 的源码实现:
以上方法主要分为三部分:
根据负载数据容量是 60bits 还是 52bits,区分为类标识是基础类标识还是扩展类标识。也可以说根据 tag 类标识区间判断。
tag 是基础类标识,返回 classSlotForBasicTagIndex(tag)的结果;
tag 是扩展类标识,对 tag 进行位操作,然后取出存在 objc_tag_ext_classes 数组里的结果返回。
这里有两个重要的全局数组:
数组 objc_tag_classes:存储苹果定义的几个基础类;
数组 objc_tag_ext_classes:存储苹果预留的扩展类;
在源码中,包括源码中的汇编位置,都没有找到初始化这两个数组的代码~了解这两个全局数组的初始化位置的,请告知笔者,非常感谢~
我们继续看 classSlotForBasicTagIndex 的源码:
以上方法主要分为以下两个部分:
对 tag 类标识,进行了一系列的位运算。(运算里面的宏定义在 5.2 中有讲~有兴趣的可以自己算算哦~)
根据判断是 macOS or iOS,来获取 objc_tag_classes 数组里面的类指针。
5.2 生成 Tagged Pointer 指针
方法主要分为以下三部分:
根据负载内容位进行区分:
传入的 tag 为类标识,同时也可以用于区分负载数据容量,苹果根据不同的负载数据容量对 Tagged Pointer 进行了不同的处理。
对传入 objc_tag_index_t tag 和 value 进行位运算:
以 NSNumber *a = @(1); 为例:
tag 为 OBJC_TAG_NSNumber(3) 二进制:0b011,16 进制为 0x0000000000000003,负载数据容量 为 OBJC_TAG_Last60BitPayload。
value 为 数据数值(1) + 数据类型(int 为 2) 16 进制为 0x0000000000000012。
在 iOS 下 源码中的宏定义:(有兴趣的可以源码走一波~)
_OBJC_TAG_MASK :#define _OBJC_TAG_MASK (1UL<<63)
_OBJC_TAG_INDEX_SHIFT:#define _OBJC_TAG_INDEX_SHIFT 60
_OBJC_TAG_PAYLOAD_RSHIFT:#define _OBJC_TAG_PAYLOAD_RSHIFT 4
对 tag 和 value 进行运算得到指针 result:uintptr_t result = (_OBJC_TAG_MASK | ((uintptr_t)tag << _OBJC_TAG_INDEX_SHIFT) | ((value << _OBJC_TAG_PAYLOAD_RSHIFT) >> _OBJC_TAG_PAYLOAD_LSHIFT));
(uintptr_t)tag << _OBJC_TAG_INDEX_SHIFT):tag 为 0x0000000000000003 左移 _OBJC_TAG_INDEX_SHIFT(60) 得到十六进制: 0x3000000000000000;
((value << _OBJC_TAG_PAYLOAD_RSHIFT) >> _OBJC_TAG_PAYLOAD_LSHIFT):value 为 0x0000000000000012,位运算后为 0x0000000000000012;
result 为 _OBJC_TAG_MASK(1UL<<63) 和 0x3000000000000000 和 0x0000000000000012 进行 “或” 操作;
result 为 0xb000000000000012;
进行编码(数据混淆,数据保护):对 result (0xb000000000000012)进行编码,我们看下:
无论是编码还是解码,都是对 tagged pointers 与 objc_debug_taggedpointer_obfuscator 来进行 “异或” 操作。
源码里面还有很多别的方法,例如取 Tagged Pointer 指针里面的 tag 方法,获取 Tagged Pointer 指针 里面的 value 方法等,有兴趣的可以去看看,在这里不一一叙述。
6 Tagged Pointer 使用注意
我们使用 Tagged Pointer 的时候需要注意什么呢?
所有的 oc 对象都有 isa 指针,而 Tagged Pointer 并不是真正的对象,是伪指针,它没有 isa 指针。所以通过 LLDB 打印 Tagged Pointer 的 isa,会提示下图所示的错误。打印 OC 对象的 isa 没有问题,对于 Tagged Pointer,应该换成相应的方法调用,如 isKindOfClass 和 object_getClass。
至此,关于 Tagged Pointer,已经讲完~♥️
本文转载自公众号贝壳产品技术(ID:beikeTC)。
原文链接:
评论 4 条评论