Swift 进阶:指针 & 内存管理

855 阅读9分钟

一、为什么说指针不安全

  • 比如我们在创建一个对象的时候,是需要在堆分配内存空间的。但是这个内存空间的声明周期是有限的,也就意味这如果我们使用指针指向这块内存空间,如果当前内存空间的生命周期到了(引用计数为0),那么我们当前的指针就变成未定义的行为了。
  • 我们创建的内存空间是有边界的,比如我们创建一个大小为10的数组,这个时候我们通过指针访问到了index = 11的位置,这个时候就越界了,访问了一个未知的空间。
  • 指针类型与内存的值类型不一致,也是不安全的。

二、指针

Swift 中的指针分为两类

  1. typed pointer: 指定数据类型指针,即UnsafePointer<T>,其中 T 表示泛型
  2. raw pointer: 未指定数据类型的指针(原生指针) ,即UnsafeRawPointer

与 OC 中指针的对比:

OCSwift释义
const T *unsafePointer<T>指针及所指向的内容都不可变
T *unsafeMutablePointer指针及所指向的内容都可变
const void *unsafeRawPointer指针指向的内存区域未定
void *unsafeMutableRawPointer同上

2.1 原生指针的使用

我们一起来看一下如何使用raw pointer来存储4个整形的数据,我们这里使用UnsafeMutableRawPointer

// 开辟内存,分配32字节大小的空间(Int占8字节),对齐方式是8字节对齐
let p = UnsafeMutableRawPointer.allocate(byteCount: 32, alignment: 8)

// 存值
for i in 0..<4 {
    // 指定当前移动的步数,即i * 8
    p.advanced(by: i * 8).storeBytes(of: i + 1, as: Int.self)
}
//取值              
for i in 0..<4 {
    //p是当前内存的首地址,通过内存平移来获取值
    let value = p.load(fromByteOffset: i * 8, as: Int.self)
    print("index: \(i), value: \(value)")
}

//使用完需要手动释放
p.deallocate()

运行程序,查看执行结果,值可以正常打印: image.png

移动步数时可以使用MemoryLayout动态获取步幅,修改代码如下:

p.advanced(by: i * MemoryLayout<Int>.stride).storeBytes(of: i + 1, as: Int.self)

MemoryLayout<Int>.size // size 指当前类型的实际大小
MemoryLayout<Int>.stride // stride 翻译过来是步幅,这里可以认为是内存对齐之后的大小
MemoryLayout<Int>.alignment // alignment 指的是内存对齐的方式,是1字节对齐,还是4字节对齐

运行程序,仍然可以正常打印: image.png

2.2 类型指针的使用

我们还是通过示例来介绍type pointer,我们获取基本数据类型地址可以通过withUnsafePointer(to:)方法:

var age = 18
let p = withUnsafePointer(to: &age) { ptr in
    return ptr
}
print(p)

输出结果:0x0000000100008058

2.2.1 如何访问指针指向的值

我们可以通过指针的pointee属性访问变量值

var age = 18
let p = withUnsafePointer(to: &age){$0}

print(p.pointee)

输出:18

2.2.2 如何修改指针指向的值

  1. 间接修改

    var age = 18
    age = withUnsafePointer(to: &age) { ptr in
        return ptr.pointee + 12
    }
    print(age)
    
    输出结果:30
    
  2. 通过withUnsafeMutablePointer

    var age = 18
    withUnsafeMutablePointer(to: &age) { ptr in
        ptr.pointee += 12
    }
    print(age)
    
    输出结果:30
    
  3. 通过allocate创建UnsafeMutablePointer

    var age = 18
    //分配容量大小,为8字节
    let ptr = UnsafeMutablePointer<Int>.allocate(capacity: 1)
    //初始化
    ptr.initialize(to: age)
    ptr.deinitialize(count: 1)
    
    ptr.pointee += 12
    print(ptr.pointee)
    
    //释放
    ptr.deallocate()
    
    输出结果:30
    

    通过allocate创建UnsafeMutablePointer,需要注意以下几点

    • initialize 与 deinitialize需成对使用
    • deinitialize中的count与申请时的capacity需要一致
    • 使用完后必须deallocate

