ios开发方向——swift内存基础

内存基础

Swift 内存管理的核心是 ARC(自动引用计数) ,而理解内存的第一步是区分值类型引用类型------ 这是内存存储的根本差异。


一、值类型 vs 引用类型(内存存储的核心)

这是 Swift 内存最基础、面试最常考的点,直接决定了数据存在哪里、赋值时的行为。

表格

特性 值类型(Value Type) 引用类型(Reference Type)
包含类型 Struct、Enum、Tuple、Int/String 等基本类型 Class、Closure
内存位置 栈(Stack) 堆(Heap)
赋值行为 拷贝整个值(深拷贝) 拷贝引用地址(浅拷贝)
修改影响 仅修改自身副本 修改所有引用同一对象的变量

代码示例(带注释)

Swift 复制代码
// 1. 值类型:Struct
struct Person {
    var name: String
}
var p1 = Person(name: "小明")
var p2 = p1 // 值拷贝:p2 是 p1 的独立副本
p2.name = "小红"
print(p1.name) // 小明(p1 不受影响)

// 2. 引用类型:Class
class Animal {
    var name: String
    init(name: String) { self.name = name }
}
var a1 = Animal(name: "猫")
var a2 = a1 // 引用拷贝:a1 和 a2 指向堆上的同一个对象
a2.name = "狗"
print(a1.name) // 狗(a1 也被修改)

二、栈(Stack) vs 堆(Heap)

值类型和引用类型分别存在这两个内存区域,理解它们的差异能帮你明白为什么值类型更快、更安全。

特性 栈(Stack) 堆(Heap)
管理方式 系统自动管理(无需开发者操心) ARC 管理(需注意循环引用)
速度 极快(连续内存,直接存取) 较慢(需分配、查找内存)
存储内容 值类型(Struct、Enum 等) 引用类型的实例(Class 对象)
生命周期 作用域结束自动释放(如函数返回) 引用计数为 0 时释放
线程安全 天然线程安全(每个线程有独立栈) 需手动处理线程安全

三、ARC(自动引用计数)------ 引用类型的内存管家

ARC 只针对引用类型(Class) ,核心原理是:每个对象有一个「引用计数」,计数为 0 时系统自动释放对象

1. 引用计数的增减

  • 增加 :当有一个新的强引用(var/let 默认都是强引用)指向对象时,计数 +1
  • 减少 :当强引用被赋值为 nil、或作用域结束时,计数 -1
代码示例
Swift 复制代码
class Dog {
    var name: String
    init(name: String) { 
        self.name = name 
        print("\(name) 被创建")
    }
    deinit { 
        print("\(name) 被释放") 
    }
}

// 引用计数变化
var dog1: Dog? = Dog(name: "旺财") // 计数 +1 → 1
var dog2 = dog1 // 计数 +1 → 2
dog1 = nil // 计数 -1 → 1
dog2 = nil // 计数 -1 → 0 → 触发 deinit,打印"旺财 被释放"

四、循环引用与解决

ARC 有一个致命问题:两个对象互相强引用 ,导致双方引用计数都不为 0,永远不会被释放 ------ 这就是内存泄漏

1. 循环引用的产生

Swift 复制代码
class Person {
    var pet: Dog? // 强引用 Dog
}
class Dog {
    var owner: Person? // 强引用 Person
}

// 循环引用:person 和 dog 互相持有,计数都为 1,永远不释放
var person: Person? = Person()
var dog: Dog? = Dog()
person?.pet = dog
dog?.owner = person
person = nil // 计数 -1 → 1(因为 dog 还持有它)
dog = nil // 计数 -1 → 1(因为 person 还持有它)

2. 解决方法:weak(弱引用)或 unowned(无主引用)

两者都不增加引用计数,区别在于:

表格

