4、内存管理崩溃:分析EXC_BAD_ACCESS的常见原因

EXC_BAD_ACCESS,这大概是iOS开发里最让人头疼的崩溃了。没有友好的错误提示,没有清晰的堆栈信息,就给你一个「坏内存访问」的结论。我刚开始做iOS开发那会儿,遇到这个错误基本就是两眼一抹黑,只能靠猜。

其实说白了,这个崩溃就一个核心原因:你访问了一块已经不属于你的内存。要么是对象已经被释放了,你还拿着它的指针去发消息;要么是你压根没初始化,就拿着个野指针到处跑。今天我们就来彻底搞懂它。

4.1 野指针:指向「不存在」的对象

什么叫野指针?就是指针指向的内存地址,已经不再是一个有效的对象了。但指针本身的值(那个地址)还在,你拿着它去调用方法,系统就炸了。

典型场景:

// 假设有个自定义的 Person 类
Person *p = [[Person alloc] init];
[p release];  // 或者用 ARC 下,出了作用域被释放
[p setName:@"Tom"];  // 崩溃!p 已经是野指针了

我在项目中遇到过最坑的一次,是一个全局变量被多个地方持有。A模块释放了对象,B模块不知道,继续用。结果就是随机崩溃,而且不是每次必现,特别难复现。你想想看,这种问题要是上了线,用户反馈来了,你连怎么复现都不知道。

4.2 过度释放:释放了不该释放的对象

过度释放是MRC时代的重灾区。虽然现在ARC已经帮我们处理了大部分内存管理,但有些场景还是会踩坑——比如Core Foundation对象、C语言结构体、或者你在MRC模式下维护的老项目。

常见陷阱:

  • 对同一个对象调用了两次 release
  • 在 dealloc 里释放了本不属于自己的对象
  • 使用 assign 修饰的 delegate,对象释放后没有置 nil

嗯,这里要注意。ARC下虽然不用手动release,但assignunsafe_unretained这两个修饰符,依然会产生野指针。我建议能用weak就别用assign,除非你明确知道自己在做什么。

4.3 使用 Zombie Objects 定位野指针

好了,理论说完了,咱们来点实际的。怎么定位这种崩溃?

我个人最常用的工具就是 Zombie Objects。它的原理很简单:对象释放后,不真正回收内存,而是把它变成一个「僵尸对象」。当你再访问它时,Xcode会告诉你:这个对象是什么类型、在什么时候被释放的、谁还在持有它。

开启步骤:

  1. Edit Scheme -> Run -> Diagnostics
  2. 勾选 Zombie Objects
  3. 运行App,复现崩溃

开启后,崩溃信息会变成这样:

*** -[Person setName:]: message sent to deallocated instance 0x600000123450

看到没?它直接告诉你是 Person 对象,地址是 0x600000123450。你拿着这个地址去断点或者 Instruments 里一查,谁释放的、谁最后持有的,一目了然。

注意:Zombie Objects 会占用大量内存,因为对象释放后不回收。调试完记得关掉,别带着它打包上线。

4.4 使用 Address Sanitizer 检测内存越界

Zombie Objects 解决的是「访问已释放对象」的问题。但还有一种情况:内存越界。比如你给一个数组写了越界的数据,或者C语言里访问了已释放的堆内存。

这时候就该 Address Sanitizer(ASan) 出场了。它会在编译时插入额外的检查代码,每次内存读写都做校验。一旦发现非法访问,立刻中断并给出详细报告。

开启步骤:

  1. Edit Scheme -> Run -> Diagnostics
  2. 勾选 Address Sanitizer
  3. 建议同时勾选 Detect use of stack after return

开启后,崩溃信息会精确到哪一行代码、哪个变量、什么操作导致的。我记得有一次排查一个诡异的数组越界问题,堆栈信息完全不对,开了ASan后直接定位到了是某个C函数里指针偏移算错了。那一刻,我真的想给ASan磕一个。

4.5 实战对比:什么时候用哪个?

场景 推荐工具 原因
访问已释放的OC对象 Zombie Objects 直接告诉你对象类型和释放时间
内存越界(数组、C指针) Address Sanitizer 精确到代码行和变量
堆内存损坏 Address Sanitizer 检测缓冲区溢出、use-after-free
多线程野指针 两个都开 Zombie 定位对象,ASan 定位线程

4.6 避坑指南

我曾经在一个大版本上线前,被一个EXC_BAD_ACCESS折磨了整整两天。现象是:用户快速滑动列表时,偶尔闪退。Zombie Objects开了,没抓到。ASan开了,也没抓到。后来发现是KVO的观察者在dealloc时没有移除,导致对象释放后,KVO系统还在往这个地址发通知。

所以,这里给你几个我总结的检查清单:

  • dealloc里有没有清理KVO、Notification、Timer? 这些是野指针的高发区。
  • delegate用weak了吗? 用assign的话,对象释放后delegate不会自动置nil。
  • Block里捕获了self吗? 小心循环引用,但也要小心捕获了已经释放的对象。
  • Core Foundation对象桥接时,ownership声明对了吗?__bridge还是__bridge_transfer,搞错了就是过度释放或内存泄漏。

最后说一句:EXC_BAD_ACCESS不可怕,可怕的是你不敢面对它。打开Zombie Objects,打开ASan,让工具帮你说话。调试多了你会发现,大部分内存崩溃,其实就那么几个套路。

我的调试习惯:开发阶段一直开着ASan(性能影响可以接受),遇到野指针再临时开Zombie Objects。上线前关掉所有诊断工具,跑一遍完整的回归测试。

好了,这一章就到这里。下一章我们聊聊 循环引用——另一个让ARC开发者又爱又恨的话题。