從值型別複製引發的Swift記憶體的思考01

Jani發表於2018-02-07

Question

前不久看了一篇文章,喵神的值型別和引用型別,在閱讀的時候有一個結論 值型別被複制的時機是值型別的內容發生改變時... 這個時候本來是想記下來的,後來轉念一想,實踐出真知,所以我就基於這個問題: 值型別到底是什麼時候被賦值的? 做了一些調查和實踐,從而有了這系列文章...

Answer

我在iOS Playground中寫了如下示例,初始化了Int String Struct Array並且立刻進行了賦值操作:

struct Me {
    let age: Int = 22            // 8
    let height: Double = 180.0   // 8
    let name: String = "XiangHui"// 24
    var hasGirlFriend: Bool?     // 1
}
    
var a = 134
var cpa = a
    
var b = "JoJo"
var cpb = b
    
var me = Me()
var secondMe = me
    
var likes = ["comdy", "animation", "movies"]
var cpLikes = likes
複製程式碼

並且隨後使用一個swift指標方法來輸出值型別在記憶體中的地址:

    withUnsafeBytes(of: &T, { bytes in
        print("T: \(bytes)")
    })
複製程式碼

那麼其實我們可以猜測一下,如果是在值型別發生改變的時候才去賦值的話(寫時複製),那麼以上覆制的變數的地址應該和原變數是一樣的,結果如下:

a: UnsafeRawBufferPointer(start: 0x00007ffee3500ef8, count: 8)
cpa: UnsafeRawBufferPointer(start: 0x00007ffee3500f00, count: 8)
b: UnsafeRawBufferPointer(start: 0x00007ffee3500f18, count: 24)
cpb: UnsafeRawBufferPointer(start: 0x00007ffee3500ee0, count: 24)
me: UnsafeRawBufferPointer(start: 0x00007ffee3500fa8, count: 41)
secondMe: UnsafeRawBufferPointer(start: 0x00007ffee3500f40, count: 41)
likes: UnsafeRawBufferPointer(start: 0x00007ffee3500f30, count: 8)
cpliles: UnsafeRawBufferPointer(start: 0x00007ffee3500f08, count: 8)
複製程式碼

顯然,值型別的值並非是在改變的時候才去複製,而是在賦值的時候就會進行復制! 這個結論顯然是有問題的! 如果把上面的每一種型別拆開的話可以得到的結論大概是Int,Double, String, Struct等)是在賦值的時候複製的,為什麼?因為對於基本型別來講寫時複製帶來的開銷其實有時比直接複製帶來的開銷更大!而對於集合型別來講,當然上面我的例項是陣列,它直接複製的只是一個引用而已,集合型別(Array,Dictionary,Set)並非是在賦值時複製的,而是在寫時複製的!

根據喵神的指導,我使用了以下方式來輸出陣列的地址:

func address<T: AnyObject>(of object: T) -> String {
    let addr = unsafeBitCast(object, to: Int.self)
    return String(format: "%p", addr)
}
    
func address(of object: UnsafeRawPointer) -> String {
    let addr = Int(bitPattern: object)
    return String(format: "%p", addr)
}

var likes = ["animation", "movies", "comdy"]
var cpLikes = likes

print("Array")
print(address(of: &likes))
print(address(of: &cpLikes))

cpLikes.removeLast()

print(address(of: &cpLikes))
複製程式碼

最後輸出的是:

Array
0x6080000d4370
0x6080000d4370
0x6080000d5480
複製程式碼

分析:前兩次輸出的起始地址是一樣的,所以在賦值的時候值並沒有發生變化,但是在移除cplikes最後一個元素時,陣列的地址就發生了變化,所以可以得出的結論是陣列是寫時複製的!

以下是喵神的原話:

從值型別複製引發的Swift記憶體的思考01

Deep in

當這個問題解決之後又不禁有了新的疑問:

  • 在系統中記憶體究竟是如何分配的?
  • 棧中的資料到底是如何儲存的?
  • 堆上的資料又是如何儲存的?

針對我的這三個簡單但是寬泛的問題,我做了大量的閱讀和實踐,然後有了下面的一些思考和總結:

Concept

在進行更抽象的記憶體理論之前,得了解幾個基本的概念,首先是可操作記憶體區域,在程式中我們使用的記憶體區域就是圖中的綠色區域:

從值型別複製引發的Swift記憶體的思考01

在這塊區域中我們可以簡要的分為三個區域堆,棧,全域性區。在現代的CPU每次讀取資料的時候,都會讀取一個word,在64位上,也就是8個位元組。

  • Stack 儲存方法呼叫;區域性變數(Method invocation; Locial variables)
  • Heap 儲存物件(all objects!)
  • Global 儲存全域性變數;常量;程式碼區

