Zombie 问题导致的崩溃对开发者来说是比较棘手的问题之一。主要是因为崩溃时有效信息缺少,对此 Xcode 在 Diagnostics 中提供了 Zombie Objects 功能,用于检测开发阶段的 Zombie 问题,能够提供 Zombie 对象的 Class、Method 以及 Address 信息帮助开发者找到问题原因。
但 Apple 提供的 Zombie Objects 功能仅能在线下使用,无法监控线上 Zombie 问题。对此业界仿照 Zombie Objects 的实现原理,实现了线上 Zombie 的功能。但开启 Zombie 功能会额外带来 CPU、内存、磁盘I/O 等消耗,本文探讨如何将 Zombie side effect 最小化以及如何提高功能丰富度。(关于如何实现 Zombie,网上文章很多,本文不再赘述。)
线上 Zombie 应该根据对象的类型做不同的处理,分为:NS 对象和 CF 对象。网上基本都是基于 NS 对象处理,鲜有方案会考虑 CF 对象。
NS 对象
hook 方法
一般会选择 hook free 函数或者 dealloc 方法。因为 free 函数更底层,影响范围更大,所以从性能角度思考选择 hook dealloc 方法会更合适。
类型过滤
选择 hook dealloc 方法通常会基于 OC 中的基类:NSObject、NSProxy。其实只需要关心我们使用到的 OC 类型即可。使用到的 OC 类型一般包括以下两类:
- 自定义的类,包括动态库;
- 使用到的系统类;
自定义类
判断是否是自定义类,只需要获取每个动态库 __DATA 的 vmaddr 和 vmsize 以及运行时的 ASLR 即可。如果对象所属的类在某个 [slide + vmaddr, slide + vmaddr + vmsize] 区间范围内,则可以认为该对象所属的类是自定义类。
struct load_command* load_cmd = (struct load_command*)cmd_ptr;
switch(load_cmd->cmd) {
case LC_SEGMENT:
{
struct segment_command* seg_cmd = (struct segment_command*)cmd_ptr;
if(strcmp(seg_cmd->segname, SEG_DATA) == 0) {
seg_data_size += seg_cmd->vmsize;
seg_data_addr = seg_cmd->vmaddr;
}
break;
}
case LC_SEGMENT_64:
{
struct segment_command_64* seg_cmd = (struct segment_command_64*)cmd_ptr;
if(strcmp(seg_cmd->segname, SEG_DATA) == 0) {
seg_data_size += seg_cmd->vmsize;
seg_data_addr = seg_cmd->vmaddr;
}
break;
}
}
系统类
所使用到的系统类可以通过 __DATA, __objc_classrefs 获取:
unsigned long size = 0;
uintptr_t *classrefs = (uintptr_t *)getsectiondata((mach_header_64 *)header, SEG_DATA, "__objc_classrefs", &size);
for (size_t i = 0; i < size / sizeof(uintptr_t); i++) {
Class cls = (Class)classrefs[i];
if (cls) {
}
}
其它
还有一些其它需要考虑的细节:类簇、动态创建的类。
1.类簇
对于 NSArray、NSDictionary 等类型,Apple 对开发者隐藏了其内部实现。当我们创建不同类型的数组时,其内部的实现子类会有所区别。所以针对此类型需要获取其父类,根据父类的类型判断是否需要过滤:
Class super_cls = class_getSuperclass(cls);
while (super_cls) {
if (CFSetContainsValue(g_class_cluster_list, super_cls)) {
}
super_cls = class_getSuperclass(super_cls);
}
2.动态创建的类
运行时创建的类,无法通过以上逻辑获取,所以需要单独处理:
malloc_zone_t *malloc_zone = malloc_zone_from_ptr((__bridge const void *)cls);
if (malloc_zone) {
}
另外有文章提到需要对 TaggedPointer 做额外判断,其实不需要,因为 TaggedPointer 类型将信息存储在指针地址中,不会存在 Zombie 问题,也不会调用 dealloc 方法。
信息存储
Zombie 的关键信息是:类名和 dealloc 时的线程信息(堆栈和线程名等)。这些信息都可以绑定在未被释放的 Zombie 对象上。 iOS 中对象的最小大小为 16 字节,其中 8 字节用来存储 ISA 指针。为了减少内存占用,需要尽可能的用另外 8 字节存储类名和堆栈指针。这时可以用 union 结构存储:
typedef union {
Class originalCls;
ThreadStack *threadStack;
} Info;
将类名获取和堆栈获取分开,在数据消费时通过崩溃时堆栈进行关联。
堆栈其实就是 64 位的地址集合,arm64 的最大虚拟地址上限为 0x0000000FC0000000ULL,只使用了 36 位空间。所以在存储堆栈信息时,只需要使用 36 位信息存储即可。
CF 对象
很多创建的 NS 对象底层都会转换成 CF 对象,比如 NSString:
可以看到 obj 是 __NSCFString 类型,__NSCFString 是 Core Foundation 框架中的 CFString 类型的桥接实现,类似的还有很多,比如:__NSCFData、__NSCFNumber、__NSCFArray、__NSCFDictionary 等。
hook 方法
因为 CF 对象的释放是通过 CFRelease 函数,而 CFRelease 函数无法被 hook,所以只能通过其它方式实现。
AutoreleasePool 会在 Pop 的时候对 Pool 中的对象调用 release 方法,__NSCFString 也实现了 release 方法:
所以可以对 __NSCFString 等桥接类 hook release 方法。
类型过滤
因为这里是针对典型的类进行 hook,所以不需要增加额外的类型过滤逻辑。
信息存储
因为是对于具体的典型类型,所以可以获取更多的信息。比如对于 __NSCFString,可以 dump 字符串的内容,类似 CoreDump 的原理,从内存中获取额外信息。
以 __NSCFString 为例,可以通过指针的地址偏移来获取字符串具体内容,根据字符串内容长短需要区分偏移量,可以通过获取 user_tag 来判断:
vm_address_t address = (uintptr_t)self;
vm_size_t size = 0;
natural_t nesting_depth = 0;
struct vm_region_submap_info_64 info;
mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64;
kern_return_t ret = vm_region_recurse_64(mach_task_self(), &address, &size, &nesting_depth, (vm_region_info_64_t)&info, &count);
if (ret != KERN_INVALID_ADDRESS) {
if (info.user_tag == VM_MEMORY_MALLOC_NANO) {
reason = [NSString stringWithFormat:@"%@, str: %s", reason, (const char *)((uintptr_t)self + 17)];
} else if (info.user_tag == VM_MEMORY_MALLOC_TINY) {
reason = [NSString stringWithFormat:@"%@, str: %s", reason, (const char *)((uintptr_t)self + 24)];
} else {
}
}
因为需要获取 __NSCFString 具体存储内容,所以 8-16 字节无法用来存储类和线程信息。但 OC 对象是以 16 字节对齐,所以可能存在末位 8 字节为 0x0 的情况,这时就可以利用未使用的空间存储类和线程信息。
获取末位 8 字节地址:
(uintptr_t)obj + malloc_size(obj) - sizeof(void *)
如果为 0x0,则将原类信息复制到该地址:
if ((uintptr_t)*(void **)address == POINTER_ZERO) {
memcpy((void *)address, (void *)self, sizeof(void *));
}
因为存在 nonpointer isa 机制,所以在获取类信息时,需要进行判断处理:
uintptr_t ptr = (uintptr_t)*(void **)address;
// nonpointer : 1
if (ptr & 1) {
originalClass = (Class)(ptr & ISA_MASK);
} else {
originalClass = (Class)(ptr);
}
最终效果:
*** Terminating app due to uncaught exception 'HSZombieException', reason: '(-[__NSCFString retain]) was sent to a zombie object at address: 0x3016e71c0, thread_name: main-thread, str: hello zombie!'
*** First throw call stack:
(0x19562c7cc 0x1928ff2e4 0x100705ba8 0x1007052f8 0x1006584d0 0x19804cc60 0x19809b800 0x19809b3d8 0x197f2fb70 0x197f3009c 0x197f39f3c 0x197e32c60 0x197e309d8 0x197e30628 0x197e3159c 0x195600328 0x1956002bc 0x1955fddc0 0x1955fcfbc 0x1955fc830 0x1e15dc1c4 0x198162eb0 0x1982115b4 0x1006585c8 0x1bafeaec8)
libc++abi: terminating due to uncaught exception of type NSException
其它
因为线上 Zombie 是性能有损的,所以需要针对线上用户进行采样开启,同时可以将监控的范围进行拆分独立,比如部分用户监控 NS 对象,另外部分用户监控 CF 对象,甚至可以针对某个具体类型进行拆分,最大程度避免对一个用户产生较大的性能问题。
另外可以通过 Zombie 监控实现 Zombie 防护,考虑下发白名单,当触发 Zombie 问题时,不抛出异常使程序继续运行。
有些情况下当崩溃堆栈和释放堆栈都在 Autorelease 中时还是比较难定位的,需要采集 alloc 的堆栈,但这种无疑会导致更大的性能损耗。下期分享如何利用 CoreDump 获取更多有用信息。