Effective Objective-C 2.0 读书笔记——内存管理(下)

Effective Objective-C 2.0 读书笔记------内存管理(下)

在 dealloc 方法中只释放引用并解除监听

对象在经历其生命期后 ,最终会为系统所回收 ,这时就要执行dealloc 方法了。 在每个对象的生命期内,此方法仅执行一次,也就是当保留计数降为0的时候。在这个方法之中,主要就是释放对象所拥有的引用。

比如CoreFoundation 对象就必须手工释放,因为它们是由纯C的API 所生成的。 在dealloc方法中,通常还要做一件事,那就是把原来配置过的观测行为(observation behavior) 都清理掉。之前讲到通知传值就有说到,我们当不需要监听后就需要在dealloc函数当中将注册的通知中心进行销注。

系统不保证每个对象的 dealloc 都会在预定时机调用,特别是在应用终止时可能仍有对象存活,而操作系统会在程序退出后回收所有资源。对于那些开销较大或系统内稀缺的资源(例如文件描述符、套接字等),最好在对象不再需要时主动调用专门的"清理方法"(如 close),而不是等到 dealloc 时才释放。

以下是书中给出的例子:

objc 复制代码
-(void) close{
	//关闭资源
	_close = YES;

}

-(void)dealloc {
	if(!_close) {
		NSLog(@"数据库没有在dealloc之前关闭!!!");
  	[self close];
  }

}

有时候我们不只是想输出一条错误的消息,可能需要我们抛出对应的异常来表明我们这是一个严重的错误。

调用 dealloc 后,对象已经不再有效,任何进一步调用属性存取方法或其他业务逻辑的方法都可能导致错误。

为此,开发者应确保在 dealloc 中不调用那些可能依赖对象正常状态的操作。

编写 "异常安全代码" 时留意内存管理问题这一章节

在之前我们学习了书中关于如何抛出异常的方法,即使用@try...@catch...@finally或者NSError。这一章节主要讲了使用@try...@catch...@finally在MRC的情况下需要注意的一些点。

当发生异常时,内存管理需要特别关注,否则可能会导致内存泄漏。书中的这一段话强调了以下关键点:

  1. 异常可能导致对象泄漏
    try 代码块中,如果先对某个对象进行了 retainalloc 操作(增加了引用计数),但在释放它之前抛出了异常,而 catch 代码块未能正确处理该问题,那么该对象的内存将不会被释放,从而导致内存泄漏。
  2. C++ 对象的析构函数由 Objective-C 的异常处理机制执行
    在 C++ 中,每个对象都有一个析构函数(destructor),用于在对象生命周期结束时执行清理操作。而 Objective-C 的异常处理机制会确保异常发生时,C++ 对象的析构函数能够正确执行,以防止 C++ 对象在异常发生时泄漏。
  3. 系统资源(如文件句柄)更容易泄漏
    C++ 对象由于析构函数的存在,能在异常发生时得到一定程度的保护,但文件句柄、网络连接等系统资源没有自动管理机制。如果异常发生后没有手动清理这些资源,就会造成更严重的资源泄漏。因此,在处理这类资源时,应当使用 @try ... @finally 结构来保证清理操作一定会执行。
objc 复制代码
@try {
    EOCSomeClass *object = [[EOCSomeClass alloc] init];
    [object doSomethingThatMayThrow];
    [object release];
}
@catch (NSException *exception) {
    NSLog(@"Whoops, there was an error. Oh well...");
}

不难看出如果在执行doSomethingThatMayThrow如果抛出异常,那么就没有办法对obj进行release,那我们可以对代码进行修改

objc 复制代码
EOCSomeClass *object = [[EOCSomeClass alloc] init];
@try {
    [object doSomethingThatMayThrow];
}
@catch (NSException *exception) {
    NSLog(@"Whoops, there was an error. Oh well...");
}
@finally {
    [object release];
}

样可以保证无论是否发生异常,对象都能最终被释放,从而避免内存泄漏。