從值型別複製引發的Swift記憶體的思考01

這樣一看其實有一點豁然開朗的感覺,其實基本只有方法或者特定型別如結構體中出現的變數才是區域性變數,也就是說在方法中宣告的變數都是分配在棧上的,然而在類中宣告一個基本型別作為物件屬性,其實是在堆上分配的

class Test {
	let a = 4 // 分配在堆上
	func printMyName() {
		let myName = "JoJo" // 分配在棧上
		print("\(myName)")
	}
}
複製程式碼

MemoryLayout

 //值型別
 MemoryLayout<Int>.size           //8
 MemoryLayout<Int>.alignment      //8
 MemoryLayout<Int>.stride         //8

 MemoryLayout<String>.size        //24
 MemoryLayout<String>.alignment   //8
 MemoryLayout<String>.stride      //24

 //引用型別 T
 MemoryLayout<T>.size             //8
 MemoryLayout<T>.alignment        //8
 MemoryLayout<T>.stride           //8


 //指標型別
 MemoryLayout<unsafeMutablePointer<T>>.size           //8
 MemoryLayout<unsafeMutablePointer<T>>.alignment      //8
 MemoryLayout<unsafeMutablePointer<T>>.stride         //8

 MemoryLayout<unsafeMutableBufferPointer<T>>.size           //16
 MemoryLayout<unsafeMutableBufferPointer<T>>.alignment      //16
 MemoryLayout<unsafeMutableBufferPointer<T>>.stride         //16
複製程式碼

MemoryLayout<Type>是一個泛型,通過它的三個屬性可以獲取具體型別在記憶體中的分配:size表明該型別實際使用了多少個位元組;alignment表明該型別必須對齊多少位元組(如為8,意味著地址的起點地址可以被8整除);stride表明從開始到結束一共需要佔據多少位元組。 Swift中基本型別的size和stride在記憶體中是一樣的 (可選型如Double?實際使用了9個位元組,但是卻需要佔據16個位元組) 記憶體對齊的好處這裡針對記憶體對齊的好處有了比較詳盡的描述,主要是速度快。

MemoryLayout

Struct Stack Memory

從一個棧的例項來看棧中記憶體的分配情況:

struct Me {
    let age: Int = 22                    
    let height: Double? = 180.0         
    let name: String = "XiangHui"        
    var hasGirlFriend: Bool = false      
 }
 //MemoryLayout<Double?>.size         9
 //MemoryLayout<Double?>.alignment    8
 //MemoryLayout<Double?>.stride       16
 
 class MyClass {
	func test() {
		var me = Me()
		print(me)
	}
 }
 
 let myClass = MyClass()
 myclass.test()
 
複製程式碼

在方法裡打個斷點使用偵錯程式輸出棧中的記憶體,在這之前可以猜想一下,Int型別佔8個位元組,Double?雖然size是9個位元組,但是它的stride是16位元組,所以佔據了16位元組,String型別佔據了24個位元組,最後Bool型別佔據8個位元組,一共8 + 16 + 24 + 8 = 56位元組,也就是說這個結構體在棧上佔據56位元組的記憶體,列印如下:

(lldb) po MemoryLayout.size(ofValue: me)
49

(lldb) po MemoryLayout.stride(ofValue: me)
56
複製程式碼

奇怪,為什麼size是49呢?因為size是從開始到實際結束所佔據的記憶體,即Bool的size和stride都是為1個位元組,這樣的話,當前word還有7個位元組是沒有使用的記憶體,所以實際大小為49位元組。再看詳細地址列印:

(lldb) frame variable -L me
0x00007ffeea2cda50: (MemorySwiftProject.Me) me = {
0x00007ffeea2cda50:   age = 22
0x00007ffeea2cda58:   height = 180
0x00007ffeea2cda68:   name = "XiangHui"
0x00007ffeea2cda80:   hasGirlFriend = false
}
複製程式碼

地址是從棧底一直向上增加的,我畫出示意圖如下:(Boolsize為1)

從值型別複製引發的Swift記憶體的思考01

原來在結構體中棧的儲存如此簡單, 那麼如果結構體中有宣告引用型別呢?結果是引用型別佔一個word(指標所佔空間為8個位元組);那麼如果在結構體中有方法體呢? 結論是結構體中即使有方法實現依然不佔據記憶體,這個問題留待下篇文章來解決!但是可以有一個初步的猜測,我覺得應該是和方法的靜態呼叫有關,也即是和編譯器的編譯相關。

