写点什么

iOS 开发面试攻略(KVO、KVC、多线程、锁、runloop、计时器)

作者:iOSer
  • 2022 年 4 月 21 日
  • 本文字数:8342 字

    阅读完需:约 27 分钟

KVO & KVC

KVO用法和底层原理

  • 使用方法:添加观察者,然后怎样实现监听的代理

  • KVO底层使用了 isa-swizling的技术.

  • OC中每个对象/类都有isa指针, isa 表示这个对象是哪个类的对象.

  • 当给对象的某个属性注册了一个 observer,系统会创建一个新的中间类(intermediate class)继承原来的class,把该对象的isa指针指向中间类。

  • 然后中间类会重写setter方法,调用setter之前调用willChangeValueForKey, 调用setter之后调用didChangeValueForKey,以此通知所有观察者值发生更改。

  • 重写了 -class 方法,企图欺骗我们这个类没有变,就是原本那个类。

KVO 的优缺点

  • 优点

  • 1、可以方便快捷的实现两个对象的关联同步,例如view & model

  • 2、能够观察到新值和旧值的变化

  • 3、可以方便的观察到嵌套类型的数据变化

  • 缺点

  • 1、观察对象通过string类型设置,如果写错或者变量名改变,编译时可以通过但是运行时会发生crash

  • 2、观察多个值需要在代理方法中多个if判断

  • 3、忘记移除观察者或重复移除观察者会导致crash

怎么手动触发KVO

  • KVO机制是通过willChangeValueForKey:didChangeValueForKey:两个方法触发的。

  • 在观察对象变化前调用willChangeValueForKey:

  • 在观察对象变化后调用didChangeValueForKey:

  • 所以只需要在变更观察值前后手动调用即可。

给 KVO 添加筛选条件

  • 重写automaticallyNotifiesObserversForKey,需要筛选的key返回NO

  • setter里添加判断后手动触发KVO


+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {    if ([key isEqualToString:@"age"]) {        return NO;    }    return [super automaticallyNotifiesObserversForKey:key];}- (void)setAge:(NSInteger)age {    if (age >= 18) {        [self willChangeValueForKey:@"age"];        _age = age;        [self didChangeValueForKey:@"age"];    }else {        _age = age;    }}
复制代码

使用 KVC 修改会触发 KVO 吗?

  • 会,只要accessInstanceVariablesDirectly返回YES,通过 KVC 修改成员变量的值会触发 KVO。

  • 这说明 KVC 内部调用了willChangeValueForKey:方法和didChangeValueForKey:方法

直接修改成员变量会触发 KVO 吗?

  • 不会

KVO 的崩溃与防护

崩溃原因:


  • KVO 添加次数和移除次数不匹配,大部分是移除多于注册。

  • 被观察者dealloc时仍然注册着 KVO,导致崩溃。

  • 添加了观察者,但未实现 observeValueForKeyPath:ofObject:change:context: 。 防护方案 1:

  • 直接使用 facebook 开源框架KVOController 防护方案 2:

  • 自定义一个哈希表,记录观察者和观察对象的关系。

  • 使用fishhook替换 addObserver:forKeyPath:options:context:,在添加前先判断是否已经存在相同观察者,不存在才添加,避免重复触发造成 bug。

  • 使用fishhook替换removeObserver:forKeyPath:removeObserver:forKeyPath:context,移除之前判断是否存在对应关系,如果存在才释放。

  • 使用fishhook替换dealloc,执行dealloc前判断是否存在未移除的观察者,存在的话先移除。

KVC 底层原理

setValue:forKey:的实现

  • 查找setKey:方法和_setKey:方法,只要找到就直接传递参数,调用方法;

  • 如果没有找到setKey:_setKey:方法,查看accessInstanceVariablesDirectly方法的返回值,如果返回NO(不允许直接访问成员变量),调用setValue:forUndefineKey:并抛出异常NSUnknownKeyException

  • 如果accessInstanceVariablesDirectly方法返回YES(可以访问其成员变量),就按照顺序依次查找 _key、_isKey、key、isKey 这四个成员变量,如果查找到了就直接赋值;如果没有查到,调用setValue:forUndefineKey:并抛出异常NSUnknownKeyException

