objc一道内存有关的测试题的刨根问底

引题

先抛出一道测试题,也许不少同学可能见到过类似的

- (void)viewDidLoad {
    [super viewDidLoad];
    NSObject *obj = @"i am obj";
    NSObject *obj1 = @"i am obj1";
    NSObject *obj2 = @"i am obj2";
    id objyyy = [Animal class];
    void *p = &objyyy;
    [(__bridge id)p print];
}

最后打印结果?以下是.h .m文件

@interface Animal : NSObject {
    @public int _aNumber;
}
@property (nonatomic,strong) NSString *bnameString;
- (void)print;
@end

@implementation Animal

- (void)print {
    NSLog(@"the print value is %@",self.bnameString);
}

@end

打印结果:

[62875:12820329] the print value is i am obj1

有不清楚迷惑的想要理解上面的原理,需要弄懂大概以下几点,如果很清楚的可以略过了直接文末总结,如有不同见解可以一起讨论。

  • 内存布局
  • oc对象
  • 内存地址和字节
  • class ivar偏移、内存对齐
  • 函数栈
    本文不打算对每一点做由浅入深的展开讨论,每一点尽量做到点睛,有不理解的可以自行深入研究。

内存布局

每个进程都有独立的虚拟内存地址空间,也就是所谓的进程地址空间。我们稍微简化一下,一个 iOS app 对应的进程地址空间大概如下图所示


image.png

每个区域实际上都存储相应的内容,其中代码区、常量区、静态区这三个区域都是自动加载,并且在进程结束之后被系统释放,开发者并不需要进行关注。
栈区一般存放局部变量、临时变量,由编译器自动分配和释放,每个线程运行时都对应一个栈。而堆区用于动态内存的申请,由用户来分配和释放。
地址分配上来说用户栈地址>堆地址

oc对象

在这里分析oc对象的本质并不会基于runtime底层源码去剖析,相信这样的文章甚多,大家都有一定的了解,oc对象底层结构是一个结构体,class和实例对象皆对象,因为他们共有着一个isa等;实例对象->class->meta-class关系链等,以及方法寻找过程等就不一一细说,大体知道这么个流程;
那这里从宏观角度来看待oc上的对象。我们基于文章开头两行代码来分析,平时我们更多的是类似这么声明创建一个对象

Animal *animal = [[Animal alloc] init];
[animal print];

那如果这样呢?

id objyyy = [Animal class];
void *p = &objyyy;
[(__bridge id)p print];

首先是会正常调用的;objyyy被转换成了一个指向Animal Class的指针,然后使用id转换成了objc_object类型。objyyy现在已经是一个Animal类型的实例对象了。当然接下来可以调用print的方法。
我们在第三行下一个断点


image.png

或者lldb:

(lldb) po ((objc_object *)p)->isa
Animal
(lldb) p &objyyy
(id *) $0 = 0x00007ffee89fd1c0
(lldb) p &((objc_object *)p)->isa
(objc_class **) $1 = 0x00007ffee89fd1c0

objyyy指针地址(&objyyy)和对象的isa指针地址(&((objc_object *)p)->isa)一样,在转换成objc_object类型的时候,类对象指针objyyy就是isa了,继而成为objc对象,可以看出这和我们通常的alloc init后的实例对象一毛一样有木有;

struct objc_object {
private:
    isa_t isa;
}

isa作为对象的第一个成员变量和类的首地址是一致的。id obj = &objyyy objyyy自然就作为isa了。
所以,Objc对象到底是什么呢?
Objc中的对象保存着一个指向类对象地址的变量,即 id obj = &objyyy objyyy = [xxx class]

内存地址和字节