// 方法體在結構體中並不佔據記憶體
struct Test {
    let a = 1
    func test01() {}
}
let test = Test()
MemoryLayout.size(ofValue: test)  // 8
    
struct Test2 {
    func test01() {}
}
let test2 = Test2()
MemoryLayout.size(ofValue: test2) // 0
複製程式碼

Method Stack Memory

本來應該是要了解了解堆的,結果在方法呼叫斷點輸出的時候,發現了一些值得一提的點,所以就決定聊一聊關於方法棧中的記憶體!關於方法的排程,其實就是一個一個方法的入棧,棧頂方法執行完之後出棧,然後新的棧頂方法執行完之後出棧。如果是在一個遞迴方法的執行過程中,這個就感覺看起來很有意思。
但是呢,現在不聊方法的排程,而是聊一聊當執行一個方法的時候,方法的內部是如何進行記憶體分配的,首先一點,方法在執行過程中記憶體是分配在棧上的!

struct Me {
	let age: Int = 22              // 8
	let height: Double? = 180.0    // size: 9 stride: 16
	let name: String = "XiangHui"  // 24
	let a = MemoryClass()          // 8
	let hasGirlFriend = false      // 1
 }
  
 // MemoryLayout<Me>.stride  64(8 + 16 + 24 + 8 + 8 = 64)

func test() {
	var number = 134        // stride: 8
	var name = "JoJo"       // stride: 8
	var me = Me()	 		// stride: 64
	var likes = ["comdy", "animation", "movies"] // stride: 8
	
    withUnsafeBytes(of: &number, { bytes in
        print("number: \(bytes)")
    })
    
    withUnsafeBytes(of: &name, { bytes in
        print("name: \(bytes)")
    })
    
    withUnsafeBytes(of: &me, { bytes in
        print("me: \(bytes)")
    })
    
    withUnsafeBytes(of: &likes, { bytes in
        print("likes: \(bytes)")
    })
}
複製程式碼

在這裡首先解釋一下為什麼結構體的stride是64個位元組嗎?通過上述講了這裡應該很明瞭了吧,在這個結構體中有Int Double? String Class Bool型別,一共8 + 16 + 24 + 8 + 8 = 64位元組。還有一個小細節為什麼陣列likes的stride是8個位元組呢?因為在棧上分配的依然是一個陣列指標而已,它指向記憶體中的另一塊儲存空間,至於實際陣列所儲存的記憶體空間是如何分配呢?留待下篇文章解決~ 程式碼輸出結果如下:

0x00007ffee46f2ac0: (Int) number = 134
0x00007ffee46f2aa8: (String) name = "JoJo"
0x00007ffee46f2a68: (MemorySwiftProject.Me) me = {
0x00007ffee46f2a68:   age = 22
0x00007ffee46f2a70:   height = 180
0x00007ffee46f2a80:   name = "XiangHui"
scalar:   a = 0x000060c00001de10 {}  //引用型別在堆中的具體地址
0x00007ffee46f2aa0:   hasGirlFriend = false
}
0x00007ffee46f2a20: ([String]) likes = 3 values {
0x00007ffc9d780500:   [0] = "comdy"
0x00007ffc9d721710:   [1] = "animation"
0x00007ffc9d6443d0:   [2] = "movies"
}
複製程式碼

通過withUnsafeBytes(of:&T) {}方法,count輸出的是Size。那麼接下來開始分析了:首先有一點值得注意,輸出的記憶體居然是依次遞減的,也就是說棧底的元素反而記憶體地址較高,而後入棧的元素,地址是依次變小的,所以結構體如下:

從值型別複製引發的Swift記憶體的思考01
奇怪,為什麼會多出64個位元組呢?而且還是和結構體的size一樣大。針對這個情況一開始我以為是陣列的問題,以為這個和陣列有關係,然後做出了大量的測試,如果沒有陣列的話,將陣列變數換成一個Int型別,結果還是一樣多出64位元組,那我就想,就應該是結構體的原因了,結果去掉結構體變數後,發現一切正常,所有變數按照stride和alignment一一入棧,無異常。

然後接下來我改變結構體的大小結果發現,在方法棧中多出的這塊記憶體依舊和結構體例項的size一樣大,為什麼呢?為什麼在方法棧中給結構體分配記憶體的時候會多出一塊記憶體呢,而且size還和它的size一樣大?同樣留著這個問題吧!

Heap Memory

在我們看完棧上的記憶體之後,堆上的記憶體其實也是一樣的,程式碼例項如下:

class MemoryClass {
    static let name = "Naruto"
    let ninjutsu = "rasengan"   // 24
    let test = TestClass()      // 8
    let age = 22                // 8
    
