第35条:用“僵尸对象”调试内存管理问题
本条要点:(作者总结)
- 系统在回收对象时,可以不将其真的回收,而是把它转化为僵尸对象。通过环境变量 NSZombieEnable 可开启此功能。
- 系统会修改对象的 isa 指针,令其指向特殊的僵尸类,从而使该对象变为僵尸对象。僵尸类能够相应所有的选择子,响应方式为:打印一条包含消息内容及其接收者的消息,然后终止应用程序。
调试内存管理问题很令人头疼。大家都知道,向已回收的对象发送消息是不安全的。这么做有时可以,有时不行。具体可行与否,完全取决于对象所占内存有没有为其他内容所覆写。而这块内存有没有移作他用。又无法确定,因此,应用程序只是偶尔崩溃。在没有崩溃的情况下,那块内存可能只复用了其中一部分,所以对象中的某些二进制数据依然有效。还有一种可能,就是那块内存恰好为另外一个有效且存活的对象所占据。在这种情况下,运行期系统会把消息发到新对象那里,而此对象也许能应答也许不能。如果能,那程序就不崩溃,可你会觉得奇怪:为什么收到消息的对象不是预想的那个呢?若新对象无法响应选择子,则程序依然会崩溃。
所幸 Cocoa 提供了 “僵尸对象”(Zombie Object)这个非常方便的功能。启用这项调试功能之后,运行期系统会把所有已经回收的实例转化成特殊的“僵尸对象”,而不会真正回收它们。这种对象所在的核心内存无法重用,因此不可能遭到覆写。僵尸对象收到消息后,会抛出异常,其中准确说明了发送过来的消息,并描述了回收之前的那个对象。僵尸对象是调试内存管理问题的最佳方式。
将 NSZombieEnabled 环境变量设为 YES,即可开启此功能。比方说,在 Mac OX X 系统中用 bash 运行应用程序时,可以这么做:
export NSZomebieEnabled = "YES";
./app
给僵尸对象发送消息后,控制台会打印消息,而应用程序则会终止。打印出来的消息就像这样:
*** -[CFString respondsToSelector:]:message sent to
deallocated instance 0x7ff9e9c080e0
也可以在Xcode 里打开此选项,这样的话,Xcode 在运行应用程序时会自动设置环境变量。开启方法:编辑应用程序的 Scheme,在对话框左侧选择 "Run",然后切换至 "Diagnostics" 分页,最后勾选 “Enable Zombie Objects” 选项,图演示了Xcode 的配置对话框,以及启用僵尸对象所需勾选的选项。
那么,僵尸对象的工作原理是什么呢?它的实现代码深植于Objective-C 的运行期程序库、Foundation 框架及 CoreFoundation 框架中。系统在即将回收对象时,如果发现通过环境变量启用了僵尸对象功能,那么还将执行一个附加步骤。这一步就是对象转化为僵尸对象,而不彻底回收。
下列代码有助于理解这一步所执行的操作:
1 #import <Foundation/Foundation.h> 2 3 #import <objc/runtime.h> 4 5 @interface EOCClass : NSObject 6 7 @end 8 9 10 11 @implementation EOCClass 12 13 @end 14 15 void PrintClassInfo(id obj) { 16 17 Class cls = object_getClass(obj); 18 Class superCls = class_getSuperclass(cls); 19 NSLog(@"=== %s: %s ===", class_getName(cls), class_getName(superCls)); 20 } 21 22 23 24 int main(int argc, char *argv[]) { 25 26 EOCClass *obj = [[EOCClas alloc] init]; 27 NSLog(@"Before release"); 28 PrintClassInfo(obj); 29 [obj release]; 30 NSLog(@"After release"); 31 PrintClassInfo(obj); 32 }
为了便于演示普通对象转化为僵尸对象的过程,这段代码采用了手动引用计数。因为假如使用 ARC 的话,str 对象就会根据代码需要,尽可能多存活一段时间,于是在这个简单的例子中,就不可能变成僵尸对象了。这并不是说对象在 ARC 下绝对不可能转化为僵尸对象。即便用了 ARC ,也依然会出现这种内存 bug,只不过一般要通过稍微复杂些的代码才能表现出来。
范例代码中有个函数,可以根据给定的对象打印出所属的类及其超类名称。此函数没有直接给对象发送 Objective-C 的 class 消息,而是调用了运行期库里的 object_getClass() 函数。因为如果参数已经是僵尸对象了,那么给其发送 Objective-C 消息后,控制台会打印错误消息,而且应用程序会崩溃。范例代码将输出下面这种消息:
1 Before release 2 3 === EOCClass : NSObject === 4 5 After release 6 7 === NSZombie_EOCClass : nil ===
对象所属的类已由 EOCClass 变为 _NSZombie_EOCClas。但是,这个新类是从哪里来的呢?代码中没有定义一个这样的类。而且,在启用僵尸对象后,如果编译器每看到一种可能变成僵尸的对象。就创建一个与之对应的类,那也太低效了。_NSZombie_EOCClass 实际上是在运行期生成的,当首次碰到 EOCClass 类的对象要变成僵尸对象时,就会创建这么一个类。创建过程中用到了运行期程序库里的函数,它们的功能很强大,可以操作类列表(class list)。
僵尸类(zombie class)是从名为 _NSZombie_ 的模板类里复制出来的。这些僵尸类没有多少事情可做,只是充当一个标记。接下来介绍它们是怎样充当标记的。首先来看下面这段伪代码,其中演示了系统如何根据需要创建出僵尸类,而僵尸类又如何把待回收的对象转化成僵尸对象。
1 // Obtain the class of the object being deallocted 2 3 Class cls = object_getClass(self); 4 5 6 7 // Get the class's name 8 9 const char *clsName = class_getName(cls); 10 11 //Prepend _NSZombie_ to the class name 12 13 const char *zombieClsName = "_NSZombie_" + clsName; 14 15 16 17 // See if the specifie class exists 18 19 Class zombieCls = objc_lookUpClass(zombieClsName); 20 21 22 23 // If the specific zombie class doesn't exist, then it needs to be created 24 25 if (!zombieCls) { 26 27 // Obtain the template zombie class called _NSZombie_ 28 29 Class baseZombieCls = objc_lookUpClass("_NSZombie_"); 30 31 32 33 // Duplicate the base zombie class, where the new class's 34 35 // name is the prepended string from above 36 37 zombieCls = objc_duplicateClass(baseZombieCls, zombieClsName, 0); 38 39 } 40 41 // Perform normal destruction of the object being deallocated 42 43 objc_destructInstance(self); 44 45 // Set the class of the object being deallocated 46 47 // to the zombie class 48 49 objc_setClass(self, zombieCls); 50 51 52 53 // The class of 'self' is now _NSZombie_OriginalClss
这个过程其实就是 NSObject 的 dealloc 方法所做的事。运行期系统如果发现 NSZombieEnable 环境变量已设置,那么就把 dealloc 方法 “调配” (swizzle, 参见第13条)成一个会执行上述代码的版本。执行到程序末尾时,对象所属的类已经变为 _NSZombie_OriginalClass 了,其中 OriginalClass 指的是原类名。
代码中的关键之处在于:对象所占内存没有(通过调用free()方法)释放,因此,这块内存不可复用。虽说内存泄漏了,但这只是个调试手段,制作正式发行的应用程序时不会把这项功能打开,所以这种泄漏问题无关紧要。
但是,系统为何要给每个变为僵尸的类都创建一个对应的新类呢?这是因为,给僵尸对象发消息后,系统可由此知道该对象原来所属的类。假如把所有僵尸对象都归到 _NSZombie_ 类里,那原来的类名就丢了。创建新类的工作由运行期函数 objc_duplicateClass() 来完成,它会把整个 _NSZombie_ 类结构拷贝一份,并赋予其新的名字。副本类的超类、实例变量及方法都和复制前相同。还有种做法也能保留旧类名,那就是不拷贝 _NSZombie_ ,而是创建继承自 _NSZombie_ 的新类,但是用相应的函数完成此功能,其效率不如直接拷贝高。
僵尸类的作用会在消息转发例程(参见第 12 条)中体现出来。 _NSZombie_ 类(以及所有从该类拷贝出来的类)并未实现任何方法。此类没有超类,因此和 NSObject 一样,也是个“根类”,该类只有一个实例变量,叫做 isa ,所有 Objective-C 的根类都必须有此变量。由于这个轻量级的类没有实现任何方法,所以发给它的全部消息都要经过 “完整的消息转发机制”(full forwarding mechanism, 参见第 12 条)。
在完整的消息转发机制中, __forwarding__ 是核心,调试程序时,大家可能在栈回溯消息里看见过这个函数。它首先要做的事情就包括检查接收消息的对象所属的类名。若名称前缀为 _NSZombie_,则表明消息接收者是僵尸对象,需要特殊处理。此时会打印一条消息(本条目开头曾列出),其中指明了僵尸对象所收到的消息及原来所属的类,然后应用程序就终止了。在僵尸类名中嵌入原始类名的好处,这时就可以看出来了。只要把 _NSZombie_ 从僵尸类名的开头拿掉,剩下的就是原始类名。下列伪代码演示了这一过程:
1 // Obtain the object's class 2 3 Class cls = object_getClass(self); 4 5 6 7 // Get the class's name 8 9 const char *clsName = class_getName(cls); 10 11 12 13 // Check if the class is prefixed with _NSZombie_ 14 15 if (string_has_prefix(clsName, "_NSZombie_")) { 16 17 // If so, this object is a zombie 18 // Get the original class name by skipping past the 19 // _NSZombie_ , i.e. taking the substring from character 10 20 const char *originalClsName = substring_from(clsName, 10); 21 22 23 // Get the selector name of the message 24 const char *selectorName = sel_getName(_cmd); 25 26 27 // Log a message to indicate which selector is 28 // being sent to which zombie 29 Log(''*** - [%s %s]: message sent to deallocated instance %p", originalClsName, selectorName, self); 30 // Kill the application 31 abort(); 32 }
把本书开头那个范例扩充一下,试着给变成僵尸的 EOCClass 对象发送 description 消息:
EOCClass *obj = [[EOCClass alloc] init];
NSLog(@"Before release:");
PrintClassInfo(obj);
[obj release];
NSLog(@"After release:");
PrintClassInfo(obj);
NSString *desc = [obj description];
若是开启了僵尸对象功能,那么控制台会输出下列消息:
Before release:
=== EOCClass : NSObject ===
After release:
=== _NSZombie_EOCClass : nil ===
*** -[EOCClass description] : message sent to deallocated
instance 0x7fc821c02a00
大家可以看到,这段消息明确指出了僵尸对象所收到的选择子及其原来所属的类,其中还包含接收消息的僵尸对象所对应的 “指针值”(pointer value)。在调试器中深入分析程序时,也许会收到此消息,而且若能与适当的工具(比如Xcode 自带的 Instruments)相搭配,则效果甚佳。
END