Swift 的值类型, 引用类型, 内存管理
值类型和引用类型相比, 最大优势可以高效的使用内存, 值类型在栈上操作, 引用类型在堆上操作, 栈上操作仅仅是单个指针的移动, 而堆上操作牵涉到合并, 位移, 重链接, Swift 这样设计减少了堆上内存分配和回收次数, 使用 copy-on-write
将值传递与复制开销降到最低
Swift 数据结构的类型
值类型
struct
(Int
,Double
,Float
,String
,Array
,Dictionary
,Set
)enum
tuple
引用类型
class
block
NSString
: 但是不可被修改 (没有修改的接口)1
2
3
4
5var str1: NSMutableString = "1"
let str2: NSString = str1
str1.append("2")
print(str1) // "12"
print(str2) // "12"默认情况下 NSString 是不可变的 (而且我还加了 let 属性), 但是因为 NSString 是引用类型, NSMutableString 又是可变的引用类型, 而且可以将 NSMutableString 向下转型到 NSString, 因此可以通过修改 str1 来达到修改 str2 的效果.
NSMutableString
: 可被修改 (有修改接口, 如 append)NSArray
: 但是不可被修改 (没有修改的接口)NSMutableArray
: 可被修改 (有修改接口, 如 append)
class 与 struct 比较
- 类是引用类型 (只有类是引用类型, 很特殊), 结构体是值类型. 引用类型的数据传递不会产生复制效果, 值类型的数据传递会复制; 继承是类独有的特点, 子类与父类是继承关系, 并且子类可以对父类进行扩展. final 加在类后可以增加编译效率, 但是不能再被继承属性!
- 在类中的 init 方法构造的是参数, 方便调用填写, 填写完后会将值传入类别的属性中.
class
可以用deinit
来释放资源- 一个
class
可以被多次引用 - 子类别只能将父类别中的相关属性进行替换覆写, 新增覆写, 但不能将其部分删除
mutating
定义在struct
中, 因为struct
中的function
不能重新赋值property
的值, 使用mutating
可以进行重写struct
结构较小, 适用于复制操作, 相比较一个class
实例被多次引用,struct
更安全struct
无需担心内存泄露问题, 因为其不使用ARC
自动计数struct
是深拷贝, 拷贝的是内容;class
是浅拷贝, 拷贝的是指针.struct
比 class 更轻量:struct
分配在栈中,class
分配在堆中.struct
不可以继承,class
可以继承.class
在初始化时不能直接把property
放在 默认的constructor
的参数里, 而是需要自己创建一个带参数的constructor
Swift
语言的特色之一就是可变动内容和不可变内容用var
和let
來甄别, 如果初始为let
的变量再去修改会发生编译错误.struct
也遵循这一特性. 但是class
不存在这样的问题: 因为class
存储的是引用地址.
堆 (heap) 与栈 (stack)
堆
- 分配方式: alloc, 速度相对栈比较慢, 容易产生内存碎片
- 管理方式: 程序员, ARC 下面, 堆区的分配和释放基本也是系统操作
- 地址分布: 从低到高, 非连续
- 大小: 取决于计算机系统的有效的虚拟空间
- 作用: 动态分配内存, 存储变量, 延长生命周期
栈
- 一端进行插入和删除操作的特殊线性表
- 分配方式: 系统, 速度比较快
- 管理方式: 系统, 不受程序员控制
- 地址分布: 从高到低, 连续
- 大小: 栈顶的地址和容量是系统决定
- 生命周期: 出了作用域就会释放
- 入栈出栈:
先进后出, 类似羽毛球筒, 先放入的羽毛球, 总是最后才能拿到
堆与栈区别
栈用完就释放, 不需要担心内存泄漏. 堆有时会造成内存泄漏, 虽然有 ARC, 但是仍然没有栈安全
栈是连续的一段空间, 速度快. 堆是不连续的一段空间, 速度相对较慢
引用类型存储在堆上, 值类型存储在栈上
栈内存在编译时即确定, 需要执行方法时直接在栈上开辟空间 (即将栈的尾指针向栈底移动). 方法执行完毕时自动释放掉空间 (即将栈的尾指针向栈顶移动). 这个开辟到释放的过程即一次完整的内存分配.
堆的内存比栈大得多, 但是运行速度也比栈慢得多. 堆可以在运行时动态地分配内存, 补充栈内存分配的不足. 堆内存的分配比较复杂, 不会在方法执行结束后立即回收, 堆上内存使用 ARC 原则.
当我们创建一个类的实例时, 系统会在堆中申请一个内存块用于存储实例本身. 同时将把存储该实例的变量和堆中的地址存储在栈中. 而当创建一个结构体时, 将会把变量和值都存储在栈中.
Copy on Write
1 | var a = [1, 2, 3] // a address: 0x001 |
只有集合 (Array, Dictionary, Set) 类型才具有 copy on write 特性, 其他的 string, int 等结构并不具有, 因为对那些简单结构来说修改时再复制时的开销更大 (因为需要检查引用计数), 还不如直接复制. e.g. 如果有一些大的 struct, 并且经常进行单一引用下的赋值的话, 可以考虑为他们实现写时复制.
内存原理
内存分区
区域划分
- 栈区:
- 存放的局部变量, 先进后出, 一旦出了作用域就会被销毁;
- 函数跳转地址, 现场保护等;
- 程序员不需要管理栈区变量的内存;
- 栈区地址从高到低分配;
- 堆区
- 堆区的内存分配使用的是 alloc;
- 需要程序员管理内存;
- ARC 的内存的管理, 是编译器再便宜的时候自动添加 retain, release, autorelease;
- 堆区的地址是从低到高分配
- 全局区/静态区
- 包括两个部分: 未初始化区 , 已初始化区;
- 在内存中是放在一起的, 已初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另一块区域;
- 常量区
常量字符串就是放在这里. - 代码区
存放 App 代码(二进制)
代码区存放于低地址, 栈区存放于高地址. 区与区之间并不是连续的.
注意事项
- 在 iOS 中, 堆区的内存是应用程序共享的, 堆中的内存分配是系统负责的;
- 系统使用一个链表来维护所有已经分配的内存空间(系统仅仅纪录, 并不管理具体的内容);
- 变量使用结束后, 需要释放内存, OC 中是根据引用计数来判断;
- 当一个 app 启动后, 代码区, 常量区, 全局区大小已固定, 因此指向这些区的指针不会产生崩溃性的错误. 而堆区和栈区是时时刻刻变化的(堆的创建销毁, 栈的弹入弹出), 所以当使用一个指针指向这两个区里面的内存时, 一定要注意内存是否已经被释放, 否则会产生程序崩溃(也即是野指针报错).
- iOS 是基于 UNIX, Android 是基于 Linux 的, 在 Linux 和 unix 系统中, 内存管理的方式基本相同;
- Android 应用程序的内存分配也是如此. 除此以外, 这些应用层的程序使用的都是虚拟内存, 它们都是建立在操作系统之上的, 只有开发底层驱动或板级支持包时才会接触到物理内存,
- 在嵌入式 Linux 中, 实际的物理地址只有 64M 甚至更小, 但是虚拟内存却可以高达 4G;
内存大小获取
Memlayout.size(ofValue value: T)
: 获取变量实际占用的内存大小Memlayout.stride(ofValue value: T)
: 获取创建变量所需要的分配的内存大小MemoryLayout.alignment(ofValue: T)
: 获取变量的内存对齐数
通常为了提高cpu对内存访问的效率, 在分配内存空间时都会进行内存对齐操作, 所以size指的是变量实际所占用的内存大小, stride指的是经过内存对齐后创建变量需要开辟的内存空间大小, 但实际上多余出来的内存空间并没有使用, 仅仅是为了将内存对齐而已.
1 | enum Color { |
内存地址及真实存储值获取
在我们知道了一个内存地址后, 我们可以通过下面两种方式查看地址对应内存空间存放的数据:
- 我们可以在
Xcode
->Debug
->Debug workflow
->View Memory
中输入内存地址定位到那块内存空间 - 在lldb中使用指令
memory read
+ 内存地址读取指针对应的内存. 也可以直接使用指令 x 简化书写, 效果等同于memory read
因此在我们下方执行代码并断点后可以看到
1 | enum Color { |
内存地址打印方法
数组
1
2
3func print(address o: UnsafeRawPointer) {
print(String(format: "%p", Int(bitPattern: o)))
}值类型数据
1
withUnsafePointer(to: &a) { print("a: \($0)")}
引用类型数据
1
print(Unmanaged.passUnretained(sing1 as AnyObject).toOpaque())
内存泄漏
内存泄漏是指一个对象不再被使用却仍然占据着内存空间, 内存泄漏会随着程序运行时间的增长而积累, 直到发生破坏性的错误. 弱引用与无主引用可大大减少循环引用, 从而减少内存泄漏.
引用循环
由于 ARC 的作用, 正常情况下当一个内存不被任何物件依赖时, 其引用计数会为 0, 然后会自动回收释放, 但是当两份内存相互依赖的时候, 我们无法通过将其变量设为 nil 来使得其引用计数归 0(因为另一份内存始终会存在一份引用), 因此无法得到释放
解决引用循环
- 转换为值类型, 只有类会存在引用循环, 所以如果能不用类, 是可以解引用循环的,
delegate
使用weak
属性.- 闭包中, 对有可能发生循环引用的对象, 使用 weak 或者 unowned, 修饰
什么是指针
指针是数据在内存中的地址, 指针变量是用来保存这些地址的变量
指针变量的大小与 CPU 位数有关, 64 位 CPU 的指针变量大小就是 64 bit, 即 8 字节
指针变量可以有如下行为:
改变该变量的值
取得该变量的值
这和其他变量是一样的, 但是指针还可以做到:
改变该变量指向的那个地址的值
取得该变量指向的地址的值
指针是有类型的, 因为其他数据有类型, 所以指针也得有类型. 指针的类型表明: 你期望从这个地址中取出来的数据是什么类型的
内存
在程序员眼中的内存应该是下面这样的.
也就是说, 内存是一个很大的, 线性的字节数组 (平坦寻址). 每一个字节都是固定的大小, 由 8 个二进制位组成.
最关键的是, 每一个字节都有一个唯一的编号, 编号从 0 开始, 一直到最后一个字节.
如上图中, 这是一个 256M 的内存, 他一共有 256x1024x1024 = 268435456 个字节, 那么它的地址范围就是 0 ~268435455 .
由于内存中的每一个字节都有一个唯一的编号, 因此, 在程序中使用的变量, 常量, 甚至数函数等数据, 当他们被载入到内存中后, 都有自己唯一的一个编号, 这个编号就是这个数据的地址.
指针的值 (虚拟地址值) 使用一个机器字的大小来存储.
也就是说, 对于一个机器字为 w 位的电脑而言, 它的虚拟地址空间是 0 ~ 2w - 1
, 程序最多能访问 2w 个字节.
这就是为什么 xp 这种 32 位系统最大支持 4GB 内存的原因了.
内存的数据
内存的数据就是变量的值对应的二进制, 一切都是二进制.
97 的二进制是 : 00000000 00000000 00000000 0110000 , 但使用的小端模式存储时, 低位数据存放在低地址, 所以图中画的时候是倒过来的.
内存数据的类型
内存的数据类型决定了这个数据占用的字节数, 以及计算机将如何解释这些字节.
num 的类型是 int, 因此将被解释为 一个整数.
内存数据的名称
内存的名称就是变量名. 实质上, 内存数据都是以地址来标识的, 根本没有内存的名称这个说法, 这只是高级语言提供的抽象机制 , 方便我们操作内存数据.
而且在 C 语言中, 并不是所有的内存数据都有名称, 例如使用 malloc 申请的堆内存就没有.
内存数据的地址
如果一个类型占用的字节数大于 1, 则其变量的地址就是地址值最小的那个字节的地址.
因此 num 的地址是 0028FF40. 内存的地址用于标识这个内存块.
内存数据的生命周期
num 是 main 函数中的局部变量, 因此当 main 函数被启动时, 它被分配于栈内存上, 当 main 执行结束时, 消亡.
如果一个数据一直占用着他的内存, 那么我们就说他是活着的, 如果他占用的内存被回收了, 则这个数据就 “消亡了.
C 语言中的程序数据会按照他们定义的位置, 数据的种类, 修饰的关键字等因素, 决定他们的生命周期特性.
实质上我们程序使用的内存会被逻辑上划分为: 栈区, 堆区, 静态数据区, 方法区.
不同的区域的数据有不同的生命周期.
无论以后计算机硬件如何发展, 内存容量都是有限的, 因此清楚理解程序中每一个程序数据的生命周期是非常重要的.