    func beatSomeone() {
        let a = ninjutsu + ninjutsu
        print(a)
    }
}

func heapTest() {
    let myClass = MemoryClass()
    
    print(myClass)
}

heapTest()

複製程式碼

在heapTest( )方法中打個斷點可以得到以下輸出:

(lldb) frame variable -L myClass
scalar: (MemorySwiftProject.MemoryClass) myClass = 0x000060400027ca80 {
0x000060400027ca90:   ninjutsu = "rasengan"
scalar:   test = 0x00006040004456d0 {
0x00006040004456e0:     name = "Hui"
  }
0x000060400027cab0:   age = 22
}
(lldb) po malloc_size(Unmanaged.passUnretained(myClass as AnyObject).toOpaque())
64
複製程式碼

那麼根據輸出的結果可以得出以下結論:

堆記憶體
在這裡有三個地方是多出來的三個位元組,他們分別存什麼呢?我從最後一個word開始分析

堆上的每次記憶體分配

為什麼從最後一個word開始分析呢?因為每次新建一個object,object的屬性都是從第16個位元組開始分配的,所以在每個物件的前兩個word都必然儲存一些其他的資訊,因為之前的OC基礎,所以可以猜測應該是儲存的一個isa指標之類的資訊。但是最後8個位元組就不一定出現了,接下來我的測試方式是在MyClass中增加不停的增加Bool型別的成員變數,一開始預測,每一次新增都會增加一個word的位元組數,結果通過malloc_size(UnsafeRawPointer)方法我得到的每一次記憶體大小為64 80 96 ...都是以16個位元組遞增的,所以我可以初步確定這是堆分配記憶體的特性,每次都會分配16個位元組的倍數的記憶體,回到上圖,那麼如果增加一個Int成員變數,它的記憶體大小為應該為64位元組,而實驗結果大小正好也是64位元組,符合!如果再增加一個Bool型的成員變數,它的記憶體大小為80位元組,也正如推測。所以結論是:至少在iOS 64 系統上,堆上對物件分配記憶體時,每次都是分配的16個位元組的倍數

class MemoryClass {
    static let name = "Naruto"
    let ninjutsu = "rasengan"   // 24
    let test = TestClass()      // 8
    let age = 22                // 8
    
    let age2 = 22               // 8
}
// malloc_size(Unmanaged.passUnretained(myClass as AnyObject).toOpaque()) 64

class MemoryClass {
    static let name = "Naruto"
    let ninjutsu = "rasengan"   // 24
    let test = TestClass()      // 8
    let age = 22                // 8
    
    let age2 = 22               // 8
    let a = false               // 1  (只多了一個Bool型別)
}
// malloc_size(Unmanaged.passUnretained(myClass as AnyObject).toOpaque()) 80

複製程式碼

消失的型別變數

使用static修飾的name屬性,在初始化類例項的時候並沒有出現堆上的記憶體中,這在開篇第二幅圖中就解釋了這個問題,在整個記憶體區域可以分為棧區;堆區;全域性變數,靜態變數區;常量區;程式碼區。下面是我畫的圖:

從值型別複製引發的Swift記憶體的思考01

型別變數並不會分配在堆上,而是會在編譯的時候就分配在Global Data區域中,所以這也是在堆上為什麼型別變數沒有分配記憶體的原因.

物件的第一個Word是什麼?

其實這個問題呢我也思考了很久,感覺上應該就是OC中的isa指標指向它的類,結果也是如此,這篇文章有很明確的解釋:C++中物件的isa指標指向的是VTable,它只是單純的方法列表,而在swift中更復雜一些,實際上所有的Swift類都是Objective-C類,如果新增了@obj或者繼承NSObject的類會更直觀,但是即使是純粹的Swift類依然在本質上就是Objective-C類。針對這個問題我專門在twitter上詢問了大神@mikeash,他回覆的原話:

Yes, they subclass a hidden SwiftObject class.

所以第一個word其實就是一個isa指標,指向的就是Class; 但是更準確的說,不一定是isa指標,有時候是isa指標和其他的東西,比如說和當前物件相關聯的其他物件(當前物件釋放時它也需要清理)... 但是通常意義上我們可以理解為就是isa指標。

我們可以做一個實驗,改變當前物件的isa指標,指向其他的型別,那麼會發生什麼呢?

class Cat {
    var name = "cat"
    
    func bark() {
        print("maow")
    }
    
    //可變原始指標(當前例項的指標)
    func headerPointerOfClass() -> UnsafeMutableRawPointer {
        return Unmanaged.passUnretained(self as AnyObject).toOpaque()
    }
}

