WWDC-2020内存优化

2020WWDC 链接视频内容

作者ben来自于runtime团队, 此次更新极大的提高了内存的使用效率.

  • 首先,你可能不需要做任何改动, 你的app也会变得更快.
  • 学习如何防止别人在你的代码库上工作时, 访问他们不应该访问的东西
  • 本次涉及三个改动
    • (1): 数据结构的改变
    • (2): 方法列表的变化
    • (3): tagged pointer的变化

数据结构的改变

磁盘的数据格式是这样的->


01.png

一经使用就会发生变化

clean memory和dirty memory的区别
clean memory是指加载后不会发生更改的内存
dirty memory是指在进程运行是会发生更改的内存

类一旦被使用就会变成dirty memory
因为runtime会向它写入新的数据
dirty memory比clean memory更加消耗内存和性能, 只要进程在运行, 它就必须一直存在.
clean memory可以进行移除, 从而节省更多的内存空间. 如果你需要clean memory系统可以从磁盘中重新加载

macOS可以选择换出dirty memory, 但是因为iOS不使用swap, 所以dirty memory在iOS中代价很大.

Swap In/Out & Page In/Out

  • 磁盘内部有一个区域叫做交换空间(Swap Space),MMU(内存管理单元) 会将暂时不用的内存块内容写在交互空间上(硬盘),这就是Swap Out;当需要时候再从Swap Space中读取到内存中,这就是Swap In;Swap in和swap out的操作都是比较耗时的, 频繁的Swap in和Swap out操作非常影响系统性能;
  • Page In/Out和 Swap In/Out 概念类似,只不过Page In/Out是将某些页的数据写到内存/从内存写回磁盘交互区;而Swap In/Out是将整个地址空间的数据写到内存/从内存写回磁盘交互区;本质都是交互机制。
  • macOS支持这类交换机制,但是iOS不支持;主要有两方面考虑吧:
    • 移动设备的闪存读写次数有限,频繁写会降低寿命;
    • 相比PC机,移动设备闪存空间有限(15年6s最小存储空间16GB、最大128GB;19年XS Max最小64GB,最大521GB)
      swap链接

dirty memory是类数据被分成两部分的原因, 可以保持clean的数据越多越好, 像clean memory通过分离那些永远不会改变的数据, 可以把大部分的类数据存储为clean memory


02.png
03.png
04.png

runtime需要追踪每个类更多的信息
当类第一次被使用的时候->运行时会为它分配额外的存储容量->


05.png

runtime分配的存储容量是class_rw_t用于读写的数据
在这个数据结构中, 我们存储了只有runtime才会生成的新的信息->


06.png

所有的类会链接成一个树状结构, 通过First Subclass和Next Sibling Class指针实现的.
这就允许了runtime遍历当前使用的所有类, 这对于方法缓存无效非常有用.

但是为什么方法和属性也在只读数据中时, class_rw_t还要有方法和属性呢?
因为在运行时这些方法是可以进行更改的, 当category被加载时, 它可以向类中添加新的方法.
程序员可以使用运行时API动态地添加它们.

因为class_ro_t是只读的, 我们需要在class_rw_t中追踪这些数据.
所以, 这样的结构占用了相当多的内存, 在给定的设备中都有许多类在使用.

在iPhone上的整个系统中测量了大概有30M的class_rw_t的结构, 如何去缩小这些结构?
我们在rw部分需要这些东西, 因为它们可以在runtime进行更改, 但是通过检查实际设备的使用情况, 大约只有10%的类真正的更改了它们的方法.

07.png

demangled name这个字段只有swift会使用->


08.png

并且Siwft类并不需要这一字段, 除非有东西询问他们OC名称时才需要.

所以我们拆除平时不怎么使用的部分->


09.png

这样就将class_rw_t的大小减少了一半
对于那些确实需要额外信息的类, 可以分配这些扩展记录中的一个, 并把它划到类中供其使用.->


10.png

90%的类不需要这些扩展数据, 在系统范围内, 这样可以节省大约14MB的内存.

可以在终端运行一些简单的命令看到这些变化:
heap 可以显示所有的堆分配, 最好使用grep管道筛选一下, 命令:heap Mail | egrep ‘class_rw|COUNT’


grep.png

一共有22万个class_rw_t, class_rw_ext_t一共2.2万相差了10倍, 所以有20万个类里面节省了class_rw_ext_t字节的数量, 大约节省了六七M的内存.

