Objective-C ARC 下一个容易被遗忘的坑

2,241 阅读2分钟
Photo by Taneli Lahtinen on Unsplash

最近在做一个基于 JavaScriptCore 的动态化方案,与 JSPatch 类似,我们需要在 JS 脚本中与 OC 进行交互,例如创建对象、发消息、访问属性等等(与 JSPatch 不同的是我们提供的运行时功能是受限的,因此不会有修改应用原有行为的问题)。

但是在实现这个框架的过程中我遇到了一个令人十分头疼的问题。我通过 JSContext 的全局对象向 JS 环境注入了一个与 OC 交互的工具类,其中有一个创建对象的方法:

- (id)allocObject {
    PRELUDE();
    CHECK_ARGS_COUNT_EQ(1);

    JSValue *arg0 = arguments[0];

    if (!arg0.isString) {
        THROW_ERROR(@"Class name must be a string");
    }

    Class cls = NSClassFromString([arg0 toString]);
    if (!cls) {
        THROW_ERROR(@"Class not found");
    }
    
    id object = [cls alloc];

    return [JSValue valueWithObject:object inContext:context];
}

这段代码看上去是没有任何问题的,但是在实际运行的时候我发现了严重的内存泄漏问题。

例如执行下面这段测试代码:

void test() {
    @autoreleasepool {
        QNDynamicEngine *engine = [QNDynamicEngine defaultEngine];
        [engine unsafeEvaluateScript:@"for (var i = 0; i < 2000; i++) {"
         "window.ObjC.allocObject('Foo')"
         "}"];
    }
}

尽管强制 GC,代码所创建的 Foo 对象也没有被释放(调试用的 dealloc 方法未执行),Xcode 内存图显示泄漏:

也就是说,这个 JSValue 对象现在没有被任何其他对象持有,但它的 retainCount 仍然不是 0。这困扰了我一上午,直到我发现,同一个类下的另一个方法,与该方法有着相同的逻辑,但并没有任何内存问题。排除了 JSC 的问题,我打算从汇编入手。

上图是同类下另一个方法末尾的汇编内容,可以看到标准的内存管理语义,对于启动了 ARC 的程序,编译器会插入 objc_autoreleaseReturnValue 调用,它会根据 caller 的环境选择是否真的做 autorelease 操作。

有关这方面的详细介绍可以看我之前的文章:再谈 ARC 和 autorelease

然而有问题的方法的汇编内容是这样的:

能够看到,它末尾根本没有插入 objc_autoreleaseReturnValue 调用,而是直接返回了。根据内存管理规则:“不释放不属于自己的对象”,调用方(也就是 JSC)并不会在使用完这个对象后执行 release 操作,对象泄露了!

查阅了相关资料后发现,clang 是根据方法名来识别拥有特殊内存管理语义的方法的,alloc- 开头的方法不会插入 autorelease 语义。

解决的方法也很简单,直接在方法的声明上添加一个 attribute:

- (id)allocObject __attribute__((objc_method_family(none)))

这样即便方法名是以 alloc 开头的,编译器也会以普通方法来对待。

问题被优雅地解决了。


References:

1. Objective-C Automatic Reference Counting (ARC)
2. Memory Management Policy
3. ARC method naming rules for alloc, new, copy and mutableCopy