Swift 中结构体和类的内存分析

点赞评论,希望能在你的帮助下越来越好

作者:@iOS成长指北,本文首发于公众号 iOS成长指北,欢迎各位前往指正

如有转载需求请联系我,记住一定要联系哦!!!

这是我参与更文挑战的第5天,活动详情查看:更文挑战

在前文 关于 iOS 内存分配的胡思乱想 一文中,笔者并未讨论关于 Swift 中的分配情况,本文简单阐述一下 Swift 中结构体和类的在栈中的分配情况。

在了解 Swift 中的内存分配之前,你需要基本以下知识:

  1. 为什么说 Swift 是一门内存安全的语言?那么不安全 指的是什么呢?
  2. 值类型及引用类型
  3. 栈和堆
  4. MemoryLayout 的基本使用

基础知识

内存安全[1](Memory Safety)

默认情况下,Swift 会阻止可能发生的不安全行为。

Swift 确保:

  • 变量在使用之前被初始化,内存在被释放后不被访问

  • 检查数组索引是否有越界错误。

  • 为了实现内存安全,Swift 还通过要求修改内存中某个位置的代码具有对该内存的独占访问权限[2],来确保对同一内存区域的多次访问不会冲突。

  • 避免未定义行为(Unexpected Behaviour

let array = [10.1, 10.2, 10.3, 10.4]
// let b = array.first + 1.0 //你无法这样使用
let b = array.first! + 1.0
//let c = array[4] + 1.0 //检查数组是否越界
//Fatal error: Index out of range
let c = array[2] + 1.0
复制代码
var stepSize = 1

func increment(_ number: inout Int) {
    number += stepSize
}

increment(&stepSize) 
// 确保对同一内存区域的多次访问不会冲突
// Simultaneous accesses to **, but modification requires exclusive access.
// Previous access (a modification) started at **
// Current access (a read) started at:
复制代码

当编译器或者 Debug 时,编译器报错或 crash,Swift 通过这种方式来告诉你这样子的使用是不安全的。

不安全 的 Swift

不安全这个词听起来很可怕,但是在实际工程过程中我们可能会遇到这样子的取舍。

  • 我们需要实现一些黑魔法,来满足我们的需求或者工程分析工具,越接近底层为安全所做的防护越少
  • 与其他不安全的语言进行交互,例如 C 簇的代码,语言的选择是一种取舍平衡,为了某种要求而进行某种设计。

Swift 中这不安全的部分是指 —— UnsafePointer

在我们学习 Swift 提供的不安全的 API 之前,我们需要了解一下 MemoryLayout 的基本使用。

值类型和引用类型

在 Swift 中的对象类型分成了两个部分_值类型_ 和 引用类型
在学习 Swift 的内存分配之前为什么要了解这部分呢?因为对于值类型和引用类型来说,其内存的分配方式是不一样的。

栈和堆

在学习内存分配的时候,我们需要知道什么是堆、什么是栈,或者说我们得知道代码是分布在栈区还是堆区。

iOS 中的内存大致可以分为代码区,全局/静态区,常量区,堆区,栈区,其地址由低到高。

我们所编写的代码中,存在比栈区更高的区域吗?在酷酷的哀殿的 iOS 的系统类信息在栈上? 说明了在栈之上还有一个系统共享库的内存区域。

MemoryLayout

为了了解 Swift 中的布局情况,我们需要利用 Swift 提供的 API —— MemoryLayout,其可以在运行时告知所提供类型的 SizeAlignmentStride

  • Size:类型连续内存占用,以字节为单位。
  • Alignment:类型默认的内存对齐方式,以字节为单位。
  • Stride:存储在连续内存或数组中时,从上一个实例的开始到下一个实例的开始的字节数。

其用法很简单,我理解为两个API,一个是对对象MemoryLayout<T>.size/stride/alignment,一个是对实例 MemoryLayout.size/stride/alignment(ofValue: point)

MemoryLayout<Int>.size          //  8 
MemoryLayout<Int>.alignment     //  8 
MemoryLayout<Int>.stride        //  8

MemoryLayout<Bool>.size         //  1 
MemoryLayout<Bool>.alignment    //  1 
MemoryLayout<Bool>.stride       //  1

MemoryLayout<Double>.size       //  8 
MemoryLayout<Double>.alignment  //  8 
MemoryLayout<Double>.stride     //  8

struct Point {
    let x: Int
    let y: Int
    let isFilled: Bool
}

MemoryLayout<Point>.size      // 17
MemoryLayout<Point>.stride    // 24
MemoryLayout<Point>.alignment // 8

var point = Point(x: 3, y: 4, isFilled: true)

MemoryLayout.size(ofValue: point)// 17
MemoryLayout.alignment(ofValue: point) // 24
MemoryLayout.stride(ofValue: point) // 8

复制代码

MemoryLayout 作为我们分析 Swift 中的内存的主要工具,在前文我们提到过,在内存分配中,值类型和引用类型是不一样的,我们会依次进行分析。那么 SizeAlignmentStride 之间的区别是什么呢

我们以Unsafe Swift: A road to Memory[3]中的图示来简单说明:

size-stride-alignment-summary.png

在一段连续的内存中(我们测试代码一般在栈中),Size 是指其属性/成员变量所占用的内存大小,而 Alignment 一般指的是属性/成员变量所占内存大小中最大的那个,而 Stride 则是值在一段连续内存中,其占用的大小,不足位数补零。

UnsafePointer

什么是 UnsafePointer?

UnsafePointer[4] 是一个 Swift API,用于访问特定类型数据的指针。

Types that works with direct memory, get the prefix Unsafe

其格式为:

Unsafe [Mutable] [Raw] [Buffer] Pointer [<Type>]

  • Mutable:可变性与不可变性,Swift 中通过 letvar 关键字区分变量的可变性,指针中也采用了类似方案,让开发者针对性地控制指针的可变性,即其指向的内存块的可写性。
  • Raw:表示它指向一个字节,加不加就是原始指针和泛型指针的区别
  • Buffer:其本质就是一个指针加上一个大小 count,即一串连续的内存块。
  • <Type>:表示泛型类型指针

MutableRawBuffer 组合起来就是8 种组合 Unsafe Pointer API,可以根据需要使用它们来实现某个目标。

  1. UnsafePointer<T>
  2. UnsafeMutablePointer<T>
  3. UnsafeRawPointer
  4. UnsafeMutableRawPointer
  5. UnsafeBufferPointer<T>
  6. UnsafeMutableBufferPointer<T>
  7. UnsafeRawBufferPointer
  8. UnsafeMutableRawBufferPointer

你可以参照 Exploring Swift Memory Layout[5] 来实践 Unsafe*Pointer 的使用。

MJ 在其 Swift 高手课里用于打印内存信息的工具——Mems,就是通过 Unsafe*Pointer 来实现的,后续我们将利用这个工具辅助进行内存分析。

至此,我们已经有了基本概念以及辅助内存分析的两个工具 MemoryLayoutMems

Swift 中的 MemoryLayout

诚如我在前文所说,值类型和引用类型其在内存中的表现是不一样的,所以我们依次进行分析。

本部分参考 Memory layout in Swift [6]

分析值类型的内存

在 Swift 中值类型的内存(指的是我们自定义的符合类型),我们一般可以根据其属性的大小,根据字节对齐计算而来。

计算值类型大小

如下面的代码例子,当我们存在一个 Int 和一个 Bool 类型的基本类型时

struct Example {
    let foo: Int  // 8
    let bar: Bool // 1
}
//基本上结构体的内存大小与其实例的大小是一致的
let example = Example(foo: 8, bar: true)

print(MemoryLayout<Example>.size)      // 9
print(MemoryLayout<Example>.stride)    // 16
print(MemoryLayout<Example>.alignment) // 8
复制代码

其在一段连续内存中的样式应该如下图所示:利用 Debug 工具 View Memory of example 获取

swift_memory_struct_1.png

我们可以简单的计算出 ExampleSizeAlignmentStride。即 Size 的大小等于 Int + Bool 之和,Int 所占字节数为 8 个字节为最大的字节数,所以基于此作为对齐的大小,最终补足了 7 字节作为 padding。

在使用值类型的复合类型时,我们是不是都根据这种方式来进行计算呢?

事实上,并不完全是,我们将 barfoo 调换一个位置:

struct Example {
  let bar: Bool // 1
  let foo: Int  // 8
}
//基本上结构体的内存大小与其实例的大小是一致的
let example = Example(bar: true, foo: 8)
print(MemoryLayout<Example>.size)      // 16
print(MemoryLayout<Example>.stride)    // 16
print(MemoryLayout<Example>.alignment) // 8
复制代码

Size 的值变成 16,是不是跟我刚开始见到时一样的困惑呢?为什么 Size 的大小会增加?

其在一段连续内存中的样式应该如下图所示:利用 Debug 工具 View Memory of example 获取

swift_memory_struct_2.png

由此可知,Size 其含义应该是一连串连续内存的大小,Swift 并不会像 Objective-C 一样进行对齐优化,而是根据当前属性的顺序进行排列。

目前来看似乎毫无影响,因为其 Stride 的大小同样也是 16 字节。

但是考虑一下下面两个类:

struct Example1 {
  let foo: Int
  let bar: Bool
  let bar1: Bool
} // Int, Bool, Bool

struct Example2 {
  let bar: Bool
  let foo: Int
  let bar1: Bool
} // Bool, Int, Bool
复制代码

下面我们来分析一下:

对 Example1 来说, 其 Size 值为 8 个字节,加上 1个字节,加上 1 个字节,总和为 10 个字节,其 Stride 的大小为基于 8 个字节进行对齐所得的 16 个字节,补足了 6 字节作为 padding。

对 Example1 来说, 其 Size 值为 8 个字节,加上 8个字节,加上 1 个字节,总和为 17 个字节,其 Stride 的大小为基于 8 个字节进行对齐所得的 24 个字节,在第一个 BoolInt 之间存在 7 个字节的补位,后面也补了 7 个字节 。

print(MemoryLayout<Example1>.size)      // 10
print(MemoryLayout<Example1>.stride)    // 16

print(MemoryLayout<Example2>.size)      // 17
print(MemoryLayout<Example2>.stride)    // 24
复制代码

如果你真的对内存敏感或者与一些不安全的库进行交互的话,建议你用最合理的布局方式进行布局。

分析引用类型的内存

请注意上述的计算方式只适合值类型,我们不能类比出引用类型内存的计算方式。

我们同样利用 MemoryLayout 来针对引用类型的类进行分析

class Point {
    var x: Int
    var y: Int
    init(_ xI: Int, _ yI: Int) {
        x = xI
        y = yI
    }
}

print("Point.size", MemoryLayout<Point>.size)      // 8
print("Point.stride", MemoryLayout<Point>.stride)  // 8

let point = Point(10, 20)
// 利用MemoryLayout.size(ofValue: _)获取实例的大小
print("point.size", MemoryLayout.size(ofValue: point))     // 8
print("point.stride", MemoryLayout.stride(ofValue: point)) // 8
复制代码

为什么呢?

一般来说,值类型(struct、Int、Bool、Float 等)存在于栈中,而引用类型(类)在堆中分配,当然这并非 100% 正确。

我们利用 MemoryLayout 测量的只是 Point 的指针,指针 sizestride 的大小都为 8 个字节。

一个引用类型的实例对象究竟有多大呢?

在前文 关于 iOS 内存分配的胡思乱想 一文中,我们阐述了在 iOS 中一个对象的大小最小为 16 字节并且其大小为 16 的倍数,对 Swift 的引用类型该定义同样适用。

如何证明?

使用汇编证明调用了 malloc 方法

关于 malloc 的源码在前文 关于 iOS 内存分配的胡思乱想 我们分析了很多次,在这里我们只要证明分配的过程会调用 malloc 方法。

利用查看汇编代码的方式分析当我们引用类型(类)初始化的时候会执行哪些方法:

在过程中明确一些比较明确的名词例如 alloc 和 malloc,这种词意味着系统正在分配

定位到初始化方法:

swift_memory_ assembly_0.png

灵活利用 lldb 的命令 sifinish 命令快速跳过部分代码,不过谨慎使用

swift_memory_ assembly_2.png

请细心走到 swift_slowAlloc 方法

swift_memory_ assembly_3.png

其实现中存在 mallioc_zone_malloc 方法

swift_memory_ assembly_4.png

到此我们就不在阐述 mallioc_zone_malloc 方法究竟执行了什么。当然你也可以查看Swift的源码进行对应的分析。

利用 Unsafe*Pointer 方法来分析

如同在 Objective-C 中可以利用class_getInstanceSize 获取类对象所占用最小内存一样,在 Swift 中同样可以使用:

class Empty {}
print(class_getInstanceSize(Empty.self)) // 16

class Point {
    var x: Int // 8
    var y: Int // 8
    init(_ xI: Int, _ yI: Int) {
        x = xI
        y = yI
    }
}

print(class_getInstanceSize(Point.self)) // 32
复制代码

与 Objective-C 运行时有所不同的时,Swift 中最简单的一个 Empty 类其也有 16 个字节的大小,除了 isa 指针以外 还存在 8 个字节用于记录引用计数。

我们需要探究的是具体对象分配的大小,也就是获取 malloc_size,在文章的开篇我们提到了关于利用 Unsafe*Pointer ,利用系统自带的 mallocUnsafe*Pointer 分析内存。

import Foundation

class Point {
    var x: Int
    var y: Int
    init(_ xI: Int, _ yI: Int) {
        x = xI
        y = yI
    }
}
let point = Point(10, 20)
let ptr = UnsafeRawPointer(bitPattern: unsafeBitCast(point, to: UInt.self))!
print(malloc_size(ptr)) // 32
复制代码

其他

无论使用值类型还是引用类型,其对齐方式都是根据属性的顺序来的并没有进行对齐优化。

class Example1 {
    let foo: Int = 10
    let bar: Bool = true
    let bar1: Bool = false
} // Int, Bool, Bool

print(class_getInstanceSize(Example1.self)) // 32

let example1 = Example1()
let ptr1 = UnsafeRawPointer(bitPattern: unsafeBitCast(example1, to: UInt.self))!
print(malloc_size(ptr1)) //32

class Example2 {
    let bar: Bool = true
    let foo: Int = 10
    let bar1: Bool = false
} // Bool, Int, Bool

print(class_getInstanceSize(Example2.self)) // 40

let example2 = Example2()
let ptr2 = UnsafeRawPointer(bitPattern: unsafeBitCast(example2, to: UInt.self))!
print(malloc_size(ptr2)) //48
复制代码

善于进行内存分析工具进行分析的话,能够编写出内存更合理的应用程序。

总结

继上次 恬不知耻 分析 Objective-C 中 NSObject alloc 的过程之后,笔者又针对 Swift 中结构体/类初始化的进行了简单的分析。

本文主要介绍了在 Swift 中主要的内存分析工具 MemoryLayout 以及 Unsafe*Pointermalloc_size 来分析值类型和引用类型的大小。

由于内存的昂贵,在创建复杂对象时,其合理处理其属性的顺序,以期获取最合理的大小。

参考资料

内存安全:docs.swift.org/swift-book/…

[译] Swift 5 强制独占性原则:juejin.cn/post/684490…

Unsafe Swift: A road to Memory:medium.com/swlh/unsafe…

UnsafePointer:developer.apple.com/documentati…

GOTO 2016 • Exploring Swift Memory Layout • Mike Ash:www.youtube.com/watch?v=ERY…

Memory layout in Swift:theswiftdev.com/memory-layo…


如果你有任何问题,请直接评论,如果文章有任何不对的地方,请随意表达。如果你愿意,可以通过分享这篇文章来让更多的人发现它。

感谢你阅读本文! ?

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享