这里书中也介绍了一个"objc-arc-exceptions"这个编译器参数。ARC 主要依赖编译器自动生成内存管理代码,默认假定代码中不会使用异常。这样可以生成更高效的代码,因为编译器不必为异常清理路径额外插入内存释放代码。如果我们的程序一定需要使用@try...@catch...@finally

以弱引用避免保留环

ARC 引入了 strongweak 引用来简化内存管理:

  • strong :默认情况下,使用 strong 引用来保持对象,表示对象在引用计数上被强保留。
  • weakweak 引用不会增加引用计数,因此不会导致对象无法释放。在对象被销毁时,weak 引用会自动设置为 nil,避免悬挂指针的问题。

使用例子:

objc 复制代码
@interface MyClass : NSObject
@property (nonatomic, strong) MyClass *strongObject;  // 强引用
@property (nonatomic, weak) MyClass *weakObject;      // 弱引用
@end
  • 强引用 (strong):确保对象在引用它的地方存在,直到没有任何强引用指向它。
  • 弱引用 (weak) :不保持对象的生命周期,如果对象被释放,它的引用会自动变为 nil,防止访问已释放对象导致崩溃。

以 "自动释放池块" 降低内存峰值

当我们在调用autorelease的时候,我们就会将这个对象加入自动释放池,创建自动释放池的方式如下

objc 复制代码
@autoreleasepool {

}

如果在没有创建自动释放池的情况下,直接使用autorelease的话,编译器就会弹出以下警告

Object Oxabcd0123 of class NSCFString autoreleased with no pool ni place - just leaking - break on objc_autoreleaseNoPool()to debug

一般来说,我们并不会担心自动释放池的问题,因为在iOS程序的main函数当中经常这么写:

objc 复制代码
int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        // Setup code that might create autoreleased objects goes here.
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

花括号定义了自动释放池的边界,从左花括号开始创建,到右花括号就自动清空,自动释放池也可以嵌套,书中的例子:

objc 复制代码
@autoreleasepool {
    // 创建一个字符串,格式化输出数字 1
    NSString *string = [NSString stringWithFormat:@"1 = %d", 1];
    NSLog(@"%@", string);
    
    @autoreleasepool {
        // 在内层自动释放池中创建一个 NSNumber 对象
        NSNumber *number = [NSNumber numberWithInt:1];
        NSLog(@"%@", number);
    }
}

将自动释放池嵌套用的好处是,可以借此控制应用程序的内存峰值,使其不致过高。那么什么是内存峰值呢?我们再举一个例子

objc 复制代码
NSArray *databaseRecords - */ ... */ ; 
NSMutableArray *people = [NSMutableArray new];
for (NSDictionary *record in databaseRecords) {
  EOCPerson *person = [[EOCPerson alloc] initWithRecord: record];
  
	[people addobject: person] ;
}

假设我们要从一个数据库读取很多数据,不难看出就会创建出大量person的临时对象,那么势必占据许多内存,如果我们将自动释放池放在循环外面,自动释放池只会在循环结束释放内存,而我们可以添加自动释放池添加在循环之中。

objc 复制代码
NSArray *databaseRecords - */ ... */ ; 
NSMutableArray *people = [NSMutableArray new];
for (NSDictionary *record in databaseRecords) {
	@autoreleasepool{
		EOCPerson *person = [[EOCPerson alloc] initWithRecord: record];
		[people addobject: person] ;
	
	}
 
}

这个程序就会在循环运行的过程之中对临时对象进行释放,应用程序执行循环的内存峰值就会降低。内存峰值(high-memory waterline)是指应用程序在某个特定时段内的最大内存用量 (highest memory footprint )。自动释放机制就像栈一样,系统创建好池以后,就将其自动推入池中,相当于从栈上弹出。在对象执行自动释放操作就等同于推入池中。

用 "僵尸对象" 调试内存管理问题

正常释放 vs. 僵尸对象

