從值類型複製引起的Swift內存的思考01

Question

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

Answer

我在iOS Playground中寫了以下示例,初始化了Int String Struct Array而且馬上進行了賦值操做:github

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指針方法來輸出值類型在內存中的地址: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)並不是是在賦值時複製的,而是在寫時複製的!緩存

根據喵神的指導,我使用瞭如下方式來輸出數組的地址:bash

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))
複製代碼

最後輸出的是:markdown

Array
0x6080000d4370
0x6080000d4370
0x6080000d5480
複製代碼

分析:前兩次輸出的起始地址是同樣的,因此在賦值的時候值並無發生變化,可是在移除cplikes最後一個元素時,數組的地址就發生了變化,因此能夠得出的結論是數組是寫時複製的!ide

如下是喵神的原話:oop

Deep in

當這個問題解決以後又不由有了新的疑問:post

  • 在系統中內存到底是如何分配的?
  • 棧中的數據究竟是如何存儲的?
  • 堆上的數據又是如何存儲的?

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

Concept

在進行更抽象的內存理論以前,得了解幾個基本的概念,首先是可操做內存區域,在程序中咱們使用的內存區域就是圖中的綠色區域:

在這塊區域中咱們能夠簡要的分爲三個區域堆,棧,全局區。在現代的CPU每次讀取數據的時候,都會讀取一個word,在64位上,也就是8個字節。

  • Stack 存儲方法調用;局部變量(Method invocation; Locial variables)
  • Heap 存儲對象(all objects!)
  • Global 存儲全局變量;常量;代碼區

這樣一看其實有一點豁然開朗的感受,其實基本只有方法或者特定類型如結構體中出現的變量纔是局部變量,也就是說在方法中聲明的變量都是分配在棧上的,然而在類中聲明一個基本類型做爲對象屬性,實際上是在堆上分配的

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)

原來在結構體中棧的存儲如此簡單, 那麼若是結構體中有聲明引用類型呢?結果是引用類型佔一個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。那麼接下來開始分析了:首先有一點值得注意,輸出的內存竟然是依次遞減的,也就是說棧底的元素反而內存地址較高,然後入棧的元素,地址是依次變小的,因此結構體以下:

奇怪,爲何會多出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屬性,在初始化類實例的時候並無出現堆上的內存中,這在開篇第二幅圖中就解釋了這個問題,在整個內存區域能夠分爲棧區;堆區;全局變量,靜態變量區;常量區;代碼區。下面是我畫的圖:

類型變量並不會分配在堆上,而是會在編譯的時候就分配在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~~

相關文章
相關標籤/搜索