Objective-C 内存管理基础
前言
之前的两篇拙文C语言-内存管理基础、C语言-内存管理深入 介绍了关于C语言在内存管理方面的相关知识。但是对于从事iOS开发的同胞们来说,显然Objective-C用的更多,所以笔者想用两篇文章尽量完整的介绍一下Objective-C的内存管理,本文为第一部分,将从类和对象、所有权策略及引用计数机制、内存管理原则、内存管理方式等几个方面展开。如果你能赏脸阅读此文,你会发现本文利用近一半的篇幅介绍ObjC对象的相关知识,这是因为Objective-C内存管理-管理的是继承自NSObject的对象的内存。阅读本文要求读者对C语言内存管理有一定的了解,尚不熟悉的同学请移步到这里:C语言-内存管理基础。话不多说,先附上本文内容的思维导图。
主要内容思维导图类的结构与加载过程
Objective-C作为一门扩充C的面向对象编程语言,其和C语言的区别之一在于引入“面向对象”思想,能够灵活的使用类和对象进行编程。因此了解类的结构和本质对于学习Objective-C是非常重要的。
-
类的结构
Objective-C中的类是本身也是一个Class
类型的对象,简称类对象。而Class
实际上是一个指向objc_class
结构体的指针。
typedef struct objc_class *Class;
通过查看 objc/runtime.h
文件,得知objc_class
结构组成,其中包括了一个类很多信息。
struct objc_class {
Class isa OBJC_ISA_AVAILABILITY;
#if !__OBJC2__
Class super_class OBJC2_UNAVAILABLE; // 父类
const char *name OBJC2_UNAVAILABLE; // 类名
long version OBJC2_UNAVAILABLE; // 类的版本信息,默认为0
long info OBJC2_UNAVAILABLE; // 类信息,供运行期使用的一些位标识
long instance_size OBJC2_UNAVAILABLE; // 该类的实例变量大小
struct objc_ivar_list *ivars OBJC2_UNAVAILABLE; // 该类的成员变量链表
struct objc_method_list **methodLists OBJC2_UNAVAILABLE; // 方法定义的链表
struct objc_cache *cache OBJC2_UNAVAILABLE; // 方法缓存
struct objc_protocol_list *protocols OBJC2_UNAVAILABLE; // 协议链表
#endif
} OBJC2_UNAVAILABLE;
ObjC对象结构图
- 获取内存中的类对象
- 利用类的
class
方法
【函数原型】+ (Class)class
【方法说明】类方法,通过特定的一个类调用该方法返回一个Class
类型的类对象。
Class classP = [Person class]; //classP现在一个`Class `类型的类对象
- 利用类的实例对象的
class
方法
【函数原型】- (Class)class
【方法说明】对象方法,通过某个类的实例对象调用该方法返回一个Class
类型的类对象。
Person *objectP = [[Person alloc] init];
Class classP1 = [objectP class];
可以利用objectP
类对象创建Person
类的实例对象,并通过打印确定classP
是Person
类型。
Person *objectP1 = [[classP new] init];
NSLog(@"%@", NSStringFromClass(classP));
控制台输出:
//2017-02-05 15:49:57.916948 类和对象[4473:131075] Person
打印classP
、classP 1
内存地址发现数值相同,说明类在内存中只有一份。
NSLog(@"%p, %p",classP, classP1);
// 2017-02-05 15:28:53.340127 类和对象[4238:123830] 0x1000011e0, 0x1000011e0
- 类的加载和初始化
-
类的加载
【函数原型】+ (void)load
【函数说明】 程序运行把Xcode的"Compile Sources"选项中存在的类和分类(不管有没有用到)加载进来时调用,先调用父类再调用子类,每个类调用一次。 -
类的初始化
【函数原型】+ (void)initialize
【函数说明】第一次使用到该类时调用。优先调用该类分类中的initialize
方法,子类调用时会先调用父类的initialize
方法初始化父类。
【区别】:load
方法类和分类都会调用,因为他们是分开加载的,分类的加载顺序和编译顺序有关;initialize
方法是首次使用到该类时调用,调用顺序是先调用该类分类中initialize
,再调用该类父类分类的initialize
;如果没有会调用该类的initialize
,再调用父类的initialize
。
对象的创建
Objective-C中创建对象,先调用类的alloc
方法返回对象再调用对象的init
或initWithSomething
方法返回自己亦或是直接调用该类的new
方法。例如下面代码:
Person *p1 = [[Person alloc] init];
Person *p2 = [Person new];
依次介绍下面三个方法:
-
+ (instancetype) alloc
该方法被调用时系统首先在堆区分配合适大小的内存存储该对象(参考C语言-内存管理基础-C语言操作堆内存的函数部分内容),并返回一个未被初始化的该类型的对象,并完成下面三件事情:(引自谈ObjC两段构造模式)
1,将该新对象的引用计数 (Retain Count) 设置成 1。
2,将该新对象的 isa 成员变量指向它的类对象。
3,将该新对象的所有其它成员变量的值设置成零。(根据成员变量类型,零有可能是指 nil 或 Nil 或 0.0)
-
- (instancetype) init
根据对象具体成员变量的类型真正初始化对象的成员变量的值。 -
+ (instancetype) new
可以简单的理解为将alloc
、init
合并为一次操作。
-
对象的存储
上述创建p1
、p2
的代码可以解读为:
- 系统在内存的栈区分别开辟空间存储
p1
、p2
指针变量 - 系统在内存的堆区分别开辟空间存储两个
Person
对象并使栈区的p1
、p2
指针变量分别指向堆区的Person
对象。
对象存储简单图解
-
更多细节
关于ObjC对象的更多细节请参考下面的文章:
唐巧:谈ObjC两段构造模式
冰霜:Objc 对象的今生今世
Matt Gallagher:What is a meta-class in Objective-C?
上文中有部分描述引自前者,已经做出了说明。
对象的所有权及引用计数
-
所有权策略:任何自己创建的对象都归自己所有且都存在一个或多个所有者,只要对象至少存在一个所有者,该对象就不会被销毁,其占用的内存空间就会一直存在不被释放(除非整个程序已经退出)。类似于一瓶矿泉水可能被一个或多个人饮用,只要还有一个人需要饮用该矿泉水,它就不会被环卫工人回收。
通过NSObject协议中的retainCount
属性可以获得该对象当前的引用计数值。 -
引用计数器:Cocoa采用一种引用计数机制,为每个对象都绑定一个NSUInteger类型的整数表示该对象当前被引用的次数(即当前共有多少个所有者引用着该对象),称之为该对象的引用计数器。每个ObjC对象都有自己的引用计数器(在64位编译器环境下占用8个字节空间)。类似于一个整数记录一瓶矿泉水当前被多少个人饮用,当该矿泉水没有被人饮用时就要被环卫工人回收再利用了。
-
引用计数器的作用:对象刚被创建时其引用计数默认为1,当对象的引用计数器值变为0时(即已经没有所有者引用该对象),系统销毁该对象,释放并重新利用该对象在堆区对应分配的存储空间。系统通过判断对象的引用计数器值是否为0来决定是否需要销毁该对象并释放其占用的内存空间。
一种例外情况:若对象值为nil
时其引用计数为0但系统不回收空间,因为系统尚未为该对象分配空间。
Person *p1 = nil;
NSLog(@"%lu", (unsigned long)p1.retainCount); //p1.retainCount = 0
- 与引用计数相关的操作
-
- (instancetype)retain
:使对象的引用计数器值+1 -
- (oneway void)release
:使对象的引用计数器值-1(不代表销毁该对象) -
- (NSUInteger)retainCount
:获得对象当前的引用计数器值 -
- (instancetype)autorelease
:待稍后清理“自动释放池”(@autoreleasepool
)时,再减少对象的引用计数
值得注意的是:retain
操作无法使一个已经被释放的对象的引用计数器值+1,这也很好理解,retain
无法让一个已经死去的人起死回生,就像环卫工人已经回收了你的矿泉水瓶,那么你将不能再持有该水瓶。
UIView *view = [[UIView alloc] init];
NSLog(@"创建之后的默认值是:%ld",(unsigned long)view.retainCount); //创建之后的默认值是:1
[view retain];
NSLog(@"通过一次retain操作之后:%ld",(unsigned long)view.retainCount); //通过一次retain操作之后:2
[view release];
NSLog(@"通过一次release操作之后:%ld",(unsigned long)view.retainCount); //通过一次release操作之后:1
上面的四种操作中除开`autorelease `方法外都比较好理解,关于`autorelease `可以点击[黑幕背后的Autorelease](http://blog.sunnyxx.com/2014/10/15/behind-autorelease/)。同时《Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法》书中有这样解释的:
在OC引用计数框架中,自动释放池是一项重要特性,调用release会立刻递减对象的引用计数(而且很可能令系统回收此对象)然而有时候可以不调用它,改为调用autorelease,此方法会在稍后递减计数,通常是在下一次“事件循环“(even loop)时递减,不过也可能执行的更早。此特性很有用,尤其是在方法中返回对象时更应该用它。
该书中还举出了这样的例子:
- (NSString *)stringValue {
NSString *str = [[NSString alloc] initWithFormat:@"i am this: %@",self];
return str;
}
此时返回的str
对象计数值比期望值要+1,因为方法内部调用了一次alloc
操作而又没有与之对应的释放操作,计数器+1就意味着调用者要负责处理多出来的这一次保留操作。但是不能在- (NSString *)stringValue
方法内部释放,否则还没等方法返回,系统就把该对象回收了,这时候应该使用autorelease
,他能够延长对象的生命周期,保证方法返回后该对象一定有效并且在合适的时机得以释放。
【注意】:关于一个对象的retainCount
大部分情况下是合理的,但是有些时候其数值是令人无法理解的。例如苹果官方文档对其做了下面的解释:
文档的大致内容是:此方法在调试内存管理问题上作用不大。 因为任意的框架对象可能引用该对象,同时自动释放池对对象有延迟释放作用,因此无法通过调用此方法获取到有用的信息。
-
对象的销毁
- 对象何时被销毁:当对象的引用计数器值为0时,它将被系统销毁,其占用的内存空间将得到释放。
-
- (void)dealloc
:当对象被销毁时,系统会调用该方法,重写该方法并在其中释放相应的资源如移除通知等(类似于临终遗言),一旦重写dealloc
方法就必须在代码块最后调用[super dealloc]方法,调用[super dealloc]方法是为了让super
释放相应的资源,使得任何继承来的对象都能够得到释放;另外我们不应该直接调用对象的delloc
方法。
例如下面的代码:
@implementation Person
- (void)dealloc
{
NSLog(@"对象被销毁了");
[super dealloc];
}
@end
Person *p1 = [[Person alloc] init];
NSLog(@"%lu",(unsigned long)p1.retainCount);
[p1 release];
控制台输出信息
2017-01-28 18:11:29.431764 OC中对象内存管理[20948:160747] 1
2017-01-28 18:11:29.432173 OC中对象内存管理[20948:160747] 对象被销毁了
内存管理的范围
有了上面关于Objc对象知识的铺垫,我们可以很好的引入内存管理这个概念。
-
范围:管理任何继承自NSObject类型的对象的内存。对于其他基本数据类型如:int、char、double、结构体、枚举类型的变量无效。
-
根本原因:ObjC对象和其他的基本类型变量在内存中存储位置不同,对象所占的内存由系统在堆区动态分配需要程序员管理手动释放,而其他基本数据类型的变量(局部变量)一般分配在栈区由系统自动释放。
内存管理的方式
- Objective-C中为我们提供了两种(曾经是三种)管理内存的方式:
-
Mannul Reference Counting (MRC)
手动内存管理,指的是通过retain
、release
、autorelease
等使用引用计数器操作的方式由程序员手动管理内存。本文中所有涉及到以上三个方法的代码都是MRC的实践。MRC最大的问题在于持有和释放对象的时机,不当的retain
和release
极可能产生“野指针”和“内存泄露”等内存方面的问题(下文中有具体介绍)。 -
Automatic Reference Counting (ARC)
ARC与引用计数操作
自动内存管理,ARC作为WWDC2011和iOS5之后LLVM 3.0编译器的一项新特性,极大的解放了iOS开发者的双手(苹果推荐使用)。在ARC的编译环境下开发者再也不用写任何含有retain
、release
、autorelease
的代码了,编译器将自动在合适的地方插入上述代码实现内存的管理。事实上现在绝大部分代码都是使用的ARC管理内存,只是作为初学者可能“日用而不知”,并没有意识到其中深层次的关系,并且在ARC环境下编写含有引用计数操作的代码是无法编译通过的。例如:
ARC与 [supper delloc]
-
关于更多MAC和ARC机制的内容请参考以下博客:
王巍 (@onevcat):手把手教你ARC——iOS/Mac开发ARC入门和使用
HIT-Alibaba:Objective-C 中的内存分配
Apple:Transitioning to ARC Release Notes
- Gargage Collection (垃圾回收机制)
Objective-C 2.0以后存在垃圾回收机制。垃圾回收机制监视整个对象关系图,查找那些在作用域内已没有任何指针指向的对象,并自动释放这些对象。
另外在《Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法》一书中对垃圾回收机制有这样的描述:
从Mac OS X10.8开始,“垃圾收集器”(garbage collector)已经正式废弃了,以后Objective-C代码编写Mac OS X程序时不应再使用它,而iOS则从未支持过垃圾收集。
由于关于Objective-C垃圾回收的资料本人收集的比较少,仅在《好学的Objective-C》一书中看到过相关介绍,而且该机制从未在iOS开发中使用到,所以笔者这里姑且认为Objective-C中是存在过三种内存管理的方式,只是没法介绍关于它的更多内容。有兴趣的同学可以查阅一下该书。
内存管理的原则
- 只要对象还在被使用其占用的内存空间就不应该被回收;需要使用该对象,使该对象引用计数+1;使用完该对象,使该对象的引用计数-1。
- 谁创建,谁release
- 谁retain,谁release
具体来说当使用alloc
、new
、copy
(生成一个接受对象的副本)创建对象时,其引用计数器值被置为1,当不需要使用该对象时需要做一次release
操作;当使用retain
操作持有一个对象时其引用计数器值+1,当不需要使用该对象时需要做一次release
操作;一次创建对应着一次release
,一次retain
对应着一次release
,这样对象才能“有始有终”。
内存管理不当导致的问题
- 野指针
- 定义的指针变量没有初始化。
- 指向的堆内存空间已经被释放的指针。
Dog *yellowDog = [[Dog alloc] init];
[yellowDog release];
NSLog(@"%@", yellowDog);
上面的代码在yellowDog
的引用计数器值为0时已经将在堆区分配的 Dog 对象的内存释放掉了,再调用NSLog
通过yellowDog
指针访问堆区的对象就很可能会出问题。
Person *p1 = [Person new];
[p1 release];
[p1 release];
如上的代码,当第一次执行[p1 release]
代码时,p1指向的对象的retainCount
减为0,系统销毁该对象且其在堆区的存储空间会被回收,如果此时再次执行[p1 release]
代码,尝试给已经被释放的Pweson
对象发送release
消息,很可能会触发运行时错误。具体来说程序运行成功但是控制台会输出这样的信息:
Person object 0x1002049e0 overreleased while already deallocating; break on objc_overrelease_during_dealloc_error to debug
《Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法》一书是这样解释 “很可能”这个词的:
之所以说“很可能”没有说一定,是因为对象在堆区所占的内存在“解除分配”(deallocated)之后只是放回到“可用内存池”,如果此时执行NSLog时尚未覆盖对象内存,那么该对象依旧有效,这时程序不会奔溃。由此可见,因过早释放对象而导致的bug很难调试。
当我们开启Xcode的僵尸对象检测功能时,程序运行崩溃,在控制台输出如下信息:
*** -[Person release]: message sent to deallocated instance 0x100200640
//release 消息发送给了一个已经释放掉的对象
上面的情况在日常开发中常常体现为Thread:EXC_BAD_ACCESS
(坏访问错误)
【坏访问错误】:即访问了一块坏内存(不可用的已经被回收的内存)。这样的错误我们也称为“野指针错误”,意思是利用野指针操作了一块不可用的内存。
【僵尸对象】:所占用的内存空间已经被回收不可用的对象。僵尸对象不应该再使用。
【解决方案】:为避免在不经意间使用了无效对象,一般调完realease
之后都会清空指针。这样就能保证不会可能出现指向无效对象的指针。即在对象释放完毕后将指向对象的指针置为nil
,给nil
发消息不会产生任何反应。
综上所述:没有置为nil
却指向了无效对象的指针就是野指针。
所以正确的做法应该是:
Dog * yellowDog = [[Dog alloc] init];
[yellowDog release];
yellowDog = nil //对象释放后,将指向对象的指针置为nil
- 内存泄漏
{ //代码块内创建Dog对象
Dog *husky = [[Dog alloc] init];
}
形如上面的代码,当我们在创建Dog
对象时系统分别在栈区为husky
变量分配内存、在堆区为husky
所指向的Dog
对象分配内存,由于变量husky
的作用域在大括号内属于局部变量,当代码块执行完毕,栈中的husky
变量就被释放了,但是此时在代码块中并没有对husky
变量指向的堆区的对象存储空间进行释放,所以我们说堆区中对应的存储空间就被泄露了。
总结为:基本的数据类型由于占用的存储空间是固定的,一般存在栈区中。由于栈中主要存放的是局部变量,局部变量占用的内存空间是其所在的代码块或者是函数结束的时候自动回收,指向对象的指针也会被回收,这个过程不需要程序员管理。但是对象创建完成后是存放在堆区的,由于此时指向对象的指针已经被回收,但是对象仍然存在内存中,就会造成内存泄漏(申请的空间已经不再使用却没有被及时合理的释放掉)。
文章最后
以上就是笔者对于Objective-C内存管理基础认识的全部内容,部分描述引自书籍和其它博客文中已经做出了说明,但凡是本人认为重要的概念均附上其它博客的链接用于扩展相关知识。
另外:本文涉及到MRC的代码需要在Xcode进行如下设置才能编译通过:在Build Settings中,找到"Objective-C Automatic Reference Counting"这个选项,将它的值改为"NO"。
如果文中有任何纰漏或错误欢迎在评论区留言指出,本人将在第一时间修改过来;喜欢我的文章,可以关注我以此促进交流学习; 如果觉得此文戳中了你的G点请随手点赞;转载请注明出处,谢谢支持。