Objective-c 初阶——异常处理(try-catch)

一、@try/@catch/@throw/@finally 执行顺序

objectivec 复制代码
void doSomething() {
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];

    @try {
        // 这一步抛异常
        [self riskyMethod]; 
    } @catch (NSException *e) {
        @throw;  // 把异常继续往上抛
    } @finally {
        // ❗ 注意:这里的 finally 会 **立刻执行**
        [pool release]; // 🔥🔥🔥 外层 pool 提前 pop 了
    }

    // ❌ 如果你此时还没处理完那个 exception,它里面的对象已经被释放了
}

1. riskyMethod 抛出异常
2. 进入 @catch 块
3. @catch 里调用了 @throw(重新抛出)
4. ❗ 根据编译器实现,@finally 会 **立刻执行**
    ⬅️ 在这个"再抛之前",@finally 就执行了
5. @finally 执行时,释放了 autorelease pool
6. ❌ 异常对象(NSException 实例)可能在 autorelease pool 中,它被提前释放了
7. 异常还没被真正交到上层 catch,就已经是 zombie 了

二、try-catch 内存管理(坑)

1、普通内存泄漏

1.1. 如何泄漏

objectivec 复制代码
- (void)doSomething {
    NSMutableArray *anArray = [[NSMutableArray alloc] initWithCapacity:0];
    @try {
        [self doSomethingElse:anArray]; // 假设一定会抛错  
    } @catch(...) {
        @throw;
    }
    [anArray release];
}

就像这种情况,我在 catch 里又重新抛异常,那么就会导致 [anArray release]; 就不会被执行了,所以 anArray 就会内存泄漏了。

1.2. 解决方案

objectivec 复制代码
- (void)doSomething {
    NSMutableArray *anArray = [[NSMutableArray alloc] initWithCapacity:0];
    @try {
        [self doSomethingElse:anArray]; // 假设一定会抛错  
    } @catch(...) {
        @throw;
    } @finally {
        [anArray release];
    }
}

2、提前释放 auto release pool 引起的内存泄漏

2.1. 如何泄漏

objectivec 复制代码
- (void)doSomething {
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];

    NSException *ex = [NSException exceptionWithName:@"MyError" reason:nil userInfo:nil]; // 创建 NSException,retain 一次,retainCount + 1
    @throw ex; // 由 runtime retain 1 次,retainCount + 2

    [pool release]; // 不会执行
}

@try {
    [someObject doSomething]; // 抛出 ex
} @catch (NSException *e) {
    @throw; // 再次抛出 ex,但这是 rethrow, retainCount 不会加 1
} @finally {
    [outerPool release]; // 释放 ex 所在 autorelease pool,retainCount - 1;
                         // 因为 ex 是通过 unwind 找到当前栈帧的,且当前栈帧有 @finally,
                         // 所以会调一次 runtime cleanup 来清理对象,retainCount - 1;
                         // 此时 ex 的 retainCount 为 0,ex 被 dealloc
}

因为 doSomething: 函数内部创建了一个 auto release pool;而且外层又有一个 auto release pool,所以这两个 auto release pool 是这样的:

然后 ex 的引用计数看注释就可以了。顺带提一下,只要是被 unwind 到当前栈帧且当前栈帧有 @finally,系统就会在 finally 块里调 cleanup 函数,给当前所有对象的 retain count 减 1.

那什么时候会被 unwind 呢?其实就是当异常对象被 throw 或当前函数栈帧无法捕获异常时,系统就会调unwind 来一个个函数栈帧往上找。只要当前栈帧(没有 throw 的栈帧)的 catch 能捕获到异常且 catch 不再重新抛出异常,unwind 就不会继续被调。

2.2. 解决方案

objectivec 复制代码
- (void)doSomething {
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];

    NSException *ex = [NSException exceptionWithName:@"MyError" reason:nil userInfo:nil]; 
    @throw ex; 

    [pool release]; // 不会执行
}