2.2.3 通过指针访问结构体对象

先定义一个结构体

struct SSLTeacher {
    var age = 18
    var height = 1.85
}
var t = SSLTeacher()

然后使用UnsafeMutablePointer创建指针,并通过 3 种方式访问结构体对象 t:

// 分配2个 SSLTeacher 大小的空间
let ptr = UnsafeMutablePointer<SSLTeacher>.allocate(capacity: 2)
// 初始化第一个空间
ptr.initialize(to: SSLTeacher())
// 移动,初始化第2个空间
ptr[1] = SSLTeacher(age: 20, height: 1.75)

//访问方式一 下标访问
print(ptr[0])
print(ptr[1])

//访问方式二 内存平移
print(ptr.pointee)
print((ptr+1).pointee)

//访问方式三 successor()
print(ptr.pointee)
//successor 往前移动
print(ptr.successor().pointee)

//必须和分配是一致的
ptr.deinitialize(count: 2)
//释放
ptr.deallocate()

运行程序,查看执行结果: image.png

2.3 Macho 指针操作案例

接下来通过案例来熟悉指针的操作,我们用指针来读取 Macho 中的属性名称类名vTable 中的方法

2.3.1 获取 classDescriptor 指针

先通过指针操作获取 classDescriptor 指针,代码如下:

class SSLTeacher{
    var age: Int = 18
    var name: String = "SSL"
}

// 当前程序运行地址
var mhHeaderPtr = _dyld_get_image_header(0)
// 16进制转化为10进制
let mhHeaderPtr_IntRepresentation = UInt64(bitPattern: Int64(Int(bitPattern: mhHeaderPtr)))

// 获取虚拟内存基地址
var setCommond64Ptr = getsegbyname("__LINKEDIT")
var linkBaseAddress: UInt64 = 0
if let vmaddr = setCommond64Ptr?.pointee.vmaddr, let fileOff = setCommond64Ptr?.pointee.fileoff{
    linkBaseAddress = vmaddr - fileOff
}

//__swift5_types section 的 pFile 地址
var size: UInt = 0
var ptr = getsectdata("__TEXT", "__swift5_types", &size)

// __swift5_types section 的 pFile 在 Macho 中的地址
var offset: UInt64 = 0
if let unwrappedPtr = ptr{
    let intRepresentation = UInt64(bitPattern: Int64(Int(bitPattern: unwrappedPtr)))
    offset = intRepresentation - linkBaseAddress
}

// DataLO 的内存地址
var dataLoAddress = mhHeaderPtr_IntRepresentation + offset
// DataLO 指针
var dataLoAddressPtr = withUnsafePointer(to: &dataLoAddress){return $0}
// DataLO 中存储的内容
var dataLoContent = UnsafePointer<UInt32>.init(bitPattern: Int(exactly: dataLoAddress) ?? 0)?.pointee

// descriptor 在Macho 中的地址
let typeDescOffset = UInt64(dataLoContent!) + offset - linkBaseAddress
// descriptor 地址
var typeDescAddress = typeDescOffset + mhHeaderPtr_IntRepresentation

//print(typeDescAddress)
struct TargetClassDescriptor{
    var flags: UInt32
    var parent: UInt32
    var name: Int32
    var accessFunctionPointer: Int32
    var fieldDescriptor: Int32
    var superClassType: Int32
    var metadataNegativeSizeInWords: UInt32
    var metadataPositiveSizeInWords: UInt32
    var numImmediateMembers: UInt32
    var numFields: UInt32
    var fieldOffsetVectorOffset: UInt32
    var Offset: UInt32
    var methods: UInt32
}

// 得到 descriptor 指针
let classDescriptor = UnsafePointer<TargetClassDescriptor>.init(bitPattern: Int(exactly: typeDescAddress) ?? 0)?.pointee
// 打印
print(classDescriptor)

