13: 汇编分析String、Array底层

一:汇编分析String底层

汇编分析String、Array底层视频

iOS程序的内存布局

面试题
  • 1个String变量占用多少内存?
  • 下面2个String变量,底层存储有什么不同?
var str1 = "0123456789"
var str2 = "0123456789ABCDEF"
  • 如果对String进行拼接操作,String变量的存储会发生什么变化?
str1.append("ABCDE")
str1.append("F")
str2.append("G")
题目1: 个String变量占用多少内存?
import Foundation
var str1 = "0123456789"
  • 打开debug汇编:debug->debug Workflow->Always show Disassembly


1.0x100003f6a <+26>: movq %rax, 0x409f(%rip) ; Study_Share.str1 : Swift.String: 占用8个字节

  1. 0x100003f71 <+33>: movq %rdx, 0x40a0(%rip) ; Study_Share.str1 : Swift.String + 8 0x100003f71 <+33>: movq %rdx, 0x40a0(%rip) ; Study_Share.str1 : Swift.String + 8: 占用高地址8个字节

所以总共占用16字节

  • 另一种验证方式

print(MemoryLayout.stride(ofValue: str1)) 输出16

题目2: 下面2个String变量,底层存储有什么不同?
var str1 = "0123456789"
var str2 = "0123456789ABCDEF"

我们来继续分析str1内存地址:


地址a+地址b=str1的内存地址 0x100008038
我们用x/2xg打印该地址0x100008038会发现很有趣的问题
ASCII码表

`(lldb) x/2xg 0x100008038
0x100008038: 0x3736353433323130 0xea00000000003938`

ASCII表

发现有个特点: 30表示为0,
我们发现0x3736353433323130 0xea00000000003938地址从小端模式30到39就已经表示了"0123456789", 那0xea其中a表示自负串长度,如a等于10。那问题来了a这个地址的最大值多少呢?是的,你猜想的是对的,最大值是15,即自符串最大长度为15。字符串的值直接存在用地址表示,这个就很类似OC的tagger pointer

现在我们来试试你的猜想,试试字符串长度大于15的情况

var str1 = "0123456789ABCDEF"

此时str1的真实地址就是rip+rdi

(lldb) x/2xg 0x100008038
0x100008038: 0xd000000000000010 0x8000000100003f60

说明0123456789ABCDEF就不再存储在0xd000000000000010 0x8000000100003f60上面了。
接下来我们汇编分析下var str1 = "0123456789ABCDEF"的流程及怎么存储的

我们给callq方法打上断点,然后进入到这个函数:
方式1: 按住control键 然后点击如图鼠标处图标


方式2:打印台输入si即可 (结束finish)
如图会进入

  1. Swift的 String.init初始化器

  2. 然后会执行cmpq $0xf, %rsi
    rsi表示字符串长度
    然后cmpq作用的用来比较大小的,cmpq $0xf, %rsi就是rsi比较0xf(长度15)。
    15是分界线,会走到不同的流程

0x100008038: 0xd000000000000010 0x8000000100003f60
字符串的真实地址:0x7fffffffffffffe0 - 0x8000000100003f60
字符串的真实地址: 0x0000000100003f60 + 0x20 0x8换成0x0

0x8000000100003f60 - 0x7fffffffffffffe0 = 0x100003F80

(lldb) x 0x100003F80
0x100003f80: 30 31 32 33 34 35 36 37 38 39 41 42 43 44 45 46  0123456789ABCDEF
0x100003f90: 00 0a 00 20 00 00 00 00 18 fe ff ff 03 00 00 00  ... ............

真实地址0x100003F80发现他就存储着 0123456789ABCDEF的值
0xd000000000000010又有啥作用呢 其实这个10表示的是自负串长度

  • 扩展: 那0123456789ABCDEF具体存储在什么局呢
    其实可以直接看mach-o文件的,我们直接使用MachOView软件查看
    如图所示:存储在常量区
`//字符串长度<=0xF, 字符串内存直接存在str1变量的内存中`
var str1 = "0123456789"

`//字符串长度>0xF, 字符串内容存放在__TEXT.cstring中(常量区)常量区是没法再更改的`
var str2 = "0123456789ABCDEFR"
题目三:如果对String进行拼接操作,String变量的存储会发生什么变化?
str1.append("ABCDE")
str1.append("F")
str2.append("G")

结论:

1. 字符串长度<=0xF, 字符串内存直接存在str1变量的内存中

var str1 = "0123456789"