@try {
    [someObject doSomething]; // 抛出 ex
} @catch (NSException *e) {
    [e retain];
    @throw; 
} @finally {
    [outerPool release]; 
}

是的,就是给 e 的 retainCount 多加 1 就可以了。这样就可以给整个异常对象的引用计数加 1,不让他被 dealloc 掉。

三、性能对比:32 位的 try-catch VS 64 位的 try-catch

先提前下个结论:根据苹果开发者文档,其实 32 位跟 64 位的 try-catch 各有利弊。因为苹果针对 64 位情况重新实现了 objective-c 的异常处理机制,也就是说采用了 cpp 的 zero-cost 的异常处理机制;因此,32 位的 try-catch 在 try 的部份性能比 64 位的 try 部份差;而 64 位的 throw 部份会比 32 位的 throw 部份差。下面我会分别讲这两部分性能差异的原理。不过先叠个甲,里面的伪代码实现都是用于逻辑自洽的,语法上难免会有错误。

1、32 位下 objective-c 的异常处理机制

其实在 32 位下 objective-c 的异常处理机制是这样的:每进入一个 @try 块,都会先创建一个异常栈帧(不是函数的栈帧),把当前 @try 代码块的地址写到 jmp_buf env 里,然后再调 setjmp 函数来标记 longjmp() 应该跳到的地方。

对于每一个 throw 函数,都会调 longjmp() 函数,然后让程序的 pc 指针指向当前异常栈帧的 setjmp(frame.env) 处。因为 setjmp(frame.env) 此时返回值不是 0,所以跳到 catch 执行......然后一直沿着 try 的调用链往上找 catch。

cpp 复制代码
typedef struct ExceptionFrame {
    jmp_buf env;
    struct ExceptionFrame *prev;
    const char *handledType; // 这个 try 块能处理的异常类型
    Exception *caughtException; // 用于传值给 catch
} ExceptionFrame;

__thread ExceptionFrame *topFrame = NULL; // 每个线程一份
cpp 复制代码
// try 的伪代码实现
#define TRY(type) do { \
    ExceptionFrame frame; \
    frame.prev = topFrame; \
    frame.handledType = type; \
    frame.caughtException = NULL; \
    topFrame = &frame; \
    if (setjmp(frame.env) == 0)

#define CATCH(x) else for (Exception *x = frame.caughtException; x != NULL; x = NULL)

#define END_TRY \
    topFrame = frame.prev; \
} while (0)

// throw 的伪代码实现
#define THROW(e) do { \
    currentException = e; \
    ExceptionFrame *frame = topFrame; \
    while (frame) { \
        if (strcmp(frame->handledType, e->type) == 0 || strcmp(frame->handledType, "*") == 0) { \
            frame->caughtException = e; \
            topFrame = frame->prev; \
            longjmp(frame->env, 1); \
        } \
        frame = frame->prev; \
    } \
    fprintf(stderr, "Uncaught exception: %s\n", e->message); \
    abort(); \
} while (0)
cpp 复制代码
TRY {
    // 这里写 try 块的内容
    maybeDangerousFunction();
    THROW(&someException);
} CATCH(e) {

} END_TRY;

2、64 位下 objective-c 的异常处理机制

但是在 64 位下 objective-c 的异常处理用的是 zero-cost 那套,也就是每进入一个 try 块时并不会创建一个 exception frame。但一旦遇到 throw 时,就会从当前函数栈帧开始,找每个栈帧的匹配的 catch 块,找不到就再去上一个函数栈帧找。

cpp 复制代码
struct StackFrame {
    StackFrame *prev;          // 栈上的上一个帧
    FunctionMeta *functionMeta; // 指向 LSDA + handler 信息
    void *pc;                  // 当前执行地址 (程序计数器)
};

struct Exception {
    const char *type;
    const char *message;
};

typedef struct {
    char *type;                   // 异常类型
    void (*handler)(Exception*);  // catch handler 跳转landing pad
    void (*cleanup)(void);        // cleanup handler,例如 finally
    int hasCleanup;               // 是否有 cleanup
} CatchClause;