特性 weak(弱引用) unowned(无主引用)
对象释放后 自动置为 nil 不会置 nil(需确保对象存在,否则崩溃)
类型要求 必须是可选类型(? 可以是非可选类型
适用场景 两个对象生命周期独立(如主人和宠物) 一个对象生命周期依赖另一个(如视图和控制器)

weak 解决循环引用

Swift 复制代码
class Person {
    var pet: Dog?
}
class Dog {
    weak var owner: Person? // 用 weak 修饰,不增加计数
}

// 现在不会循环引用了
var person: Person? = Person()
var dog: Dog? = Dog()
person?.pet = dog
dog?.owner = person
person = nil // 计数 0 → 释放
dog = nil // 计数 0 → 释放

五、总结

  1. 值类型 (Struct/Enum)存栈,赋值拷贝,系统自动管理;引用类型(Class)存堆,赋值拷贝引用,ARC 管理。
  2. 快、系统管、线程安全;慢、ARC 管、需注意循环引用。
  3. ARC 靠引用计数释放对象,强引用增减计数。
  4. 循环引用weak(可选,自动置 nil)或 unowned(非可选,需确保存在)解决。

Swift 完整的 5 大内存分区

之前重点讲了栈和堆,这里补全 Swift 程序运行时的完整内存布局,从低地址到高地址依次为:

内存分区 核心作用 存储内容 生命周期 管理方式
代码区(Text Segment) 存放程序执行代码 编译后的二进制机器指令,只读不可修改 App 启动时加载,App 终止时释放 系统自动管理
常量区(Constant Segment) 存放只读常量 字符串常量(如let str = "hello"中的"hello")、数字常量、全局常量 App 启动时加载,App 终止时释放 系统自动管理
全局 / 静态区(Data Segment) 存放全局 / 静态变量 分两个子区:・已初始化区(Data):有初始值的全局 / 静态变量・未初始化区(BSS):无初始值的全局 / 静态变量 App 启动时分配,App 终止时释放 系统自动管理
栈区(Stack) 存放临时局部数据 函数参数、局部值类型变量、函数返回地址、寄存器现场 函数进入时分配栈帧,函数返回时自动销毁栈帧,先进后出 系统自动管理,无额外开销
堆区(Heap) 存放动态分配的对象 引用类型实例(Class、Closure)、超出栈存储能力的大尺寸值类型、装箱的协议类型值 手动申请 / ARC 管理,引用计数为 0 时释放 ARC 管理,有内存分配 / 销毁的额外开销

补充关键细节:

  • iOS 主线程默认栈大小为1MB ,子线程默认栈大小为512KB,栈溢出会直接触发崩溃(如递归死循环)
  • 堆上的 Swift 对象有固定的 16 字节基础开销:8 字节的 isa 指针(类型信息)+ 8 字节的引用计数相关信息,最小占用 16 字节内存

二、值类型进阶核心:写时复制(COW, Copy-On-Write)

之前提到值类型赋值会做拷贝,这里纠正一个核心误区:Swift 标准库的核心值类型(Array、String、Dictionary、Set、Data),并不是赋值时立刻深拷贝,而是通过 COW 实现延迟拷贝,大幅优化性能

1. COW 核心原理

  1. 当你对一个 COW 类型执行赋值操作(let arr2 = arr1)时,不会立刻拷贝内存,而是让两个变量共享同一块底层内存,同时通过引用计数标记这块内存的共享次数
  2. 只有当你对其中一个变量执行修改操作时,才会真正开辟新的内存,拷贝原始内容,实现「只读共享,修改拷贝」
  3. 全程对开发者透明,无需额外代码,既保留了值类型的语义安全,又避免了无意义的内存拷贝

2. 代码演示

Swift 复制代码
var arr1 = [1,2,3,4,5]
var arr2 = arr1

// 此时arr1和arr2共享同一块底层内存,无拷贝发生
print(arr1.baseAddress!) // 打印原始内存地址
print(arr2.baseAddress!) // 和arr1地址完全相同

// 对arr2执行修改,触发COW,执行真正的拷贝
arr2.append(6)

// 此时arr2的地址发生变化,拥有了独立的内存副本
print(arr1.baseAddress!) // 地址不变
print(arr2.baseAddress!) // 新的内存地址
print(arr1) // [1,2,3,4,5] 不受影响

3. 关键补充

  • 自定义的Struct/Enum默认没有实现 COW,只有 Swift 标准库的核心集合类型内置了 COW
  • 面试高频考点:COW 是 Swift 值类型高性能的核心,也是 Swift 推荐优先使用 Struct 的重要原因之一

三、纠正核心误区:值类型一定存在栈上吗?

结论:不是。Swift 编译器会做「内存逃逸分析」,尽可能把值类型分配在栈上,但以下场景,值类型会被分配到堆上:

  1. 结构体 / 枚举尺寸过大,超出栈的合理存储范围
  2. 值类型包含协议类型(存在性类型,如any Equatable),编译期无法确定内存尺寸,需要装箱后存到堆上
  3. 逃逸闭包捕获了值类型的变量,编译器无法确定其生命周期
  4. indirect修饰的递归枚举(需要通过指针引用自身,只能存堆上)

补充:即使值类型被分配到堆上,它依然保留值类型的语义(赋值拷贝、独立修改),只是存储位置发生了变化。


四、ARC 进阶原理与细节

1. 引用计数的底层存储

Swift 对象的引用计数分为两部分存储:

  • 64 位系统下,对象的isa指针(Non-pointer isa)中,预留了专门的位域存储内联引用计数,操作速度极快
  • 当内联引用计数存满时,会使用SideTable(侧边表) 存储额外的引用计数,同时 SideTable 还负责存储弱引用表的相关信息

2. weak 弱引用的底层实现

  • 系统维护了一个全局的弱引用表,是一个哈希表结构
  • 当你用weak修饰一个变量时,会把该变量的内存地址注册到弱引用表中,和目标对象绑定
  • 当目标对象的引用计数为 0、即将释放时,系统会自动遍历弱引用表,把所有指向该对象的weak指针全部置为nil
  • 这也是为什么weak修饰的变量必须是可选类型:它会在对象释放时自动变为nil

3. unowned 无主引用的进阶细节

unowned分为两种模式,日常开发默认使用安全模式:

类型 特性 适用场景 风险
unowned(safe)(默认) 不增加引用计数,对象释放后,访问会触发运行时断言,直接崩溃 两个对象生命周期强绑定,被引用方生命周期 >= 引用方 有安全检查,崩溃有明确提示
unowned(unsafe) 不增加引用计数,对象释放后,指针变为野指针,无安全检查 极致性能优化场景,极度不推荐日常使用 野指针访问会触发不可预期的崩溃,极难排查

4. weak vs unowned 终极选择标准

  • 当两个对象的生命周期互相独立 ,被引用的对象可能先于引用方释放 → 必须用weak
  • 当两个对象的生命周期强绑定 ,被引用的对象生命周期一定大于等于引用方,引用方离开被引用方就没有存在的意义 → 可以用unowned
  • 日常开发优先用weakunowned的崩溃风险更高

五、开发中最高频的内存泄漏场景与解决方案

之前只讲了两个类互相强引用的基础场景,这里补充开发中 90% 的内存泄漏都来自的场景,也是面试高频考点。

1. 逃逸闭包的循环引用(最常见)

泄漏原理
  • 闭包是引用类型,会强引用捕获到的外部变量
  • 如果self持有了这个闭包,同时闭包内部又强引用了self,就会形成「self → 闭包 → self」的闭环,双方引用计数永远无法归零,造成内存泄漏
  • 只有逃逸闭包(@escaping) 会有这个风险,非逃逸闭包不会持有 self,无需处理
解决方案:捕获列表(Capture List)

在闭包开头通过[weak self]/[unowned self]声明捕获规则,打破循环引用。

代码示例
Swift 复制代码
class ViewController: UIViewController {
    // self 强引用了闭包
    var completionHandler: (() -> Void)?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 错误写法:闭包强引用self,形成循环引用
        completionHandler = {
            self.view.backgroundColor = .red
        }
        
        // 正确写法:捕获列表用weak self,打破循环
        completionHandler = { [weak self] in
            // 解包self,避免后续频繁写可选链
            guard let self = self else { return }
            self.view.backgroundColor = .red
        }
    }
    
    deinit {
        print("ViewController 被释放")
    }
}