2. 直接字面量创建的 字符串长度>0xF, 字符串内容存放在__TEXT.cstring中(常量区) 常量区是没法再更改的

var str2 = "0123456789ABCDEFR"

3. 由于字符串长度<=0xF 15位,所以字符串内容依然存放在str1变量的内存中

str1.append("F")

4. 再次append之后字符串长度>0xF,这个时候内存地址已无法再存放,所以会开辟堆空间

str1. append("GHH")

注:在这里我们就汇编分析证明字符串存储在堆上的情况了,因为发现不同系统版本卡的流程有较大区别,很繁杂

二:关于Array的思考

官方定义的数组是结构体(值类型):

public struct Array<Element>

  • 1个Array变量占用多少内存?
    构体的内存占用大小是把存放到结构体中的变量占用内存大小加起来(字节对齐)。
    示例代码:

struct Point {
var x = 0, y = 0
}
var p = Point()
print(MemoryLayout.stride(ofValue: p))
// 输出:16
var arr = [1, 2, 3, 4]
print(MemoryLayout.stride(ofValue: arr))
// 输出:8

结构体一共占用16个字节内存。
数组也是结构体,占用内存大小的计算方法是否和上面的示例代码一致呢?
很遗憾,只占用8个字节内存,且是Int
类型占用的内存大小。那么数组里面的内容是存放在哪里呢?

  • 数组中的数据存放在哪里?

通过汇编分析可以知道,数组中数据是存放在堆空间的,数组变量内存存放着堆空间的数组对象地址。

示例代码:

var arr = [1, 2, 3, 4]
print(Mems.memStr(ofRef: arr))
/*
输出:
0x00007fff8e5f54d8
0x0000000200000002
0x0000000000000004
0x0000000000000008
0x0000000000000001
0x0000000000000002
0x0000000000000003
0x0000000000000004
*/

通过内存布局看到,数组内容需要跳过前面的32个字节。那么前面的字节分别存放着什么东西呢?

第一段8个字节:存放着数组相关引用类型信息内存地址
第二段8个字节:数组的引用计数
第三段8个字节:数组的元素个数
第四段8个字节:数组的容量
后面依次存放着数组的元素
数组的容量会自动扩容至元素个数的两倍,且是8的倍数。

用来窥探Swift内存的小工具

三:总结

平时我都知道他们是值类型,但是通过汇编窥探发现,String 也有存放在堆空间的情况, Array 就是放在堆空间的,这不是和 Swift 说的 值类型冲突了吗? 并不是这样,底层实现是苹果官方的做法,他把数组设计成引用类型,只限于在底层的实现 , 但是对于我们使用着 String 和 Array 的人来说, String 和 Array 行为表现官方定义它就是表现为值类型,我们开发者看到 String 和 Array ,就知道是值类型,把它当做值类型来用。

四:复习OC-NSString

面试题

以下代码会有什么问题?

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    dispatch_queue_t queue = dispatch_queue_create("com.cjl.cn", DISPATCH_QUEUE_CONCURRENT);
    /// 代码段1
    for (int i = 0; i<10000; i++) {
      dispatch_async(queue, ^{
          self.name = @"1234567890ABCDEFG";  // // 字面量创建的对象,值存在常量区,不会产生过度释放问题
          NSLog(@"self.name %@",self.name);
       });
    }
    
    /// 代码段2
    for (int i = 0; i<10000; i++) {
      dispatch_async(queue, ^{
          self.name = [NSString stringWithFormat:@"CJL"];  // alloc 堆 iOS优化 - taggedpointer
          NSLog(@"self.name %@",self.name);
       });
    }
    
    /// 代码段3
    for (int i = 0; i<10000; i++) {
        dispatch_async(queue, ^{
            self.name = [NSString stringWithFormat:@"1234567890ABCDEFG"];
            NSLog(@"self.name %@",self.name);
        });
    }
}
1)先说结论吧
  • 代码段1: 不会有任何问题, 无论字符串长度是多少,都不会有问题,此时name是__NSCFConstantString,字符串存放在常量区
  • 代码段2/代码段3: 但由数字、英文字母组合且长度超过9位 或者有中文或者其他字符时,就会崩溃, 原理: 当符合上述条件时, 此时name是NSTaggedPointerString小对象 , 字符串值直接用地址表示,当不符合上述条件是,这个时候就是一个堆对象了__NSCFString,此时多线程先问,就会多线程执行[_name release], 导致过度释放,然后崩溃了
