Swift 开发中,为什么要远离 Heap?

WWDC的视频 — Understanding Swift Performance 中,苹果上来就说,Heap 的操作复杂度要远远超越 Stack。所以大家在选择数据结构时,要尽量选择诸如结构体这种存储在 Stack 上的值数据类型,而不要选择像类这种存储在 Heap上的数据类型。问题是,相比于 Stack,Heap 操作复杂体现在什么地方?

要回答这个问题,我们就必须了解 Swift 中,Heap 是用来做什么的。同时,在 Heap 上发生了哪些操作,才导致其在性能上被诟病?

什么是 Heap

一般提到 Heap,可能指两种东西,一种是数据结构中的 Heap,另一种是内存中的 Heap。本文要谈的是内存中的 Heap。对于数据结构的 Heap,就是一种特殊的二叉树。它满足以下条件:

  • 堆的最小或最大值在根节点。其所有子节点都小于或大于其父节点。
  • 堆是完全的二叉树。除最底层所有节点都被填满。最底层节点填充从左到右。

具体的细节这里不作展开,网上相关的文章一大把,大家可以自行查阅。

言归正传。本文中,堆是可以被用来动态分配空间的内存块。这个定义中有一个关键词 —— 动态分配。

所谓动态分配,就是对于数据、变量,系统并不预先分配一定的空间,而是根据程序的运行和需求进行即时分配,它发生在程序调入和运行(run time)的时候。而静态分配,是在编译时就已经知道数据需要的空间,所以在程序编译和连接(compile time)时,系统就给相应数据分配了空间。举个例子:

// assume defaultCellHeight is a static global constant
static let defaultCellHeight = 44.0  

// assume bar is a var used in a specific data structure
var bar: MyClass?

其中defaultCellHeight一看就是个浮点变量,所需的内存大小确定,所以它的分配是静态分配。而bar是个类,编译时不知道它有多少个字节、需要多少空间,故而只有当程序运行后,内存才可以确定这些细节,故而它是动态分配。

而内存中,负责动态分配内存的数据区有两个,一个是栈(Stack),另一个是堆(Heap)。

Heap 和 Stack 在内存管理上的比较

Heap Stack
结构 基于链表、数组、树
特点 手动分配大小,随时释放空间,数据进出无序 自动分配大小,自动释放内存,数据先进后出
操作 查询之后分配/释放,之后再做整合,复杂度高 依靠栈底指针移动来分配/释放,复杂度低
对象 引用类型如 class。引用计数,变量类型等信息 值类型如 struct, enum, Int。函数返回值,局部变量
场景 C 中的 malloc 和 free 操作,java 中的garbage collection,iOS 中的 MRC、ARC 适用于撤销、保存操作
线程 共享,多线程不安全 独享,多线程安全

Swift 中 Heap 的设计

Swift 中 Heap 是由双向链表实现的,其操作也是调用了 C++ 的 malloc 和 dealloc 方法。那么为什么是双向链表?我们来仔细分析一下。

刚才已经说明,Stack 的操作只是指针移动,故而复杂度低,为常数。而 Heap 的操作却十分复杂,那么具体是怎样的?我们不妨来看两个底层函数:retain 和 release。

首先明确一下需求,retain 即分配空间,比如 [myString retain],就是给一个字符串分配一定字节的内存。而 release 即释放之前的空间,比如[myString release],就是释放这个字符串分配的内存。

最直观的设计:数组

最简单粗暴的设计 Heap 的方法如下:将其设计成数组,其中所占的内存切分成 n 等分,每一等分代表一个字节。从左往右顺序分配空间,同样顺序释放空间,这样所有的操作都是线性。然而想象很美好,现实却很残酷。假如 Heap 一共有10个字节,我们有以下4个字符串:

let string1 = "abcd" // 假设4个字节
let string2 = "a"    // 假设1个字节
let string3 = "abc"  // 假设3个字节
let string4 = "abcde"  // 假设5个字节

然后我们做下面几个操作:

每一步的 heap 数组长这样,注意数字代表是内存大小,'代表空闲:

