在开发过程中,常见到这样的写法:[NSDictionary dictionaryWithDictionary:otherDict]
、[otherDict copy]
。在很多情况下,我们甚至不用去考虑两种方法的异同,随机地选择用哪个方法。但二者又确确实实地有着些许不同。
-copy
和 +dictionaryWithDictionary
首先我们可以知道一个是实例方法一个是类方法,这是最直观的区别。然后,如果otherDict为nil的话,既然copy是一个实例方法,那么其返回的值必然是nil。而对于类方法的+dictionaryWithDictionary
来说,其返回值为空的字典(@[])。
在内存管理方面,-copy
返回的是retain +1的对象,而+dictionaryWithDictionary
返回的是autoreleased的对象。在MRC环境下,前者需要我们手动去释放,而后者不用。当然在ARC环境下,这其中的区别就显得不是那么重要了。
然后,对于otherDict为NSDictionary和NSMutableDictionary的情况,-copy
依旧有话说。如果otherDict为可变字典的话,那么-copy
将返回一个retainCount = 1的复制的对象。对于不可变字典情况,因为其不可变性,将返回当前的otherDict,并且retainCount + 1。
很多情况下,两者混用并没有什么大问题,甚至于如果仅凭喜好来说,有人倾向于更短的-copy
,相当于当成一个语法糖来使用。
是的,大部分情况下没什么差别,所以大部分之外情况引出了血案。
AFNetworking引发的血案
闲来无事,修个Bug玩吧。于是AFNetworking的一个问题成功引起了我的注意,最终问题代码定位在这儿:
其引起的崩溃关键Log如下:
Fatal Exception: NSInvalidArgumentException
*** -[__NSPlaceholderDictionary initWithObjects:forKeys:count:]: attempt to insert nil object from objects[6]
0 CoreFoundation 0x183976db0 __exceptionPreprocess
1 libobjc.A.dylib 0x182fdbf80 objc_exception_throw
2 CoreFoundation 0x18385f77c -[__NSPlaceholderDictionary initWithObjects:forKeys:count:]
3 CoreFoundation 0x18389f010 -[NSDictionary initWithDictionary:copyItems:]
4 CoreFoundation 0x18389ee2c +[NSDictionary dictionaryWithDictionary:]
5 TaLiCaiCommunity 0x1002b9d80 -[AFHTTPRequestSerializer HTTPRequestHeaders] (AFURLRequestSerialization.m:309)
6 TaLiCaiCommunity 0x1002bae0c -[AFHTTPRequestSerializer requestBySerializingRequest:withParameters:error:] (AFURLRequestSerialization.m:474)
7 TaLiCaiCommunity 0x1002bec10 -[AFJSONRequestSerializer requestBySerializingRequest:withParameters:error:] (AFURLRequestSerialization.m:1244)
显然是插入了空值,那么我们可以知道这又是一个由高并发引起的血案。当然抛去中间蛋疼的排查,可以知道开发者理所当然地用上了单例,是的,AFHTTPSessionManager的单例。我可以想到开发者想到单例的时候对自己的骄傲和崇拜。但一时的壮举可能依然会无心插柳酿成悲剧。一旦用上单例,那么所有的变量都将为无数的并发请求所使用……和操作。并发的种种问题便接踵而至。
可以想象,当一个请求正在修改self.mutableHTTPRequestHeaders时,另一个请求调用了-HTTPRequestHeaders
方法,这时进入+dictionaryWithDictionary
便华华丽丽地崩溃了,并书写下了上述的死亡讯息。
很经典的并发问题,很经典的解法——dispatch_barrier_async可解。事实上,AFNetworking中也利用过此方法规避过此类问题。
但,如果这么轻易地就解决了,那还有后来的恩怨情愁么?
我们来构造个崩溃
没有一点点防备,就这么构造出了如下的代码,很有效地崩溃了:
一个在主线程不断复制,一个在其他线程不断设置和移除。可以模拟出大并发情况下,是必崩的。但机(dan)智(teng)如我,试验了下注释中的[_dict copy]
,在此情况下非常稳定地运行到了最后。哎哟不错,你成功引起我的注意了。
从上面构造的代码可知,_dict的count是从0-1不断变化的(假设_dict初始为空)。考虑到如下的崩溃原因,我们可以猜测是因为removeObject:forKey:
,被移除掉的对象被释放了,从而变成nil导致出错。
*** -[__NSPlaceholderDictionary initWithObjects:forKeys:count:]: attempt to insert nil object from objects[0]
虽然问题原因根据Log可以猜出八九不离十,但为什么copy就相安无事呢?我们当然可以继续猜测其并不检查nil。是不是真的是这样,我们需要继续分析。但上面所说的区别已经无法解释这种不同了,我们只好祭出大杀器——翠花,上汇编!
Assembly
汇编可谓大杀器也!苹果不会什么都告诉你,所以很多实现都需要通过分析汇编代码来获取细节。虽然自从大学学完8086汇编就很少见面了,再见面已是时过境迁,这时我们面对的是x86_64(模拟器中)了(其带来的副作用就是一开始搞混各种指令和其格式,呜呼哀哉!)。由于代码太长,下面的分析当中不会贴上全篇代码,而且由于分次获取的代码,可能不同方法间跳转的地址可能有所出入,所幸我们只需检查同一方法的汇编代码即可。如果需要检查,则可以选中Xcode中Debug - Debug Workflow - Always show disassembly,打断点后通过control + F7进行逐步调试。
首先,我们比较+dictionaryWithDictionary
和-copy
都调用了什么。
+dictionaryWithDictionary
可以知道,+dictionaryWithDictionary
主要调用了-[NSDictionary initWithDictionary:copyItems:]
方法,我们就针对这个方法继续查看。此方法调用到的方法大致有如下几个:
isNSDictionary__
count
getObjects:andKeys:count:
copyWithZone:
initWithObjects:forKeys:count:
release
对于1,因为上面的操作始终为Dictionary,那么可以跳过。对于2,且消息对象为可变NSMutableDictionary为如下代码:
可以看到,并无什么特殊代码,所以此时包含在%rax中返回的count将为此时Dictionary的个数,即在上面构造的崩溃代码中,count可能为0或者1。此时并不会崩溃,当然会导致后续崩溃的情况是count = 1,我们可以继续看看3。
对于3,也没什么可疑的代码,只有一个判断count是否是否过大的代码,也可以暂且跳过。其提示内容如下:
同样对于4,也是相对简单的几行代码,(代码连贴都不贴地)华丽丽地跳过。
接下来终于来到5了,基本就可以肯定在这儿了,毕竟release大概也没做什么出格的事。我们搜索下关键词,可以看到的确有对应的提示信息:
我们搜索0x10d3241dc可以知道有两个地方会跳转至此:
可以看到在0x10d324130和0x10d324160处分别进行了判空操作,跳转显示错误信息。前者判断了首地址是否为空,后者循环判断count内的元素是否为空。判空操作完成后,调用+[__NSDictionaryI __new:::::]
方法。
罪魁祸首就在这儿了,但为什么copy不存在此情况呢?咱继续走起~
-copy
我们继续分析-copy
情况,首先其主要调用了-[__NSDictionaryM copyWithZone:]
。现在主要还是针对此方法进行分析。
同样的,可以看到分别调用了如下方法:
count
getObjects:andKeys:count:
__new:::::
前两者不必再分析,第3因为跟+dictionaryWithDictionary
调用的同样的方法,所以也不去分析。并且在getObjects:andKeys:count:
和__new:::::
之间,可以看到并未掺杂其它东西:
所以整体来说-copy
无判断Object为空的流程。这也是-copy
不崩溃的原因。
但问题又来了,同样遇上空值,也同样调用了+[__NSDictionaryI __new:::::]
。但为什么+[__NSDictionaryI __new:::::]
能不出问题?
# +[__NSDictionaryI __new:::::]
首先new:::::
这个方法名可能看起来比较奇怪,但事实上Objective-C中完全支持这样的写法。当前这不在我们讨论范围之内,我们现在只需要知道此方法显式传入5个参数(还有两个隐式传入的self和_cmd)。由外部调用可知至少传入getObjects:andKeys:count:中所包含的objects、keys、count,一个应为标志是否copy的布尔值(当前始终为NO),和一个可能标志是否避免retain的参数(当前始终为NO),后二者可以由具体汇编代码推断出来。
可以看到此方法的主要分为三个部分:
- 根据Capacity,通过某种策略分配空间;
- 根据objects、keys、count等进行初始化(如果需要copy则copy);
- 返回。
分配策略方面因为传入的count始终是个合理的值,所以分配空间并不会出现什么问题。唯一需要警惕的是2中的初始化过程。
对于objects和count,可能的情况有objects.count == count,objects.count < count(其中一个objects被释放)。前者是正常情况,后者是可能引起问题的情况。后面假设其中一个object被释放进行分析。
还是摘取最后的一段汇编代码(此处为一段循环后半部分):
从0x10d2e19c6和0x10d2e19d6可以分别看出,其实key或者object此时是否为空并不影响,会照常将0存储在新分配的对象中。这就是为什么即使为空,这个方法也不会崩溃的原因。如果object为空的话,此时获取到的value为nil。而且由于不阻碍继续循环,此时依旧可以复制其他元素进去,也就是碰到nil不会中途停下。
另外可以看到,虽然有这么别扭的方法名(即object放在前面,key放在后面):dictionaryWithObjectsAndKeys
,但事实上key和object在内存中的分布依然是key在前object在后。
对于nil的想法,我们我们可以再改造下上述的代码进行验证:
此时当count == 1的时候,还是有可能出现输出is Empty
的情况。这就验证了上面的想法。
至于后面的nil不影响其他元素的想法,也可以通过类似的方法进行验证,在此不赘述。
结语
从上面的分析来看,如果直接使用copy,那么虽然会造成高并发时获取到的部分value为nil,但其并不会对其他未进行操作的key/value有影响。而且此法不需要进行加锁,避免了一些开销。如果仅仅针对避免崩溃的情况,直接修改为copy是可行的。但如果需要保证线程安全,那么就需要进行传统的加锁操作,但此时占用较多资源。考虑到上面的情况是为误用单例,那么为了避免崩溃,可以直接使用copy。
当然以上仅仅是在模拟器中获取x86_64汇编代码的,跟ARM中指令还是有所区别的,但我想逻辑理应是相通的,有时间再去看看是否真机中也是如此(我想是个有生之年系列)。
PS: 在准备提交一个Pull Request的时候发现最新版本已经为有问题的方法添加上了dispatch_barrier_async
、dispatch_sync
等,残念。
PS2: 事实上dispatch_barrier_async
、dispatch_sync
的方案作为此问题的patch是很好的,可以兼顾到其他情况,所以不要学我:D。
最后的最后,嗯,我只是把[NSDictionary dictionaryWithDictionary:otherDict]
改成[otherDict copy]
而已。
参考资料
NSDictionary +dictionaryWithDictionary or -copy?
What is the role of the “copy” in the ARC
x86 Assembly Language Reference Manual