从值类型复制引起的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~~

相关文章
相关标签/搜索