// ********** 输出结果 **********
Optional(SwiftTest.TargetClassDescriptor(flags: ...))
  • VM Address:Virtual Memory Address,段的虚拟内存地址,在内存中的位置
  • VM Size:Virtual Memory Size,段的虚拟内存大小,占用多少内存
  • File Offset:段在虚拟内存中的偏移量
  • File Size:段在虚拟内存中的大小
  • Address Space Layout Random,地址空间布局随机化,是一种针对缓冲区溢出的安全保护技术,通过对堆、栈、共享库映射等线性区布局的随机化,通过增加攻击者预测目的地址的难度,防止攻击者指针定位攻击代码位置,达到阻止溢出攻击的一种技术
  • 相关 Macho : image.png image.png

2.3.2 获取 类名

接上面代码,添加如下代码打印类名:

if let name = classDescriptor?.name{
    let nameOffset = Int64(name) + Int64(typeDescOffset) + 8
    let nameAddress = nameOffset + Int64(mhHeaderPtr_IntRepresentation)
    if let cChar = UnsafePointer<CChar>.init(bitPattern: Int(nameAddress)){
        print(String(cString: cChar))
    }
}

// ********** 输出结果 **********
SSLTeacher

2.3.3 获取 属性名

接上面代码,添加如下代码打印属性名:

// fieldDescriptor 属性地址
let filedDescriptorRelaticveAddress = typeDescOffset + 16 + mhHeaderPtr_IntRepresentation

struct FieldDescriptor  {
    var mangledTypeName: Int32
    var superclass: Int32
    var Kind: UInt16
    var fieldRecordSize: UInt16
    var numFields: UInt32
//    var fieldRecords: [FieldRecord]
}

struct FieldRecord{
    var Flags: UInt32
    var mangledTypeName: Int32
    var fieldName: UInt32
}

// fieldDescriptor 偏移值
let fieldDescriptorOffset = UnsafePointer<UInt32>.init(bitPattern: Int(exactly: filedDescriptorRelaticveAddress) ?? 0)?.pointee
// fieldDescriptor 内存地址
let fieldDescriptorAddress = filedDescriptorRelaticveAddress + UInt64(fieldDescriptorOffset!)
// fieldDescriptor 指针
let fieldDescriptor = UnsafePointer<FieldDescriptor>.init(bitPattern: Int(exactly: fieldDescriptorAddress) ?? 0)?.pointee

// 获取属性名
for i in 0..<fieldDescriptor!.numFields{
    // FieldRecord 的大小是12,所以步幅就是12
    let stride: UInt64 = UInt64(i * 12)
    // 移动 16 个字节到 fieldRecords
    let fieldRecordAddress = fieldDescriptorAddress + stride + 16
    let fieldNameRelactiveAddress = UInt64(2 * 4) + fieldRecordAddress - linkBaseAddress + mhHeaderPtr_IntRepresentation
    let offset = UnsafePointer<UInt32>.init(bitPattern: Int(exactly: fieldNameRelactiveAddress) ?? 0)?.pointee
    let fieldNameAddress = fieldNameRelactiveAddress + UInt64(offset!) - linkBaseAddress
    if let cChar = UnsafePointer<CChar>.init(bitPattern: Int(fieldNameAddress)){
        print(String(cString: cChar))
    }
}

// ********** 输出结果 **********
age
name

2.3.4 获取 方法名

接上面代码,修改 SSLTeacher 类添加 3 个方法,并添加代码打印方法名:

@interface SSLTest : NSObject
+ (void)callImp:(IMP)imp;
@end

@implementation SSLTest
+ (void)callImp:(IMP)imp {
    imp();
}   
@end

class SSLTeacher {
    
    func teach1() {
        print("testch1");
    }
    func teach2() {
        print("testch2");
    }
    func teach3() {
        print("testch3");
    }
}

// 方法个数
let numVTables = classDescriptor?.methods
struct VTable {
    var kind: UInt32
    var offset: UInt32
}