[heap init] -> [10‘]
[string1 retain] -> [4, 6']
[string2 retain] -> [4, 1, 5']
[string3 retain] -> [4, 1, 3, 2']
[string3 release] -> [4, 1, 3', 2']
[string4 retain] -> ?

这时候我们发现,Heap 中虽然有5个字节的空余空间,却无法分配给 string4,因为这5个字节的空余空间不连续。系统只认为有一个3字节的空余空间和一个2字节的空余空间。于是我们发现数组的想法过于天真,没有处理 release 之后整合空余空间的问题。

链表设计

于是我们想想有什么办法解决这个问题。假如我们利用链表,将所有的内存块连起来,并且在 release 时通过调整链表指针来整合空间,这样就能解决我们刚才的问题。顺着这个思路,我们实现了下面这种 Heap 结构:

  • 内存块用链表进行连接
  • 每个内存块的头结点表明了内存块的大小,以及该内存块是否已被 retain(0表示空余,1表示已被占用)
  • 最开头和最末尾的节点表明已经到了 Heap 的头和尾

Retain 操作这个时候有下面三种设计方式可控选择:

  • 从头遍历链表。找出第一个能分配足够空间的空闲内存块。这样的操作的复杂度是线性的。
  • 从之前搜索过的位置起搜索链表的空余内存块,并找到合适的那块。这样可以跳过之前搜索过的、肯定不符合的内存块,一般情况下会稍微快点。
  • 从头遍历链表。找到最适合(大小最接近)的空闲内存块,这样空间利用率会很高,可惜时间复杂度上相比于之前两种更高。

Release 的操作除了分配内存空间,还要注意整合内存块。我们刚才那个例子,当 [string3 release]之后,它分配出来的内存块会和空余的整合在一起。这个过程如下所示:

这个设计已经及格了。但是它有一个很严重的问题:性能。因为一般而言,Heap 比较大,每次遍历去找空余空间比较耗时;其二,每次 release 之后都必须判断当前内存块的前一块和后一块是否为空闲,如果是则要分别整合。这又牵涉到遍历链表查询的问题,当然解决办法也比较简单,用双向链表。

优化:双向链表

双向链表的引入主要是引入 release 之后的内存块整合问题,这样可以快速查询前后内存块是否为空。同时为了解决之前设计每次遍历极度耗时的性能问题,我们这样设计,我们只把空闲内存块用指针连起来形成链表。于是 Heap 的数据结构变成了下面这样:

这样每次 retain 操作,我们可以少遍历一半的内存(已经分配的),效率理论上来讲提高一倍。而 release 操作,我们可以采用 LIFO 机制,将多出来的空余空间插入到 Heap 头处,并与原来的第一个空余空间整合。这样的做法复杂度是常数,非常高效。最后,我们再也不用花多余的精力去用 1 和 0 来表示当前内存块是否为空。

release 操作之后整合空闲内存块的一种情况

为了提高空间利用率,我们还可以引入数组。数组中的每一个元素都是一个双向链表。每一个双向链表连接了 Heap 中的大小相近的空余空间。这样我们在 retain 的时候,我们先根据大小快速定位到比较合适的空余空间所在链表,再做遍历。如此一来,空间利用率得到提高,遍历元素数量减少,Heap 效率更高。

总结

虽然现在苹果已经用 ARC 帮我们自动处理内存分配和释放的问题了。相比于 MRC 时代手动的 retain 和 release 操作,我们无需过度担忧内存调度。然而,在优化方面,苹果推荐 Stack 和值类型,是因为 Stack 的性能很高,复杂度几乎为常数。虽然 Heap 在动态内存分配中似乎更自由、更灵活,但相对而言,其性能很低,复杂度较高。所以,Swift 的值类型才如此受欢迎啊。

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

推荐阅读更多精彩内容

  • __block和__weak修饰符的区别其实是挺明显的:1.__block不管是ARC还是MRC模式下都可以使用,...
    LZM轮回阅读 3,202评论 0 6
  • 多线程、特别是NSOperation 和 GCD 的内部原理。运行时机制的原理和运用场景。SDWebImage的原...
    LZM轮回阅读 1,977评论 0 12
  • iOS面试小贴士 ———————————————回答好下面的足够了------------------------...
    不言不爱阅读 1,872评论 0 7
  • ———————————————回答好下面的足够了---------------------------------...
    恒爱DE问候阅读 1,672评论 0 4
  • 史上最全的iOS面试题及答案 iOS面试小贴士———————————————回答好下面的足够了----------...
    Style_伟阅读 2,317评论 0 35