iOS基础系列(一):底层分析

面试总结六部曲

1、OC对象本质

1、一个NSObject对象占用多少内存?

系统分配了16个字节给NSObject对象(通过malloc_size函数获得),但NSObject对象内部只使用了8个字节的空间(64bit环境下,可以通过class_getInstanceSize函数获得)

2、OC对象主要可以分为3种

  • 1、instance对象(实例对象):instance实例对象就是通过alloc出来的对象,每次调用alloc都会产生新的instance对象
  • 2、class对象(类对象):每个类的内存中有且只有一个类对象
  • 3、meta-class对象(元类对象):每个类的内存中有且只有一个元类对象

3、对象的isa指针指向哪里?

  • instance对象的isa指向class对象:instance的isa指向class,当调用对象方法时,通过instance的isa找到class,最后找到对象方法的实现进行调用
  • class对象的isa指向meta-class对象:class的isa指向meta-class,当调用类方法时,通过class的isa找到meta-class,最后找到类方法
  • meta-class对象的isa指向基类的meta-class对象

** isa查找流程**

  • 1、instance的isa指向class
  • 2、class的isa指向meta-class
  • 3、meta-class的isa指向基类的meta-class
  • 4、class的superclass指向父类的class,如果没有父类,superclass指向nil
  • 5、meta-class的superclass指向父类的meta-class,基类的meta-class的superclass指向基类的class
  • 6、instance的调用轨迹:isa找class,方法不存在,就通过superclass找父类
  • 7、class调用类方法的轨迹:isa找到meta-class,方法不存在,就通过superclass找父类

3、OC的类信息存放在哪里?

实例对象的存储信息

  • isa指针
  • 其他成员变量

类对象的存储信息

  • isa指针
  • superClass指针
  • 类的属性信息(@property),类的对象方法信息(method),类的协议信息(protocol),类的成员变量信息(ivar)

元类的存储信息

  • isa指针
  • superClass指针
  • 类的属性信息(@property),类的对象方法信息(method),类的协议信息(protocol),类的成员变量信息(ivar)

元类和类的存储结构是一样的,但是用途不一样

1592287848556-a6be984f-c821-4987-9e72-29710fa8d73b.png

2、KVO

1、iOS用什么方式实现对一个对象的KVO?(KVO的本质是什么?)

KVO是为了监听一个对象的某个属性值是否发生变化。在属性值发生变化的时候,肯定会调用其setter方法。所以KVO的本质就是监听对象有没有调用被监听属性对应的setter方法

  • 利用RuntimeAPI动态生成一个子类,并且让instance对象的isa指向这个全新的子类
  • 当修改instance对象的属性时,会调用Foundation的_NSSetXXXValueAndNotify函数
    • 1、调用willChangeValueForKey方法
    • 2、调用setAge方法
    • 3、调用didChangeValueForKey方法
    • 4、didChangeValueForKey方法内部调用oberser的observeValueForKeyPath:ofObject:change:context:方法

KVO的内部实现

  • 1、重写class方法是为了我们调用它的时候返回跟重写继承类之前同样的内容。KVO底层交换了 NSKVONotifying_Person 的 class 方法,让其返回 Person

  • 2、重写setter方法:在新的类中会重写对应的set方法,是为了在set方法中增加另外两个方法的调用

  • 3、重写dealloc方法,销毁新生成的NSKVONotifying_类。

  • 4、重写_isKVOA方法,这个私有方法估计可能是用来标示该类是一个 KVO 机制声称的类。

2、如何手动触发KVO?

手动调用willChangeValueForKey:和didChangeValueForKey:

3、直接修改成员变量会触发KVO么?

不会触发KVO,因为KVO的本质就是监听对象有没有调用被监听属性对应的setter方法,直接修改成员变量,是在内存中修改的,不走set方法

4、哪些情况下使用kvo会崩溃,怎么防护崩溃

通过会导致KVO Crash的两种情形

  • 1、KVO的被观察者dealloc时仍然注册着KVO导致的crash
  • 2、添加KVO重复添加观察者或重复移除观察者(KVO注册观察者与移除观察者不匹配)导致的crash

5、不移除KVO监听,会发生什么

  • 不移除会造成内存泄漏
  • 但是多次重复移除会崩溃。系统为了实现KVO,为NSObject添加了一个名为NSKeyValueObserverRegistration的Category,KVO的add和remove的实现都在里面。在移除的时候,系统会判断当前KVO的key是否已经被移除,如果已经被移除,则主动抛出一个NSException的异常

6、kvo的优缺点

**一、KVO优点   **
        1.能够提供一种简单的方法实现两个对象间的同步。例如:model和view之间同步;
        2.能够对非我们创建的对象,即内部对象的状态改变作出响应,而且不需要改变内部对象(SKD对象)的实现;
        3.能够提供观察的属性的最新值以及先前值;
        4.用key paths来观察属性,因此也可以观察嵌套对象;
        5.完成了对观察对象的抽象,因为不需要额外的代码来允许观察值能够被观察
二、KVO缺点
        1.我们观察的属性必须使用strings来定义。因此在编译器不会出现警告以及检查;
        2.对属性重构将导致我们的观察代码不再可用;
        3.复杂的“IF”语句要求对象正在观察多个值。这是因为所有的观察代码通过一个方法来指向;
        4.当释放观察者时不需要移除观察者。

3、KVC

KVC(Key-value coding)键值编码,指iOS的开发中,可以允许开发者通过Key名直接访问对象的属性,或者给对象的属性赋值而不需要调用明确的存取方法。

1、通过KVC修改属性会触发KVO么?

会触发KVO,因为KVC是调用set方法,KVO就是监听set方法

2、KVC的赋值和取值过程是怎样的?原理是什么?

KVC的setValue:forKey原理

  • 1、按照setKey,_setKey的顺序查找成员方法,如果找到方法,传递参数,调用方法
  • 2、如果没有找到,查看accessInstanceVariablesDirectly的返回值(accessInstanceVariablesDirectly的返回值默认是YES),
    • 返回值为YES,按照_Key,_isKey,Key,isKey的顺序查找成员变量, 如果找到,直接赋值,如果没有找到,调用setValue:forUndefinedKey:,抛出异常
    • 返回NO,直接调用setValue:forUndefinedKey:,抛出异常

access Instance Variables Directly 直接访问实例变量

3、KVC异常处理

当根据KVC搜索规则,没有搜索到对应的key或者keyPath,则会调用对应的异常方法。异常方法的默认实现,在异常发生时会抛出一个NSUndefinedKeyException的异常,并且应用程序Crash。我们可以重写下面两个方法,根据业务需求合理的处理KVC导致的异常。

- (nullable id)valueForUndefinedKey:(NSString *)key;
- (void)setValue:(nullable id)value forUndefinedKey:(NSString *)key;
- (void)setNilValueForKey:(NSString *)key;
复制代码

其中重写这两个方法,在key值不存在的时候,会走下面方法,而不会异常抛出

- (nullable id)valueForUndefinedKey:(NSString *)key;
- (void)setValue:(nullable id)value forUndefinedKey:(NSString *)key;
复制代码

重写这个方法,当value值为nil的时候,会走下面方法,而不会异常抛出

- (void)setNilValueForKey:(NSString *)key;
复制代码

4、Category

1、Category的实现原理

  • Category编译之后的底层结构是struct category_t,里面存储着分类的对象方法、类方法、属性、协议信息
  • 在程序运行的时候,runtime会将Category的数据,合并到类信息中(类对象、元类对象中)

Category是Objective-C 2.0之后添加的新语言特性,分类和类别都是指的Category。Category的主要作用是为已经存在的类添加方法和属性,其作用是在运行期决定的。

struct _category_t {
    const char *name;//类名字
    struct _class_t *cls;//类
    const struct _method_list_t *instance_methods;////category中所有给类添加的实例方法的列表
    const struct _method_list_t *class_methods;//category中所有添加的类方法的列表
    const struct _protocol_list_t *protocols;//category实现的所有协议的列表
    const struct _prop_list_t *properties;//category中添加的所有属性列表
};
复制代码

在程序启动时,系统会自动做好class与class对应的Category的映射,会调用remethodizeClass方法来修改class的_method_list_t的结构,从而实现为原类添加方法和协议。这才是runtime实现Category的关键

通过上面的分类底层代码我们可以找到category_t 结构体,它里面包含了对象方法,类方法,协议,属性,既然分类的底层代码里面已经包含了属性,为什么我们面试的时候会被问到分类为什么不能添加属性?