现在从很多类中获取数据的代码, 必须同时处理那些有扩展和没扩展数据的类.
runtime会处理这一切, 并且从外部看一切和往常一样, 缺使用了更少的内存.
之所以会这样, 是因为读取这些结构的代码, 都在runtime内, 并且还会同时进行更新.

坚持使用这些API真的很重要, 因为任何视图直接访问数据结构的代码, 都会在今年的OS版本中无法工作.->


11.png

因为这些底层数据结构以及改动过了, 但是用户代码并不知道, 所以会失效或者因为这些变化崩溃.
除了自己的代码, 还需要看一看一些自己引用的代码, 它们可能会有这部分问题.

这些结构中的所有信息都可以通过官方的API获取:
class_getName
class_getSuperclass
class_copyMethodList
使用官方的API将会更加的安全和稳定, 兼容性更高, 所有的API都可以在OC的runtime的文档中找到developer.apple.com

数据结构的改变

接下来深入了解一下类的数据结构:
Relative Method Lists, 相对方法列表( 看看其中的变化)

每一个类都会附带一个方法列表, 当你在类上编写新的方法时, 它就会被添加到列表中:->


12.png

runtime使用这些列表来解析消息发送
每个方法都包含三个信息:

  1. 方法的名称或者叫选择器, 选择器是字符串, 但是具有唯一性, 所以可以使用指针相等来进行比较
  2. 方法的类型编码: 表示入参和返回类型的字符串, 它不是用来发送消息的, 但它是运行时introspection和消息forwarding所必须的东西
  3. 方法的实现指针: 指向方法的实际代码, 当你编写一个方法被编译成C函数, 方法列表中的entry就会指向该函数

例如: init方法
方法列表中的每一条数据都是一个指针, 64位系统中一个method就占用了24个字节, 这个是clean memory并不是免费的
它还是必须从磁盘中加载, 并且使用时会占用内存.

这是一个进程中内存的放大图->, 比例无关


13.png

这有一个很大地址空间, 需要64位来寻址, 在这个地址空间内, 划分成了几个部分, 栈, 堆, 可执行文件和库或者二进制镜像
而这些都加载到了进程中, 这里以蓝色表示的方法并查看其中的一个二进制镜像->


14.png

这里显示了三个方方法表的条目, 它们指向其二进制文件中的位置.

这里像我们展示了另外一个代价, 二进制镜像可以加载到内存中的任何地方, 这取决于dyld(动态链接器)把它放在哪里.这就意味着, 链接器需要将指针解析到镜像中, 并且加载时将其修正为指向其在内存中的实际位置. 这也是有代价的

请注意一个来自二进制文件的类方法条目, 永远指向该二进制文件内的方法实现.
我们不可能使一个方法的元数据存在于一个二进制文件中, 而实现它的代码在另一个二进制文件中.
这意味着, 方法列表实际上并不需要能够引用整个64位地址空间.
它们只需要能够引用自己二进制中的函数. 而且这些函数总是在附近.

因此无需使用绝对的64位地址, 它可以使用32位的相对偏移. ->


15.png

这就是今年做出的一个更改.

这有几个好处,

  1. 偏移量始终是相同的, 不管image在哪里加载到内存中, 磁盘在加载后不需要修正
  2. 由于不需要修正, 所以它们可以存储在真正的只读内存中, 这样更加安全.
  3. 只需要32位.内存减少了一半


    16.png

在一台iPhone的系统范围, 测量了大约80M的这些方法:
内容减半, 节省了40MB的内存, app可以拥有更多的内存, 用户体验更好.

这样的话swizzling怎么办呢?
二进制中的方法列表不能引用完整的地址空间, 但是如果swizzle一个方法, 它就可以在任何地方执行.

我们刚刚说过希望保持这些方法列表为只读:
为了处理该问题, 我们提供了一个全局表:
这个全局表将方法映射到它们被swizzle的实施上.->


17.png

swizzle不常见, 大部分方法没被swizzle过, 所以这个表最终不会变的很大.
更好的是这个表会更紧凑, 内存每次都是按页面来”弄脏”的.
使用旧式的方法列表swizzle一个方法会”弄脏”它所在的整个页面
一次的swizzle就会导致产生大量千字节的dirty memory.
有了这个全局表, 我们只需要为一个额外的表的条目付出一点代价.
和往常一样, 你看不见这些变化, 一切还是照常进行.
这些相对方法列表在新的OS版本上是受支持的, iOS14, macOS BIg Sur, tvOS 14, watchOS 7