struct FunctionMeta {
    CatchClause *clauses; // 存 catch 代码块地址的数组,一个 catch 对应一个 clause
    int clauseCount;
    void (*personality)(StackFrame *, Exception *);
};
cpp 复制代码
// 默认的 personality 函数,带 cleanup 处理
void defaultPersonality(StackFrame *frame, Exception *e) {
    int handled = 0; // 该层栈帧的 catch 能否捕获异常,可以为 1,不可以为 0

    // 先查找 catch clause
    for (int i = 0; i < frame->functionMeta->clauseCount; ++i) {
        CatchClause *clause = &frame->functionMeta->clauses[i];
        
        if (strcmp(clause->type, e->type) == 0) {
            // 找到匹配的 catch
            clause->handler(e);  // 执行 catch handler(landing pad)
            handled = 1;
            break;
        }
    }

    // 调用 cleanup handler(无论 catch 是否找到都会调用 cleanup)
    for (int i = 0; i < frame->functionMeta->clauseCount; ++i) {
        CatchClause *clause = &frame->functionMeta->clauses[i];
        if (clause->hasCleanup && clause->cleanup != nullptr) {
            clause->cleanup(); // 执行 cleanup,比如 finally
        }
    }

    if (!handled) {
        // 当前栈帧不能处理异常,继续向上 unwind
        unwind(frame->prev, e);
    }
}

// throw 异常时当前栈帧直接调 unwind,可以看成 defaultPersonality 是私有的
void unwind(StackFrame *frame, Exception *e) {
    if (!frame) {
        printf("Uncaught exception: %s\n", e->message);
        abort();
    }
    frame->functionMeta->personality(frame, e);
}

unwind 执行什么呢?就是先看看当前栈帧有没有符合类型的 catch 可以捕获异常;然后在看看当前栈帧有没有 finally,有的话就调 cleanup();然后如果当前栈帧可以捕获异常,不继续调 unwind ,否则继续调上一个栈帧的 unwind.

3、性能差异原因

所以 32 位下 oc 的 try 比 64 的 try 性能消耗更大是因为 32 位下 oc 会给每一个 try 块创建一个叫 jmp_buf env 的东西,而且还会调一次 setjmp() ;但 64 位对每一个 try 块并不会做任何事,所以 32 位下 try 块会比 64 位需要更多的内存。

那 64 位的 throw 比 32 位的 throw 性能更差是因为 32 位下用的是 exception frame + longjmp() 来跳转,每一跳肯定可以跳到最近有 catch 的函数栈帧中,所以 32 位找对应 catch 所花的时间是比 64 位一个一个栈帧找要快的。

相关推荐
OKkankan9 分钟前
string类的模拟实现
开发语言·数据结构·c++·算法
好好研究2 小时前
使用JavaScript实现轮播图的自动切换和左右箭头切换效果
开发语言·前端·javascript·css·html
汽车功能安全啊3 小时前
利用对称算法及非对称算法实现安全启动
java·开发语言·安全
Flobby5294 小时前
Go语言新手村:轻松理解变量、常量和枚举用法
开发语言·后端·golang
nbsaas-boot5 小时前
SQL Server 窗口函数全指南(函数用法与场景)
开发语言·数据库·python·sql·sql server
东方佑5 小时前
递归推理树(RR-Tree)系统:构建认知推理的骨架结构
开发语言·r语言·r-tree
Warren985 小时前
Java Stream流的使用
java·开发语言·windows·spring boot·后端·python·硬件工程
伍哥的传说6 小时前
Radash.js 现代化JavaScript实用工具库详解 – 轻量级Lodash替代方案
开发语言·javascript·ecmascript·tree-shaking·radash.js·debounce·throttle
不自律的笨鸟6 小时前
iPhone 神级功能,3D Touch 回归!!!
ios·手机·iphone
xidianhuihui6 小时前
go install报错: should be v0 or v1, not v2问题解决
开发语言·后端·golang