valueForKey:的实现

  • 按照getKey,key,isKey的顺序查找方法,只要找到就直接调用;

  • 如果没有找到,accessInstanceVariablesDirectly返回YES(可以访问其成员变量),按照顺序依次查找_key、_isKey、key、isKey 这四个成员变量,找到就取值;如果没有找到成员变量,调用valueforUndefineKey并抛出异常NSUnknownKeyException

  • accessInstanceVariablesDirectly返回NO(不允许直接访问成员变量),那么会调用valueforUndefineKey:方法,并抛出异常NSUnknownKeyException

多线程

进程和线程的区别

  • 进程:进程是指在系统中正在运行的一个应用程序,一个进程拥有多个线程。

  • 线程:线程是进程中的一个单位,一个进程想要执行任务, 必须至少有一条线程。应程序启动默认开启主线程。

进程都有什么状态

  • Not Running:未运行。

  • Inactive:前台非活动状态。处于前台,但是不能接受事件处理。

  • Active:前台活动状态。处于前台,能接受事件处理。

  • Background:后台状态。进入后台,如果又可执行代码,会执行代码,代码执行完毕,程序进行挂起。

  • Suspended:挂起状态。进入后台,不能执行代码,如果内存不足,程序会被杀死。

什么是线程安全?

  • 多条线程同时访问一段代码,不会造成数据混乱的情况