首先,要搞清楚三个概念:

  • 1、属性。Property
  • 2、实例变量。Ivar(属性是给成员变量默认添加了setter和getter方法。tips:如果不用@dynamic修饰的话。)
  • 3、isa指针。在Objective-C中,任何类的定义都是对象。类和类的实例(对象)没有任何本质上的区别。任何对象都有isa指针。但是分类没有。

Category可以动态添加属性,但是不能添加实例变量。

通过结构体 category_t ,我们就可以知道,在 Category 中我们可以增加实例方法、类方法、协议、属性。这里没有 objc_ivar_list 结构体,代表我们不可以在分类中添加实例变量。

因为在运行期,对象的内存布局已经确定,如果添加实例变量就会破坏类的内部布局,这个就是 Category 中不能添加实例变量的根本原因

2、Category和Class Extension的区别是什么?

  • Extension
    • 在编译器决议,是类的一部分,在编译器和头文件的@interface和实现文件里的@implement一起形成了一个完整的类。
    • 伴随着类的产生而产生,也随着类的消失而消失。
    • Extension一般用来隐藏类的私有消息,你必须有一个类的源码才能添加一个类的Extension,所以对于系统一些类,如NSString,就无法添加类扩展
  • Category
    • 是运行期决议的
    • 类扩展可以添加实例变量,分类不能添加实例变量
    • 原因:因为在运行期,对象的内存布局已经确定,如果添加实例变量会破坏类的内部布局,这对编译性语言是灾难性的。

Extension

  • 可以说成是特殊的分类,也叫做匿名的分类

  • 可以给类添加属性,但是是私有变量

  • 可以给类添加方法,也是是私有方法

  • **编译时决议,是类的一部分,**在编译时和头文件的@interface和实现文件里的@implement一起形成了一个完整的类。

  • 伴随着类的产生而产生,也随着类的消失而消失。

  • Extension一般用来隐藏类的私有消息,你必须有一个类的源码才能添加一个类的Extension,所以对于系统一些类,如NSString,就无法添加类扩展

3、load、initialize方法的区别什么?

Initialize:[ɪˈnɪʃəlaɪz]

  • 1.调用方式
    • 1> load是根据函数地址直接调用
    • 2> initialize是通过objc_msgSend调用
  • 2.调用时刻
    • 1> load是runtime加载类、分类的时候调用(只会调用1次 )
    • 2> initialize是类第一次接收到消息的时候调用,每一个类只会initialize一次(父类的initialize方法可能会被调用多次)

4、load、initialize的调用顺序

1.load

  • 1> 先调用类的load
    • a) 先编译的类,优先调用load
    • b) 调用子类的load之前,会先调用父类的load
  • 2> 再调用分类的load
    • a) 先编译的分类,优先调用load

2.initialize

  • 1> 先初始化父类
  • 2> 再初始化子类(可能最终调用的是父类的initialize方法)

5、如何实现给分类“添加成员变量”?

默认情况下,因为分类底层结构的限制,不能添加成员变量到分类中。但可以通过关联对象来间接实现

关联对象提供了以下API
添加关联对象
void objc_setAssociatedObject(id object, const void * key,
                                id value, objc_AssociationPolicy policy)

获得关联对象
id objc_getAssociatedObject(id object, const void * key)

移除所有的关联对象
void objc_removeAssociatedObjects(id object)
复制代码

5、Block

1、block的原理是怎样的?本质是什么?

  • block本质上也是一个OC对象,它内部也有个isa指针
  • block是封装了函数调用以及函数调用环境的OC对象

2、block的变量捕获

为了保证block内部能够正常访问外部的变量,block有个变量捕获机制

总结:

  • 1、因为自动变量(auto)分配的内存空间在栈区(stack),编译器会自动帮我们释放,如果我们把block写在另外一个方法中调用,自动变量就会被释放,block在使用的时候就已经被释放了,所以需要重新copy一下
  • 2、静态变量在程序结束后有系统释放,所以不需要担心被释放,block只需要知道他的内存地址就行
  • 3、对于全局变量,任何时候都可以直接访问,所以根本就不需要捕获

内存空间的分配。

  • 1、栈区(stack) 由编译器自动分配并释放,存放函数的参数值,局部变量等。栈空间分静态分配 和动态分配两种。静态分配是编译器完成的,比如自动变量(auto)的分配。动态分配由alloca函数完成。
  • 2、堆区(heap) 由程序员分配和释放,如果程序员不释放,程序结束时,可能会由操作系统回收 ,比如在ios 中 alloc 都是存放在堆中。
  • 3、全局区(静态区) (static) 全局变量和静态变量的存储是放在一起的,初始化的全局变量和静态变量存放在一块区域,未初始化的全局变量和静态变量在相邻的另一块区域,程序结束后有系统释放。
  • 4、程序代码区 存放函数的二进制代码

3、Block类型有哪几种

block有3种类型,可以通过调用class方法或者isa指针查看具体的类型,但是最终都是继承者NSBlock类型

  • 1、NSGlobalBlock,没有访问auto变量,在数据段
  • 2、NSStackBlock,访问了auto变量,在栈区
  • 3、NSMallocBlockNSStackBlock调用了copy方法,在堆区

4、block的copy

在ARC环境下,编译器会根据情况自动将栈上的block复制到堆上,比如以下情况

  • 1、block作为函数返回值时
  • 2、将block赋值给__strong指针时
  • 3、block作为Cocoa API中方法名含有usingBlock的方法参数时
  • 4、block作为GCD API的方法参数时
MRC下block属性的建议写法
@property (copy, nonatomic) void (^block)(void);

ARC下block属性的建议写法
@property (strong, nonatomic) void (^block)(void);
@property (copy, nonatomic) void (^block)(void);
复制代码

每一种类型的Block调用copy后的结果

  • 1、NSStackBlock原来在栈区,copy以后从栈复制到堆
  • 2、NSGlobalBlock原来在程序的数据段,copy以后什么也不做
  • 3、NSMallocBlock原来在堆区,复制以后引用计数加1

5、auto变量修饰符__weak

  • 1、当Block内部访问了auto变量时,如果block是在栈上,将不会对auto变量产生强引用
  • 2、如果block被拷贝到堆上,会根据auto变量的修饰符(**strong,**weak,__unsafe_unretained),对auto变量进行强引用或者弱引用
  • 3、如果block从堆上移除的时候,会调用block内部的dispose函数,该函数自动释放auto变量
  • 4、在多个block相互嵌套的时候,auto属性的释放取决于最后的那个强引用什么时候释放

当Block内部访问了auto变量时,如果block是在栈上,将不会对auto变量产生强引用,因为当Block在栈上的时候,他自己都不能保证自己什么时候被释放,所以block也就不会对自动变量进行强引用了

在ARC环境下如果我们对自动变量进行一些修饰符,那么block对auto变量是进行怎么引用呢
我们还是老方法,把main文件转化为c++文件,我们找到__main_block_func_0执行函数,

  • 当不用修饰符修饰的时:Person *p = __cself->p; // bound by copy
  • 当使用__strong修饰时:Person *strongP = __cself->strongP; // bound by copy
  • 当使用__weak修饰的时:Person *__weak weakP = __cself->weakP; // bound by copy

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m出错了,我们需要支持ARC,指定运行时系统版本,xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc -fobjc-arc -fobjc-runtime=ios-8.0.0 main.m

6、__block修饰符

  • 1、在ARC环境下,Block被引用的时候,会被Copy一次,由栈区copy到了堆
  • 2、在Block被copy的时候,Block内部被引用的变量也同样被copy一份到了堆上面
  • 3、被__Block修饰的变量,在被Block引用的时候,会变成结构体也就是OC对象,里面的__forwarding也会由栈copy道对上面
  • 4、栈上block变量结构体中block变量结构体,堆上__block变量结构体中__forwarding指针指向自己
  • 5、当block从堆中移除时,会调用block内部的dispose函数,dispose函数内部会调用_Block_object_dispose函数,_Block_object_dispose函数会自动释放引用的__block变量(release)

7、循环引用

我们也只能在Block持有OC对象的时候,给OC对象添加弱引用修饰符才比较合适,有两个弱引用修饰符__weak__unsafe_unretained

  • 1、 __weak:不会产生强引用,指向的对象销毁时,会自动让指针置为nil
  • 2、__unsafe_unretained:不会产生强引用,不安全,指向的对象销毁时,指针存储的地址值不变

