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] 区间范围内,则可以认为该对象所属的类是自定义类。
c 代码解读复制代码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 获取:
c 代码解读复制代码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 对开发者隐藏了其内部实现。当我们创建不同类型的数组时,其内部的实现子类会有所区别。所以针对此类型需要获取其父类,根据父类的类型判断是否需要过滤:
c 代码解读复制代码Class super_cls = class_getSuperclass(cls);
while (super_cls) {
if (CFSetContainsValue(g_class_cluster_list, super_cls)) {
}
super_cls = class_getSuperclass(super_cls);
}
2.动态创建的类
运行时创建的类,无法通过以上逻辑获取,所以需要单独处理:
c 代码解读复制代码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 结构存储:
c 代码解读复制代码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 来判断:
c 代码解读复制代码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 字节地址:
c 代码解读复制代码(uintptr_t)obj + malloc_size(obj) - sizeof(void *)
如果为 0x0,则将原类信息复制到该地址:
c 代码解读复制代码if ((uintptr_t)*(void **)address == POINTER_ZERO) {
memcpy((void *)address, (void *)self, sizeof(void *));
}
因为存在 nonpointer isa 机制,所以在获取类信息时,需要进行判断处理:
c 代码解读复制代码uintptr_t ptr = (uintptr_t)*(void **)address;
// nonpointer : 1
if (ptr & 1) {
originalClass = (Class)(ptr & ISA_MASK);
} else {
originalClass = (Class)(ptr);
}
最终效果:
vbnet 代码解读复制代码*** 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 获取更多有用信息。
评论记录:
回复评论: