Swift 值类型和引用类型的内存管理

1、内存分配

1.1 值类型的内存分配

  • 在 Swift 中定长的值类型都是保存在栈上的,操作时不会涉及堆上的内存。变长的值类型(字符串、集合类型是可变长度的值类型)会分配堆内存。

    • 这相当于一个 “福利”,意味着你可以使用值类型更快速的完成一个方法的执行。
    • 值类型的实例只会保存其内部的存储属性,并且通过 “=” 赋值的实例彼此的存储是独立的。
    • 值类型的赋值是拷贝的,对于定长的值类型来说,由于所需的内存空间是固定的,所以这种拷贝的开销是在常数时间内完成的。

      1
      2
      3
      4
      struct Point {
      var x: Double
      var y: Double
      }
      1
      2
      3
      4
      5
      let point1 = Point(x: 3, y: 5)
      var point2 = point1

      print(point1) // Point(x: 3.0, y: 5.0)
      print(point2) // Point(x: 3.0, y: 5.0)
  • 上面的示例在栈上的实际分配如下图。

    1
    2
    3
    4
    5

    point1 x: 3.0
    y: 5.0
    point2 x: 3.0
    y: 5.0
  • 如果尝试修改 point2 的属性,只会修改 point2 在栈上的地址中保存的 x 值,不会影响 point1 的值。

    1
    2
    3
    4
    point2.x = 5

    print(point1) // Point(x: 3.0, y: 5.0)
    print(point2) // Point(x: 5.0, y: 5.0)
    1
    2
    3
    4
    5

    point1 x: 3.0
    y: 5.0
    point2 x: 5.0
    y: 5.0

1.2 引用类型的内存分配

  • 引用类型的存储属性不会直接保存在栈上,系统会在栈上开辟空间用来保存实例的指针,栈上的指针负责去堆上找到相应的对象。

    • 引用类型的赋值不会发生 “拷贝”,当你尝试修改示例的值的时候,实例的指针会 “指引” 你来到堆上,然后修改堆上的内容。
  • 下面把 Point 的定义修改成类。

    1
    2
    3
    4
    5
    6
    7
    8
    class Point {
    var x: Double
    var y: Double
    init(x: Double, y: Double) {
    self.x = x
    self.y = y
    }
    }
    1
    2
    3
    4
    5
    let point1 = Point(x: 3, y: 5)
    let point2 = point1

    print(point1.x, point1.y) // 3.0 5.0
    print(point2.x, point2.y) // 3.0 5.0
  • 因为 Point 是类,所以 Point 的存储属性不能直接保存在栈上,系统会在栈上开辟两个指针的长度用来保存 point1point2 的指针,栈上的指针负责去堆上找到对应的对象,point1point2 两个实例的存储属性会保存在堆上。

  • 当使用 “=” 进行赋值时,栈上会生成一个 point2 的指针,point2 指针与 point1 指针指向堆的同一地址。

    1
    2
    3
    4
    5
    6
               栈              堆
    point1 [ ] --|
    |--> 类型信息
    point2 [ ] --| 引用计数
    x: 3
    y: 5
  • 在栈上生成 point1point2 的指针后,指针的内容是空的,接下来会去堆上分配内存,首先会对堆加锁,找到尺寸合适的内存空间,然后分配目标内存并解除堆的锁定,将堆中内存片段的首地址保存在栈上的指针中。

  • 相比在栈上保存 point1point2,堆上需要的内存空间要更大,除了保存 xy 的空间,在头部还需要两个 8 字节的空间,一个用来索引类的类型信息的指针地址,一个用来保存对象的 “引用计数”。

  • 当尝试修改 point2 的值的时候,point2 的指针会 “指引” 你来到堆上,然后修改堆上的内容,这个时候 point1 也被修改了。

    1
    2
    3
    4
    point2.x = 5

    print(point1.x, point1.y) // 5.0 5.0
    print(point2.x, point2.y) // 5.0 5.0
  • 我们称 point1point2 之间的这种关系为 “共享”。“共享” 是引用类型的特性,在很多时候会给人带来困扰,“共享” 形态出现的根本原因是我们无法保证一个引用类型的对象的不可变性。