其实还有一种解决方法,那就是使用__Block,需要在Block内部吧OC对象设置为nil

__block id weakSelf = self;
self.block = ^{
  weakSelf = nil;
}
self.block();
复制代码

8、iOS的weakSelf与strongSelf

避免循环引用的标准做法:weakSelf+strongSelf

为什么要用strongSelf

其实大家可以试一下,直接用weakSelf,你会发现引用计数不变,那为啥要加这玩意儿,难道为了装逼?!主要是有时候weakSelf在block里在执行doSomething还存在,但在执行doMorething前,可能会被释放了,故为了保证self在block执行过程里一直存在,对他强引用strongSelf

- (void)setUpModel{
    XYModel *model = [XYModel new];
   
    __weak typeof(self) weakSelf = self;
    model.dataChanged = ^(NSString *title) {
        [weakSelf doSomething];        
        [weakSelf doMore];                
    };
    
    self.model = model;
}
复制代码

为什么使用weakSelf

   通过 clang -rewrite-objc 源代码文件名 将代码转为c++代码(实质是c代码),可以看到block是一个结构体,它会将全局变量保存为一个属性(是__strong的),而self强引用了block这会造成循环 引用。所以需要使用__weak修饰的weakSelf。

为什么在block里面需要使用strongSelf

    是为了保证block执行完毕之前self不会被释放,执行完毕的时候再释放。这时候会发现为什么在block外边使用了__weak修饰self,里面使用__strong修饰weakSelf的时候不会发生循环引用?!
   PS:strongSelf只是为了保证在block内部执行的时候不会释放,但存在执行前self就已经被释放的情况,导致strongSelf=nil。注意判空处理。

不会引起循环引用的原因

   因为block截获self之后self属于block结构体中的一个由__strong修饰的属性会强引用self, 所以需要使用__weak修饰的weakSelf防止循环引用。    block使用的__strong修饰的weakSelf是为了在block(可以理解为函数)生命周期中self不会提前释放。strongSelf实质是一个局部变量(在block这个“函数”里面的局部变量),当block执行完毕就会释放自动变量strongSelf,不会对self进行一直进行强引用。

总结

   外部使用了weakSelf,里面使用strongSelf却不会造成循环,究其原因就是因为weakSelf是block截获的属性,而strongSelf是一个局部变量会在“函数”执行完释放。

6、RunTime

1、isa指针

在arm64架构之前,isa就是一个普通的指针,存储着Class、Meta-Class对象的内存地址;但是从arm64之后,对isa进行了优化,变成了一个共用体(union)结构,还使用位域来存放跟多的信息。

我们发现isa的结构是这种共用体(union)结构,其实使用这种共用体是一种优化,isa不在单独存放的是一个指针信息了,里面存放了更多的其他信息。

最后我们在看一下isa结构吧

  • 1、nonpointer:0,代表普通的指针,存储着Class、Meta-Class对象的内存地址;1,代表优化过,使用位域存储更多的信息

  • 2、has_assoc:是否有设置过关联对象,如果没有,释放时会更快

  • 3、has_cxx_dtor:是否有C++的析构函数(.cxx_destruct),如果没有,释放时会更快

  • 4、shiftcls:存储着Class、Meta-Class对象的内存地址信息

  • 5、magic:用于在调试时分辨对象是否未完成初始化

  • 6、weakly_referenced:是否有被弱引用指向过,如果没有,释放时会更快

  • 7、deallocating:对象是否正在释放

  • 8、extra_rc:里面存储的值是引用计数器

  • 9、has_sidetable_rc:引用计数器是否过大无法存储在isa中;如果为1,那么引用计数会存储在一个叫SideTable的类的属性中

2、方法缓存

1592290122918-31119555-5b1a-42ee-9df6-1f0325f3c5d1.png

  • 1、class类中只要有isa指针、superClass、cache方法缓存、bits具体的类信息
  • 2、bits & FAST_DATA_MASK指向一个新的结构体Class_rw_t,里面包含着methods方法列表、properties属性列表、protocols协议列表、class_ro_t类的初始化信息等一些类信息

Class_rw_t

Class_rw_t里面的methods方法列表、properties属性列表都是二维数组,是可读可写的,包含类的初始内容,分类的内容
1592290135150-d85fcf15-30f4-412a-ad14-a53ef1764ed0.png

class_ro_t

class_ro_t里面的baseMethodList,baseProtocols,Ivars,baseProperties是一维数组,是只读的,包含类的初始化内容

1592290146483-1167ac9b-d89d-4b83-978b-a9c40bfcef15.png

method_t

method_t是对方法的封装

struct method_t{
  SEL name;//函数名
  const char *types;//编码(返回值类型,参数类型)
  IMP imp;//指向函数的指针(函数地址)
}
复制代码

IMP
MP代表函数的具体实现

typedef id _Nullable (*IMP)(id _Nonnull, SEL _Nonnull, ...);
复制代码

第一个参数是指向self的指针(如果是实例方法,则是类实例的内存地址;如果是类方法,则是指向元类的指针),第二个参数是方法选择器(selector)

SEL
SEL代表方法名,一般叫做选择器,底层结构跟char *类似

  • 可以通过@selector()和sel_registerName()获得
  • 可以通过sel_getName()和NSStringFromSelector()转成字符串
  • 不同类中相同名字的方法,所对应的方法的选择器是相同的
  • 具体实现typedef struct objc_selector *SEL

types
types包含了函数返回值,参数编码的字符串
结构为:返回值 参数1 参数2…参数N
iOS中提供了一个叫做@encode的指令,可以将具体的类型表示成字符串编码

方法缓存

Class内部结构中有一个方法缓存cache_t,用散列表(哈希表)来缓存曾经调用过的方法,可以提高方法的查找速度。

1592290174444-e01d88b2-4078-4190-83f5-9ab1d059e184.png

cache_t结构体里面有三个元素

  • buckets散列表,是一个数组,数组里面的每一个元素就是一个bucket_t,bucket_t里面存放两个
    • _keySEL作为key
    • _imp函数的内存地址
  • _mask散列表的长度
  • _occupied已经缓存的方法数量

3、objc_msgSend执行流程

OC中的方法调用,其实都是转化为objc_msgSend函数的调用,objc_msgSend的执行流程可以分为3大阶段

  • 1、消息发送
  • 2、动态方法解析
  • 3、消息转发

1、消息发送

消息发送流程是我们平时最经常使用的流程,其他的像动态方法解析和消息转发其实是补救措施。具体流程如下

  • 1、首先判断消息接受者receiver是否为nil,如果为nil直接退出消息发送

  • 2、如果存在消息接受者receiverClass,首先在消息接受者receiverClass的cache中查找方法,如果找到方法,直接调用。如果找不到,往下进行

  • 3、没有在消息接受者receiverClass的cache中找到方法,则从receiverClass的class_rw_t中查找方法,如果找到方法,执行方法,并把该方法缓存到receiverClass的cache中;如果没有找到,往下进行

  • 4、没有在receiverClass中找到方法,则通过superClass指针找到superClass,也是现在缓存中查找,如果找到,执行方法,并把该方法缓存到receiverClass的cache中;如果没有找到,往下进行

  • 5、没有在消息接受者superClass的cache中找到方法,则从superClass的class_rw_t中查找方法,如果找到方法,执行方法,并把该方法缓存到receiverClass的cache中;如果没有找到,重复4、5步骤。如果找不到了superClass了,往下进行

  • 6、如果在最底层的superClass也找不到该方法,则要转到动态方法解析

2、动态方法解析

  • 开发者可以实现以下方法,来动态添加方法实现
    • +resolveInstanceMethod:
    • +resolveClassMethod:
  • 动态解析过后,会重新走“消息发送”的流程,从receiverClass的cache中查找方法这一步开始执行

3、消息转发

  • 调用forwardingTargetForSelector,返回值不为nil时,会调用objc_msgSend(返回值, SEL)
  • 调用methodSignatureForSelector
    • 返回值不为nil,调用forwardInvocation:方法。开发者可以在forwardInvocation:方法中自定义任何逻辑
    • 返回值为nil时,调用doesNotRecognizeSelector:方法
  • 以上方法都有对象方法、类方法2个版本(前面可以是加号+,也可以是减号-)

4、@dynamic关键字

