Swift 進階:指標 & 記憶體管理

語言: CN / TW / HK

一、為什麼說指標不安全

  • 比如我們在建立一個物件的時候,是需要在堆分配記憶體空間的。但是這個記憶體空間的宣告週期是有限的,也就意味這如果我們使用指標指向這塊記憶體空間,如果當前記憶體空間的生命週期到了(引用計數為0),那麼我們當前的指標就變成未定義的行為了。
  • 我們建立的記憶體空間是有邊界的,比如我們建立一個大小為10的陣列,這個時候我們通過指標訪問到了index = 11的位置,這個時候就越界了,訪問了一個未知的空間。
  • 指標型別與記憶體的值型別不一致,也是不安全的。

二、指標

Swift 中的指標分為兩類 1. typed pointer: 指定資料型別指標,即UnsafePointer<T>,其中 T 表示泛型 1. raw pointer: 未指定資料型別的指標(原生指標) ,即UnsafeRawPointer

與 OC 中指標的對比:

| OC | Swift | 釋義 | | --- | --- | --- | | 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.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.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.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.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.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.init(bitPattern: Int(exactly: filedDescriptorRelaticveAddress) ?? 0)?.pointee // fieldDescriptor 記憶體地址 let fieldDescriptorAddress = filedDescriptorRelaticveAddress + UInt64(fieldDescriptorOffset!) // fieldDescriptor 指標 let fieldDescriptor = UnsafePointer.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.init(bitPattern: Int(exactly: fieldNameRelactiveAddress) ?? 0)?.pointee let fieldNameAddress = fieldNameRelactiveAddress + UInt64(offset!) - linkBaseAddress if let cChar = UnsafePointer.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.size + Int(i) * MemoryLayout.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) { 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) { print(p) }

let uint8Ptr = UnsafePointer.init(bitPattern: 10) uint8Ptr?.withMemoryRebound(to: Int8.self, capacity: 1) { (int8Ptr: UnsafePointer) 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 InlineRefCounts; typedef RefCounts SideTableRefCounts;

template class RefCounts { std::atomic 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 InlineRefCounts; typedef RefCounts SideTableRefCounts;

class alignas(sizeof(void*) * 2) SideTableRefCountBits : public RefCountBitsT { 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 ```