for i in 0..<numVTables!{
    // 计算偏移量
    let vTableOffSet = Int(typeDescOffset) + MemoryLayout<TargetClassDescriptor>.size + Int(i) * MemoryLayout<VTable>.size
    // 获得vTable 地址
    let vTableAddress = mhHeaderPtr_IntRepresentation + UInt64(vTableOffSet)

    let vTable = UnsafePointer<VTable>.init(bitPattern: Int(exactly: vTableAddress) ?? 0)?.pointee

    // 得到 imp ,因为加了两遍linkBaseAddress所以删除一个
    let impAddress = vTableAddress + 4 + UInt64(vTable!.offset) - linkBaseAddress

    SSLTest.callImp(IMP(bitPattern: UInt(impAddress))!);
}   

// ********** 输出结果 **********
testch1
testch2
testch3

三、内存绑定

Swift 提供了三种不同的 API 来绑定/重新绑定指针:

3.1 assumingMemoryBound(to:)

这个 API 的作用是告诉编译器预期是什么类型(让编译器绕过类型检查,并没有发生实际类型的转换),看下面的例子:

image.png

  • 上面的报错是说将元组类型的指针赋值给了Int 类型指针,类型不匹配
  • 但实际上元组是值类型,本质上这块内存空间中存放的就是 Int 类型的数据
  • 下面通过 assumingMemoryBound(to:) 函数进行修改

image.png

3.2 bindMemory(to:capacity:)

用于更改内存绑定的类型,如果当前内存还没有类型绑定,则将首次绑定为该类型。否则重新绑定该类型,并且内存中所有的值都会变成该类型。

func testPointer(_ p: UnsafePointer<Int>) {
    print(p[0])
    print(p[1])
}

let tuple = (10,20)
withUnsafePointer(to: tuple){ (tupleStr: UnsafePointer<(Int, Int)>) in
    testPointer(UnsafeRawPointer(tupleStr).bindMemory(to: Int.self, capacity: 1))
}

输出:
10
20

3.3 withMemoryRebound(to:capacity:body:)

withMemoryRebound(to:capacity:body:) 用来临时更绑定内存类型,看下面示例

func testPointer(_ p: UnsafePointer<Int8>) {
    print(p)
}

let uint8Ptr = UnsafePointer<UInt8>.init(bitPattern: 10)
uint8Ptr?.withMemoryRebound(to: Int8.self, capacity: 1) { (int8Ptr: UnsafePointer<Int8>) in
    testPointer(int8Ptr)
}

四、强引用

Swift 中使用自动引用计数(ARC)机制来追踪和管理内存。

先添加如下代码:

class SSLTeacher {
    var age: Int = 18
    var name: String = "Kody"
}

var t = SSLTeacher()

print(Unmanaged.passUnretained(t as AnyObject).toOpaque()) // 固定用法实例对象内存地址

NSLog("end")

断点调试,打印内存情况:

image.png

实例对象内存地址的前 16 个字节的后 8 个字节是存储引用计数的,这里的值是 0x3,这个 0x3 是怎么来的呢,下面进行分析。

4.1 源码分析 refCounts

我们先找到引用计数的定义,打开源码在 HeapObject.h 中找到 refCounts

#define SWIFT_HEAPOBJECT_NON_OBJC_MEMBERS       \
  InlineRefCounts refCounts

refCountsInlineRefCounts 类型的,查看 InlineRefCounts 的定义:

typedef RefCounts<InlineRefCountBits> InlineRefCounts;
typedef RefCounts<SideTableRefCountBits> SideTableRefCounts;

template <typename RefCountBits>
class RefCounts {
  std::atomic<RefCountBits> refCounts;
  ...
}

InlineRefCountsRefCounts<InlineRefCountBits> 的别名,RefCounts 是一个模版类,接收了 InlineRefCountBits

看下 InlineRefCountBits 的定义:

typedef RefCountBitsT<RefCountIsInline> InlineRefCountBits;