Objective-C 2.0 提供了@dynamic关键字。这个关键字有两个作用:

  • 1 让编译器不要创建实现属性所用的实例变量;
  • 2 让编译器不要创建该属性的get和setter方法。

5、Class&SuperClass

  • 1、self和super的消息接受者都是self,只不过他们查找方法的开始地方不一样,self是从自己开始查找方法,super是从父类中开始查找方法
  • 2、-class实例对象返回类对象,类对象返回原类对象;-superclass返回类对象的父类
  • 3、+Class返回的是self;+superclass如果是实例对象,返回实例对象的父类,如果是类对象返回类对象的父类;

6、isKindOfClass和isMemberOfClass区别

  • isMemberOfClass:一个对象是否是指定类的实例对象
  • isKindOfClass:判断一个对象是否是指定类或者某个从该类继承类的实例对象

7、RunLoop

iOS中有2套API来访问和使用RunLoop

  • 1、Fundataion:NSRunLoop
  • 2、Core Fundataion:CFRunLoop

NSRunLoop是基于CFRunLoop的一层OC包装,CFRunLoop是开源的

1、讲讲 RunLoop,项目中有用到吗?

  • 1、定时器切换的时候,为了保证定时器的准确性,需要添加runLoop
  • 2、在聊天界面,我们需要持续的把聊天信息存到数据库中,这个时候需要开启一个保活线程,在这个线程中处理
  • 3、卡顿监控
  • 4、性能优化

应用范畴

  • 1、定时器(Timer)、PerformSelector
  • 2、GCD
  • 3、事件响应、手势识别、界面刷新
  • 4、网络请求
  • 5、AutoreleasePool

2、runloop内部实现逻辑

每次运行RunLoop,线程的RunLoop会自动处理之前未处理的消息,并通知相关的观察者。具体顺序

  • 1、通知观察者(observers)RunLoop即将启动
  • 2、通知观察者(observers)任何即将要开始的定时器
  • 3、通知观察者(observers)即将处理source0事件
  • 4、处理source0
  • 5、如果有source1,跳到第9步
  • 6、通知观察者(observers)线程即将进入休眠
  • 7、将线程置于休眠知道任一下面的事件发生
    • 1、source0事件触发
    • 2、定时器启动
    • 3、外部手动唤醒
  • 8、通知观察者(observers)线程即将唤醒
  • 9、处理唤醒时收到的时间,之后跳回2
    • 1、如果用户定义的定时器启动,处理定时器事件
    • 2、如果source0启动,传递相应的消息
  • 10、通知观察者RunLoop结束

3、RunLoop与线程

  • 1、每一条线程都有唯一的一个与之对应的RunLoop对象
  • 2、RunLoop保存在一个全局的Dictionary里,线程作为Key,RunLoop作为Value
  • 3、线程刚创建时,并没有RunLoop对象,RunLoop会在第一次获取她时创建
  • 4、RunLoop会在线程结束的时候销毁
  • 5、主线程的RunLoop已经自动获取(创建),子线程默认没有开启RunLoop

苹果不允许直接创建 RunLoop,它只提供了两个自动获取的函数:CFRunLoopGetMain() 和 CFRunLoopGetCurrent()

4、RunLoop相关类RunLoop相关类

Core Foundation中关于RunLoop一共有5个类

  • 1、CFRunLoopRef
  • 2、CFRunLoopModeRef
  • 3、CFRunLoopSourceRef
  • 4、CFRunLoopTimerRef
  • 5、CFRunLoopObserverRef

其中 CFRunLoopModeRef 类并没有对外暴露,只是通过 CFRunLoopRef 的接口进行了封装

一个 RunLoop 包含若干个 Mode,每个 Mode 又包含若干个 Source/Timer/Observer。每次调用 RunLoop 的主函数时,只能指定其中一个 Mode,这个Mode被称作 CurrentMode。如果需要切换 Mode,只能退出 Loop,再重新指定一个 Mode 进入。这样做主要是为了分隔开不同组的 Source/Timer/Observer,让其互不影响

  • 1、Source0:
    • 处理触摸事件,
    • performSelector:onThread:
  • 2、Source1:
    • 基于Port的线程间通信,
    • 系统事件的捕捉
  • 3、Timer :
    • NSTimer,
    • performSelector:withObject:afterDelay:
  • 4、Observers:
    • 用于监听RunLoop的状态
    • UI刷新
    • Autorelease pool(BeforeWaiting)

5、CFRunLoopModeRef

  • 1、CFRunLoopModeRef代表着RunLoop的运行模式
  • 2、一个RunLoop包含若干个Mode,每个Mode又包含若干个Source0/Source1/Timer/Observer
  • 3、RunLoop启动的时候只能选择其中一个Mode作为currentMode
  • 4、如果要切换Mode,只能退出当前Loop,再重新选择一个Mode进入,不同组的Source0/Source1/Timer/Observer互不影响
  • 5、如果Mode里面没有任何Source0/Source1/Timer/Observer,RunLoop会立刻退出

RunLoop的mode的作用 系统注册了5中mode

kCFRunLoopDefaultMode //App的默认Mode,通常主线程是在这个Mode下运行
UITrackingRunLoopMode //界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响
UIInitializationRunLoopMode // 在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用
GSEventReceiveRunLoopMode // 接受系统事件的内部 Mode,通常用不到
kCFRunLoopCommonModes //这是一个占位用的Mode,不是一种真正的Mode
复制代码

但是我们只能使用两种mode

kCFRunLoopDefaultMode //App的默认Mode,通常主线程是在这个Mode下运行
UITrackingRunLoopMode //界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响
复制代码

6、RunLoop有几种状态

kCFRunLoopEntry = (1UL << 0), // 即将进入RunLoop 
kCFRunLoopBeforeTimers = (1UL << 1), // 即将处理Timer 
kCFRunLoopBeforeSources = (1UL << 2), // 即将处理Source 
kCFRunLoopBeforeWaiting = (1UL << 5), //即将进入休眠 
kCFRunLoopAfterWaiting = (1UL << 6),// 刚从休眠中唤醒
 kCFRunLoopExit = (1UL << 7),// 即将退出RunLoop
复制代码

7、苹果用 RunLoop 实现的功能

AutoreleasePool

App启动后,苹果在主线程 RunLoop 里注册了两个 Observer,其回调都是 _wrapRunLoopWithAutoreleasePoolHandler()。

第一个 Observer 监视的事件是 Entry(即将进入Loop),其回调内会调用 _objc_autoreleasePoolPush() 创建自动释放池。其 order 是-2147483647,优先级最高,保证创建释放池发生在其他所有回调之前。

第二个 Observer 监视了两个事件: BeforeWaiting(准备进入休眠) 时调用_objc_autoreleasePoolPop() 和 _objc_autoreleasePoolPush() 释放旧的池并创建新池;Exit(即将退出Loop) 时调用 _objc_autoreleasePoolPop() 来释放自动释放池。这个 Observer 的 order 是 2147483647,优先级最低,保证其释放池子发生在其他所有回调之后。

在主线程执行的代码,通常是写在诸如事件回调、Timer回调内的。这些回调会被 RunLoop 创建好的 AutoreleasePool 环绕着,所以不会出现内存泄漏,开发者也不必显示创建 Pool 了

事件响应

苹果注册了一个 Source1 (基于 mach port 的) 用来接收系统事件,其回调函数为 __IOHIDEventSystemClientQueueCallback()。

当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由 IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收。这个过程的详细情况可以参考这里。SpringBoard 只接收按键(锁屏/静音等),触摸,加速,接近传感器等几种 Event,随后用 mach port 转发给需要的App进程。随后苹果注册的那个 Source1 就会触发回调,并调用 _UIApplicationHandleEventQueue() 进行应用内部的分发。

_UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture/处理屏幕旋转/发送给 UIWindow 等。通常事件比如 UIButton 点击、touchesBegin/Move/End/Cancel 事件都是在这个回调中完成的。

手势识别

当上面的 _UIApplicationHandleEventQueue() 识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin/Move/End 系列回调打断。随后系统将对应的 UIGestureRecognizer 标记为待处理。

苹果注册了一个 Observer 监测 BeforeWaiting (Loop即将进入休眠) 事件,这个Observer的回调函数是 _UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理的 GestureRecognizer,并执行GestureRecognizer的回调。

当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。

界面更新

当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去。

苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行一个很长的函数:

_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()。这个函数里会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。

定时器

