一、环境介绍
mac版本
:Mac Mojave 10.14
objc版本
:objc runtime 750
二、为什么要使用TaggedPointer?
以前我们初始化一个对象(64位为例),开发的代码如下
1 | NSNumber *number2 = [NSNumber numberWithInteger:2]; |
此时的内存图如下
可以看到我就想存一个2
用掉了24
个字节,由于我们的NSNumber
和NSDate
对象的值一般不需要8
个字节,4
个字节的长度2^31=2147483648
可以表达的数量已经达到了20
多亿了,为了不造成内存的浪费,想到将指针的值(8
个字节)进行拆分,一部分表示数据,一部分用来表示是一个特殊的指针,他不执行任何对象,这就是TaggedPointer
技术,这样指针
= Data
+ Tag
,那么我们的存一个数字只需要8
个字节就够了。
三、一个简单的例子
3.1 版本新特性
1 | NSNumber *number1 = @1; |
输出结果却是这个样子的
1 | number1 pointer is 0x19ec25e574ba1459 |
这个地址有点特殊,研究了一下,发现原来是在10_14
以后苹果对TaggedPointer
进行了混淆,文件objc-runtime-new.m
写到
1 | static void |
混淆的代码也很简单,类似这种加入加密前的数据是a
,加密后的数据为b
,
那么:
加密
:b
= a
^ objc_debug_taggedpointer_obfuscator
,解密
: a
= b
^ objc_debug_taggedpointer_obfuscator
.
这里利用了异或的特性,源码如下:
1 | static inline void * _Nonnull |
所以要想知道0x19ec25e574ba1459
是什么意思,还是要知道objc_debug_taggedpointer_obfuscator
值,这是个随机值,要想获取这个值:
方法一:通过断点来获取
通过lldb指令读取
1 | (lldb) p/x objc_debug_taggedpointer_obfuscator |
方法二: 看来runtime
源码知道objc_debug_taggedpointer_obfuscator
是个全局变量,只要在我们用的地方申明一下即可
1 | extern uintptr_t objc_debug_taggedpointer_obfuscator; |
通过NSLog
打印就可以了
1 | NSLog(@"%lx",objc_debug_taggedpointer_obfuscator); |
为了方便查看,简单写了一个方法,用来解开混淆
1 | uintptr_t _objc_decodeTaggedPointer_(id ptr) { |
3.2 真实的地址
1 | NSNumber *number1 = @1; |
输出
1 | number1 pointer is 0xfda27e12be89be71---真实地址:==0x127 |
会发现,不管运行多少次,都是以27
结尾,我们有理由相信,苹果贡献了1
个字节(8个bit)来标识这是个特殊的指针,最后1个字节用来标识,这个类指针,判断是否是TaggedPointer
不同平台判断的方式不一样,但对我们理解根本不影响
1 | static inline bool |
mac
平台最后一个为1
;iPhone
和模拟器,为最高位是1
。
那么剩下的7个字节是不是都用来存放数据呢?
3.3 TaggedPointer存储的数字的最大值
1 | NSNumber *numberF13 = @(0xFFFFFFFFFFFFF); |
输出如下
1 | number1 pointer is 0x20f9850034a2e631---真实地址:==0x127 |
从输出可以看出,到numberF14
地址已经是真正的oc
对象的地址了,说明有效存储位置有56
位,所以TaggedPointer
所能表达的数字范围为[0 2^65)
。
四、思考:你会如何实现NSString
的TaggedPointer?
我们现在想做的事情就是如何利用指针来存储我们的字符数据,而指针的大小就是8
个字节,一共64
位,如何利用这个64
位呢?由NSNumber
的灵感,可以使用低1
位来表示是TaggedPointer
类型,其他三位来表示具体哪个类的,对于字符串,需要存储它的长度,再让出4
位,还剩下56
位,从而问题转为如何利用这个56
位。
计算机中存储的就是0
和1
,对于字符串的编码有ASCII
和非ASCII
:
ASCII
是利用一个字节的大小表示字符的,一共是128
个(最高位都为0);- 后面为了统一编码出现了
Unicode
编码,Unicode
是规定了符号的二进制代码,没有规定如何存储,具体如何存储的,后来就出现了,UTF-16
(字符用两个字节或四个字节表示)、UTF-32
(字符用四个字节表示)和UTF-8
(最常用的,兼容了ASCII
)
对于非ASCII
:
- 如果是
UTF-32
编码的,要想包含所有Unicode
,需要4
个字节,那么最多也只能保存1个字符,没有任何意义; - 如果是
UTF-16
编码的,要想包含所有Unicode
,也需要4
个字节,最少也需要2
个字节,按最少的算,那么56
位,也只能放3
个16
为的字符,还是很少; - 如果是
UTF-8
,如果撇开ASCII
的话,那么也是最多需要4
个字节,最少2
个字节,56
位还是最多放3
个字节。
对于非ASCII
我们貌似没有找到一个好的方案来存储,那么我们要实现TaggedPointer
的话,是不是可以不考虑非ASCII
的情况,毕竟在实际场景,我们用到ASCII
的场景的几率还是比非ASCII
大的多,对于非ASCII
的还是交给开辟控件的方式。
对于ASCII
:
如果我们不考虑非ASCII
的话,那么有以下方案可以用来存储数据:
- 方案一: 使用
8
位存储一个字符,这也是默认计算机存储ASCII
的方式,由于占用一个字节,那么这种方式56
位可以放7
个字节; - 方案二: 使用
7
位存储一个字符,ASCII
其实真正存储数据的是7
位,如果是用7
位表示一个字符的话,那么最多可以放8
个字节,比方案一多出一个字节; - 方案三: 使用
6
位存储,有人可能想6
位怎么可能,存储ASCII
最少也得7
位啊,6
怎么存储,是的,直接存是不行的,但是我们可以不直接存字符,而是提供一个表格,存索引。ASCII
一共有128
个,但是我们常用的根本就没有那么多,那么我们可以不可以选出一些常用的来作为我们的可选值 ?6
位的话,最多可以存储2^ 6 = 64
个不同的字符,所以肯定是不能满查找ASCII
集合,但是,我们可以找来常见的64
个字符比如[a-zA-z0-9./_-]
,这里就有66
个了,再从这个66
个里面取出2个不常用的就可以了,这样的话我们就可以存储9
个字节了; - 方案四: 使用
5
位存储,这种的话我们的查找范围就缩小为了2^5 = 32
个,也就是我们要在方案三的基础上在找出更加常用的32
个字符,这种方案可以存储11
个字符; - 方案五: 使用
4
位存储,那范围就是2^4 = 16
个,这种感觉行也行,但是范围太小了 - 更少的想想不大可能了
下面看下苹果是如何实现的
五、对于NSString苹果是如何使用TaggedPointer的?
5.1 现象
添加测试如下测试代码
1 | NSMutableString *imutable = [NSMutableString string]; |
输出,这里我省去了源地址,因为这里打印了类的类型更直观写
1 | 真实地址:0x6115 a NSTaggedPointerString |
前面提到过最后一个字节低4
位标志是TaggedPointer
信息,高4
位存放字符串的长度,所以最后一个数字5
是标志位,倒数一个数字就是字符串的长度。
从上面的输出可以看出:
- 当字符串的长度
<=7
的时候,苹果是直接存储的字符ASCII
值,a
的ASCII
值是61
,b
是62
…。 - 当字符串长度大于
7
的时候具体如何做的,我们通过逆向CoreFoundation.framework
来查看
5.2 hopper -> length
先来看下length
方法,看看是不是和我们猜测的一样
翻译一下就是
1 |
|
已经很显然了,就是拿低1字节的高4位的值,证明了我们的猜想。
5.3 hopper -> characterAtIndex
苹果是如何将字符转成NSTaggedPointerString
的,不是很好查,但是我们可以反向思考,通过取数据来反推如何存的,
下面开始简化该伪代码,如果你觉得不想看,可以直接跳到第四次简化
开始看。
___stack_chk_guard
是为了安全加的,不考虑,前面分析过((((r8 ^ rdi) & 0xe) == 0xe ? 0x1 : 0x0) << 0x3 | 0x4)
在这里等价于0x4
,arg2
就是传进来的index
5.3.1 第一次简化
1 | unsigned short -[NSTaggedPointerString characterAtIndex:](void * self, void * _cmd, unsigned long long arg2) { |
继续分析这段代码
self >> 0x4 & 0xf;
其实就是字符串的length
self >> 0x4 >> 0x4;
其实就是字符串的开始位置0xffffffffffffffc7
其实是-0x39 = -57
的补码,0xffffffffffffffc7
是-0x38 = -56
的补码
5.3.2 第二次简化
1 | unsigned short -[NSTaggedPointerString characterAtIndex:](void * self, void * _cmd, unsigned long long arg2) { |
bp
其实就是栈指针,这里使用bp
说明是通过bp
来操控栈空间的,然后每次循环dx
都减1,然后r8
左移6
位或者5
位,这个一般都是数组操作了,如果是5
位的话最多存11
个字节,所以这里使用一个长度11
的数组buffer[11]
,dx
其实就会游离指针了我们用变量cursor
表示
5.3.3 第三次简化
1 | unsigned short -[NSTaggedPointerString characterAtIndex:](void * self, void * _cmd, unsigned long long arg2) { |
_sixBitToCharLookup
到底是什么呢,其实就是字符串
也就是eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX
其实程序还少了一段代码,hopper
翻译伪代码的时候漏掉了
1 | 0000000000060d87 cmp rbx, 0x8 |
var_38
就是-56
其实就是将r8
的值放到[bp-56]
的内存处,由于是小端存储,其实就是讲self>> 8
的内容存放到对应的内存地址,类似于下面的代码,但是是占8
个字节的
1 | *(uint64_t *)buffer = self >> 8; |
5.3.4 第四次简化
1 | unsigned short -[NSTaggedPointerString characterAtIndex:](void * self, void * _cmd, unsigned long long arg2) { |
这就显而易见了,对于字符串苹果的处理如下:
- 对于小于
8
个字符的,使用的是8
位存储; [8,10)
的是通过6
位存储的;[10,11]
的是通过5
位存储的。
根据这个结论我们再来看下5.1
的现象,对于上面的判断条件分别选一个代表
5.3.4.1 小于8
位代表0x66656463626165 -> abcdef
可以看出是直接存储的;
5.3.4.2 [8,10)
代表:0x22038a01169585 -> abcdefgh
去掉后面的95
剩下0x22038a011695
,6
位排列如下
001000 100000 001110 001010 000000 010001 011010 010101
,每一个就对应这个字符串eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX
的索引值,为了方便查找做了一个对照表
所以
001000 100000 001110 001010 000000 010001 011010 010101
分别对应
a b c d e f g h
5.3.4.3 [10,11]
位代表abcdefghij
但是这个类是__NSCFString
并不是我们的NSTaggedPointerString
,按道理说5
位的话是可以存放10
个字节的啊,这是什么原因呢?
原来:不管是5
位还是6
位都是查询的同一个字符串eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX
,也就是上图索引表的颜色区分,5
位里面没有包含b
字符,但是我们的abcdefghij
有b
字符,所以不行,修改demo如下看看
1 |
|
输出
1 | 真实地址:0x10e5023aa86d2a5 acdefghijk NSTaggedPointerString |
可以看到能够支持11
个字节了,0x10e5023aa86d2a5
去掉0x10e5023aa86d2
,按5
位排列下看看
01000 01110 01010 00000 10001 11010 10101 00001 10110 10010
也就是 a c d e f g h i j k
所以我们可以得出能够存[10,11]
位字符是以所存字符在eilotrm.apdnsIc ufkMShjTRxgC4013
内为前提的。
最后再来看下苹果对于非ASCII
是怎么处理的,以汉字方
(Unicode)编码为\u65b9
,占3个字节,按道理也是可以放进指针里面的,我们看看苹果有没有这样做
1 | NSString *notAscii_1 = [NSString stringWithFormat:@"方"]; |
输出
1 | 源地址:0x101907df0 方 __NSCFString |
发现苹果并没有放进指针内,而是真实的oc
对象。
至此,我们之前的猜测一一验证了。
下面总结一下TaggedPointer
的特点
六、什么样的字符会放进TaggedPointer?
总结了以下表格,注意这个只适用ASCII
的情况,对于非ASCII
都是使用的oc
对象。
传入的字符任意一个不在所在行的范围,存的地方就会发生变化。
七、一个和TaggedPointer相关的面试题
下面代码会发生什么问题?
1 |
|
先说下结果吧 ,方式一会闪退,方式二正常运行。
分析这个道题,target
的set
方法实现
1 | - (void)setTarget:(NSString *)target { |
方式一是真正的oc
对象,由于是多线程会出现[_target release];
被调用多次,从而闪退;
方式二不是oc
对象,而是TaggedPointer
,在release
和retain
的时候都会判断是不是TaggedPointer
1 |
|
其他的方式可以加锁解决,就不说了。