写在前面
在iOS开发调试内存问题时,Xcode提供了一个内存管理调试选项:Zombie Objects。打开这个选项,可以在访问已释放内存对象的方法时,进行警告。这里探讨下这个僵尸对象内部实现原理,以及动手去实现自定义的一个僵尸对象类。相关知识点主要涉及Objc的对象模型及消息机制。
属性修饰符
Objc中对象是基于引用计数进行内存管理的。并且编译器支持由开发者通过声明的方式定义属性,而由编译器自动合成相应的访问方法。对象的属性特性由我们声明的、用于控制由编译器生成的存取访问接口的特性(attribute),正确的使用特性修饰符才能有效避免对象内存管理错误。
Objc的对象属性声明主要包括以下四种特性修饰符:
原子性
主要包括
atomic
及nonatomic
两个修饰,默认情况下编译器会添加加锁代码来保证原子性访问。如果自行编写访问器代码,则自行控制加锁提供原子性访问。读写控制
主要包括
readonly
、readwrite
控制是否可读写,即是否相应提供getter
、setter
访问方法。需要注意的是,这仅是对由编译器生成的属性存取接口的控制,对属性所指向的对象本身没有约束。举例,如若一个属性指向一个
NSMutableArray
类型对象,并且声明为readonly
,那么只提供了getter
属性访问器,但是对应的属性对象本身是可变的。内存管理
属性封装的是数据,而数据必须有明确的所有权管理,这也是只影响
setter
访问器的,也就是在setter
方法中,是否需要持有新值亦或是单纯的赋值给相应的实例变量。主要包括:assign
:仅用于对数值类型的值的简单赋值;strong
:声明了持有关系,当一个新值被设置时,新值首先被retain
持有,旧值被release
释放,然后执行赋值;weak
:声明了非持有关系,新值被设置时,新值不会被retain
,旧值也不会被release
,基本与assign
相同,但当对象被释放时,该值还会被设置为nil
;unsafe_unretained
:类似于assign
,声明了非持有关系,但是用于对象类型,对象被释放时不会被置空;copy
:声明了持有关系,但不同于strong
的是,它不对新值做持有而是做了复制;常用于NSString
这种内部可变的类型。
还有对应实例变量的几个修饰符:
__strong
、__weak
、__unsafe_unretained
、__autoreleasing
。方法名称
也就是用于指定
getter
及setter
的名称。
需要关注的也就是上述的“内存管理”的属性特性修饰,通常因为不当的使用assign
或unsafe_unretained
导致野指针及对野指针访问crash。
僵尸对象的内存诊断
当访问野指针时,如果野指针所在的内存区域尚未被覆盖使用,则一切方法调用如常;一般情况下该野指针原先占用内存区域会被覆盖,此时访问野指针行为将是不确定的。比如,开发过程中可能会遇到这样的情况:crash并报错”[classXXX methodYYY]….unrecognized selector sent to instance 0x608000001fc0”,而classXXX没有这样的方法声明,相反selector是另外一个类的方法。这种情况是,野指针所指向区域刚巧覆盖了一个classXXX的实例对象。而大多数情况可能是其他未知类型数据,所报的则是“EXC_BAD_ACCESS”之类错误。
Xcode所加的Zombie Object的内存诊断则可以明确的指出错误原因是因为实例对象被释放、野指针访问的内存问题。测试报错如下:“ -[NTLObject call]: message sent to deallocated instance 0x608000036040”。
既然测试对象NTLObject已经被“释放”了,那具体报错信息是怎么得到的?怎么知道野指针原来指向的对象类型?以及当前所调用导致出错的方法?
事实上测试对象并未被释放(这导致调试时大量对象得不到释放会有内存问题),因为编译器替换了根对象的释放方法,并将原对象的isa
指针指向了动态生成的一个僵尸类,来实现对旧对象指针访问时的报错处理。这是基本的处理思路。
NSZombie实现原理
在测试例子中,添加调试代码输出释放前后对象的类信息(包括类名、父类、元类、实例变量、方法列表等),可以看到,对于测试对象NTLObject
,在对象“释放”后,对象isa
指向了一个_NSZombie_NTLObject
的类,其父类为空,元类为_NSZombie_
。下面我们来重建自定义的僵尸类如NTLZombie,实现:
- 对象僵尸化,也就是对象释放后会变成一个“僵尸对象”
- 方法捕获,因为对象重新指向了新的类,新的类不可能全部实现原类的方法;
对象的内存释放
对象僵尸化,我们首先需要阻止对象被释放,否则对野指针的调用直接导致内存访问错误(这正是NSZombie要监测的问题)。
Objc中的对象(继承NSObject
)都是在堆中分配的内存(关于这点另有撰文介绍),libobjc(实际上还有Core Foundation框架)将对象实例其实现为一个objc_object
的结构体(参考objc4-647源码):
1 | @interface NSObject <NSObject> { |
对于对象的创建,所有对象创建时都调用基类NSObject
的+alloc
方法(不自定义构建函数的话),将通过:
- (id)alloc
- _objc_rootAlloc(self)
- callAlloc(cls, false /*checkNil*/, true /*allocWithZone*/)
申请内存后进行isa
的初始化及构建函数的调用。
对于对象的释放,则是编译器添加代码,自动调用各继承层次的-dealloc
方法,进行非自动管理内存的释放;对于实例变量及本身堆内存的释放则是在根类调用:
- [NSObject dealloc]
- _objc_rootDealloc(self);
- obj->rootDealloc();
- object_dispose((id)this);
- _object_dispose(obj);
- objc_destructInstance(anObject);
- free(anObject)
释放内存,并将原内存块数据isa
指向一个“已释放类”(freedObjectClass
,12字节的被特殊置位的内存块),避免消息发送时对Nil
类的检查。关键步骤是objc_destructInstance
,其实现为:
1 | void *objc_destructInstance(id obj) |
主要包含三个步骤:
object_cxxDestruct
,对象的析构_object_remove_assocations
,移除关联对象,从AssociationsManager
中移除相应的ObjectAssociationMap
objc_clear_deallocating
,清理弱引用
步骤1,沿继承链向上逐级调用析构方法(类中对应selector为SEL_cxx_destruct
的方法,.cxx_destruct
),这个方法是编译器添加的实现,大概过程是遍历声明中的所有实例变量,调用objc_storeStrong
函数进行release
及置空操作。(注:SEL_cxx_destruct
对应的是.cxx_destruct
方法,这个是编译器添加的代码,后来搜索到了swift开源代码中的CodeGenModule.cpp,再然后发现sunny也做过相应的介绍,可以参考sunny的介绍)
步骤3,另外对于对象弱引用,libobc是将其保存到一个SideTable
的类对象(C++)中,步骤3调用objc_object::sidetable_clearDeallocating()
进行弱引用清理,具体可见后文的附1。
因此,为避免对象被真的释放了,我们需要替换根类的-dealloc
方法。
对象僵尸化
我们对象原本被释放后再访问需要触发警告,首先原本的方法调用不能正常进行,再者要能记录调用的方法进行报警。被“释放”后的对象需要能实现这点,就需要我们所谓对象僵尸化,将被“释放”的对象的isa
指向另一个类型,否则原本的类实例依然能查找到相应的方法实现(后文有介绍),达不到警告的需求。
所以所谓的对象僵尸化,是我们将被“释放”的对象的isa
指向一个新的“僵尸”类,并由僵尸类处理方法调用异常问题。这里有一个问题,我们如何保存对象原来的类信息?既然原本的对象已经被“释放”了,内存空间作废了,能不能利用上?
对象实例objc_object
的内存布局中,首先是isa
指针,紧跟着各继承层次的ivar
实例变量。假如使用实例变量所在内存区域来保存原来的类的信息的话,是没有保证的(有些类并没有声明实例变量)。因此,原来类信息无法保存到实例对象内存区域,僵尸对象类也不能是一个固定的类,否则实例对象无法识别原来类信息。因此对于每一个被僵尸化的类,需要动态注册一个类,并保存原来的类信息。通过log出来可以看到,苹果的做法是,将原来类名作为僵尸类的后缀,并且替换了旧的dealloc
方法为__dealloc_zombie
。
具体实现,可以通过objc/runtime提供的接口,动态创建一个僵尸类:(代码应加锁,代码示例参考mikeash)
1 | Class makeZombieClass(Class class) { |
替换dealloc
方法,修改对象的isa
类型指针:
1 | void __dealloc_zombie(id obj, SEL _cmd) { |
并在僵尸化时进行方法替换:(注意,不是对僵尸类添加dealloc
方法)
1 | Method m = class_getInstanceMethod([NSObject class], @selector(dealloc)); |
此后当对象被释放时,其isa
将指向_NSZombie_XXXObject
类对象,由后者进行消息传递的管理。
消息捕获
新的僵尸类,未实现任何方法,在未做任何处理时,向原来的对象指针发送消息,会导致异常抛出。当然也不能继承原有类,否则消息方法的IMP还是能被搜索到。(注:实际上苹果的Zombie实现中,动态生成的僵尸类,其superclass
为nil
)
再来回顾一下objc的消息机制(消息查找具体可见源码lookUpImpOrForward
方法实现),举例:调用[object message]
向Object
对象发送消息message
首先编译器层面会转换为
objc_msgSend
函数的调用:((void (*)(id, SEL))(void *)objc_msgSend)((id)object, sel_registerName("message"));
,对父类发消息或者根据函数返回值不同objc_msgSend
会被替换为objc_msgSendSuper
或objc_msgSend_stret
。根据
isa
继承链,在类信息中进行方法查找,若能找到相应的方法IMP则调用该方法,否则按以下3-6进行动态消息解析、消息转发(及异常抛出)。方法IMP的查找见下面附3详述。动态消息解析(Method Resolution)
当步骤2中无法查找得到方法实现时, 将调用
+resolveInstanceMethod:
或者+resolveClassMethod:
方法,可以动态提供一个函数实现。如果你添加了函数并返回 YES, 那运行时系统就会重新启动一次消息发送的过程(triedResolver保证仅一次)。对对象进行respondsToSelector
自省也可能会导致动态消息解析。(题外话,动态消息解析可以用在ORM方案中,我们可以在类初始化或者调用时进行方法的绑定)1
2
3
4
5
6
7
8if (resolver && !triedResolver) {
rwlock_unlock_read(&runtimeLock);
_class_resolveMethod(cls, sel, inst);
// Don't cache the result; we don't hold the lock so it may have
// changed already. Re-do the search from scratch instead.
triedResolver = YES;
goto retry;
}在无法动态解析方法时,将在类对象的方法缓存中加入占位的方法缓存入口,进入消息转发。(CoreFoundation中
forwarding_prep_0_
->_forwarding_
)快速消息转发(Fast forwarding)
对象实现
-forwardingTargetForSelector:
方法时,若返回非空对象,则消息被转发到该对象。(注:可以利用这个作为proxy)若返回空的快速转发对象,则进行正常的消息转发。
消息转发(Normal forwarding)
基类的实现是:调用基类的
-doesNotRecognizeSelector
抛出异常。这通常是我们对消息处理的最后时机。runtime会构建一个
NSInvocation
的对象作为参数,调用方法forwardInvocation
:- 首先,调用
-methodSignatureForSelector:
(CF实现)获得函数的方法签名(参数及返回值类型),若无法获得方法签名、返回nil,则直接导致-doesNotRecognizeSelector:
错误;否则可以对NSInvocation
进行唤起:-invokeWithTarget:
。(注:可以自行提供类型编码字符串获得方法签名[NSMethodSignature signatureWithObjCTypes:"v@:"]
) NSInvocation
是对一个消息的描述,包括selector 以及参数等信息,还可以在-forwardInvocation:
里修改传进来的NSInvocation
对象,然后发送-invokeWithTarget:
消息给它,传进去一个新的目标等,可以控制消息的转发。
methodForSelector
获取IMP时,在没有对应方法时,返回的是 _objc_msgForward ,但不会有消息转发的过程。- 首先,调用
因此我们可以在methodSignatureForSelector
方法中捕获到方法调用进行处理,或者提供相应的方法签名,在forwardInvocation
中处理。
僵尸对象方法调用
主要是修改方法签名的获取方法:
1 | NSMethodSignature *MethodSignatureForSelector_zombie(id obj, SEL _cmd, SEL selector) { |
并在上文代码“// … 消息捕捉处理”中添加方法:
1 | class_addMethod(zombieClass, @selector(methodSignatureForSelector:), (IMP)MethodSignatureForSelector_zombie, "@@::"); |
僵尸对象的处理即完成。
其他
附1:对象释放清理弱引用过程
1 | void objc_object::sidetable_clearDeallocating() |
SideTable
是用来保存对象引用计数及弱引用的类,其中包括3个公开属性自旋锁slock
、引用计数散列表refcnts
、弱引用表weak_table
,并使用一个静态数组table_buf
保存所有SideTable
实例,提供工厂方法tableForPointer
查找对应的table。
- 首先是查找
refcnts
得到其引用计数,它是一个DenseMap
类型,其值为site_t
类型(其低位第0位是标记是否被弱引用),若存在弱引用则执行weak_clear_no_lock
清理: weak_table_t
以被引用对象指针哈希化(ptr_hash
)之后作为索引,weak_entry_t
类型作为值,weak_entry_t
保存了所有weak
引用了该对象的指针的指针。对象dealloc
后执行到该步骤,会将该对象的所有弱引用指针置为nil
,并从weak_table
中移除该entry。
另外,上面所提到的table_buf
是在可执行文件被操作系统加载、链接符号绑定之后的初始化进行创建的,具体调用可查看入口点_objc_init
方法。
附2:objc_class
1 | struct objc_class : objc_object { |
以上是Objc-2.0以前的类对象结构定义,Objc-2.0之后大概是这样
1 | //Class的实际结构 |
主要是对类信息等数据的封装,从概念上没有太大差异。
附3:IMP查找
objc_msgSend()
会调用class_getMethodImplementation(cls, sel)
继而调用lookUpImpOrNil
进行方法查找,若查找失败,则返回_objc_msgForward
的IMP,这个实现了具体的消息转发过程(见上文)。
1 | imp = lookUpImpOrNil(cls, sel, nil, |
lookUpImpOrForward
正是上文提到的消息查找:
1 | IMP lookUpImpOrForward(Class cls, SEL sel, id inst, |
上述代码中的realizeClass
主要处理类对象初始化过程:
- 父类(
superclass
)、元类(metaclass
)及根类处理 - 实例大小设置
- 构造函数调用
- 附加类别方法(到方法列表)(所以方法的查找以类别的优先,类别的加载在符号绑定后的回调)
另外需要说明的是SEL
类型,这实际上可以看做是一个整型值,实际有一份字符串与SEL之间的映射表,详细可查看__sel_registerName
方法。这个在方法缓存中也可以看到,缓存的存取是以SEL为key的,key的类型被定义为typedef uintptr_t cache_key_t;
、typedef unsigned long uintptr_t;
。
Author: Jason
Permalink: http://blog.knpc21.com/ios/ios-nszombie-implementation/
文章默认使用 CC BY-NC-SA 4.0 协议进行许可,使用时请注意遵守协议。
Comments