NSTimer 其实就是 CFRunLoopTimerRef,他们之间是 toll-free bridged 的。一个 NSTimer 注册到 RunLoop 后,RunLoop 会为其重复的时间点注册好事件。例如 10:00, 10:10, 10:20 这几个时间点。RunLoop为了节省资源,并不会在非常准确的时间点回调这个Timer。Timer 有个属性叫做 Tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。

如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。就比如等公交,如果 10:10 时我忙着玩手机错过了那个点的公交,那我只能等 10:20 这一趟了。

CADisplayLink 是一个和屏幕刷新率一致的定时器(但实际实现原理更复杂,和 NSTimer 并不一样,其内部实际是操作了一个 Source)。如果在两次屏幕刷新之间执行了一个长任务,那其中就会有一帧被跳过去(和 NSTimer 相似),造成界面卡顿的感觉。在快速滑动TableView时,即使一帧的卡顿也会让用户有所察觉

PerformSelecter

当调用 NSObject 的 performSelecter:afterDelay: 后,实际上其内部会创建一个 Timer 并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效。

当调用 performSelector:onThread: 时,实际上其会创建一个 Timer 加到对应的线程去,同样的,如果对应线程没有 RunLoop 该方法也会失效。

关于GCD

GCD 提供的某些接口也用到了 RunLoop, 例如 dispatch_async()。

当调用 dispatch_async(dispatch_get_main_queue(), block) 时,libDispatch 会向主线程的 RunLoop 发送消息,RunLoop会被唤醒,并从消息中取得这个 block,并在回调 CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE() 里执行这个 block。但这个逻辑仅限于 dispatch 到主线程,dispatch 到其他线程仍然是由 libDispatch 处理的

8、Runloop-实际开发你想用的应用场景

1、线程保活

线程保活问题,从字面意思上就是保护线程的生命周期不结束.正常情况下,当线程执行完一次任务之后,需要进行资源回收,但是当有一个任务,随时都有可能去调用,如果在子线程去执行,并且让子线程一直存活着,为了避免来回多次创建毁线程的动作, 降低性能消耗.

2、NSTimer问题

原因

  • NSTimer被添加在mainRunloop中,模式是NSDefaultRunLoopMode, mainRunloop负责所有的主线程事件,例如UI界面的操作,负责的运算使当前Runloop持续的时间超过了定时器的间隔时间,那么下一次定时就被延后,这样就造成timer的阻塞
  • 模式的切换,当创建的timer被加入到NSDefaultRunLoopMode时,此时如果有滑动UIScrollView的操作时,runloop的mode会切换为TrackingRunloopMode,这时tiemr会停止回调

解决方案

  • Mode方式的改变,兼顾TrackingRunloopMode
  • 在子线程中创建timer,在主线程进行定时任务的操作或者在子线程中创建timer,在子线程中进行定时任务的操作,需要UI的操作时再切换到主线程进行操作
  • GCD操作: dispatch_source_create以及depatch_resume等方法

3、监控卡顿

卡顿问题,就是在主线程上无法响应用户交互的问题。如果一个 App 时不时地就给你卡一下,有时还长时间无响应,这时你还愿意继续用它吗?所以说,卡顿问题对 App 的伤害是巨大的,也是我们必须要重点解决的一个问题。

现在,我们先来看一下导致卡顿问题的几种原因:

  • 复杂 UI 、图文混排的绘制量过大;
  • 在主线程上做网络同步请求;
  • 在主线程做大量的 IO 操作;
  • 运算量过大,CPU 持续高占用;
  • 死锁和主子线程抢锁。

将创建好的观察者 runLoopObserver 添加到主线程 RunLoop 的 common 模式下观察。然后,创建一个持续的子线程专门用来监控主线程的 RunLoop 状态。

一旦发现进入睡眠前的 kCFRunLoopBeforeSources 状态,或者唤醒后的状态 kCFRunLoopAfterWaiting,在设置的时间阈值内一直没有变化,即可判定为卡顿。接下来,我们就可以 dump 出堆栈的信息,从而进一步分析出具体是哪个方法的执行时间过长。

4、性能优化

当tableview的cell有多个ImageView,并且是大图的话,会不会在滑动的时候导致卡顿,答案是显然意见的。
通过上面讲述Runloop的原理,我们可以使用Runloop每次循环添加一张图片。

总结一下思想

  • 加载图片的代码保存起来,不要直接执行,用一个数组保存 block

  • 监听我们的Runloop循环 CFRunloop CFRunloopObserver

  • 每次Runloop循环就让它从数组里面去一个加载图片等任务出来执行

9、PerformSelector和runloop的关系

1、基础用法

performSelecor响应了OC语言的动态性:延迟到运行时才绑定方法。当我们在使用以下方法时:

[self performSelector:@selector(play)];
[self performSelector:@selector(play:) withObject:@{@"A":@"a"}];
[self performSelector:@selector(play:with:) 
                        withObject:@{@"A":@"a"} 
            withObject:@{@"B":@"b"}];
复制代码

编译阶段并不会去检查方法是否有效存在,只会给出警告:

Undeclared selector 'play'
Undeclared selector 'play:'
Undeclared selector 'play:with:'
复制代码

2、延迟执行

[obj performSelector:@selector(play) withObject:@"李周" afterDelay:4.f]
复制代码

该方法在当前线程的运行循环(runloop)中设置一个计时器(timer)来执行aSelector消息。该计时器Mode为NSDefaultRunLoopMode。当触发计时器时,线程会尝试从runloop中出列dequeue该消息并执行该selector。如果runloop正在运行并且处于default mode,则成功。否则,该计时器将等待,直到runloop处于default mode

我们知道线程刚创建时并没有runloop,如果不主动获取,那它一直不会有。从上面我们知道该方法内部会创建一个Timer,而只有主线程中的runloop是默认开启的,其他线程没有,所以在上面的代码中,performSelector:withObject:afterDelay:方法在一个子线程中执行,由于该线程中并runloop并没有开启,所以performSelector:withObject:afterDelay:方法会失效,也就不会执行aSelector了。

8、多线程

多线程有4种技术方案

  • **1、**pthread:一套多线程API;可跨平台使用;使用难度大
  • 2、NSThread:使用面向对象;简单易用,可直接操作线程对象
  • 3、GCD:旨在替代NSThread等线程技术;充分利用设备的多核
  • 4、NSOperation:基于GCD(底层是GCD);比GCD多了一些简单实用的功能;使用更加面向对象

1、NSThread介绍

NSThread 是苹果官方提供的,使用起来比 pthread 更加面向对象,简单易用,可以直接操作线程对象。不过也需要需要程序员自己管理线程的生命周期(主要是创建),我们在开发的过程中偶尔使用 NSThread。比如我们会经常调用[NSThread currentThread]来显示当前的进程信息

2、GCD介绍

GCD 的好处具体如下

  • GCD 可用于多核的并行运算
  • GCD 会自动利用更多的 CPU 内核(比如双核、四核)
  • GCD 会自动管理线程的生命周期(创建线程、调度任务、销毁线程)
  • 程序员只需要告诉 GCD 想要执行什么任务,不需要编写任何线程管理代码

1、GCD任务和队列

任务:就是执行操作的意思,换句话说就是你在线程中执行的那段代码。在 GCD 中是放在 block 中的

  • 同步执行(sync)
    • 同步添加任务到指定的队列中,在添加的任务执行结束之前,会一直等待,直到队列里面的任务完成之后再继续执行。
    • 只能在当前线程中执行任务,不具备开启新线程的能力
  • 异步执行(async)
    • 异步添加任务到指定的队列中,它不会做任何等待,可以继续执行任务。
    • 可以在新的线程中执行任务,具备开启新线程的能力。
// 同步执行任务创建方法
dispatch_sync(queue, ^{
  // 这里放同步执行任务代码
});
// 异步执行任务创建方法
dispatch_async(queue, ^{
  // 这里放异步执行任务代码
});
复制代码

队列(Dispatch Queue):这里的队列指执行任务的等待队列,即用来存放任务的队列。队列是一种特殊的线性表,采用 FIFO(先进先出)的原则,即新任务总是被插入到队列的末尾,而读取任务的时候总是从队列的头部开始读取。每读取一个任务,则从队列中释放一个任务

  • 串行队列(Serial Dispatch Queue)
    • 每次只有一个任务被执行。让任务一个接着一个地执行。(只开启一个线程,一个任务执行完毕后,再执行下一个任务)
  • 并发队列(Concurrent Dispatch Queue)
    • 可以让多个任务并发(同时)执行。(可以开启多个线程,并且同时执行任务)