class Dog {
    var name = "dog"
    
    func bark() {
        print("wangwang")
    }
    
    //可變原始指標(當前例項的指標)
    func headerPointerOfClass() -> UnsafeMutableRawPointer{
        return Unmanaged.passUnretained(self as AnyObject).toOpaque()
    }
}

    func heapTest() {
        let cat = Cat()
        let dog = Dog()
        
        let catPointer = cat.headerPointerOfClass()
        let dogPointer = dog.headerPointerOfClass()
        
        catPointer.advanced(by: 0)
            .bindMemory(to: Dog.self, capacity: 1)
            .initialize(to: dogPointer.assumingMemoryBound(to: Dog.self).pointee, count: 1)
        
        cat.bark()  // wangwang
    }
複製程式碼

因為cat例項的isa指標指向了Dog型別,swift中的方法都是靜態派發的,只有加上加上dynamic關鍵字才是動態派發的,在這裡其實就是cat的第一個word指向了dog,它會直接呼叫方法列表中的第一個方法,問題來了:如果在bark() 前面再加上另一個方法如fuck()會如何? 答案是執行fuck()!因為並非是動態的尋找執行的方法,只是利用偏移量去找到對應的方法執行的!swift類預設都是靜態派發的,根據偏移量找到對應方法。

既然提到了isa指標,那麼接下來有會有疑惑了isa指向的Class的結構到底是怎樣的呢?因為之前已經提到了Swift類本質上是OC類,所以我們看OC類的定義就可以了,因為Objective-C類定義是開源的,所以就看下圖唄:

	Class isa
	Class super_class
	const char *name
	long version
	long info
	long instance_size
	struct objc_ivar_list *ivars
	struct objc_method_list **methodLists
	struct objc_cache *cache
	struct objc_protocol_list *protocols
複製程式碼

記憶體中的Class儲存了類名;它的例項大小;屬性列表;方法列表;協議列表;快取(加快了方法排程)等等...但是,這畢竟是一個Objective-C Class中的結構,事實上Swift Class擁有Objective-C Class裡的所有內容而且還新增了一些東西,但是本質上,Swift Class只是擁有更多東西的Objective-C Class

	uint32_t flags;
	uint32_t instanceAddressOffset;
	uint32_t instanceSize;
	uint16_t instanceAlignMask;
	uint16_t reserved;
	uint32_t classSize;
	uint32_t classAddressOffset;
	void *description;
複製程式碼

物件裡的第二個Word

好吧,第一個Word儲存的可以簡單地說就是指向Class的指標,那麼第二個Word呢?其實第二個Word存放的是引用計數,在Swift是使用的引用計數來管理物件的生命週期的,Swift中有兩種引用計數,一種是強引用,一種是弱引用,而在兩者都在這個Word中,每一種引用計數的大小31個位元組! 那麼接下來那張圖就可以完善了:

堆

總結

其實這一篇下來還是學了挺多東西的,接下來我來捋一捋脈絡:

  • 首先值型別到底是在什麼時候進行復制:基本資料型別在賦值的時候複製,集合型別(Array, Set, Dictionary)是在寫時複製的
  • 然後介紹一些基本的關於記憶體的基本概念:MemoryLayout三屬性等
  • 通過一些例項來說明了Struct在棧中的儲存結構,要注意棧底位置和地址增加方向
  • 接著說明了在方法棧中Method的儲存結構,棧底在頂部,地址是從棧底向棧頂遞減的,如果方法棧中有結構體也正好是可以符合儲存結構的
  • 最後講了物件在Heap中的儲存結構,第一個Word是存放isa指標,第二個Word是存放的retain counts;以及在針對物件分配記憶體的時候,記憶體是以16個位元組的倍數遞增的。

但是呢,也給自己留下了一些問題,這些問題就留待在下篇文章解答吧:

  1. Swift的集合型別的記憶體到底怎麼分配的?
  2. Swift結構體中並沒有方法的儲存空間,為什麼呢?
  3. 類中的方法又是如何排程的呢(靜態排程和動態排程)?
  4. 協議又是如何儲存的?結構體繼承協議會怎樣?類繼承協議會怎樣?
  5. 方法棧中如果出現結構體,會多出和結構體大小一致的空間,這是為什麼呢?

參考文章:

Unsafe Swift: Using Pointers And Interacting With C
Exploring Swift Memory Layout
Swift 物件記憶體模型探究(一)
Swift進階之記憶體模型和方法排程
Printing a variable memory address in swift

最後附上我的Blog地址,如果覺得寫得不錯歡迎關注我的掘金,或者常來逛我的Blog~~

相關文章