在正常情况下,当一个对象的引用计数降为 0 时,系统会调用它的 dealloc 方法,从内存中真正销毁该对象。然而,如果在对象被销毁后继续发送消息,就会发生"野指针"访问,导致程序崩溃(通常表现为 EXC_BAD_ACCESS)。

僵尸对象的机制

当启用僵尸对象调试后,对象在引用计数为 0 时不会真正被销毁,而是转变为"僵尸"(zombie)状态。僵尸对象会保留在内存中,但会拦截所有后续发送给它的消息,并打印出一条错误日志,指出你正在尝试对一个已经释放的对象进行操作。这样,你就能准确定位是哪种对象在被错误地访问,从而帮助你查找内存管理错误。

启用这项调试功能之后,运行期系统会把所有已经回收的实例转化成特殊的"僵尸对象",而不会真正回收 它们。这种对象所在的核心内存无法重用 ,因此不可能遭到覆写。僵尸对象收到消息后,会抛出异常,其中准确说明了发送过来的消息,并描述了回收之前的那个对象。

如果程序在发送消息时崩溃(如 EXC_BAD_ACCESS),启用僵尸对象后,Xcode 的调试输出会提示类似下面的错误信息:

objc 复制代码
*** -[SomeClass someMethod]: message sent to deallocated instance 0x12345678

书中用了一个例子:

objc 复制代码
#import <Foundation/Foundation.h>
#import <objc/runtime.h>

@interface EOCClass : NSObject
@end

@implementation EOCClass
@end

void PrintClassInfo(id obj) {
    Class cls = object_getClass(obj);
    Class superCls = class_getSuperclass(cls);
    NSLog(@"Class: %@, Superclass: %@", class_getName(cls), class_getName(superCls));
}

int main(int argc, char *argv[]) {
    EOCClass *obj = [[EOCClass alloc] init];
    
    NSLog(@"Before release:");
    PrintClassInfo(obj); // 输出当前对象的类名
    
    [obj release]; // 释放对象
    
    NSLog(@"After release:");
    PrintClassInfo(obj); // 释放后的对象已经变为僵尸对象
    
    // 如果开启了僵尸对象功能,发送消息会打印以下内容
    NSString *desc = [obj description]; // 发送消息到已释放对象
    
    return 0;
}

我们运行这段程序我们得到以下结果

objc 复制代码
Before release:
=== EOCClass :NSOb ject === 
After release:
== _NSZombie_EoCClass:nil ==

我们看到对象所属的类从 EOCClass 变 为 _NSZombie_EOCClass ,这个类是在编译过程之中形成的,当首次碰到EOCClass 类的对象要变成僵尸对象时,就会创建这么一个类。创建过程中用到了运行期程序库里的函数,它们的功能很强大 ,可以操作类列表(classlist)。以下是创造僵尸对象的代码方法

objc 复制代码
#import <objc/runtime.h>

void handleZombieObject(id self) {
    // 获取对象的类
    Class cls = object_getClass(self);
    
    // 获取类的名字
    const char *clsName = class_getName(cls);
    
    // 将 "_NSZombie_" 前缀加到类名上
    const char *zombieClsName = [NSString stringWithFormat:@"_NSZombie_%s", clsName].UTF8String;
    
    // 查找特定的僵尸类
    Class zombieCls = objc_lookUpClass(zombieClsName);
    
    // 如果这个特定的僵尸类不存在,创建它
    if (!zombieCls) {
        // 获取模板僵尸类 _NSZombie_
        Class baseZombieCls = objc_lookUpClass("_NSZombie_");
        
        // 复制模板僵尸类,创建新的僵尸类,名字加上原类名的前缀
        zombieCls = objc_duplicateClass(baseZombieCls, zombieClsName, 0);
    }
    
    // 执行对象的正常销毁过程
    objc_destructInstance(self);
    
    // 将对象的类更改为僵尸类
    objc_setClass(self, zombieCls);
    
    // 现在对象的类已经变成 _NSZombie_OriginalClass
    // 这样即便对象被释放后,仍能捕捉到发送给它的消息
}