RefCountBitsT 又是一个模版类,RefCountIsInline 这个参数是 true 或者 false:

image.png

注意 bits 这个成员变量,后续的操作都是有关于这个成员变量,它是由RefCountBitsInt 中的 Type 来定义的。

点击 Type 查看:

image.png

当我们创建一个实例对象的时候,当前的引用计数是多少?

image.png image.png

查看 refCounts

image.png

其实就是 RefCountBitsT 中的这个方法:

image.png

  • strongExtraCount 就是 0,unownedCount 就是 1
  • StrongExtraRefCountShift 是 33,PureSwiftDeallocShift 是 0,UnownedRefCountShift 是 1
  • 所以:0 << 33 = 0,1 << 0 = 1,1 << 1 = 2
  • 0 | 1 | 2 = 0x0000000000000003,这样就解释了上面引用计数的地方是0x3的原因

4.2 引用计数的内存分布

创建如下代码,打 3 个断点,断点处分别打印引用计数内存情况:

image.png

  • 由上可以发现,当强引用为 1 时,因为左移了 33 位,所以引用计数存储在了高 33image.png
  • 当强引用为 2 时,因为左移了 33 位,所以引用计数存储在了高 34image.png

引用计数内存分布图如下:

image.png

  • UnownedRefCount:无主引用计数
  • isDeinitingMask:是否正在析构
  • StrongExtraRefCount:强应用计数

4.3 强引用计数增加操作

打开源码,找到强引用计数的相关增加函数:

image.png image.png image.png

通过上面的函数,可以看到强引用计数的增加方式是先将 1 左移 33位,然后加到 bits 上,这也符合我们上面对引用计数内存分布的分析。

五、弱引用

看下面的代码,teacher 和 subject 会产生循环引用,在 Swift 中解决循环引用的方式有 弱引用无主引用

5.1 弱引用概述

class SSLTeacher {
    var age: Int = 18
    var name: String = "ssl"
    var subject:SSLSubject?
}

class SSLSubject {
    var subjectName: String
    var subjectTeacher: SSLTeacher
    
    init(_ subjectName: String, _ subjectTeacher: SSLTeacher) {
        self.subjectName = subjectName
        self.subjectTeacher = subjectTeacher
    }
}

var teacher = SSLTeacher()

var subject = SSLSubject.init("数学",teacher)

teacher.subject = subject

弱引用不会对其引用的实例保持强引用,因而不会阻止 ARC 释放被引用的实例。这个特性阻止了引用变为循环强引用。声明属性或者变量时,在前面加上 weak 关键字表明这是一个弱引用。

由于弱引用不会强保持对实例的引用,所以说实例被释放了弱引用仍旧引用着这个实例也是有可能的。因此,ARC 会在被引用的实例被释放时自动的设置弱引用为 nil。由于弱引用需要允许它们的值为 nil,它们一定得是可选类型。

weak 使用示例代码:

class SSLTeacher {
    var age: Int = 18
    var name: String = "ssl"
    weak var subject:SSLSubject?
}

5.2 源码分析 SideTable引出

我们断点看一下,添加了 weak 关键字会调用什么函数

image.png image.png

可以看到 swift_weakInit 函数被调用了,这个函数可以在 HeapObject.cpp 中可以找到

image.png

声明一个 weak 变量相当于定义了一个 WeakReference 对象,点击查看 nativeInit

image.png image.png

到这里就可以发现,weak 本质上就是创建了一个 SideTable,函数中调用了 allocateSideTable 函数。

5.3 源码分析 SideTable

查看 allocateSideTable

image.png

先看下 HeapObjectSideTableEntry 和 InlineRefCounts 的对比关系:

image.png

查看 HeapObjectSideTableEntry 的定义:

image.png

HeapObjectSideTableEntry 通过 SideTableRefCounts 来存储引用计数,查看它的定义:

typedef RefCounts<InlineRefCountBits> InlineRefCounts;
typedef RefCounts<SideTableRefCountBits> SideTableRefCounts;

