从值类型复制引发的Swift内存的思考01

3,724 阅读17分钟

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最后一个元素时,数组的地址就发生了变化,所以可以得出的结论是数组是写时复制的!

以下是喵神的原话:

Deep in

当这个问题解决之后又不禁有了新的疑问:

  • 在系统中内存究竟是如何分配的?
  • 栈中的数据到底是如何存储的?
  • 堆上的数据又是如何存储的?

针对我的这三个简单但是宽泛的问题,我做了大量的阅读和实践,然后有了下面的一些思考和总结:

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~~