补充:

  • 不是所有闭包都需要加[weak self]:比如Array.sorted(by:)这类非逃逸闭包、GCD 的一次性async执行的闭包,不会被 self 持有,不会形成循环
  • 捕获列表可以同时声明多个弱引用:[weak self, weak delegate = self.delegate]

2. Timer 定时器的循环引用

泄漏原理
  • Timer创建时会强引用target,同时RunLoop会强引用激活状态的Timer
  • 如果self强引用了Timer,同时Timertargetself,就会形成「self → Timer → RunLoop → self」的闭环
  • 很多人会在deinit里调用timer.invalidate(),但循环引用会导致deinit永远不会执行,定时器永远无法停止
正确解决方案
Swift 复制代码
class ViewController: UIViewController {
    var timer: Timer?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // 正确写法:iOS10+ 推荐的block API,配合weak self
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            guard let self = self else { return }
            print("定时器执行")
        }
    }
    
    // 正确的停止时机:页面消失时停止定时器
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        timer?.invalidate()
        timer = nil
    }
    
    deinit {
        print("ViewController 被释放")
    }
}

3. NotificationCenter 基于 Block 的监听

泄漏原理

NotificationCenter.default.addObserver(forName:object:queue:using:) 这个 API 会返回一个监听者对象,系统会强引用这个对象;如果 block 内部强引用了self,同时self又强引用了这个监听者对象,就会形成循环引用。