说这一小节主要是为后面分析问题的时候补一下基础吧,了解的皆可以略过。
我们在内存中debug打印的内存地址都是十六进制来表示的;储存容量的基本单位是字节,存储单元一般以八个二进制单位也就是一个字节为单位。
例如经常说32位的操作系统最多支持4GB的内存空间,也就是说CPU只能寻址2的32次方(4GB),注意这里的4GB是以Byte为单位的,不是bit。也就是说有4G=41024M(Byte)=410241024Kb(Byte)=4102410241024bit(Byte),即2的32次方个8bit单位。每8bit单位用十六进制来表示内存地址。
所以说用4位16进制表示的内存地址和用8位16进制表示的内存地址,其实都是代表一个8bit的存储空间而已;
所以譬如0x56000050~0x56000054这一段连续内存地址之间相差4也就是相差4(4x8bit)字节

class ivar偏移、内存对齐

这里先概括下

  • 对象的实例变量,就是在对象的首地址上进行的偏移,void *ivar = &obj(isa) + offset(N)

开发过程中的对象,我们通常都是通过指针访问对象,这里首先要搞清楚指针地址和指针指向地址。

NSString *name = @"xxxx";
NSLog(@"%p",name);//指针指向的内容内存地址
NSLog(@"%p",&name);//指针变量本身的地址

不同类型的变量会被分配不同大小的内存


image.png

对象指针在OC上占用8个字节

根据对象地址获取成员变量的指针地址

我们新建父类Animal、子类Dog

@interface Animal : NSObject {
    @public int _aNumber;
}
@property (nonatomic,strong) NSString *bnameString;
- (void)print;
- (void)printIvars;
@end

@interface Dog : Animal {

@public int _cNumber;
    
};
@property (nonatomic,strong) NSString *dnameString;
@end
/////////.m文件////////////
@implementation Animal

- (void)printIvars {
    NSLog(@"-------Animal-------");
    unsigned int count = 0;
    Ivar *ivars = class_copyIvarList([Animal class], &count);
    for (int i = 0; i < count; i ++) {
        Ivar ivar = ivars[i];
        const char *name = ivar_getName(ivar);
        NSLog(@"%s   offset = %td",name,ivar_getOffset(ivar));
    }
    free(ivars);
    NSLog(@"-------Animal-------");
}

@end

@implementation Dog

- (void)printIvars {

    [super printIvars];

    NSLog(@"-------Dog-------");
    unsigned int count = 0;
    Ivar *ivars = class_copyIvarList([Dog class], &count);
    for (int i = 0; i < count; i ++) {
        Ivar ivar = ivars[i];
        const char *name = ivar_getName(ivar);
        NSLog(@"%s   offset = %td",name,ivar_getOffset(ivar));
    }
    free(ivars);
    NSLog(@"-------Dog-------");
}

- (void)print {
    NSLog(@"the print value is %@",self.bnameString);
}
@end

创建一个Dog对象,打印对象的成员变量的偏移量

[49104:11906555] -------Animal-------
[49104:11906555] _aNumber   offset = 8
[49104:11906555] _bnameString   offset = 16
[49104:11906555] -------Animal-------
[49104:11906555] -------Dog-------
[49104:11906555] _cNumber   offset = 24
[49104:11906555] _dnameString   offset = 32
[49104:11906555] -------Dog-------
(lldb) 

再分别打印出对象的实例变量地址验证下

@implementation Animal
- (void)printIvarsAddress {
    NSLog(@"self-----%p",self);
    NSLog(@"_aNumber-----%p",&_aNumber);
    NSLog(@"_bnameString-----%p",&_bnameString);
}
@end

@implementation Dog
- (void)printIvarsAddress {
    NSLog(@"self-----%p",self);
    [super printIvarsAddress];
    NSLog(@"_cNumber-----%p",&_cNumber);
    NSLog(@"_dnameString-----%p",&_dnameString);
}
@end
[49336:11910893] self-----0x6000032365e0
[49336:11910893] self-----0x6000032365e0
[49336:11910893] _aNumber-----0x6000032365e8
[49336:11910893] _bnameString-----0x6000032365f0
[49336:11910893] _cNumber-----0x6000032365f8
[49336:11910893] _dnameString-----0x600003236600