当你使用相应的最小部署目标进行构建时, 工具会自动在你的二进制文件中生成相对方法列表
旧式的方法列表, Xcode也会生成旧式的方法列表格式, 完全支持的.
系统可以同时在一个app中使用两种格式, 如果对今年的OS版本进行构建, 你的二进制文件会变小, 并且使用的内存也会减少.

最小的部署目标, 这并不仅是关于你可以使用那些SDK API, 如果Xcode知道它不需要支持旧版OS版本. 它通常可以发布更好的优化代码或数据.

有一件事需要注意, 你需要使用一个比你更新的项目, 一般Xcode会阻止, 但是也有可能会漏掉.如果你在其他地方构建自己的库或者框架, 并且加载进来了.
新的数据结构是这样的->


18.png

旧的runtime会看到这些相对方法, 并进行访问是直接指针访问的 ->


19.png

它就会把一对32位的偏移 , 作为一个64位的指针进行访问. 结果就是两个证书作为指针粘连在一起, 是一个无意义的值,实际使用时会崩溃.

可以通过运行时的指针崩溃, 查看何时发生了这种情况. 两个32位值的指针平滑链接在一起.

如果运行的代码通过这些结构进行挖掘, 读取值, 会出现一样的问题, 当用户升级设备时app就会崩溃.所以还是使用官方API进行调用. 不管底层怎么改动, API始终都不会有问题.

tagged pointer的变化

Tagged Pointer Format Changes
什么是Tagged Pointer?

你并不需要了解这些底层, 但是它很有趣, 如果你知道可以更好的帮助你提高对内存的理解.

普通的指针结构下图:
20.png

一共有64位, 但我们并没有真正的使用所有的这些位.

我们只在一个真正的对象指针中使用了中间这些位置.下图:


21.png

由于内存对齐的原因, 低位始终为0.下图:


22.png

对象必须总是位于指针大小倍数的一个地址中.
由于地址空间有限, 所以高位始终为0.


23.png

我们实际上不会用到2^64, 这些高位和低位总是0, 所以让我们从这些始终为0的位中选择一个, 并且把它设置为1.


24.png

这可以让我们立即知道, 这并不是一个真正的对象指针, 然后我们可以给其他位赋予一些其他的意义.
我们称这种指针为tagged pointer.

例如: 我们可以在其他位中塞入一个数值, 只要我们想教NSNumber如何读取这些位.

25.png

并且runtime适当地处理tagged pointer, 系统的其他部分可以把这些东西当做对象指针来处理.
并且永远不会知道其中的区别. 这样可以节省我们为每一种类似情况分配一个小数字对象的代价.
这是一个重大的改进.

这些值实际上是通过与进程启动时初始化的随机值相结合, 而被混淆的. 这一安全措施使得很难伪造tagged pointer.
接下来的讨论中, 我们将会忽略这一层, 因为它只是在顶部增加了一层.


26.png

只是要注意, 如果你真的视图在内存中查看这些值, 它们会被打乱.

inter

下图就是intel上tagged pointer的完整格式.
27.png

我们把低位设置为1, 表示这是一个tagged pointer, 正如我们所讨论的, 对于一个真正的指针, 这个位置必须为0.所以这让我们可以把他们区分开来.

接下来是3位标签号.


28.png

这表示tagged pointer的类型, 比如3表示NSNumber, 6表示NSDate.
在objc源码中都可以找到.

由于我们有3个标签位, 所以有8种可能的标签类型, 剩下的是payload有效负载.这是特定类型可以随意使用的数据.

对于标记的NSNumber这是实际的数字, 现在标签7有一个特殊情况.


29.png

它表示一个扩展标签, 扩展标签使用接下来的8位来编码类型. 这样就多出来256个标签类型. 但是代价是减少了Payload.
只要他们可以将其数据装入更小的空间. 这使得我们可以将tagged pointer用于更多的类型.
这可用于一些东西, 如用户UIColors或者NSIndexSets.

