「这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战」。
朋友提供了几个面试题,本菜鸡尝试回答一下,大多是自己写的,一部分是整理别的大佬的,可能有不对的地方,如果出现了错误或者偏差,希望大佬及时指正,真心!拜托。(之后可能需要面试)
1、iOS 的内存管理原理说一下。
iOS 内存管理机制的原理是引用计数,当这块内存被创建后,它的引用计数 +1,当有对象获取其所有权时,引用计数会再次 +1,每有一个对象释放当前内存区域的所有权,引用计数就会 -1,当释放完当前内存区域的全部所有权,当前内存就会被释放。
在类结构中第一块 8 字节内存区域是 isa
,如果是 noTaggedPoint
,isa
联合体中 extra_rc
存储了引用计数的值,如果大于引用计数的值,isa
联合体中 has_sidetable_rc
就会被置为 1
,并且创建一个 sidetalb
哈希表,将一半的引用计数的值存入。(存入一般的值是方便进行操作,因为从散列表中取值会进行加锁和解锁操作,没有直接从 isa
中获取性能好)。
2、你开发中遇到的内存泄漏吗?是怎么解决的?
一般开发中遇到内存泄漏:
-
Core 系列框架和
Foundation
、UIKit
框架通过__bridge
桥接的话, 忘记free
或者Release
的时候。 -
循环引用问题:定时器
tagert
的循环引用,block
的循环引用,delegate
的循环引用,父类和子类之间的循环引用。其实都可以归为两个对象双向强引用而造成循环引用,导致引用计数不能归 0,内存区域不能释放。
解决方式:
-
使用
Analyze
静态分析和Instrument
工具库里的Leaks
或者使用第三方库MLeaksFinder
等寻找内存泄漏的点。 -
桥接时忘记
free
或者Release
添加上即可。 -
定时器循环引用使用:
NSProxy
进行消息转发解决。 -
delegate
和父类与子类之间循环引用:查找@property
修饰词是否是weak
。 -
两个对象双向强引用:使用
weak
或者手动使一方置nil
强制打破即可。
3、block 循环引用的解决方法有几种?
大方面说就两种:
-
事前阻止:使用
weak
来解决。 -
事后补救:使用完将
block = nil
强制打破循环引用。 -
还有一种情况比较危险,不建议使用,使用
__block
修饰block
内部使用的对象,在block
中置为nil
。危险的原因在于,如果不调用block
就一定会循环引用。
4、block 中使用 self 都会造成内存泄漏吗? 为什么?
不是所有的 block
中使用 self
都会造成内存。
开发中使用 block
的时候,哪怕变量超出作用域了,都可以在 block
正确的获取到值。按照作用域原理,作用域内申请的变量,超出作用域就会被释放,但是如果能获取到正确的值,那意味着 block
会保存作用域内申请的变量。
typedef void (^blk_t)();
@interface TestClass ()
@property (nonatomic, copy) blk_t blk;
@property (nonatomic, strong) id obj;
@end
@implementation TestClass
- (instancetype)init
{
self = [super init];
_obj = [NSObject alloc];
if (self) {
_blk = ^{
/// 标注点 1
NSLog(@"_obj = %@",self.obj);
};
}
return self;
}
@end
Clang
一个捕获作用域值的 block
:
struct __TestClass__init_block_impl_0 {
struct __block_impl impl;
struct __TestClass__init_block_desc_0* Desc;
TestClass *self;
__TestClass__init_block_impl_0(void *fp, struct __TestClass__init_block_desc_0 *desc, TestClass *_self, int flags=0) : self(_self) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};
static void __TestClass__init_block_func_0(struct __TestClass__init_block_impl_0 *__cself)
{
// 这里
TestClass *self = __cself->self; // bound by copy
//...
}
这么看就非常清晰了,简述如下:
TestClass
的对象持有的了 blk_t blk
,Clang
后发现 _block_impl_0
中捕获了 TestClass
对象即 self
,在 block
的保存的方法 _block_func_0
,使用 __cself->self
取出了 TestClass
对象,也就是 self->blk->self
就造成了循环引用。
所以只要持有 block
的对象不是 _block_func_0
捕获的对象,就可以在 block
中使用 self
,比如 UIView
类对象使用的动画 block
。
5、为什么使用 weakself 可以解决循环引用问题?
weakself
其实是生成一个 self
的弱引用对象存入 SideTables
的 weak_table
中,当 self
对象销毁时候,对象会调用 dealloc
,该方法中会调用了 objc_destructInstance
时候检查散列表清理,将持有这个对象的 referent
指针置为 nil
,此时 weak
对象就被释放了。
简述生成和保存弱引用对象的过程如下:
-
传入弱引用对象地址的指针和原始对象的指针;
-
如果旧值存在,从弱引用对象地址的指针取出旧值对象指针,用旧值对象取出上一次旧值的散列表;
-
如果有新值,用新对象取出的新对象的散列表;
-
如果有旧值,但是比较上一次取出的旧值和现在再取了一次的旧值不相等,可能由于多线程问题,就跳转上一次取旧值的地方重来;
-
清理旧的弱引用:如果相等,说明没有线程问题,有旧值,找到保存旧值所有弱引用的结构体,从结构体中找到存储的弱引用对象指针的指针地址,把当前位置置为 nil,清理上一次的弱引用,判断 old_size > 1024 并且全局散列表下的弱引用对象的个数 num_entries < old_size / 16 就进行收缩表,防止内存浪费(清理值结束);
-
插入新的弱引用:从用新值地址取出的新散列表中找到保存新值所有弱引用的结构体,如果找到了就拼接上到数组后面,如果没有找到的话,就新建一个弱引用的结构体是,然后放入弱引用对象指针的指针地址,判断表是否已经装载了 3/4 或者以上,如果大于了就新建表大小为原来的一倍,将原始值拷贝到新表中(保存新的弱引用结束)。
6、使用过 NSProxy 吗?使用的场景是什么?
NSProxy
和 NSObject
是 iOS 的两个根基类,但是与后者不同的是前者没有 init
方法,没有实现全部的 NSObjectProtcol
协议。
NSProxy
一般用于对象或者框架解耦,AOP切面编程,特殊场景下可以用来避免循环引用(定时器)。
NSProxy
是虚基类,内部一般通过消息转发实现解耦,其实也就是使用了 iOS 运行时不检查对象类型的方式实现一些功能。
比如:
-
对象解耦:继承
NSProxy
的子类内部保存转发对象target
后返回子类对象,业务层可以强转成target
的类型,使用返回的proxy
对象调用方法target
的方法,然后NSProxy
的子类内部进行消息转发,以弱化对象和对象方法调用的耦合。 -
框架解耦:继承
NSProxy
抽象类,实现自己定义的转发机制,将网络接口层的各个方法的实现与声明分离。创建协议Protocol
,在NSProxy
的子类中注册Protocol
和需要转发的对象,就可以直接使用[HttpProxy sharedInstance] getUserWithID:@100];
来调用UserProfileHttpManager
中的getUserWithID
方法了。 -
AOP 切面编程:常见的是基于
runtime
的method-swizzling
,也可以使用NSProxy
的消息转发实现,Aspects
框架就是基于这个理念。
7、有一个对象 obj 调用方法 sayHello 的时候都发生了什么?
这个问题其实就想问 iOS 发消息的过程。
-
调用汇编
objc_msgSend
,找到isa
后isa.bit & ISA_MASK
取出类对象, 偏移 16 个字节找到类的cache_t
(第一个8字节是isa
, 第二个8个字节是super_class
); -
哈希查找
cache_t
是否有方法缓存(找方法其实就是比字符串,方法名唯一),汇编中找了2次,因为考虑了多线程写入缓存延迟问题;如果在缓存中找到了就直接调用,没找到(可能没调用过也可能没有当前方法)就开始慢速消息转发。 -
调用
lookupImpOrForward
到类中开始找,再次查找缓存,防止一些其他问题造成之前在汇编中没有找到(容错),找到了就返回。没找到的话使用二分法到类中找实例方法,沿着类的父类一直找,直到super_class == nil
,找到了插入缓存后就返回。 -
没找到的话设置
imp = imp_forward
,准备消息转发。 -
消息转发:调用
resolveInstanceMethod/resolveClassMethod
方法,如果没实现,汇编调用_objc_msgForward_impcache
再调用forwardingTargetForSelector
,还没有实现的话,调用methodSignatureForSelector
和forwardInvocation
,以上都没有实现会调用doesNotRecognizeSelector
打印崩溃信息。
8、什么是哈希?为什么用哈希算法?
哈希: 原理就是把任意长度的输入,通过 Hash 算法变成固定长度的输出。
使用哈希算法的场景:
-
信息加密:设计原则上 hash 不可以反向推导出原始的数据;
-
数据校验:输入数据的微小变化会得到完全不同的 hash 值;
-
快速查值:能将任意数据散列后映射到有限的空间上;
-
负载均衡:服务端可以用 hash 来做负载均衡。
9、什么是哈希冲突?怎么解决哈希冲突?
哈希冲突:由于哈希算法被计算的数据是无限的,而计算后的结果范围有限,因此总会存在不同的数据经过计算后得到的值相同,这就是哈希冲突。
解决哈希冲突:
-
开放寻址法:从发生冲突的那个单元起,按照一定的次序,从哈希表中找到一个空闲的单元。然后把发生冲突的元素存入到该单元的一种方法。开放定址法的缺点在于删除元素的时候不能真的删除,否则会引起查找错误,只能做一个特殊标记。只到有下个元素插入才能真正删除该元素。
-
链地址法:链接地址法的思路是将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。链表法适用于经常进行插入和删除的情况。
10、说一下你对 copy、mutableCopy 和 浅拷贝、深拷贝的理解。
-
浅拷贝:只复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存。 iOS 内存引用计数 +1。
-
深拷贝: 会另外创造一个一模一样的对象,新对象跟原对象不共享内存,修改新对象不会改到原对象。
对于数组这类来说:分为 不完全深拷贝 和 完全深拷贝,区分条件为:是否复制数组内的每一个对象。
-
copy
:生成一个不可变副本。 -
mutableCopy
:生成一个可变副本。
其实 copy
、mutableCopy
和浅拷贝、深拷贝没有什么关系。只要符合深拷贝的条件,使用 copy
也可以是深拷贝。比如:NSMutableString
对象调用 copy
,会生成一个和原始地址不同的对象,这就是深拷贝。
11、说一下你们工程内单例使用的场景,单例的优缺点是什么?
工程内单例使用的场景:
-
数据库插入和读写维持一个队列的时候
-
部分用户信息内存持有的时候,如:userSign, userId等。
-
网络请求网关的设计(SSL证书配置)
优点:
- 一个类只被实例化一次,提供了对唯一实例的受控访问
- 由于在系统内存中只存在一个对象,因此可以节约系统资源,当需要频繁创建和销毁的对象时单例模式无疑可以提高系统的性能
- 允许可变数目的实例
- 避免对共享资源的多重占用
缺点:
- 一个类只有一个对象,可能造成责任过重,在一定程度上违背了 “单一职责原则”
- 由于单利模式中没有抽象层,因此单例类的扩展有很大的困难。
- 滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出
12、App 启动流程是什么?让你优化启动速度,你都从哪里入手?
App 冷启动流程:
-
程序入口
__dyld_start
-
获取主程序的架构
-
保存主程序
Mach-O
的头 -
保存主程序的
ASLR
-
检测进程环境变量
-
检查共享缓存库是否禁用,iOS不能禁用
-
加载共享缓存(
UIKIT,Fundation
) -
为主要可执行文件实例化
ImageLoader
-
实例化主程序,从
Mach - O
头部 读取CPU
架构等,判断兼容性 -
根据
DYLD_INSERT_LIBRARIES
的值判断是否允许加载插入动态库 -
开始链接主程序
-
链接动态库和第三方库,进行符号绑定
-
链接插入库
-
weak
符号绑定,weakbind
是比其他后绑定的,其他的绑定是在上方link
完成时就绑定好了 -
notifySingle
回调,调用 runtime 方法 map_images,处理由 dyld 映射的 image,计算 class 数量,根据总数调整各种表的大小;调用 runtime 方法load_images
,对类初始化。 -
调用
doInitialization
一些初始化,doModInitFunctions
调用全局 C++ 方法,可以做一些逆向插入操作。 -
调用
libSystem_initializer
-
调用
libdispatch_init
-
调用
_os_object_init
-
调用
main()
App 程序执行流程:
-
执行
UApplicationMain
函数 -
创建
UIApplication
对象 -
创建
UIApplicationDelegate
对象并复制 -
读取配置文件
info.plist
,设置程序启动的一些属性 -
创建应用程序的
Main Runloop
循环 -
UApplicationDelegate
对象开始处理监听事件 -
程序启动之后,首先调用
application.didFinishLaunchingWithOptions:
方法 -
如果
info.plist
中配置了启动的story Board
的文件名,则加载storyboard
文件 -
如果没有配置,则根据代码创建
UIWindow
->rootViewController
-> 显示
优化启动速度
一般分成 Pre-Main
和 Main
之后。
Pre-Main
:
-
动态库加载越多,启动越慢
-
objc
类越多,启动的时候load_images
数量越多,启动越慢 -
constructor
函数越多,启动越慢 -
C++ 静态对象越多,启动越慢
-
+load()
越多,启动越慢
Main
之后:
-
didFinishLaunchingWithOptions
的耗时 -
首屏
UI
的绘制
解决办法
-
减少动态库数量的加载,静态库合并。
-
删除不需要的类(我记得的类加载过程中能识别到用不到的类,我回头查一下)
-
合并类的分类(一般懒加载分类会在编译期完成)
-
尽可能少的使用
+load()
方法,如果分类实现了该方法,那么就不会在编译期编译到类中,并且会在类的加载过程中,迫使主类先初始化,主类初始化的时候就会把当前继承链中的所有父类初始化,非常耗费时间 -
减少
didFinishLaunchingWithOptions
的框架初始化,有条件的可以在即将使用的时候再初始化。 -
压缩首屏要显示的图片,可以在子线程解压缩图片,减少主线程时间。
13、说一下你对 TCP/IP、UDP、Http、Https 的理解。
在说这个协议之前,需要知道 OSI(开放式系统互联模型) 的七个分层:
OSI 的 七个分层
-
应用层(Http、Https、FTP等)
-
表示层(XDR、AFP等)
-
会话层(SSL等)
-
数据传输层(TCP、UDP等)
-
网络层(IP)
-
数据链路层(以太网等)
-
物理层(光纤等) TCP/IP 是传输协议簇,TCP、UDP 属于数据传输层,主要解决数据如何在网络中传输,而 HTTP 是应用层协议,主要解决如何包装数据,IP 协议的基本功能是提供数据传输、数据包编止、数据包路由,分段等。通过 ip 编止约定,可以成功的将数据通过路由传输到正确的网络或者子网。
TCP 和 UDP 的区别:
- TCP 需要链接(3次握手,4次挥手),UDP 不需要
- TCP 可靠,UDP不可靠
- TCP 传输慢,UDP 传输快
- TCP 一般用于传输大量数据且需要使命必达的场景(必须送达的 FTP 文件传输),UDP 用于少量数据传输,成不成功都可以的情况(如:获取 DNS,ping 地址)。
Http
超文本传输协议(HTTP)是基于 TCP 的应用层协议。
1、报文分 2 部分说明:请求报文 和 响应报文。
请求报文:
- Host:指明了该对象所在的主机(www.baidu.com)
- Connection:决定当前的事务完成后,是否会关闭网络连接(keep-alive、 close)
- Content-Type:内容类型,用于定义网络文件的类型和网页的编码,决定文件接收方将以什么形式、什么编码读取这个文件(text/html;charset=ISO-8859-1)
- User-agent:首部包含了一个特征字符串,用来让网络协议的对端来识别发起请求的用户代理软件的应用类型、操作系统、软件开发商以及版本号
- Accpect-lauguage:需要返回的语言类型
响应报文:
- HTTP/1.1 200 OK:Http 版本号、响应状态码
- Connection:header (close) 告诉客户,发送完报文后将关闭 TCP 连接。
- Date:服务端向客户端返回数据的时间
- Content-Length:header 指示了被发送对象中的字节数
2、Http 请求方式。
GET、POST、PUT、DELETE、HEAD
Get 和 Post 的区别:
- Get 和 Post 都属于 TCP 链接无本质区别,只是使用时进行了区分;
- Get 属于明文请求,所有请求参数都会拼接到 URL 后面,而 Post 会放到 Body 请求体里面,这么看相对于 Get,Post 相对安全,但是抓包下没什么区别;
- Get 参数长度限制为 2048个字符,Post 没限制;
- Get 请求可以被服务器缓存,可以减轻服务器负担,Post 不会;
Https
HTTPS 协议 = HTTP 协议 + SSL/TLS 协议,即 Https 是安全的 Http 协议。
- SSL 的全称是 Secure Sockets Layer,即安全套接层协议,是为网络通信提供安全及数及数据完整性的一种安 全协议。
- TLS 的全称是 Transport Layer Security,即安全传输层协议。
Https 的连接建立流程:
-
客户端访问 Https 链接:客户端会把安全协议版本号、客户端支持的加密算法列表、随机数 C 发给服务端;
-
服务端发送证书给客户端:服务端接收秘钥算法后,和自己所支持的加密算法进行比对,如果没有支持的,就断开链接。否则服务端会在接收的算法列表中,选择一种对称算法(AES)、公钥算法(RSA)和 MAC(Message Authentication Code,消息认证码算法) 算法,以及数组证书、随机数 S 发给客户端;
-
客户端验证服务端证书:客户端使用 CA 公钥对证书的签名解密,得到 hash 值,再与证书的 hash 值进行比较是否一致,从而判断证书的有效性,如果验证失败了,Https 传输就无法继续;
-
客户端组装会话密钥:如果公钥合格,那么客户端会用服务器公钥来生成一个前主秘钥(Pre-Master Secret,PMS),并通过该前主秘钥和随机数 C、S 来组装成会话秘钥
-
客户端将前主密钥加密发给服务器:是通过服务端的公钥来对前主秘钥进行非对称加密,发送给服务端;
-
服务端通过私钥解密得到客户端传输的密钥
-
服务端组装会话秘钥:服务端通过前主秘钥和随机数 C、S 来组装会话秘钥。 至此,服务端和客户端都已经知道了用于此次会话的主密钥;
-
数据传输:客户端收到服务器发来的密文,客户端使用自己的密钥进行对称解密。
14、说一下你常使用的第三方框架的原理。
-
源码阅读:AFNetworking 这个家伙写的很全面。