由前面小节可知0x6000032365e0-0x6000032365e8-0x6000032365f0-0x6000032365f8-0x600003236600都是每相隔8字节进行偏移由低地址向高地址扩展;实际地址分配是根据具体变量类型来进行字节对齐偏移,aNumber为int类型,占四个字节的内存(0x6000032365e8、0x6000032365e9、0x6000032365ea、0x6000032365eb),_bnameString为(NSString *)类型,也就是指向NSString的指针,占八个字节的内存空间,_bnameString的内存分配没有从0x6000032365ec(0x6000032365e8+4)开始,而是从0x6000032365f0开始从而使0x6000032365ec至0x6000032365ef的四个字节成为空字节,是使用了字节对齐(iOS 64位下8字节内存对齐)
以上说明成员变量的地址确实是等于对象地址加上变量的偏移量。

函数栈

这一小节不会展开细说,毕竟展开理解的话可能需要很大篇幅。只需要了解个大概即可
函数调用时,调用者与被调用者的栈帧结构如下图所示


image.png

栈帧:每个进程都会有自己的栈空间,而进程中的各个函数也会维护自己本身的一个栈的区域,这个区域就是栈帧(esp-ebp之间的一段栈空间),函数层级调用都需要自己独立的栈帧,这种调用可能会涉及非常多的层次,编译器需要保证在这种复杂的嵌套调用中,能够正确地处理每个函数调用的堆栈平衡,所以函数调用本质来说就是不断的压栈出栈结合程序计数器,寄存器等顺利的完成程序指令的执行。
简单来说函数调用其实可以看做4个过程

  • 压栈: 函数参数压栈(入栈顺序需要具体约定,在iOS平台下不同架构体系下前几位固定参数由左到右依次保存在指定寄存器中,之后参数右到左压栈),局部变量依次按声明顺序从高-低地址分配入栈
  • 跳转: 跳转到函数所在代码处执行
  • 执行: 执行函数代码
  • 返回: 平衡堆栈,找出之前的返回地址,跳转回之前的调用点之后,完成函数调用
    每一个函数的栈帧存放局部变量、函数参数等
- (void)test2:(NSString *)a b:(NSString *)b {
    NSObject *obj = @"i am obj";
    NSObject *obj1 = @"i am obj1";
    NSObject *obj2 = @"i am obj2";
}

此时的栈空间分布如下

(lldb) frame variable
(Demo2ViewController *) self = 0x00007ff6a7401650
(SEL) _cmd = "test2:b:"
(__NSCFConstantString *) a = 0x000000010bb9e678 @"a"
(__NSCFConstantString *) b = 0x000000010bb9e698 @"b"
(__NSCFConstantString *) obj = 0x000000010bb9e618 @"i am obj"
(__NSCFConstantString *) obj1 = 0x000000010bb9e638 @"i am obj1"
(__NSCFConstantString *) obj2 = 0x000000010bb9e658 @"i am obj2"
(lldb) p self
(Demo2ViewController *) $11 = 0x00007ff6a7401650
(lldb) p &a
(NSString **) $12 = 0x00007ffee4066158
(lldb) p &b
(NSString **) $13 = 0x00007ffee4066150
(lldb) p &obj
(NSObject **) $14 = 0x00007ffee4066148
(lldb) p &obj1
(NSObject **) $15 = 0x00007ffee4066140
(lldb) p &obj2
(NSObject **) $16 = 0x00007ffee4066138
(lldb) 

调用test2函数入栈,self、a、b、obj、obj1、obj2等指针变量的地址8字节逐次递减,高-低分配入参及局部变量情况。

回到开头