2、可变性和不可变性

  • 在 Swift 中对象的可变性与不可变性是通过关键字 letvar 来限制的。

  • Swift 语言默认的状态是不可变性,在很多地方有体现。

    • 比如方法在传入实参时会进行拷贝,拷贝后的参数是不可变的。
    • 或者当你使用 var 关键字定义的对象如果没有改变时,编译器会提醒你把 var 修改为 let

2.1 引用类型的可变性和不可变性

  • 对于引用类型的对象,当你需要一个不可变的对象的时候,你无法通过关键字来控制其属性的不可变性。

  • 当你创建一个 Point 类的实例,你希望它是不可变的,所以使用 let 关键字声明,但是 let 只能约束栈上的内容,也就是说,即便你对一个类型实例使用了 let 关键字,也只能保证它的指针地址不发生变化,但是不能约束它的属性不发生变化。。

    1
    2
    3
    4
    5
    6
    7
    8
    class Point {
    var x: Double
    var y: Double
    init(x: Double, y: Double) {
    self.x = x
    self.y = y
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    let point1 = Point(x: 3, y: 5)
    let point2 = Point(x: 0, y: 0)

    print(point1.x, point1.y) // 3.0 5.0
    print(point2.x, point2.y) // 0.0 0.0

    point1 = point2 // 发生编译错误,不能修改 point1 的指针

    point1.x = 0 // 因为 x 属性是使用 var 定义的,所以可以被修改

    print(point1.x, point1.y) // 0.0 5.0
    print(point2.x, point2.y) // 0.0 0.0
  • 如果把所有的属性都设置成不可变的,这的确可以保证引用类型的不可变性,而且有不少语言就是这么设计的。

    1
    2
    3
    4
    5
    6
    7
    8
    class Point {
    let x: Double
    let y: Double
    init(x: Double, y: Double) {
    self.x = x
    self.y = y
    }
    }
    1
    2
    3
    4
    5
    let point1 = Point(x: 3, y: 5)

    print(point1.x, point1.y) // 3.0 5.0

    point1.x = 0 // 发生编译错误,x 属性是不可变的
  • 新的问题是如果你要修改 Point 的属性,你只能重新建一个对象并赋值,这意味着一次没有必要的加锁、寻址与内存回收的过程,大大损耗了系统的性能。

    1
    2
    3
    let point1 = Point(x: 3, y: 5)

    point1 = Point(x: 0, y: 5)

2.2 值类型的可变性和不可变性

  • 因为值类型的属性保存在栈上,所以可以被 let 关键字所约束。

  • 你可以把一个值类型的属性都声明称 var,保证其灵活性,在需要该类型的实例是一个不可变对象时,使用 let 声明对象,即便对象的属性是可变的,但是对象整体是不可变的,所以不能修改实例的属性。

    1
    2
    3
    4
    struct Point {
    var x: Double
    var y: Double
    }
    1
    2
    3
    4
    5
    let point1 = Point(x: 3, y: 5)

    print(point1.x, point1.y) // 3.0 5.0

    point1.x = 0 // 编辑报错,因为 point1 是不可变的
  • 因为赋值时是 “拷贝” 的,所以旧对象的可变性限制不会影响新对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    let point1 = Point(x: 3, y: 5)
    var point2 = point1 // 赋值时发生拷贝

    print(point1.x, point1.y) // 3.0 5.0
    print(point2.x, point2.y) // 3.0 5.0

    point2.x = 0 // 编译通过,因为 point2 是可变的

    print(point1.x, point1.y) // 0.0 5.0
    print(point2.x, point2.y) // 0.0 5.0

3、引用类型的共享

  • “共享” 是引用类型的特性,在很多时候会给人带来困扰,“共享” 形态出现的根本原因是我们无法保证一个引用类型的对象的不可变性。

  • 下面展示应用类型中的共享。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 标签
    class Tag {
    var price: Double
    init(price: Double) {
    self.price = price
    }
    }

    // 商品
    class Merchandise {
    var tag: Tag
    var description: String
    init(tag: Tag, description: String) {
    self.tag = tag
    self.description = description
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    let tag = Tag(price: 8.0)

    let tomato = Merchandise(tag: tag, description: "tomato")

    print("tomato: \(tomato.tag.price)") // tomato: 8.0

    // 修改标签
    tag.price = 3.0

    // 新商品
    let potato = Merchandise(tag: tag, description: "potato")

    print("tomato: \(tomato.tag.price)") // tomato: 3.0
    print("potato: \(potato.tag.price)") // potato: 3.0
  • 这个例子中所描述的情景就是 “共享”, 你修改了你需要的部分(土豆的价格),但是引起了意料之外的其它改变(番茄的价格),这是由于番茄和土豆共享了一个标签实例。

  • 语意上的共享在真实的内存环境中是由内存地址引起的。上例中的对象都是引用类型,由于我们只创建了三个对象,所以系统会在堆上分配三块内存地址,分别保存 tomatopotatotag

    1
    2
    3
    4
    5
    6
    7
                  栈                堆
    tamoto Tag --|
    description | tag
    |--> price: 3.0
    |
    patoto Tag --|
    description
  • 在 OC 时代,并没有如此丰富的值类型可供使用,有很多类型都是引用类型的,因此使用引用类型时需要一个不会产生 “共享” 的安全策略,拷贝就是其中一种。

  • 首先创建一个标签对象,在标签上打上你需要的价格,然后在标签上调用 copy() 方法,将返回的拷贝对象传给商品。

    1
    2
    3
    4
    5
    let tag = Tag(price: 8.0)

    let tomato = Merchandise(tag: tag.copy(), description: "tomato")

    print("tomato: \(tomato.tag.price)") // tomato: 8.0
  • 当你对 tag 执行 copy 后再传给 Merchandise 构造器,内存分配情况如下图。

    1
    2
    3
    4
    5
    6
                  栈                 堆
    tamoto Tag -----> Copied tag
    description price: 8.0

    tag
    price: 8.0
  • 如果有新的商品上架,可以继续使用 “拷贝” 来打标签。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    let tag = Tag(price: 8.0)

    let tomato = Merchandise(tag: tag.copy(), description: "tomato")

    print("tomato: \(tomato.tag.price)") // tomato: 8.0

    // 修改标签
    tag.price = 3.0

    // 新商品
    let potato = Merchandise(tag: tag.copy(), description: "potato")

    print("tomato: \(tomato.tag.price)") // tomato: 8.0
    print("potato: \(potato.tag.price)") // potato: 3.0
  • 现在内存中的分配如图。

    1
    2
    3
    4
    5
    6
    7
    8
    9
                  栈                 堆
    tamoto Tag -----> Copied tag
    description price: 8.0

    tag
    price: 3.0

    patoto Tag -----> Copied tag
    description price: 3.0
  • 这种拷贝叫做 “保护性拷贝”,在保护性拷贝的模式下,不会产生 “共享”。

4、变长值类型的拷贝

  • 变长值类型不能像定长值类型那样把全部的内容都保存在栈上,这是因为栈上的内存空间是连续的,你总是通过移动尾指针去开辟和释放栈的内存。在 Swift 中集合类型和字符串类型是值类型的,在栈上保留了变长值类型的身份信息,而变长值类型的内部元素全部保留在堆上。

  • 定长值类型不会发生 “共享” 这很好理解,因为每次赋值都会开辟新的栈内存,但是对于变长的值类型来说是如何处理哪些尾保存内部元素而占用的堆内存呢?苹果在 WWWDC2015 的 414 号视频中揭示了定长值类型的拷贝奥秘:相比定长值类型的 “拷贝” 和引用类型的 “保护性拷贝”,变长值类型的拷贝规则要复杂一些,使用了名为 Copy-on-Write 的技术,从字面上理解就是只有在写入的时候才拷贝。

  • 在 Swift 3.0 中出现了很多 Swift 原生的变长值类型,这些变长值类型在拷贝时使用了 Copy-on-Write 技术以提升性能,比如 Date、Data、Measurement、URL、URLSession、URLComponents、IndexPath。

5、利用引用类型的共享

  • “共享” 并不总是有害的,“共享” 的好处之一是堆上的内存空间得到了复用,尤其是对于内存占用空间较大的对象(比如图片),效果明显。所以如果堆上的对象在 “共享” 状态下不会被修改,那么我们应该对该对象进行复用从而避免在堆上创建重复的对象,此时你需要做的是创建一个对象,然后向对象的引用者传递对象的指针,简单来说,就是利用 “共享” 来实现一个 “缓存” 的策略。

  • 假如你的应用中会用到许多重复的内容,比如用到很多相似的图片,如果你在每个需要的地方都调用 UIImage(named:) 方法,那么会创建很多重复的内容,所以我们需要把所有用到的图片集中创建,然后从中挑选需要的图片。很显然,在这个场景中字典最适合作为缓存图片的容器,把字典的键值作为图片索引信息。这是引用类型的经典用例之一,字典的键值就是每个图片的 “身份信息”,可以看到在这个示例中 “身份信息” 是多么的重要。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    enum Color: String {
    case red
    case blue
    case green
    }

    enum Shape: String {
    case circle
    case square
    case triangle
    }
    1
    2
    3
    4
    5
    6
    let imageArray = ["redsquare": UIImage(named: "redsquare"), ...]

    func searchImage(color: Color, shape: Shape) -> UIImage {
    let key = color.rawValue + shape.rawValue
    return imageArray[key]!!
    }
  • 一个变长的值类型实际会把内存保存在堆上,因此创建一个变长值类型时不可避免的会对堆加锁并分配内存,我们使用缓存的目的之一就是避免过多的堆内存操作,在上例中我们习惯性的把 String 作为字典的键值,但是 String 是变长的值类型,在 searchImage 中生成 key 的时候会触发堆上的内存分配。

  • 如果想继续提升 searchImage 的性能,可以使用定长值类型作为键值,这样在合成键值时将不会访问堆上的内存。要注意的一点是你所使用的定长值类型必须满足 Hashable 协议才能作为字典的键值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    enum Color: Equatable {
    case red
    case blue
    case green
    }

    enum Shape: Equatable {
    case circle
    case square
    case triangle
    }

    struct PrivateKey: Hashable {
    var color: Color = .red
    var shape: Shape = .circle

    internal var hsahValue: Int {
    return color.hashValue + shape.hashValue
    }
    }
    1
    2
    3
    4
    5
    6
    let imageArray = [PrivateKey(color: .red, shape: .square): UIImage(named: "redsquare"),
    PrivateKey(color: .blue, shape: .circle): UIImage(named: "bluecircle")]

    func searchImage(privateKey: PrivateKey) -> UIImage {
    return imageArray[privateKey]!!
    }
文章目录
  1. 1. 1、内存分配
    1. 1.1. 1.1 值类型的内存分配
    2. 1.2. 1.2 引用类型的内存分配
  2. 2. 2、可变性和不可变性
    1. 2.1. 2.1 引用类型的可变性和不可变性
    2. 2.2. 2.2 值类型的可变性和不可变性
  3. 3. 3、引用类型的共享
  4. 4. 4、变长值类型的拷贝
  5. 5. 5、利用引用类型的共享
隐藏目录