并发队列的并发功能只有在异步(dispatch_async)函数下才有效

1592634521681-2cef7852-7a85-49d4-827f-f8ee94b52b28.png
使用sync函数往当前串行队列中添加任务,会卡住当前的串行队列(产生死锁)

并发功能只有在异步函数才会生效

2、GCD 的其他方法

1、GCD 栅栏方法:dispatch_barrier_async

就是我们在异步执行一些操作的时候,我们使用dispatch_barrier_async函数把异步操作暂时性的做成同步操作,就行一个栅栏一样分开

2、GCD 延时执行方法:dispatch_after

们经常会遇到这样的需求:在指定时间(例如3秒)之后执行某个任务。可以用 GCD 的dispatch_after函数来实现。
需要注意的是:dispatch_after函数并不是在指定时间之后才开始执行处理,而是在指定时间之后将任务追加到主队列中。严格来说,这个时间并不是绝对准确的,但想要大致延迟执行任务,dispatch_after函数是很有效的。

3、GCD 一次性代码(只执行一次):dispatch_once

我们在创建单例、或者有整个程序运行过程中只执行一次的代码时,我们就用到了 GCD 的 dispatch_once 函数

4、GCD 队列组:dispatch_group

有时候我们会有这样的需求:分别异步执行2个耗时任务,然后当2个耗时任务都执行完毕后再回到主线程执行任务。这时候我们可以用到 GCD 的队列组

  • 调用队列组的dispatch_group_async先把任务放到队列中,然后将队列放入队列组中。或者使用队列组的dispatch_group_enter、dispatch_group_leave组合 来实现dispatch_group_async。
  • 调用队列组的dispatch_group_notify回到指定线程执行任务。或者使用dispatch_group_wait回到当前线程继续向下执行(会阻塞当前线程)。
  • dispatch_group_enter标志着一个任务追加到 group,执行一次,相当于 group 中未执行完毕任务数+1
  • dispatch_group_leave标志着一个任务离开了 group,执行一次,相当于 group 中未执行完毕任务数-1
  • 当 group 中未执行完毕任务数为0的时候,才会使dispatch_group_wait解除阻塞,以及执行追加到dispatch_group_notify中的任务。

5、GCD 信号量:dispatch_semaphore

Dispatch Semaphore 提供了三个函数。

  • dispatch_semaphore_create:创建一个信号量,具有整形的数值,即为信号的总量。
  • dispatch_semaphore_signal:发送一个信号,让信号总量加1
  • dispatch_semaphore_wait:可以使总信号量减1,当信号总量为0时就会一直等待(阻塞所在线程),否则就可以正常执行。

简单来讲 信号量为0则阻塞线程,大于0则不会阻塞。则我们通过改变信号量的值,来控制是否阻塞线程,从而达到线程同步。

3、iOS线程死锁

结论:使用sync函数往当前串行队列中添加任务,会卡住当前的串行队列(产生死锁)

首先你要理解同步和异步执行的概念,同步和异步目的不是为了是否创建一个新的线程,同步会阻塞当前函数的返回,异步函数会立即返回执行下面的代码;队列是一种数据结构,队列有FIFO,LIFO等,控制任务的执行顺序,至于是否开辟一个新的线程,因为同步函数会等待函数的返回,所以在当前线程执行就行了,没必要浪费资源再开辟新的线程,如果是异步函数,当前线程需要立即函数返回,然后往下执行,所以函数里面的任务必须要开辟一个新的线程去执行这个任务。

队列上是放任务的,而线程是去执行队列上的任务的

【问题1】:以下代码是在主线程执行的,会不会产生死锁?会!

NSLog(@"执行任务1");
dispatch_queue_t queue = dispatch_get_main_queue();
dispatch_sync(queue, ^{
  NSLog(@"执行任务2");
});

NSLog(@"执行任务3");
复制代码

3、NSOperation介绍

NSOperation、NSOperationQueue 是苹果提供给我们的一套多线程解决方案。实际上 NSOperation、NSOperationQueue 是基于 GCD 更高一层的封装,完全面向对象

好处

  • 1、可添加完成的代码块,在操作完成后执行
  • 2、添加操作之间的依赖关系,方便的控制执行顺序
  • 3、设定操作执行的优先级
  • 4、可以很方便的取消一个操作的执行
  • 5、使用 KVO 观察对操作执行状态的更改:isExecuteing、isFinished、isCancelled

既然是基于 GCD 的更高一层的封装。那么,GCD 中的一些概念同样适用于 NSOperation、NSOperationQueue。在 NSOperation、NSOperationQueue 中也有类似的任务(操作)和队列(操作队列)的概念

操作(Operation)

  • 1、执行操作的意思,换句话说就是你在线程中执行的那段代码
  • 2、在GCD中是放在block中的。在NSOperation中,我们使用 NSOperation 子类NSInvocationOperation、NSBlockOperation,或者自定义子类来封装操作

操作队列(Operation Queues)

  • 1、这里的队列指操作队列,即用来存放操作的队列。不同于 GCD 中的调度队列 FIFO(先进先出)的原则。NSOperationQueue 对于添加到队列中的操作,首先进入准备就绪的状态(就绪状态取决于操作之间的依赖关系),然后进入就绪状态的操作的开始执行顺序(非结束执行顺序)由操作之间相对的优先级决定(优先级是操作对象自身的属性)。
  • 2、操作队列通过设置最大并发操作数(maxConcurrentOperationCount)来控制并发、串行
  • 3、NSOperationQueue 为我们提供了两种不同类型的队列:主队列和自定义队列。主队列运行在主线程之上,而自定义队列在后台执行

简单使用

NSOperation 需要配合 NSOperationQueue 来实现多线程。因为默认情况下,NSOperation 单独使用时系统同步执行操作,配合 NSOperationQueue 我们能更好的实现异步执行

实现步骤

  • 1、创建操作:先将需要执行的操作封装到一个 NSOperation 对象中
  • 2、创建队列:创建 NSOperationQueue 对象
  • 3、将操作加入到队列中:将 NSOperation 对象添加到 NSOperationQueue 对象中

NSOperation 是个抽象类,不能用来封装操作。我们只有使用它的子类来封装操作

  • 1、使用子类 NSInvocationOperation
  • 2、使用子类 NSBlockOperation
  • 3、自定义继承自 NSOperation 的子类,通过实现内部相应的方法来封装操作。

4、线程安全

线程安全的处理手段

  • 加锁
  • 同步执行

自旋锁和互斥锁

互斥锁:线程会从sleep(加锁)——>running(解锁),过程中有上下文的切换,cpu的抢占,信号的发送等开销。
自旋锁:线程一直是running(加锁——>解锁),死循环检测锁的标志位,机制不复杂

对比
互斥锁的起始原始开销要高于自旋锁,但是基本是一劳永逸,临界区持锁时间的大小并不会对互斥锁的开销造成影响,而自旋锁是死循环检测,加锁全程消耗cpu,起始开销虽然低于互斥锁,但是随着持锁时间,加锁的开销是线性增长。

两种锁的应用

互斥锁用于临界区持锁时间比较长的操作,比如下面这些情况都可以考虑

  • 1 临界区有IO操作
  • 2 临界区代码复杂或者循环量大
  • 3 临界区竞争非常激烈
  • 4 单核处理器

至于自旋锁就主要用在临界区持锁时间非常短且CPU资源不紧张的情况下,自旋锁一般用于多核的服务器。

自旋锁

  • 1、OSSpinLock叫做”自旋锁”
  • 2、os_unfair_lock用于取代不安全的OSSpinLock,从iOS10开始才支持

从底层调用看,等待os_unfair_lock锁的线程会处于休眠状态,并非忙等

互斥锁

  • **1、**pthread_mutex:mutex叫做”互斥锁”,等待锁的线程会处于休眠状态
  • 2、NSLock是对mutex普通锁的封装
  • 3、NSRecursiveLock是对mutex递归锁的封装,API跟NSLock基本一致
  • 4、NSCondition是对mutex和cond的封装,更加面向对象,我们使用起来也更加的方便简洁
  • 5、NSConditionLock是对NSCondition的进一步封装,可以设置具体的条件值

5、线程之间的通讯

线程间通信的体现

  • 1、一个线程传递数据给另一个线程
  • 2、在一个线程中执行完特定任务后,转到另一个线程继续执行任务