- (void)viewDidLoad {
    [super viewDidLoad];
    NSObject *obj = @"i am obj";
    NSObject *obj1 = @"i am obj1";
    NSObject *obj2 = @"i am obj2";
    id objyyy = [Animal class];
    void *p = &objyyy;
    [(__bridge id)p print];
}

viewDidLoad函数最终runtime层转换成objc_msgSend方法,self、_cmd两个默认隐藏参数
[super viewDidLoad]
通过汇编代码:


image.png

runtime最终转换为objc_msgSendSuper2,看下源码

// objc_msgSendSuper2() takes the current search class, not its superclass.
OBJC_EXPORT id _Nullable
objc_msgSendSuper2(struct objc_super * _Nonnull super, SEL _Nonnull op, ...)
    OBJC_AVAILABLE(10.6, 2.0, 9.0, 1.0, 2.0);

/// Specifies the superclass of an instance. 
struct objc_super {
    /// Specifies an instance of a class.
    __unsafe_unretained _Nonnull id receiver;

    /// Specifies the particular superclass of the instance to message. 
#if !defined(__cplusplus)  &&  !__OBJC2__
    /* For compatibility with old objc-runtime.h header */
    __unsafe_unretained _Nonnull Class class;
#else
    __unsafe_unretained _Nonnull Class super_class;
#endif
    /* super_class is the first class to search */
};
#endif

objc_msgSendSuper2方法入参是一个objc_super *super。objc_super结构体两个变量
receiver相当于self,super_class相当于self.class也就是本类,为什么不是super class?大概就是objc_msgSendSuper2的特殊性,objc_msgSendSuper2通过self.class偏移直接查找父类。
接下来就是viewDidLoad函数栈帧的局部变量obj obj1 obj2 objyyy(内存高-低分配)等,所以此时我们的viewDidLoad内的栈结构如下:


image.png

如之前小节void *ivar = &obj(isa) + offset(N)可知p.bnameString = &p + offset(2) ,由oc对象本质小节可知p对象的isa就是objyyy,所以p.bnameString = &objyyy + offset(2) ,objyyy指针地址高位偏移2位,就是obj1了;
可能有人会小迷糊了,平常大量的创建的对象为什么就很安全没问题?
其实经过alloc后系统给我们在堆上开辟了一段内存(这个大家应该都清楚),堆在内存布局中处于栈下游。
我们"正常"新创建一个对象

Animal *animal = [[Animal alloc] init];
[animal print];

下个断点lldb来观察下

(lldb) p &((objc_object *)animal)->isa
(objc_class **) $0 = 0x0000600002115bc0
(lldb) po animal
<Animal: 0x600002115bc0>

再次可知void *ivar = &obj(isa) + offset(N)就是void *ivar = &isa + offset(N),注意这里并不是&animal,而是对象的内存首地址也就是isa的指针地址&isa。
我们打印下 p的指针地址&p

(lldb) p &p
(void **) $3 = 0x00007ffee29ae1a8

animal对象的isa指针地址(0x0000600002115bc0)是要比&p(0x00007ffee29ae1a8)小的,确实是在堆上,所以我们访问实例对象是不会有问题的。(此处可能不恰当啰嗦了)

总结

  • oc对象runtime底层结构都共有一个isa,那么只要内部有一个指向类/元类的地址的变量,那它就是一个对象;
  • lldb p obj的时候或者断点查看某个对象的时候显示的地址就是isa指针变量的地址,毕竟isa作为第一个变量就是对象的首地址。
  • 对象的实例变量偏移void *ivar = 对象内存地址+ offset(N) 而对象内存地址就是所看到的isa指针变量的地址&isa,向高位偏移就是对应的实例变量
  • 局部变量压栈内存分布高-低;对象中实例变量内存分布由低-高扩展
    文笔所限,如有混乱错误的地方可以一起讨论。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,219评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,363评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,933评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,020评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,400评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,640评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,896评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,597评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,327评论 1 244
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,581评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,072评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,399评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,054评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,849评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,672评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,585评论 2 270