- objc_autoreleaseReturnValue和objc_retainAutoreleasedReturnValue
- 内存分区和tagged Pointer
首先看看一下3段代码:
- (void)test1
{
for (int i = 0; i < 10000000; ++i) {
NSString *str = [NSString stringWithFormat:@"111111111111"];
}
}
- (void)test2
{
for (int i = 0; i < 10000000; ++i) {
NSString *str = [[NSString alloc] initWithFormat:@"111111111111"];
}
}
- (void)test3
{
NSString *str1 = [NSString stringWithFormat:@"111111111111"];
NSString *str2 =[NSString stringWithFormat:@"1"];
NSString *str3 = [[NSString alloc] initWithFormat:@"1"];
NSString *str4 = @"1";
NSString *str5 = @"111111111111";
NSLog(@"%p, %p, %p, %p, %p",str1, str2, str3, str4, str5);
/// 打印结果:0x28182ad60, 0xb04824e7f56e63bc, 0xb04824e7f56e63bc, 0x10009c0d0, 0x10009c090
}
1. objc_autoreleaseReturnValue和objc_retainAutoreleasedReturnValue
分别运行前两段代码,会发现方法test1内存会不断增加,而方法test2内存几乎没有变化。会产生这种区别的原因是initWithFormat和stringWithFormat内部实现有一点区别。
首先是实例方法创建对象:
NSString *str = [[NSString alloc] initWithFormat:@"111111111111"];
上面调用实例方法模拟转换为:
{
/*编译器的模拟代码*/
id str = objc_msgSend(NSString,@selector(alloc));
objc_msgSend(str,@selector(initWithFormat:), @"111111111111");
objc_release(str);
}
然后类方法对象:
NSString *str = [NSString stringWithFormat:@"111111111111"];
上面调用类方法模拟转换为:
{
/*编译器的模拟代码*/
id str = objc_msgSend(NSString,@selector(stringWithFormat:), @"111111111111");
objc_retainAutoreleasedReturnValue(str);
objc_release(str);
}
而NSString的stringWithFormat方法编译器实现
+ (instancetype)stringWithFormat:(NSString *)format
{
return [[NSString alloc] initWithFormat:format];
}
上面又可以模拟转换为:
+ (instancetype)stringWithFormat:(NSString *)format
{
/*编译器的模拟代码*/
id obj = objc_msgSend(NSString,@selector(alloc));
objc_msgSend(obj,@selector(initWithFormat:), @"111111111111");
return objc_autoreleaseReturnValue(obj);
}
类方法和实例方法创建方式上差别就在多了objc_retainAutoreleasedReturnValue函数和objc_autoreleasedReturnValue函数;在使用非alloc/new/copy/mutableCopy等开头生成对象的方法,都会调用这两个函数。两个函数实现大致如下:
id objc_autoreleaseReturnValue(id obj) {
if ( prepareOptimizedReturn(ReturnAtPlus1)) {
return obj;
} else {
return objc_autorelease(obj);
}
}
// Try to prepare for optimized return with the given disposition (+0 or +1).
// Returns true if the optimized path is successful.
// Otherwise the return value must be retained and/or autoreleased as usual.
static ALWAYS_INLINE bool
prepareOptimizedReturn(ReturnDisposition disposition)
{
ASSERT(getReturnDisposition() == ReturnAtPlus0);
if (callerAcceptsOptimizedReturn(__builtin_return_address(0))) {
if (disposition) setReturnDisposition(disposition);
return true;
}
return false;
}
这里的ReturnAtPlus0是一个枚举
enum ReturnDisposition : bool {
ReturnAtPlus0 = false, ReturnAtPlus1 = true
};
static ALWAYS_INLINE ReturnDisposition
getReturnDisposition()
{
return (ReturnDisposition)(uintptr_t)tls_get_direct(RETURN_DISPOSITION_KEY);
}
static ALWAYS_INLINE void
setReturnDisposition(ReturnDisposition disposition)
{
tls_set_direct(RETURN_DISPOSITION_KEY, (void*)(uintptr_t)disposition);
}
id
objc_retainAutoreleasedReturnValue(id obj)
{
if (acceptOptimizedReturn() == ReturnAtPlus1) {
return obj;
}
return objc_retain(obj);
}
static ALWAYS_INLINE ReturnDisposition
acceptOptimizedReturn()
{
ReturnDisposition disposition = getReturnDisposition();
setReturnDisposition(ReturnAtPlus0); // reset to the unoptimized state
return disposition;
}
在ARC中原本对象生成之后是要注册到autoreleasepool中。但非alloc/copy/mutableCopy/new开头的方法,使用了objc_autoreleseReturnValue函数返回注册到autorelease中的对象。它会检查使用该函数的方法或函数调用方执行命令列表,如果紧接着调用了方法或函数后紧接着调用objc_retainAutoreleasedReturnValue()函数,两者成对出现,编译器会做优化,使函数最优化程序运行,不将返回的对象注册到autoreleasePool中,会先存储在TLS(Thread Local Storage)中, 然后外部接收方调用objc_retainAutoreleasedReturnValue时, 发现TLS中正好存了这个对象便直接返回这个对象,最后直接传递到方法或函数的调用方,省略注册到释放池的过程,达到了即使对象不注册到autoreleasepool中,也可以返回拿到相应的对象。
结论
调用类方法stringWithFormat会出现内存暴增的现象,正是由于stringWithFormat会调用这两个函数,并在内部对创建的字符串做一次autorelease处理,这就导致了对象的延迟释放,因为这里有个for循环,那么autoreleasepool会等到runloop的当前循环结束后才会对释放池中的每个对象发送release消息,而runloop的当前循环结束的前提是要等for循环执行完,所以for循环内创建的对象就会在for循环执行完之前一直存在在内存中,导致暴增。
而调用实例方法initWithFormat不会出现内存暴增的现象,是因为创建对象是在ARC模式中,for的单次循环结束后,当前作用域已结束,就会release一次并被释放(MRC下手动管理)。
如果要解决stringWithFormat内存暴增现象,可以将代码手动添加@autoreleasepool
中,这样每次循环结束都会release一次并自动释放。
for (int i = 0; i < 10000000; ++i) {
@autoreleasepool {
NSString *str = [NSString stringWithFormat:@"111111111111"];
}
}
2. 内存分区和Tagged Pointer
最上面第三段代码中5个字符串打印的地址为什么会有如此大的差异呢?
NSString *str1 = [NSString stringWithFormat:@"111111111111"];
NSString *str2 =[NSString stringWithFormat:@"1"];
NSString *str3 = [[NSString alloc] initWithFormat:@"1"];
NSString *str4 = @"1";
NSString *str5 = @"111111111111";
打印结果:0x28182ad60, 0xb04824e7f56e63bc, 0xb04824e7f56e63bc, 0x10009c0d0, 0x10009c090
先说结论,后面再讲分区和tagged Pointer
str1是在堆区;占用内存较大的字符串、且通过类生成的,不能再编译阶段确定,会放到堆区中,如果重复使用类初始化多个相同的字符串,但地址不一定相同。
str2和str3是tagged Pointer;占用内存较小的字符串、且通过类生成的,会用tagged Pointer技术存储,具体可以看下面。
str4和str5是在常量区;在使用字面量声明字符串的时候,在编译阶段就已经确定,字符串是放在常量区的,所以相同变量无论声明多少都是引用常量区里面的同一个字符串。
2.1 内存分区
内存的布局如下:
- bss段( bss segment、 全局区)
bss段通常是指用来存放程序中未初始化的全局变量和静态变量的一块内存区域。
通常来说如果不初始化全局变量和静态变量,编译器也会对它们进行一个隐式初始化(直接赋值就是显示初始化),赋给它们一个缺省值,是我们这里所说的未初始化。
BSS段在程序执行之前会清0,所以未初始化的全局变量(静态变量)已经是0了。所以这种情况还是存放在BSS段,一旦初始化就会从BSS段中回收掉,转存到data段(数据段)中。
bss区-Block Started by Symbol(未初始化数据段):并不给该段的数据分配空间,仅仅是记录了数据所需空间的大小。 - 数据段(data segment、常量区)
数据段分为只读数据段(常量区) 和 读写数据段
通常是指用来存放程序中已经初始化的全局变量和静态变量的一块内存区域。数据段属于静态内存分配,可以分为只读数据段和读写数据段。字符串常量等,是放在只读数据段中,结束程序时才会被收回。 - 代码段(code segment/text segment、代码区)
通常是指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读, 某些架构也允许代码段为可写,即允许修改程序。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等,这些常量放在只读数据段(data segment)中,也有叫做常量区的说法。 - 堆(heap)
堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张); 当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)
堆向高地址扩展的数据结构,是不连续的内存区域。程序员负责在何时释放内存(如用free或delete),在iOS的ARC程序中,系统自动管理计数器,计数器为0的时候,在当次的runloop结束后,释放掉内存。堆中的所有东西都是匿名的,这样不能按名字访问,而只能通过指针访问。
对于堆来讲,频繁的new/delete势必会造成内存空间的不连续性,从而造成大量的碎片 ,使程序效率降低。 - 栈 (stack)
栈又称堆栈, 是用户存放程序临时创建的局部变量,也就是说我们函数括弧“{}” 中定义的变量(但不包括static声明的变量,static意味着在数据段中存放变量)。除此以外, 在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值 也会被存放回栈中。由于栈的后进先出特点,所以 栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。
指针都存在栈区,用于指向分配在堆区的内存的地址(待验证)。
2.2 Tagged Pointer
在 2013 年 9 月,苹果推出了 iPhone5s,与此同时,iPhone5s 配备了首个采用 64 位架构的 A7 双核处理器,为了节省内存和提高执行效率,苹果提出了Tagged Pointer
的概念。对于 64 位程序,引入 Tagged Pointer 后,相关逻辑能减少一半的内存占用,以及 3 倍的访问速度提升,100 倍的创建、销毁速度提升。本文从Tagged Pointer
试图解决的问题入手,带领读者理解Tagged Pointer
的实现细节和优势,最后指出了使用时的注意事项。
我们先看看原有的对象为什么会浪费内存。假设我们要存储一个 NSNumber 对象,其值是一个整数。正常情况下,如果这个整数只是一个 NSInteger 的普通变量,那么它所占用的内存是与 CPU 的位数有关,在 32 位 CPU 下占 4 个字节,在 64 位 CPU 下是占 8 个字节的。而指针类型的大小通常也是与 CPU 位数相关,一个指针所占用的内存在 32 位 CPU 下为 4 个字节,在 64 位 CPU 下也是 8 个字节。
所以一个普通的 iOS 程序,如果没有Tagged Pointer
对象,从 32 位机器迁移到 64 位机器中后,虽然逻辑没有任何变化,但这种 NSNumber、NSDate 一类的对象所占用的内存会翻倍。如下图所示:
我们再来看看效率上的问题,为了存储和访问一个 NSNumber 对象,我们需要在堆上为其分配内存,另外还要维护它的引用计数,管理它的生命期。这些都给程序增加了额外的逻辑,造成运行效率上的损失。
为了改进上面提到的内存占用和效率问题,苹果提出了Tagged Pointer
对象。由于 NSNumber、NSDate 一类的变量本身的值需要占用的内存大小常常不需要 8 个字节,拿整数来说,4 个字节所能表示的有符号整数就可以达到 20 多亿(注:2^31=2147483648,另外 1 位作为符号位),对于绝大多数情况都是可以处理的。
所以我们可以将一个对象的指针拆成两部分,一部分直接保存数据,另一部分作为特殊标记,表示这是一个特别的指针,不指向任何一个地址。所以,引入了Tagged Pointer
对象之后,64 位 CPU 下 NSNumber 的内存图变成了以下这样:
我们也可以在 WWDC2013 的《Session 404 Advanced in Objective-C》视频中,看到苹果对于Tagged Pointer特点的介绍:
- Tagged Pointer专门用来存储小的对象,例如NSNumber和NSDate
- Tagged Pointer指针的值不再是地址了,而是真正的值。所以,实际上它不再是一个对象了,它只是一个披着对象皮的普通变量而已。所以,它的内存并不存储在堆中,也不需要 malloc 和 free。
- 在内存读取上有着 3 倍的效率,创建时比以前快 106 倍。
由此可见,苹果引入Tagged Pointer,不但减少了 64 位机器下程序的内存占用,还提高了运行效率。完美地解决了小内存对象在存储和访问效率上的问题。
Tagged Pointer
的引入也带来了问题,即Tagged Pointer
因为并不是真正的对象,而是一个伪对象,所以你如果完全把它当成对象来使,可能会让它露马脚。唐巧大佬在 《Objective-C 对象模型及应用》 中就写道,所有对象都有 isa
指针,而Tagged Pointer
其实是没有的,因为它不是真正的对象。
因为不是真正的对象,所以不是正常直接访问Tagged Pointer
的isa
成员。应该换成相应的方法调用,如 isKindOfClass
和 object_getClass
。只要避免在代码中直接访问对象的 isa 变量,即可避免这个问题。
网友评论