现在如果这对你来说非常方便, 你可能会感到失望, 因为只有runtime维护者Apple可以添加tagged pointer类型
但是如果你是一个swift程序员, 你可以创建自己的tagged pointer类型.
如果你曾经使用过一个具有关联值的枚举, 那是一个类似于tagged pointer的类.
Swift运行时将枚举判别器村存储在关联值payload的备用位中.
而且Swift对于值类型的使用, 实际上使得tagged pointer变得没有那么重要了.
因为值不在需要完全是指针大小, 例如Swift UUID类型可以是两个字并保持内联.
而不是分配一个单独的对象, 因为它不适合在一个指针里面.

arm64

在arm64上, 这些是反过来的, 最高位设置为1, 而不是最低位.用来表示一个tagged pointer,
然后在接下来的三位中出现标签号.而payload使用剩余的位.


30.png

为什么我们在ARM上使用顶部位来表示tagged pointer, 而不是和inter一样?
这实际上是对objc_msgSend的一个小优化, 我们希望msgSend中最常见的路径可以尽可能地快.
而最常见的路径是一个普通的指针, 我们有两种不太常见的情况tagged pointer和nil.
事实证明, 当我们使用最高位时, 我们可以通过一次比较对这两个进行检查.下图:

31.png

nil是0x0, 最高位是1代表是1负数, 所以一次判断就可以判断两种情况.obj_msg_send就不需要进行消息发送.
相比于分开检查nil和tagged pointer, 这就位msgSend中的常见情况省了一个条件分支.

和inter一样, 标签7有特殊情况:
32.png

接下来的8位被用作扩展标签, 然后剩下的位用于payload, 或者是iOS13之前使用的旧格式.

arm64 iOS14

在今年的版本中, 我们做了如下改动:


33.png

标签位依旧保持在最高位, 因为megSend的优化还是非常有用的, 标签号移到了最下面的低三位.
如果正在使用扩展标签, 那么它会占据标签位后的高8位.

为什么我们要这样做的?
我们来看一下正常的指针.

34.png

我们现有的工具, 比如动态链接, 会忽略指针的前8位, 这是由于一个名为Top Byte Ignore的ARM特性.
35.png

我们会把拓展标签放在Top Byte Ignore位.
对于一个对齐指针, 低3位总是0, 但是我们可以改变这一点, 只需要通过在指针上添加一个小数字.
36.png

我们将添加7在指针上, 请记住7是一个扩展标签.
这意味着我们实际上可以将上面的这个指针, 放入一个扩展标签指针Payload中.
37.png

这个结果是一个tagged pointer以及有效负载中包含一个正常指针.
38.png

为什么这很有用呢?

  • 它开启了tagged pointer的能力, 引用二进制文件中常量数据的能力, 例如字符串或者其他数据结构.
  • 否则他们将不得不占用dirty memory.
39.png

上图所示: iOS14发布以后, 直接访问这些位的代码将会失效, 在过去像这样的位检查是可以进行的.
但是在未来的OS上, 它会给你错误的结果. 然后你的app会开始莫名其妙地破坏用户数据.
所以, 不要使用那些依赖于我们所谈到的任何东西的代码, 要用API, API, API.

像isKindOfClass这样的类型检查, 它们在旧的tagged pointer格式上工作, 新版本也一样工作:


40.png

所有的NSNumber, NSString都会正常工作, 这些tagged pointer的所有信息, 都可以通过标准的API来检索.

41.png

值得注意的是, 这也适用于CF类型, 我们不想隐藏任何东西, 也不想破坏任何人的App,
当这些细节没有公开的时候, 这仅仅是因为我们需要保持灵活性来进行此类更改.
并且只要你的app不依赖这些内部细节, 它们就可以正常工作.

再次重申, 不要直接获取内部的位, 请使用API.

总结

Tagged pointer的部分, 大家可以看看这篇文章. Tagged Pointer对象安全气垫为何会失效写的非常好.

官方对内存优化了很多, 但是官方不断强调的重点就是用API!用API!用API! 不要轻易访问私有的数据结构.

内存的细节看多了, 我们在写代码的时候很容易联想到底层应该是怎样的, 那些上层调用方式可以更好的减少内存负担, 比如我们定义一个成员变量和属性, 同等情况下成员变量更优. 比如在特定情况下使用__unsafe__unretain性能要优于__weak等... 世界这么大, 学的越多知识月匮乏, 不过不管怎样还是要努力成长.

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

推荐阅读更多精彩内容