ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

搜狗iOS笔试题复盘:从内存管理到RunLoop的底层原理

2026/8/29 4:55:59 拓冰建站 浏览量
搜狗iOS笔试题复盘:从内存管理到RunLoop的底层原理 1. 先复盘一下这套笔试到底在考什么1.1 搜狗笔试的题量和风格2015年那会儿iOS开发面试正处在一个有意思的节点Swift刚发布一年多大部分团队还在用Objective-C写核心业务ARC虽然普及了但不少老项目里还能看到MRC的影子。搜狗这套iOS工程师笔试题在当年的技术群里流传度非常高我也拿它给团队的新人做过模拟每次做都能筛出一批“简历写得不错、问到底层就发懵”的候选人。这套题的题量不算大卷面风格和现在常见的海量选择题不太一样更多是“给你一段代码让你分析”或者“用简短的话解释一个机制”。它不会直接问你“什么是block”而是会在代码里塞一个循环引用问你为什么内存不释放。不会直接问你“什么是RunLoop”而是会问“NSTimer在列表滚动的时候为什么不准”。本质上它考的不是“背概念”而是“你有没有真正理解这套系统在运行时发生了什么”。1.2 一份笔试题背后的能力画像我当时拿到这套题的第一感受是出题人很清楚一个合格的iOS工程师应该长什么样。他不要求你背下所有API名也不要求你说得出每个系统框架的类名而是要求你具备几项底层能力能说清楚Objective-C对象的内存生命周期能讲明白OC的消息机制和动态派发能判断多线程并发场景下的执行顺序和死锁风险能理解RunLoop在系统级事件驱动里的作用能对网络层和数据持久化有完整且落地的认知。这些能力画像放到今天依然成立。2015年考的是MRC和RunLoop细节现在面试考的是Swift并发和内存安全但核心逻辑没变大厂招的不是API调用者而是能理解系统运行原理、能在复杂场景里做正确决策的工程师。2. OC内存管理看似送分其实最能拉开差距2.1 引用计数的本质和ARC的“编译期魔法”内存管理是2015年笔试的重头戏因为那时候正处于从MRC向ARC全面过渡的阶段出题人特别喜欢考察“你到底是靠编译器活着还是真懂内存管理”。引用计数的本质其实很朴素每个OC对象内部有一个整数表示“当前有多少个地方在持有我”。当这个整数从1变成0的时候对象就知道没人需要自己了于是主动调用dealloc释放内存。retain让计数加一release让计数减一这是MRC时代最基础的操作。ARC并没有改变这套机制它做的是在编译期帮程序员自动插入retain、release、autorelease调用。注意是编译期不是运行时。所以ARC只是“自动化”不是“垃圾回收”这也是笔试里常挖的坑——ARC和JVM的GC完全是两回事它没有stw停顿也没有全局扫描。我在卷子上见过类似这样的代码__weak NSString *weakStr; autoreleasepool { __strong NSString *strongStr [NSString stringWithFormat:search]; weakStr strongStr; } NSLog(%, weakStr);我问过不少候选人很多人能答出“输出为空”但追问一句“为什么strongStr出了作用域就释放”就答不上来了。关键在于stringWithFormat:返回的对象加入的是当前autoreleasepool如果没有强引用持有它pool drain时它就被释放了。这种题目考察的不只是ARC规则还有autoreleasepool的生命周期。2.2 weak、strong、assign、copy的区别要从底层说四个修饰符的对比表面上是语法题实际上考察的是底层的引用计数和对象语义。这里给一个比较完整的对比表按2015年笔试题的深度来答修饰符对引用计数的影响底层行为适用场景strong对象被持有计数1编译期插入retain绝大多数对象属性weak对象不被持有计数不变注册到SideTable的弱引用表对象释放时自动置nildelegate、避免循环引用assign既不持有也不置nil纯指针赋值对象释放后变野指针基本数据类型(CGFloat、NSInteger等)copy发送copy消息复制一份遵守NSCopying协议返回不可变副本NSString、NSArray、NSDictionary等属性weak的底层值得多说一层。它并不是简单地把指针记下来而是系统维护了一个全局的SideTable里面存着弱引用表。当一个对象的引用计数归零时runtime会遍历它的弱引用表把所有指向它的weak指针全部置为nil。这个机制保证了“悬垂指针”不会出现。但assign就不一样了它不做任何安全处理。你如果给一个对象属性用了assign对象释放后你的指针还指向那块已经回收的内存再访问就是野指针崩溃。笔试里偶尔会混着问“assign和weak有什么区别”很多人在属性上用了assign修饰对象这就是典型的低级错误。2.3 循环引用delegate、block、NSTimer的三种经典场景循环引用是内存管理板块的压轴题。就算不直接考概念也会在综合题里藏一个。第一种是delegate。A持有BB的delegate指向A如果双方都是strong就形成一个环。所以delegate声明时要用weak或者assign在OC里delegate一般用weak让持有关系变成单向的。第二种是block。这是最经典也最容易写错的场景。一个对象持有了blockblock内部又引用了self而self又持有这个block就形成了环。为了解决这个问题需要在block外部声明__weak typeof(self) weakSelf self;在block内部用weakSelf。但这里有个隐藏坑如果block内部有耗时操作weakSelf可能在中途被释放所以还需要在block内部第一行声明__strong typeof(weakSelf) strongSelf weakSelf;保证block执行期间self不会被释放。第三种是NSTimer。NSTimer的target参数对self是强引用而self又持有timer这也会造成循环。更麻烦的是timer还会被加入RunLoop如果不主动invalidate即使你置空了selftimer依然活着并持有target。所以正确做法是在合适时机比如viewWillDisappear或者dealloc里调用[timer invalidate]。2015年的笔试题很喜欢在这个点上设置陷阱因为很多人知道block循环引用却忽略了timer也是一个隐藏的强引用持有者。3. Runtime与消息转发OC动态性的核心考场3.1 objc_msgSend的查找链路Runtime是2015年搜狗笔试题里区分度最大的一块。OC的方法调用[obj doSomething]在编译后并不是直接跳到函数地址而是变成一条objc_msgSend(obj, selector(doSomething))消息发送。这条消息发送的查找链路值得完整背下来我按当时的答题标准梳理一下检查selector是不是空为空直接忽略从对象的isa指针找到对应的Class在Class的method_list里查找方法找到就直接调用IMP找不到就沿着superclass链往上找找到根类还没有进入消息转发流程。消息转发并不是直接崩溃它其实给了三次“补救”机会resolveInstanceMethod:可以在这里动态为类添加方法实现forwardingTargetForSelector:可以把这个消息转给另一个对象处理methodSignatureForSelector:forwardInvocation:可以完整地转发消息参数和返回值都能保留。这三次机会里第一和第二次比较快第三次最完整但性能开销最大。笔试如果考到“如何拦截未实现方法来做防崩溃”答案就是利用这三步中的任意一步做兜底处理。3.2 isa指针、Class结构与对象的真面目在2015年的Objective-C 2.0 runtime里一个NSObject对象的内存布局大概是这样的第一个成员变量或者说前8个字节是isa指针它指向这个对象所属的Class。Class内部又包含isa、superclass、cache_t方法缓存、class_data_bits_t里面可以取出class_rw_t存着方法列表、属性列表、协议列表等。当时的题目会问[obj class]和[obj superclass]分别返回什么这个比较好答。难一点的是一个类的实例方法、类方法、父类方法在内存里的查找顺序。实例方法的查找链实例对象 - isa - 类对象 - 方法列表 - 父类对象 - 父类方法列表。类方法的查找链类对象 - isa - 元类对象 - 方法列表 - 父元类对象 - 父元类方法列表。如果对元类meta-class这个概念不熟悉第一次遇到这种题基本会懵但只要理解了“类方法存在元类里”这一句整个链条就通了。3.3 method swizzling和动态方法的实战边界Method swizzling是运行时考察的实操题常客。它做的事情很简单交换两个方法的IMP指针让[obj originalMethod]实际执行的是swizzledMethod的实现。我当时推荐的标准写法是这样的 (void)load { static dispatch_once_t onceToken; dispatch_once(onceToken, ^{ Class class [self class]; SEL originalSelector selector(viewDidLoad); SEL swizzledSelector selector(swizzled_viewDidLoad); Method originalMethod class_getInstanceMethod(class, originalSelector); Method swizzledMethod class_getInstanceMethod(class, swizzledSelector); BOOL didAddMethod class_addMethod(class, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod)); if (didAddMethod) { class_replaceMethod(class, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod)); } else { method_exchangeImplementations(originalMethod, swizzledMethod); } }); }注意几个细节放在load里是因为它只会调用一次放在initialize里可能会因为类的懒加载导致时机不可控用dispatch_once保证交换只发生一次用class_addMethod做判断是为了处理“父类有实现而子类没有”的边界情况。这套写法的本质是AOP面向切面编程在UIViewController的viewDidLoad里统一埋点、统计页面曝光时间都是这种思路的应用。但笔试里如果让你评价method swizzling的风险至少要说出两个点一是交换后会影响所有使用这个方法的对象影响面巨大二是如果多个库都做同样的事情交换顺序不同可能导致行为不可预测。所以它只适合做全局性的埋点或兜底不适合当做业务逻辑的常规工具。4. 多线程与并发控制这段代码会不会死锁4.1 从GCD的队列模型看死锁多线程板块几乎必考GCD而且特别喜欢让考生判断“这段代码会不会死锁”。要判断死锁就得先理解队列和任务的组合关系。GCD里有两种队列串行队列Serial Dispatch Queue和并发队列Concurrent Dispatch Queue。同步任务sync会阻塞当前线程等待执行完成异步任务async不会。死锁的核心成因是在一个串行队列里给同一个队列提交同步任务。因为串行队列的任务必须一个接一个执行而新提交的同步任务又在等待当前任务执行完当前任务又在等待新任务执行完互相等待就永远卡住了。笔试里最经典的陷阱代码是dispatch_sync(dispatch_get_main_queue(), ^{ NSLog(Deadlock?); });一旦在主线程执行到这段代码主队列是串行队列当前正在执行的block还没结束新提交的block又在等它执行于是死锁。候选人如果答不出“主队列也是串行队列”这道题基本就跪了。判断死锁可以记一个简单结论同步任务 串行队列 在同一个队列内部提交三者同时满足必死锁。跨队列同步不一定死锁异步任务一般不会死锁但这些都需要结合具体上下文分析。4.2 锁的选择性能、优先级反转与手写读写锁并发控制里锁是绕不开的话题。2015年那会儿iOS开发常用的锁有这些锁特点注意点synchronized用起来最简单自动加解锁性能较差底层是递归锁NSLock标准互斥锁需要手动加解锁注意不要忘记unlockdispatch_semaphore信号量灵活控制并发数初始值设为1就是互斥锁OSSpinLock自旋锁性能极高存在优先级反转问题后来被弃用pthread_mutex系统级互斥锁通用需要手动初始化销毁这里特别提一下OSSpinLock2015年正是它引发讨论的时候。自旋锁在等待期间会一直占用CPU空转如果持有锁的线程优先级较低而等待锁的线程优先级较高高优先级线程一直自旋低优先级线程得不到CPU执行就会造成优先级反转甚至拖垮整个系统。当年面试官如果问“你知道自旋锁的缺陷吗”你要能答出这一层。4.3 “读多写少”的正确打开方式dispatch_barrier笔试里如果考“怎么设计一个高效的读写并发方案”很多人第一反应是用串行队列包住所有操作这当然能保证安全但读操作也被串行化了并发性能大打折扣。更好的方案是用dispatch_barrier_async配合并发队列。读操作走正常的并发执行写操作通过barrier提交barrier就像是并发队列里的一道闸门它提交后系统会等之前所有读操作执行完再单独执行写操作写操作执行完再恢复后续的读操作并发执行。// 读操作 - (id)objectForKey:(NSString *)key { __block id result; dispatch_sync(_concurrentQueue, ^{ result [_dictionary objectForKey:key]; }); return result; } // 写操作 - (void)setObject:(id)obj forKey:(NSString *)key { dispatch_barrier_async(_concurrentQueue, ^{ [_dictionary setObject:obj forKey:key]; }); }注意的是用barrier的前提是你自己创建了一个并发队列且所有读写都走同一个队列。如果你用的是全局并发队列barrier就失效了这是当年一个隐藏比较深的坑。这个方案到今天依然是面试回答“读写锁怎么实现”的高质量答案。5. RunLoop为什么滑动列表时定时器不走5.1 RunLoop的组成元素与运行机制RunLoop在平时写业务的时候存在感很低但它其实一直是iOS笔试的保留项目。搜狗那套题里也不止一次出现和RunLoop相关的场景题最典型的就是“为什么UIScrollView滑动的时候NSTimer不走”。要回答这个问题先得理清RunLoop是什么。它本质上是一个事件循环英文叫Event Loop做的事情可以概括成一句话只要有事件就处理事件没有事件就休眠等待事件到来再唤醒。这个循环不断重复驱动了整个App的运行。RunLoop里有几个关键对象Source0非基于端口的输入源比如UIEvent触摸事件、performSelector调用Source1基于端口的事件比如系统底层的事件、其他线程发来的消息Timer定时器基于时间触发的源Observer观察者用来监听RunLoop的状态变化比如进入休眠、即将处理事件、即将退出等。每次RunLoop运行先通知Observer要进入处理事件阶段然后处理Source0事件如果有Source1事件就跳转到对应处理逻辑之后处理Timer这些都不需要处理就进入休眠等待新的消息唤醒。这个“处理事件 - 休眠 - 唤醒”的循环就是App活着的根本原因。5.2 Mode与commonModes跑不跑得动的关键RunLoop还有一个很容易忽略的概念Mode。每个Mode相当于一个独立的“运行场景”里面挂着不同的Source、Timer、Observer。RunLoop每次只能运行在一个Mode下切换Mode就是切换这组监听对象。iOS系统里常见的有NSDefaultRunLoopMode、UITrackingRunLoopMode、NSRunLoopCommonModes。UIScrollView滑动的时候主线程RunLoop会从默认模式切到UITrackingRunLoopMode目的是让系统优先处理滚动事件避免其他事件干扰滑动体验。你添加的Timer默认加在NSDefaultRunLoopMode滚动时这个Mode不工作Timer自然就不走了。解决办法是把Timer加到NSRunLoopCommonModes里它是一个占位模式集合意味着这个Timer在Default和Tracking模式下都能触发。NSTimer *timer [NSTimer timerWithTimeInterval:1.0 target:self selector:selector(tick) userInfo:nil repeats:YES]; [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];这是整套题里非常典型的“看起来很简单但工程经验不足答不出来”的题目。如果只会用scheduledTimerWithTimeInterval:而不理解底层Mode切换就很难想到这个解法。5.3 常驻线程与AFNetworking的经典用法RunLoop的另一个应用场景是“常驻线程”。因为RunLoop跑完一圈或者没有事件就会退出子线程执行完一个任务后线程就被回收了。如果希望线程一直存在等待随时派发任务就得给子线程的RunLoop添加一个永远存在的Source或者Timer然后run起来。AFNetworking 2.x时代NSURLConnection的异步请求就是基于一个常驻线程实现的。它创建了一个串行队列的线程在[NSRunLoop currentRunLoop]里添加了一个空的NSMachPortSource1然后调用run这样这个子线程就一直活着专门用来处理网络回调。这样做的好处是不需要频繁创建销毁线程网络回调也能稳定地回到同一个线程上。笔试如果问你“如何让一个子线程常驻”基本思路就是在目标线程里拿当前RunLoop添加一个source或者timer然后调用run。要注意的是run方法一旦执行就不会返回所以这段逻辑要放在子线程的入口方法里不能放在主线程阻塞了界面。6. 网络层与数据持久化的考察套路6.1 HTTPS握手流程怎么答才算完整2015年大家已经在大量使用HTTPS了所以网络层考HTTPS握手是很常见的事。但大多数候选人只能说出“先证书验证再加密通信”这种模糊的话距离笔试题想要的完整描述还有距离。一个完整的HTTPS握手流程以RSA密钥交换为例大概是这样的客户端发起ClientHello携带支持的TLS版本、加密套件列表、随机数A服务端返回ServerHello选定加密套件附带证书和随机数B客户端验证证书链是否可信验证通过后用证书里的公钥加密一个随机数pre-master发给服务端服务端用私钥解出pre-master双方根据随机数A、B和pre-master通过伪随机函数生成会话密钥之后双方用会话密钥进行对称加密通信完成握手。关键点在第三步和第五步非对称加密只用来传递密钥材料真正的业务数据用对称加密这样兼顾了安全性和性能。如果笔试里碰到“HTTPS为什么比HTTP安全”要回答三层证书验证防身份伪造、非对称加密传递密钥、对称加密加密业务数据。6.2 网络请求的底层调用链从URL到数据拼装2015年主流的网络库还是AFNetworking 2.x它的底层是NSURLConnection。面试官可能会问“一次完整的HTTP请求在iOS里是怎么发出去的”。这个问题如果只是说“用NSURLSession发一下”肯定不行要往下拆URL - NSURLRequest - NSURLConnection/NSURLSession - 系统协议栈 - HTTP/HTTPS - TCP/IP - 网卡。在iOS应用层你只需要构造NSURLRequest设置请求方法、请求头、请求体然后交给NSURLSession。系统会帮你处理DNS解析、TCP三次握手、TLS协商、发送请求、接收响应。响应回来之后NSURLSession把原始data交给回调你再做JSON解析、图片解码这些事。笔试里如果问“谈谈你对AFNetworking的理解”你可以从这几个层面答对NSURLSession的封装、请求队列的管理、响应序列化的策略、安全策略ATS、证书校验、网络状态监听。这能体现你不是只会用网络库而是清楚它在系统网络栈里的位置。6.3 数据持久化的选型逻辑数据持久化也是笔试常客考察的不是你会不会用NSUserDefaults而是你能不能根据场景选择合适的方案。方案适合场景注意点NSUserDefaults轻量配置、开关、用户偏好不适合存大量数据同步读写在数据量大时效率低plist文件小规模结构化的数据一次性写入不适合频繁增删改NSKeyedArchiver归档对象模型的序列化一次读写整棵对象树SQLite结构化数据、查询复杂需要管理数据库连接、迁移、线程安全CoreData对象关系映射配合SQLite存储学习曲线陡峭多线程环境复杂一道经典考法一个聊天App的本地消息记录应该用什么存正确答案是SQLite因为消息量会非常大而且需要按时间、会话ID条件查询频繁增删NSUserDefaults和plist都扛不住。如果只是存一个“用户是否登录过”的布尔值用NSUserDefaults就够了。笔试答题的时候一定要给出“为什么”而不是列一堆技术名词。7. 考后复盘一份笔试题能带给工程师什么7.1 答题思路与书写规范聊完具体知识点回到这套题本身。这套2015年的笔试题给我最大的启发是它考的不是死答案而是你的思考路径。比如运行时那块如果不会照着印象写清楚消息转发有几个阶段、每个阶段大概做什么也能拿到一部分分。但如果只写一句“我不会”那这道题就直接零分。笔试题是踩点给分你写出的每个有效信息点都是分数。我的建议是答题时尽量用“总-分-分-例”的结构先给结论再拆解原理每个原理配一个代码场景最后补上边界条件。比如对象生命周期的问题先答“引用计数归零后自动释放”再拆到dealloc、弱引用表再配一段代码说明场景这样就完整了。7.2 从知识点到工程师思维的转变后来我自己也参与过移动团队招聘再看这份笔试题发现它的考察逻辑其实是“通过一套题判断你有没有工程师思维”。工程师思维不是说会写多少代码而是面对一个不确定的问题时你能不能用已有的知识体系推演它背后可能的机制并且表达出来。比如遇到“为什么滚动时Timer不走”不知道RunLoop细节的人只会说“我加一下CommonModes就解决了”而理解的人能完整解释Default和Tracking的切换。这两者之间的差距靠背题是补不起来的靠的是平时遇到问题多往底层挖一步。如果你打算系统性准备这类笔试我的建议是不要直接刷面经而是用一张纸把iOS运行时的事件流画下来App启动 - RunLoop循环 - 事件处理 - 对象内存管理 - 并发调度 - 网络回调。每个环节都有对应的系统组件如果你能给每个环节讲出一段完整的故事这套题无论怎么变你都能接得住。2015年的搜狗笔试如此今天的面试也一样底层逻辑从来没变过。