class alignas(sizeof(void*) * 2) SideTableRefCountBits : public RefCountBitsT<RefCountNotInline>
{
  uint32_t weakBits;
  ...
}

可以看到,SideTableRefCounts 和 InlineRefCounts 共用一个模版类RefCounts<T>,它们都继承自 RefCountBitsT,作为子类的 SideTableRefCounts 多了一个 weakBits 成员变量。

下面通过 lldb 调试,查看不同情况下引用计数的存储情况

image.png

  • 0xc0000000200c437c 是弱引用后的值
  • 这里是弱引用的创建函数 image.png
    • 把当前 side 对象的地址放到了 64 位的位域中
    • 两个标识位,一个 62,一个 63,都为 1
  • 0xc0000000200c437c 反向操作,得到散列表的内存地址 0x100621BE0 image.png
  • 通过 lldb 调试查看散列表的结构 image.png
    • 0x0000000000000003 是强引用的值
    • 0x0000000000000002 是弱引用的值

六、无主引用

和弱引用类似,无主引用不会强引用实例。但是和弱引用有所不同,无主引用会假定实例是永远有值的。

当我们向下面这样去访问一个无主引用的时候,其实有点像访问一个野指针,因为总是假定有值的,所以这里就会发生程序的崩溃

image.png

根据苹果官方文档的建议,当我们知道两个对象的生命周期并不相关,那么我们必须使用 weak。相反,非强引用对象拥有和强引用对象同样或者更长的生命周期的话,则应该使用 unowned。

七、闭包的循环引用

7.1 Swift 中的闭包循环引用

在 Swift 中闭包一般会默认捕获外部的变量,看下面代码:

var age = 18

let closure = {
    age += 1
}

closure()
print(age)

输出:19

从结果可以看出来,闭包内部对变量的修改将会改变外部原始变量的值

那同样就会有一个问题,如果我们在 class 的内部定义一个闭包,当前闭包访问属性的过程中,就会对我们当前的实例对象进行捕获。

看下面的代码,deinit 中代码被打印,这里是实例被正常释放的情况:

class SSLTeacher {
    var age: Int = 18
    
    deinit {
        print("SSLTeacher deinit")
    }
}

func testARC() {
    let t = SSLTeacher()
}
testARC()

输出:SSLTeacher deinit

再看下面的代码,teacher 和 closure 会产生循环引用,deinit 中的代码不会被打印:

class SSLTeacher {
    var age: Int = 18
    var testClosure:(() -> ())?
    
    deinit {
        print("SSLTeacher deinit")
    }
}

func testARC() {
    let t = SSLTeacher()
    
    t.testClosure = {
        t.age += 1
    }
}
testARC()

析构函数没有被调用,说明 teacher 没有被释放,产生了循环引用。

7.2 解决循环引用

通过 weak 解决循环引用:

class SSLTeacher {
    var age: Int = 18
    var testClosure:(() -> ())?
    
    deinit {
        print("SSLTeacher deinit")
    }
}

func testARC() {
    let t = SSLTeacher()
    
    t.testClosure = { [weak t] in
        t!.age += 1
    }
}

testARC()

输出:SSLTeacher deinit

可以看到通过 weak 的修饰,闭包中的内容可以正常打印,同时这里用 unowned 也是可以解决的:

func testARC() {
    let t = SSLTeacher()
    
    t.testClosure = { [unowned t] in
        t.age += 1
    }
}

testARC()

7.3 捕获列表

什么是捕获列表,默认情况下,闭包表达式从其周围的范围捕获常量和变量,并强引用这些值。您可以使用捕获列表来显示控制如何在闭包中捕获值。

在参数列表前,捕获列表被写为用逗号括起来的表达式列表,并用方括号括起来。如果使用捕获列表,必须使用 in 关键字。

参数列表中的变量,是不进行强引用的,看下面示例代码:

var age = 1
var height = 0.0

let closure = { [age] in
    print(age)
    print(height)
}

age = 10
height = 1.85

closure() // 输出结果为 1 ,1.85