1、NSThread
可以先将自己的当前线程对象注册到某个全局的对象中去,这样相互之间就可以获取对方的线程对象,然后就可以使用下面的方法进行线程间的通信了,由于主线程比较特殊,所以框架直接提供了在主线程执行的方法

- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait;

- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait NS_AVAILABLE(10_5, 2_0);
复制代码

2、GCD

//开启一个全局队列的子线程
dispatch_async(dispatch_get_global_queue(0, 0), ^{
//1. 开始请求数据
//...
// 2. 数据请求完毕
//我们知道UI的更新必须在主线程操作,所以我们要从子线程回调到主线程
dispatch_async(dispatch_get_main_queue(), ^{

//我已经回到主线程更新
});

});
复制代码

6、线程&进程

1、进程和线程的区别

**进程:**一个在内存中运行的应用程序。进程是表示资源分配的的基本概念。
**线程:**进程中的一个执行任务(控制单元),负责当前进程中程序的执行。

  • 1、根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位。
  • 2、一个线程只能属于一个进程,但是一个进程可以拥有多个线程。多线程处理就是允许一个进程中在同一时刻执行多个任务。
  • 3、进程有自己的独立地址空间,进程之间的地址空间和资源是相互独立的;而线程是共享进程中的地址空间和资源。也因此线程之间切换比进程之间切换的资源开销小,同样创建一个线程的开销也比进程要小很多。
  • 4、因为同一进程下的线程共享全局变量、静态变量等数据,所以线程之间的通信更方便。而进程之间通信需要要复杂一些。
  • 5、多进程程序更健壮,因为进程有自己独立的地址空间,一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。
  • 6、每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行:

多进程:操作系统中同时运行的多个程序
多线程:在同一个进程中同时运行的多个任务

2、进程之间如何通信

进程通信(Interprocess Communication,IPC)是一个进程与另一个进程间共享消息的一种通信方式。
进程通信的目的

数据传输:一个进程需要将其数据发送给另一进程。
共享数据:多个进程操作共享数据。
事件通知:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程。

进程通信方式

每个进程各自有不同的地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程A把数据从用户空间拷到内核缓冲区,进程B再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信。

  • 1、无名管道( pipe ):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
  • 2、高级管道(popen):将另一个程序当做一个新的进程在当前程序进程中启动,则它算是当前程序的子进程,这种方式我们成为高级管道方式。
  • 3、有名管道 (named pipe) : 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
  • 4、消息队列( message queue ) : 消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  • 5、信号量( semophore ) : 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
  • 6、信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
  • 7、共享内存( shared memory ) :共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
  • 8、套接字( socket ) : 套解字也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信。

7、问答

1、在项目什么时候选择使用 GCD,什么时候选 择 NSOperation?

  • 项目中使用 NSOperation 的优点是 NSOperation 是对线程的高度抽象,在项目中使 用它,会使项目的程序结构更好,子类化 NSOperation 的设计思路,是具有面向对 象的优点(复用、封装),使得实现是多线程支持,而接口简单,建议在复杂项目中 使用。

  • 项目中使用 GCD 的优点是 GCD 本身非常简单、易用,对于不复杂的多线程操 作,会节省代码量,而 Block 参数的使用,会是代码更为易读,建议在简单项目中 使用

2、说一下 OperationQueue 和 GCD 的区别,以及各自的优势

  • 1、GCD是纯C语⾔言的API,NSOperationQueue是基于GCD的OC版本封装

  • 2、GCD只⽀支持FIFO的队列列,NSOperationQueue可以很⽅方便便地调整执⾏行行顺 序、设 置最⼤大并发数量量

  • 3、NSOperationQueue可以在轻松在Operation间设置依赖关系,⽽而GCD 需要写很 多的代码才能实现

  • 4、NSOperationQueue⽀支持KVO,可以监测operation是否正在执⾏行行 (isExecuted)、 是否结束(isFinished),是否取消(isCanceld)

  • 5、GCD的执⾏行行速度⽐比NSOperationQueue快 任务之间不不太互相依赖:GCD 任务之间 有依赖\或者要监听任务的执⾏行行情况:NSOperationQueue

3、GCD如何取消线程?

GCD目前有两种方式可以取消线程:

  • 1、dispatch_block_cancel类似NSOperation一样,可以取消还未执行的线程。但是没办法做到取消一个正在执行的线程。

  • 2、使用临时变量+return方式取消 正在执行的Block

9、内存管理

1、使用CADisplayLink、NSTimer有什么注意点?

CADisplayLink、NSTimer会对target产生强引用,如果target又对它们产生强引用,那么就会引发循环引用

2、介绍下内存的几大区域

  • 代码段:编译之后的代码
  • 数据段
    • 字符串常量:比如NSString *str = @”123″
    • 已初始化数据:已初始化的全局变量、静态变量等
    • 未初始化数据:未初始化的全局变量、静态变量等
  • 栈:函数调用开销,比如局部变量。分配的内存空间地址越来越小
  • 堆:通过alloc、malloc、calloc等动态分配的空间,分配的内存空间地址越来越大

3、Tagged Pointer

  • 从64bit开始,iOS引入了Tagged Pointer技术,用于优化NSNumber、NSDate、NSString等小对象的存储
  • 在没有使用Tagged Pointer之前, NSNumber等对象需要动态分配内存、维护引用计数等,NSNumber指针存储的是堆中NSNumber对象的地址值
  • 使用Tagged Pointer之后,NSNumber指针里面存储的数据变成了:Tag + Data,也就是将数据直接存储在了指针中
  • 当指针不够存储数据时,才会使用动态分配内存的方式来存储数据
  • objc_msgSend能识别Tagged Pointer,比如NSNumber的intValue方法,直接从指针提取数据,节省了以前的调用开销

1592636875791-ee0c1ca0-4544-41ab-8a58-3c803535de66.png

在判断是否是TaggedPointer的时候,在iOS平台和MAC平台还是不太一样的

  • 1、iOS平台,需要把1向左移动63位,也就是最高有效位是1(第64bit)

  • 2、在Mac平台,最低有效位是1

4、讲一下你对 iOS 内存管理的理解

在iOS中,使用引用计数来管理OC对象的内存

  • 一个新创建的OC对象引用计数默认是1,当引用计数减为0,OC对象就会销毁,释放其占用的内存空间
  • 调用retain会让OC对象的引用计数+1,调用release会让OC对象的引用计数-1

内存管理的经验总结

  • 当调用alloc、new、copy、mutableCopy方法返回了一个对象,在不需要这个对象时,要调用release或者autorelease来释放它
  • 想拥有某个对象,就让它的引用计数+1;不想再拥有某个对象,就让它的引用计数-1

可以通过以下私有函数来查看自动释放池的情况extern void _objc_autoreleasePoolPrint(void);

5、copy&retain&strong原理

在开始回答copy的各种问题之前,我们需要先了解我们为什么要使用copy。

  • 1、拷贝的目的:产生一个副本对象,跟源对象互不影响
    • 修改了源对象,不会影响副本对象
    • 修改了副本对象,不会影响源对象
  • 2、iOS提供了2个拷贝方法
    • 1、copy,不可变拷贝,产生不可变副本
    • 2、mutableCopy,可变拷贝,产生可变副本
  • 3、深拷贝和浅拷贝
    • 1、深拷贝:内容拷贝,产生新的对象
    • 2、浅拷贝:指针拷贝,没有产生新的对象

1592637035928-eb26d16d-96ab-4545-80d8-f82b9feae922.png

  • **1、**不可变字符串在copy时是浅拷贝,只拷贝了指针没有拷贝对象;mutableCopy则是深拷贝,产生了新的对象
  • 2、对于可变字符串不论是copy还是mutableCopy都是深拷贝
  • 3、不可变数组在copy时是浅拷贝,只拷贝了指针没有拷贝对象;mutableCopy则是深拷贝,产生了新的对象
  • 4、对于可变数组不论是copy还是mutableCopy都是深拷贝
  • 5、不可变字典在copy时是浅拷贝,只拷贝了指针没有拷贝对象;mutableCopy则是深拷贝,产生了新的对象

让自己的类用 copy 修饰符