怎样保证线程安全?

  • 通过线程加锁

  • pthread_mutex 互斥锁(C 语言)

  • @synchronized

  • NSLock 对象锁

  • NSRecursiveLock 递归锁

  • NSCondition & NSConditionLock 条件锁

  • dispatch_semaphore GCD 信号量实现加锁

  • OSSpinLock自旋锁(不建议使用)

  • os_unfair_lock自旋锁(IOS10 以后替代OSSpinLock

你接触到的项目,哪些场景运用到了线程安全?

  • 在线列表的增员和减员,需要加锁保持其线程安全。

iOS开发中有多少类型的线程?分别说说

  • 1、pthread

  • C 语言实现的跨平台通用的多线程 API

  • 使用难度大,没有用过

  • 2、NSThread

  • OC面向对象的多线程API

  • 简单易用,可以直接操作线程对象。

  • 需要手动管理生命周期

  • 3、GCD

  • C 语言实现的多核并行 CPU 方案,能更合理的运行多核CPU

  • 可以自动管理生命周期

  • 4、NSOperation

  • OC基于GCD的封装

  • 完全面向对象的多线程方案

  • 可以自动管理生命周期

GCD 有什么队列,默认提供了哪些队列

  • 串行同步队列,任务按顺序(串行),在当前线程执行(同步)

  • 串行异步队列,任务按顺序(串行),开辟新的线程执行(异步)

  • 并行同步队列,任务按顺序(无法体现并行),在当前线程执行(同步)

  • 并行异步队列,任务同时执行(并行),开辟新的线程执行(异步)

  • 默认提供了主队列和全局队列

GCD主线程 & 主队列的关系

  • 主队列任务只在主线程中被执行的

  • 主线程运行的是一个 runloop,除了主队列的任务,还有 UI 处理和绘制任务。

描述一下线程同步与异步的区别?

  • 线程同步是指当前有多个线程的话,必须等一个线程执行完了才能执行下一个线程。

  • 线程异步指一个线程去执行,他的下一个线程不用等待他执行完就开始执行。

线程同步的方式

  • GCD的串行队列,任务都一个个按顺序执行

  • NSOperationQueue设置maxConcurrentOperationCount = 1,同一时刻只有 1 个NSOperation被执行

  • 使用dispatch_semaphore信号量阻塞线程,直到任务完成再放行

  • dispatch_group也可以阻塞到所有任务完成才放行

什么情况下会线程死锁

  • 串行队列,正在进行的任务 A 向串行队列添加一个同步任务 B,会造成 AB 两个任务互相等待,形成死锁。

  • 优先级反转,OSSpinlock

dispatch_once实现原理

  • dispatch_once需要传入dispatch_once_t类型的参数,其实是个长整形

  • 处理block前会判断传入的dispatch_once_t是否为 0,为 0 表示block 尚未执行。

  • 执行后把token的值改为 1,下次再进来的时候判断非 0 直接不处理了。

performSelectorrunloop的关系

  • 调用 performSelecter:afterDelay: ,其内部会创建一个Timer并添加到当前线程的RunLoop

  • 如果当前线程Runloop没有跑起来,这个方法会失效。

  • 其他的performSelector系列方法是类似的

子线程执行 [p performSelector:@selector(func) withObject:nil afterDelay:4] 会发生什么?

  • 上面这个方法放在子线程,其实内部会创建一个NSTimer定时器。

  • 子线程不会默认开启runloop,如果需要执行func函数得手动开启runloop


    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);        dispatch_async(queue, ^{            // [[NSRunLoop currentRunLoop] run]; 放在上面无效            // 只开启runloop但是里面没有任何事件,开启失败            [self performSelector:@selector(test) withObject:nil afterDelay:2];            [[NSRunLoop currentRunLoop] run];    });
复制代码

为什么只在主线程刷新 UI

  • UIKit是线程不安全的,UI 操作涉及到渲染和访问View的属性,异步操作会存在读写问题,为其加锁则会耗费大量资源并拖慢运行速度。

  • 程序的起点UIApplication在主线程初始化,所有的用户事件(触摸交互)都在主线程传递,所以view只能在主线程上才能对事件进行响应。

一个队列负责插入数据操作,一个队列负责读取操作,同时操作一个存储的队列,如何保证顺利进行

  • 使用GCD栅栏函数实现多度单写

  • 读取的时候使用 dispatch_sync 立刻返回数据

  • 写入的时候使用 dispatch_barrier_async 阻塞其他操作后写入

  • 注意尽量不要使用全局队列,因为全局队列里还有其他操作

为什么需要锁?

  • 多线程编程中会出现线程相互干扰的情况,如多个线程访问一个资源。

  • 需要一些同步工具,确保当线程交互的时候是安全的。

什么是互斥锁

  • 如果共享数据已经有了其他线程加锁了,线程会进行休眠状态等待锁

  • 一旦被访问的资源被解锁,则等待资源的线程会被唤醒。

  • 任务复杂的时间长的情况建议使用互斥锁

  • 优点

  • 获取不到资源时线程休眠,cpu 可以调度其他的线程工作

  • 缺点

  • 存在线程调度的开销

  • 如果任务时间很短,线程调度降低了 cpu 的效率

什么是自旋锁

  • 如果共享数据已经有其他线程加锁了,线程会以死循环的方式等待锁

  • 一旦被访问的资源被解锁,则等待资源的线程会立即执行

  • 适用于持有锁较短时间

  • 优点:

  • 自旋锁不会引起线程休眠,不会进行线程调度和 CPU 时间片轮转等耗时操作。

  • 如果能在很短的时间内获得锁,自旋锁效率远高于互斥锁。

  • 缺点:

  • 自旋锁一直占用 CPU,未获得锁的情况下处于忙等状态。

  • 如果不能在很短的时间内获得锁,使 CPU 效率降低。

  • 自旋锁不能实现递归调用。

读写锁

  • 读写锁又被称为 rw锁或者 readwrite锁

  • 不是最常用的,一般是数据库操作才会用到。

  • 具体操作为多读单写,写入操作只能串行执行,且写入时不能读取;读取需支持多线程操作,且读取时不能写入

说说你知道的锁有哪些

  • pthread_mutex 互斥锁(C 语言)

  • @synchronized

  • NSLock 对象锁

  • NSRecursiveLock 递归锁

  • NSCondition & NSConditionLock 条件锁

  • dispatch_semaphore GCD 信号量实现加锁

  • OSSpinLock自旋锁(暂不建议使用)

  • os_unfair_lock自旋锁(IOS10 以后替代OSSpinLock

说说@synchronized

  • 原理

  • 内部应该是一个可重入互斥锁(recursive_mutex_t

  • 底层是链表,存储 SyncData,SyncData 里面包含一个 threadCount,就是访问资源的线程数量。

  • objc_sync_enter(obj),objc_sync_exit(obj),通过obj的地址作为hash传参查找SyncData,上锁解锁。

  • 传入的obj被释放或为nil,会执行锁的释放

  • 优点

  • 不需要创建锁对象也能实现锁的功能

  • 使用简单方便,代码可读性强

  • 缺点

  • 加锁的代码尽量少

  • 性能没有那么好

  • 注意锁的对象必须是同一个OC对象

说说NSLock

  • 遵循NSLocking协议

  • 注意点

  • 同一线程lockunlock需要成对出现

  • 同一线程连续lock两次会造成死锁

说说NSRecursiveLock

  • NSRecursiveLock是递归锁

  • 注意点

  • 同一个程lock多次而不造成死锁

  • 同一线程当lock & unlock数量一致的时候才会释放锁,其他线程才能上锁

说说NSCondition & NSConditionLock

  • 条件锁:满足条件执行锁住的代码;不满足条件就阻塞线程,直到另一个线程发出解锁信号。

  • NSCondition对象实际上作为一个锁和一个线程检查器

  • 锁保护数据源,执行条件引发的任务。

  • 线程检查器根据条件判断是否阻塞线程。

  • 需要手动等待和手动信号解除等待

  • 一个wait必须对应一个signal,一次唤醒全部需要使用broadcast

  • NSConditionLockNSCondition的封装

  • 通过不同的condition值触发不同的操作

  • 解锁时通过unlockWithCondition 修改condition实现任务依赖

  • 通过condition自动判断阻塞还是唤醒线程

说说 GCD 信号量实现锁

  • dispatch_semaphore_creat(0)生成一个信号量semaphore = 0( 传入的值可以控制并行任务的数量)

  • dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) 使semaphore - 1,当值小于 0 进入等待

  • dispatch_semaphore_signal(semaphore)发出信号,使semaphore + 1,当值大于等于 0 放行

说说OSSpinLock

  • OSSpinLock是自旋锁,忙等锁

  • 自旋锁存在优先级反转的问题,线程有优先级的时候可能导致下列情况。

  • 一个优先级低的线程先访问某个数据,此时使用自旋锁进行了加锁。

  • 一个优先级高的线程又去访问这个数据,优先级高的线程会一直占着 CPU 资源忙等访问

  • 结果导致优先级低的线程没有 CPU 资源完成任务,也无法释放锁。

  • 由于自旋锁本身存在的问题,所以苹果已经废弃了OSSpinLock

说说 os_unfair_lock

  • iOS10 以后替代OSSpinLock的锁,不再忙等

  • 获取不到资源时休眠,获取到资源时由内核唤醒线程

  • 没有加强公平性和顺序,释放锁的线程可能立即再次加锁,之前等待锁的线程唤醒后可能也没能加锁成功。

  • 虽然解决了优先级反转,但也造成了饥饿(starvation

  • starvation 指贪婪线程占用资源事件太长,其他线程无法访问共享资源。

5 个线程读一个文件,如何实现最多只有 2 个线程同时读这个文件

  • dispatch_semaphore信号量控制

Objective-C 中的原子和非原子属性

  • OC 在定义属性时有nonatomicatomic两种选择

  • atomic:原子属性,为setter/getter方法都加锁(默认就是atomic),线程安全,需要消耗大量的资源

  • nonatomic:非原子属性,不加锁,非线程安全


atomic加锁原理:property (assign, atomic) int age; - (void)setAge:(int)age{     @synchronized(self) {         _age = age;    }}- (int)age {  int age1 = 0;  @synchronized(self) {    age1 = _age;  }}
复制代码

atomic 修饰的属性 int a,在不同的线程执行 self.a = self.a + 1 执行一万次,这个属性的值会是一万吗?

  • 不会,左边的点语法调用的是setter,右边调用的是getter,这行语句并不是原子性的。

atomic就一定能保证线程安全么?

  • 不能,只能保证settergetter在当前线程的安全

  • 一个线程在连续多次读取某条属性值的时候,别的线程同时在改值,最终无法得出期望值

  • 一个线程在获取当前属性的值, 另外一个线程把这个属性释放调了,有可能造成崩溃

nonatomic是非原子操作符,为什么用nonatomic不用atomic

  • 如果该对象无需考虑多线程的情况,请加入这个属性修饰,这样会让编译器少生成一些互斥加锁代码,可以提高效率。

  • 使用atomic,编译器会在settergetter方法里面自动生成互斥锁代码,避免该变量读写不同步。

有人说能atomic耗内存,你觉得呢?

  • 因为会自动加锁,所以性能比nonatomic差。

atomic为什么会失效

  • atomic修饰的属性靠编译器自动生成的get/set方法实现原子操作,如果重写了任意一个,atomic关键字的特性将失效

nonatomic实现

- (NSString *)userName {    return _userName;}- (void)setUserName:(NSString *)userName {    _userName = userName;}
复制代码

atomic 的实现

- (NSString *)userName {    NSString *name;    @synchronized (self) {        name = _userName;    }    return name;}- (void)setUserName:(NSString *)userName {    @synchronized (self) {        _userName = userName;    }}
复制代码

runloop

runloop是什么?

  • 系统内部存在管理事件的循环机制

  • runloop 是利用这个循环,管理消息和事件的对象。

runloop 是否等于 while(1) { do something ... }

  • 不是

  • while(1) 是一个忙等的状态,需要一直占用资源。

  • runloop 没有消息需要处理时进入休眠状态,消息来了,需要处理时才被唤醒。

runloop的基本模式

  • iOS 中有五种runLoop模式

  • UIInitializationRunLoopMode(启动后进入的第一个Mode,启动完成后就不再使用,切换到 kCFRunLoopDefaultMode

  • kCFRunLoopDefaultMode(App 的默认Mode,通常主线程是在这个 Mode 下运行)

  • UITrackingRunLoopMode(界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响)

  • NSRunLoopCommonModes (这是一个伪Mode,等效于NSDefaultRunLoopModeNSEventTrackingRunLoopMode的结合 )

  • GSEventReceiveRunLoopMode(接受系统事件的内部 Mode,通常用不到)

runLoop的基本原理

  • 系统中的主线程会默认开启runloop检测事件,没有事件需要处理的时候runloop会处于休眠状态。

  • 一旦有事件触发,例如用户点击屏幕,就会唤醒runloop使进入监听状态,然后处理事件。

  • 事件处理完成后又会重新进入休眠,等待下一次事件唤醒

runloop和线程的关系

  • runloop和线程一一对应。

  • 主线程的创建的时候默认开启runloop,为了保证程序一直在跑。

  • 支线程的runloop是懒加载的,需要手动开启。

runloop事件处理流程

  • 事件会触发runloop的入口函数CFRunLoopRunSpecific,函数内部首先会通知observer把状态切换成kCFRunLoopEntry,然后通过__CFRunLoopRun启动runloop处理事件

  • __CFRunLoopRun的核心是是一个do - while循环,循环内容如下


runloop是怎么被唤醒的

  • 没有消息需要处理时,休眠线程以避免资源占用。从用户态切换到内核态,等待消息;

  • 有消息需要处理时,立刻唤醒线程,回到用户态处理消息;

  • source0通过屏幕触发直接唤醒

  • source0通过调用mach_msg()函数来转移当前线程的控制权给内核态/用户态。

什么是用户态、核心态

  • 内核态:运行操作系统程序 ,表示一个应用进程执行系统调用后,或 I/O 中断,时钟中断后,进程便处于内核执行

  • 用户态:运行用户程序 ,表示进程正处于用户状态中执行

runloop 的状态

CFRunLoopObserverRef observerRef = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {        switch (activity) {            case kCFRunLoopEntry: NSLog(@"runloop启动"); break;            case kCFRunLoopBeforeTimers: NSLog(@"runloop即将处理timer事件"); break;            case kCFRunLoopBeforeSources: NSLog(@"runloop即将处理sources事件"); break;            case kCFRunLoopBeforeWaiting: NSLog(@"runloop即将进入休眠"); break;            case kCFRunLoopAfterWaiting: NSLog(@"runloop被唤醒"); break;            case kCFRunLoopExit: NSLog(@"runloop退出"); break;            default: break;        }    });    CFRunLoopAddObserver(CFRunLoopGetCurrent(), observerRef, kCFRunLoopDefaultMode);}
复制代码

runLoop 卡顿检测的方法

  • NSRunLoop 处理耗时主要下面两种情况

  • kCFRunLoopBeforeSourceskCFRunLoopBeforeWaiting 之间

  • kCFRunLoopAfterWaiting 之后

  • 上述两个时间太长,可以判定此时主线程卡顿

  • 可以添加Observer到主线程Runloop中,监听Runloop状态切换耗时,监听卡顿

  • 用一个do-while循环处理路基,信号量设置阈值判断是否卡顿

  • dispatch_semaphore_wait 返回值 非 0 表示timeout卡顿发生

  • 获取卡顿的堆栈传至后端,再分析

怎么启动一个常驻线程

// 创建线程NSThread *thread = [[NSThread alloc]  initWithTarget:self selector:@selector(play) object:nil];[thread start];// runloop保活[[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];[[NSRunLoop currentRunLoop] run];
// 处理事件[self performSelector:@selector(test) onThread:thread withObject:nil waitUntilDone:NO];复制代码
复制代码

计时器

NSTimer、CADisplayLink、dispatch_source_t 的优劣

  • NSTimer

  • 优点在于使用的是target-action模式,简单好用

  • 缺点是容易不小心造成循环引用。需要依赖runlooprunloop如果被阻塞就要延迟到下一次runloop周期才执行,所以时间精度上也略为不足

  • CADisplayLink

  • 优点是精度高,每次刷新结束后都调用,适合不停重绘的计时,例如视频

  • 缺点容易不小心造成循环引用。selector循环间隔大于重绘每帧的间隔时间,会导致跳过若干次调用机会。不可以设置单次执行。

  • dispatch_source_t

  • 基于GCD,精度高,不依赖runloop,简单好使,最喜欢的计时器

  • 需要注意的点是使用的时候必须持有计时器,不然就会提前释放。

NSTimer 在子线程执行会怎么样?

  • NSTimer在子线程调用需要手动开启子线程的runloop

  • [[NSRunLoop currentRunLoop] run];

NSTimer为什么不准?

  • 如果runloop正处在阻塞状态的时候NSTimer到达触发时间,NSTimer的触发会被推迟到下一个runloop周期

NSTimer的循环引用?

timertarget互相强引用导致了循环引用。可以通过中间件持有timer & target解决

GCD 计时器

NSTimeInterval interval = 1.0;_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));dispatch_source_set_timer(_timer, dispatch_walltime(NULL, 0), interval * NSEC_PER_SEC, 0);dispatch_source_set_event_handler(_timer, ^{    NSLog(@"GCD timer test");});dispatch_resume(_timer);
复制代码


同时我也整理了一些面试题,有需要的朋友可以加 QQ 群:1012951431 获取


用户头像

iOSer

关注

微信搜索添加微信 mayday1739 进微信群哦 2020.09.12 加入

更多大厂面试资料进企鹅群931542608

评论

发布
暂无评论
iOS开发面试攻略(KVO、KVC、多线程、锁、runloop、计时器)_ios_iOSer_InfoQ写作社区