解决方案

block 内加[weak self],同时在合适的时机移除监听者。


六、补充关键语法的内存原理

1. inout 关键字的内存本质

很多人误以为inout是引用传递,其实官方定义是写回拷贝(Copy-In Copy-Out),而非 C++ 的地址引用。

  1. 函数调用时:将参数的值拷贝到函数内的局部内存(Copy-In)
  2. 函数执行时:修改这个局部副本
  3. 函数返回时:将修改后的副本写回原变量的内存地址(Copy-Out)

编译器会对全局变量、存储属性、COW 类型做优化,直接转为内存引用,避免拷贝开销。

  • 误区纠正:inout不是传地址,只是实现了「修改外部变量」的语义,和引用传递有本质区别
  • 注意:inout参数不能有默认值,不能是let常量

2. Swift 中的 autoreleasepool

OC 中常用的自动释放池,在 Swift 中依然适用,用于降低大循环中的内存峰值

  • 适用场景:循环内创建大量临时的大对象(如图片处理、大文件读写),避免循环过程中内存持续上涨
  • 语法示例:
Swift 复制代码
// 大循环处理图片,手动加自动释放池
for _ in 0..<1000 {
    autoreleasepool {
        // 循环内创建的临时对象,会在本次autoreleasepool结束时统一释放
        let image = UIImage(contentsOfFile: "大图片路径")
        // 图片处理逻辑
    }
}

七、deinit 执行规则与注意事项

deinit是对象的析构函数,是判断对象是否正常释放的核心依据,补充关键规则:

  1. 执行时机:对象引用计数归零后,内存释放之前,在主线程同步执行
  2. 执行顺序:子类的deinit先执行,之后自动调用父类的deinit
  3. 禁止操作:
    • 不能手动调用deinit,只能由系统自动触发
    • 不能在deinit里强引用self,会导致对象复活,内存无法释放
    • 不能调用异步方法、不能开启新的 RunLoop,因为对象即将被销毁
  4. 开发技巧:在deinit里打印日志,快速判断对象是否正常释放,排查内存泄漏

八、内存问题排查工具

日常开发中,可通过 Xcode 自带工具快速定位内存问题:

  1. Memory Graph Debugger:Xcode 调试栏的内存图标,可视化展示所有对象的引用关系,一键定位循环引用
  2. Instruments - Leaks:专业的内存泄漏检测工具,可定位泄漏的对象、内存地址和调用堆栈
  3. Instruments - Allocations:查看内存分配情况,定位内存峰值和异常上涨的原因

补充总结

  1. Swift 内存管理的核心是值类型与引用类型的区分,值类型优先栈分配、语义安全,引用类型堆分配、ARC 管理
  2. COW 是 Swift 值类型高性能的核心,实现了「只读共享,修改拷贝」
  3. ARC 的核心痛点是循环引用,解决方案是weakunowned,日常开发优先用weak
  4. 90% 的内存泄漏来自三个场景:类互相强引用、逃逸闭包强引用 self、Timer 循环引用
  5. 可通过deinit日志、Memory Graph、Instruments 快速排查内存问题
相关推荐
minji...2 小时前
Linux 多线程(四)线程等待,线程分离,线程管理,C++多线程,pthread库
linux·运维·开发语言·网络·c++·算法
麦德泽特2 小时前
基于 Go 语言的 Modbus 项目实战:构建高性能、可扩展的工业通信服务器
服务器·开发语言·golang·modbus·rtu
SY.ZHOU2 小时前
移动端架构体系(二):本地持久化与动态部署
flutter·ios·安卓
还是大剑师兰特2 小时前
pnpm format 什么作用
开发语言·javascript·ecmascript
QuZero2 小时前
Java Synchronized principle
java·开发语言
单片机学习之路2 小时前
【Python】输入input函数
开发语言·python
cch89182 小时前
ThinkPHP6.x全面升级:性能与功能双飞跃
开发语言·vue.js·后端·golang
yangyanping201082 小时前
Go语言学习之Go Gin 生产级 flag 启动命令模板
开发语言·学习·golang
xyq20242 小时前
R语言处理JSON文件的方法详解
开发语言