我们copy属性一般只对NSString NSArray NSDictionary NSSet等这些可用,假如我们要对我们的类对象进行copy实现,我们应该怎么做呢
若想令自己所写的对象具有拷贝功能,则需实现 NSCopying 协议。如果自定义的对象分为可变版本与不可变版本,那么就要同时实现 NSCopying 与 NSMutableCopying 协议。

  • 1、需声明该类遵从 NSCopying 协议
  • 2、实现 NSCopying 协议- (id)copyWithZone:(NSZone *)zone;
  • 3、在- (id)copyWithZone:(NSZone *)zone;方法中对类对象进行重新赋值

如何重写带 copy 关键字的 setter

- (void)setName:(NSString *)name {
  if (_name != name) {
    //[_name release];//MRC
    _name = [name copy];
  }
}
复制代码

Strong

  • 1、源对象为不可变字符串而言,不论使用copy还是strong属性,所对应的值是不发生变化,strong和copy并没有开辟新的内存,即并不是深拷贝。此时,使用copy或是strong,并没有对数据产生影响
  • 2、数据源为可变字符串而言,使用copy申明属性,会开辟一块新的内存空间存放值,源数据不论怎么变化,都不会影响copy属性中的值,属于深拷贝;使用strong申明属性,不会开辟新的内存空间,只会引用到源数据内存地址,因此源数据改变,则strong属性也会改变,属于浅拷贝

在实际开发中,我们不希望源数据改变影响到属性中的值,故而使用copy来申明。

6、ARC 都帮我们做了什么:LLVM + Runtime

  • LVVM生成release代码
  • RunTime负责执行

7、weak指针的实现原理

weak其实就是一个hash表,key是所指对象的地址,value是weak指针的地址数组

Runtime维护了一个weak表,用于存储指向某个对象的所有weak指针。weak表其实是一个hash(哈希)表,Key是所指对象的地址,Value是weak指针的地址(这个地址的值是所指对象的地址)数组

weak 的实现原理可以概括一下三步

  • 1、初始化时:runtime会调用objc_initWeak函数,初始化一个新的weak指针指向对象的地址
  • 2、添加引用时:objc_initWeak函数会调用 storeWeak() 函数, storeWeak() 的作用是更新指针指向,创建对应的弱引用表
  • 3、释放时,调用clearDeallocating函数。clearDeallocating函数首先根据对象地址获取所有weak指针地址的数组,然后遍历这个数组把其中的数据设为nil,最后把这个entry从weak表中删除,最后清理对象的记录

assign

assign一般用来修饰基本的数据类型,包括基础数据类型 (NSInteger,CGFloat)和C数据类型(int, float, double, char, 等等),为什么呢?assign声明的属性是不会增加引用计数的,也就是说声明的属性释放后,就没有了,即使其他对象用到了它,也无法留住它,只会crash。但是,即使被释放,指针却还在,成为了野指针,如果新的对象被分配到了这个内存地址上,又会crash,所以一般只用来声明基本的数据类型,因为它们会被分配到栈上,而栈会由系统自动处理,不会造成野指针

weak和assign的区别

  • 1.修饰变量类型的区别
    • weak只可以修饰对象。如果修饰基本数据类型,编译器会报错-Property with ‘weak’ attribute must be of object type。
    • assign可修饰对象,和基本数据类型。当需要修饰对象类型时,MRC时代使用unsafe_unretained。当然,unsafe_unretained也可能产生野指针,所以它名字是unsafe_。
  • 2.是否产生野指针的区别
    • weak不会产生野指针问题。因为weak修饰的对象释放后(引用计数器值为0),指针会自动被置nil,之后再向该对象发消息也不会崩溃。 weak是安全的。
    • assign如果修饰对象,会产生野指针问题;如果修饰基本数据类型则是安全的。修饰的对象释放后,指针不会自动被置空,此时向对象发消息会崩溃。

8、autorelease对象在什么时机会被调用release

AutoreleasePool的实现原理

  • 1、每个AutoreleasePoolPage对象占用4096字节内存,除了用来存放它内部的成员变量,剩下的空间用来存放autorelease对象的地址
  • 2、所有的AutoreleasePoolPage对象通过双向链表的形式连接在一起
  • 3、调用push方法会将一个POOL_BOUNDARY入栈,并且返回其存放的内存地址
  • 4、调用pop方法时传入一个POOL_BOUNDARY的内存地址,会从最后一个入栈的对象开始发送release消息,直到遇到这个POOL_BOUNDARY
  • 5、id *next指向了下一个能存放autorelease对象地址的区域
  • 6、AutoreleasePoolPage空间被占满时,会以链表的形式新建链接一个AutoreleasePoolPage对象,然后将新的autorelease对象的地址存在child指针

autoreleased释放时机

  • 1、iOS在主线程的Runloop中注册了2个Observer
  • 2、第1个Observer监听了kCFRunLoopEntry事件,会调用objc_autoreleasePoolPush()
  • 3、第2个Observer监听了kCFRunLoopBeforeWaiting事件,会调用objc_autoreleasePoolPop()、objc_autoreleasePoolPush() 监听了kCFRunLoopBeforeExit事件,会调用objc_autoreleasePoolPop()

autoreleased 对象是在 runloop 的即将进入休眠时进行释放的

子线程默认不会开启 Runloop,那出现 Autorelease 对象如何处理?不手动处理会内存泄漏吗

在子线程你创建了 Pool 的话,产生的 Autorelease 对象就会交给 pool 去管理。如果你没有创建 Pool ,但是产生了 Autorelease 对象,就会调用 autoreleaseNoPage 方法。在这个方法中,会自动帮你创建一个 hotpage(hotPage 可以理解为当前正在使用的 AutoreleasePoolPage,如果你还是不理解,可以先看看 Autoreleasepool 的源代码,再来看这个问题 ),并调用 page->add(obj)将对象添加到 AutoreleasePoolPage 的栈中,也就是说你不进行手动的内存管理,也不会内存泄漏啦

以下代码会有什么问题吗?

Person *p = [[[[Person alloc]init] autorelease] autorelease];
复制代码

以上代码会导致程序崩溃,连续调用2次 autorelease,对象被加入自动释放吃2次, 在释放时候,此一次释放对象就已经销毁了,第二次再释放就会崩溃;

9、atomic 一定是线程安全的吗

nonatomic内部实现

//mrc 环境
//implementation
@synthesize name = _name;

//set
-(void)setName:(NSString *)name
{
  if(_name != name)
  {
    [_name release];
    _name = [name retain];
  }
}
//get
-(NSString *)name
{
  return _name;
}
复制代码

atomic内部实现
系统生成的getter/setter方法会进行加锁操作,注意:这个锁仅仅保证了getter和setter存取方法的线程安全.

//mrc 环境
//implementation
@synthesize name = _name;

//set
-(void)setName:(NSString *)name
{
  //同步代码块
  @synchronized (self) {

    if(_name != name)
    {
      [_name release];
      _name = [name retain];
    }
  }
}
//get
-(NSString *)name
{
  NSString *name = nil;
  //同步代码块
  @synchronized (self) {

    name = [[_name retain] autorelease];
  }
  return name;
}
复制代码

很多文章谈到atomic和nonatomic的区别时,都说atomic是线程安全,其实这个说法是不准确的.
atomic只是对属性的getter/setter方法进行了加锁操作,这种安全仅仅是set/get 的读写安全,并非真正意义上的线程安全,因为线程安全还有读写之外的其他操作(比如:如果当一个线程正在get或set时,又有另一个线程同时在进行release操作,可能会直接crash)

10、方法里有局部对象, 出了方法后会立即释放吗

  • 如果是普通的 局部对象 会立即释放
  • 如果是放在了 autoreleasePool 自动释放池,在 runloop 迭代结束的时候释放

11、@property 的本质是什么

@property的本质就是ivar + getter + setter

12、dealloc原理

主要步骤

  • 1、首先判断对象是不是isTaggedPointer,如果是TaggedPointer那么没有采用引用计数技术,所以直接return
  • 2、不是TaggedPointer
  • 1、清除成员变量
  • 2、将指向当前对象的弱引用指针置为nil

13、alloc和init都做了什么

我们发现alloc做了三件事

  1. cls->instanceSize(extraBytes);计算需要开辟的内存空间大小。

  2. calloc根据size申请内存,然后返回该内存地址的指针。

  3. obj->initInstanceIsa将 cls类 与 obj指针(即isa) 关联。


init方法用于初始化类的实例变量。注意,你正将init消息发送给myFraction(实例)。也就是说,要在这里初始化一个特殊的Fraction(类)对象,因此它没有发送给类,而是发送给了类的一个实例。init方法也可以返回一个值,即被初始化的对象

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享