美文网首页
objc一道内存有关的测试题的刨根问底

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

作者: Frankxp | 来源:发表于2019-12-18 14:31 被阅读0次

    引题

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

    - (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,向高位偏移就是对应的实例变量
    • 局部变量压栈内存分布高-低;对象中实例变量内存分布由低-高扩展
      文笔所限,如有混乱错误的地方可以一起讨论。

    相关文章

      网友评论

          本文标题:objc一道内存有关的测试题的刨根问底

          本文链接:https://www.haomeiwen.com/subject/hebrnctx.html