僵尸类的作用会在消息转发例程中体现出来 ,此类没有超类,因此和NSObject 一样,也是个 "根类",该类只有 一个实例变量,叫做isa。由于这个僵尸类没有实现任何方法,那么对于编译器来说,只会实现之前学习过的消息转发之中的完整的消息转发 ,在forward方法之中,若名称前缀为_NSZombie_,则表明消息接收者是僵尸对象,需要特殊处理 。此时会打印一条消息,其中指明了僵尸对象所收到的消息及原来所属的类,然后应用程序就 终止了。在僵尸类名中嵌入原始类名的好处,这时就可以看出来了。只要把_NSZombie_从僵尸类名的开头拿掉,剩下的就是原始类名。下列伪代码演示了这一过程:

objc 复制代码
// 获取对象的类
Class cls = object_getClass(self);

// 获取类的名字
const char *clsName = class_getName(cls);

// 检查类名是否以 "_NSZombie_" 开头
if (string_has_prefix(clsName, "_NSZombie_")) {
    // 如果是,则说明该对象是僵尸对象
    // 取出原始类名,即跳过 "_NSZombie_" 前缀(前10个字符)
    const char *originalClsName = substring_from(clsName, 10);

    // 获取当前正在发送的消息的选择子名称
    const char *selectorName = sel_getName(_cmd);

    // 输出日志,指出哪个选择子被发送到了哪个僵尸对象上
    Log("*** -[%s %s]: message sent to deallocated instance %p",
        originalClsName, selectorName, self);

    // 终止程序运行
    abort();
}

不要使用retainCount

  1. 内部实现的不确定性
    retainCount 返回的是对象当前的引用计数,但这个值可能受到系统内部实现细节的影响。例如,自动释放池(autoreleasepool)和其他运行时机制可能会临时增加或减少引用计数,从而使返回的值与预期不符。
  2. 不可靠的结果
    由于系统在幕后对对象的引用计数进行优化处理,retainCount 并不能准确反映实际的引用关系。你可能会看到一个值很高或很低,但这并不意味着对象马上会被销毁或保持长期存在。
  3. 不便于调试和维护
    依赖 retainCount 的检查往往会导致代码变得脆弱,因为引用计数的变化是由许多因素决定的。使用这种方法很难编写出健壮、可维护的代码。开发者应该关注对象的拥有关系(ownership),而不是直接依赖数值。
  4. 正确的内存管理方式
    在 ARC(自动引用计数)时代,内存管理已经由编译器自动处理,开发者无需关心具体的引用计数。即使在 MRC 环境下,也应遵循正确的内存管理规则(如遵循所有权原则、及时释放对象),而不是依赖 retainCount

总之,retainCount 只是一个调试时参考的工具,并不能作为判断对象生命周期或内存管理正确性的依据。在实际开发中,我们应通过正确的内存管理模式(例如 ARC、遵循内存管理规则)来确保代码的健壮性,而不依赖 retainCount 的返回值。

相关推荐
shuair34 分钟前
idea 2023.3.7常用插件
java·ide·intellij-idea
paterWang1 小时前
基于 Python 和 OpenCV 的酒店客房入侵检测系统设计与实现
开发语言·python·opencv
小安同学iter1 小时前
使用Maven将Web应用打包并部署到Tomcat服务器运行
java·tomcat·maven
Yvonne9781 小时前
创建三个节点
java·大数据
东方佑1 小时前
使用Python和OpenCV实现图像像素压缩与解压
开发语言·python·opencv
我真不会起名字啊2 小时前
“深入浅出”系列之杂谈篇:(3)Qt5和Qt6该学哪个?
开发语言·qt
laimaxgg2 小时前
Qt常用控件之单选按钮QRadioButton
开发语言·c++·qt·ui·qt5
水瓶丫头站住2 小时前
Qt的QStackedWidget样式设置
开发语言·qt
不会飞的小龙人2 小时前
Kafka消息服务之Java工具类
java·kafka·消息队列·mq
是小崔啊3 小时前
java网络编程02 - HTTP、HTTPS详解
java·网络·http