2)NSString的内存管理主要分为3种
  • __NSCFConstantString:字符串常量,是一种编译时常量,retainCount值很大,对其操作,不会引起引用计数变化,存储在字符串常量区, 字面量创建的都是字符串常量, 推荐使用

  • __NSCFString:是在运行时创建的NSString子类,创建后引用计数会加1,存储在堆上

  • NSTaggedPointerString:标签指针,是苹果在64位环境下对NSString、NSNumber等对象做的优化。对于NSString对象来说

    • 当字符串是由数字、英文字母组合且长度小于等于9时,会自动成为NSTaggedPointerString类型,存储在常量区`

    • 当有中文或者其他特殊符号时,会直接成为__NSCFString类型,存储在堆区,但用字面量创建时,依然存储在常量区,为NSCFConstantString类型。

3). 获知NSString是否是NSTaggedPointerString,最简单的验证方式是: p/x 打印
  • 直接看打印台:
  • 看地址:
    Tagged Pointer 标记:x86最后一位是标记位,arm64最高位是标记位。1表示是Tagged Pointer对象,0表示是普通对象。
4) 那为什么NSTaggedPointerString小对象不会多线程不会产生过度释放的问题呢?

我们来分析源码:

  • 进入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 既包含指针,也包含值

在之前的文章讲类的加载时,其中的_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采用了混淆处理, 无法直接从地址获得字符串值,如下所示

  • 我们可以在源码中通过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为例,假设mask0101 1000

    1010 0001 
   ^0101 1000 mask(编码)
    1111 1001
   ^0101 1000 mask(解码)
    1010 0001

  • 所以在外界,为了获取小对象的真实地址,我们可以将解码的源码拷贝到外面,将NSString混淆部分进行解码,如下所示

观察解码后的小对象地址,其中的62表示bASCII码,
到这里,我们验证了小对象指针地址中确实存储了值,那么小对象地址高位其中的0xa、0xb又是什么含义呢?

//NSString
0xa000000000000621

//NSNumber
0xb000000000000012
0xb000000000000025

  • 需要去源码中查看_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主要是用于判断是否是小对象taggedpointer,即判断条件,判断第64位上是否为1(taggedpointer指针地址即表示指针地址,也表示值)

  • 0xa 转换成二进制为 1 010(64位为1,表示为taggedpointer,63~61后三位表示 tagType类型: 2,表示NSString类型)

  • 0xb 转换为二进制为 1 011(64位为1,表示为taggedpointer,63~61后三位表示 tagType类型 : 3,表示NSNumber类型),这里需要注意一点,如果NSNumber的值是-1,其地址中的值是用补码表示的

这里可以通过_objc_makeTaggedPointer方法的参数tag类型objc_tag_index_t进入其枚举,其中 2表示NSString3表示NSNumber

image
  • 同理,我们可以定义一个NSDate对象,来验证其tagType是否为6。通过打印结果,其地址高位是0xe,转换为二进制为1 110,排除64位的1,剩余的3位正好转换为十进制是6,符合上面的枚举值

    image

Tagged Pointer 总结

  • Tagged Pointer小对象类型(用于存储NSNumber、NSDate、小NSString),小对象指针不再是简单的地址,而是地址 + 值,即真正的值,所以,实际上它不再是一个对象了,它只是一个披着对象皮的普通变量而以。所以可以直接进行读取。优点是占用空间小 节省内存

  • Tagged Pointer小对象 不会进入retain 和 release,而是直接返回了,意味着不需要ARC进行管理,所以可以直接被系统自主的释放和回收

  • Tagged Pointer内存并不存储在堆中,而是在常量区中,也不需要malloc和free,所以可以直接读取,相比存储在堆区的数据读取,效率上快了3倍左右创建的效率相比堆区快了近100倍左右

  • 所以,综合来说,taggedPointer的内存管理方案,比常规的内存管理,要快很多

  • Tagged Pointer的64位地址中,前4位代表类型,后4位主要适用于系统做一些处理,中间56位用于存储值

  • 优化内存建议:对于NSString来说,当字符串较小时,建议直接通过@""初始化,因为存储在常量区,可以直接进行读取。会比WithFormat初始化方式更加快速

附上可运行的OC源码
demo

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 198,154评论 5 464
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,252评论 2 375
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 145,107评论 0 327
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,985评论 1 268
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,905评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,256评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,978评论 3 388
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,611评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,891评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,910评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,736评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,516评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,995评论 3 301
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,132评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,447评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,034评论 2 343
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,242评论 2 339

推荐阅读更多精彩内容