浅谈iOS对象的本质和神秘的nonPointerIsa

427 阅读14分钟

对象的本质

探讨对象的本质前,我们先了解一下clang编译器:clang是一个c++编写、基于llvm、发布于llvm bsd许可证下的c/c++/objective-c/objective-c++的轻量级编译器。 来一段main.m代码如下:

#import <Foundation/Foundation.h>

@interface OCPeople : NSObject{
    NSString *nickName;
}
@property (nonatomic,copy) NSString *name;
@property (nonatomic,assign) int age;
@end

@implementation OCPeople

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        NSLog(@"Hello, World!");
    }
    return 0;
}

这里介绍一个clang命令:clang -rewrite-objc main.m -o main.cpp,这条命令是将main.m文件转化为main.cpp文件。 在终端输入上面的命令,main.m所在的文件夹內就会生成一个main.cpp文件,转化后的文件部分代码截图如下: 本质1.png 对此底层代码进行分析:发现对象OCPeople在底层被编译成了结构体。结构体OCPeople_IMPL内部还嵌套了一个结构体NSObject_IMPL NSObject_IVARS,是因为OCPeople继承自NSObject

NSObject_IVARS是成员变量isa,如下图: isa.png

值得注意这里有一句:typedef struct objc_object OCPeople;为什么OCPeople类本质的类型是objc_object这个类型呢?是因为OCPeople继承自NSObjectNSObject在底层的本质是objc_object。那class是什么类型呢?查找发现它是objc_class *,它是结构体类型的指针,如下图: class.png 为啥我们常用到的id可以指向任意对象且不用带*,是因为id本来就是objc_object *类型的结构体指针,在oc开发中,基本上所有的对象都是继承自NSObject,而它的本质是objc_object结构体类型。

底层代码main.cpp中有这样一段代码,如下图: getset.png 这段代码是属性nameageget方法和set方法。从底层代码中可以看到系统自动给这两个属性添加了get方法和set方法。而我们定义的变量nickName,系统却没有自动添加get方法和set方法,这个要注意区分。OCPeople * self, SEL _cmd是底层的两个隐藏参数。((char *)self + OBJC_IVAR_$_OCPeople$_name)可以这样理解:OCPeople对象在堆区开辟内存空间,堆区空间存放isanameage等等一些变量,对象的首地址加上OBJC_IVAR_$_OCPeople$_name所在内存空间的平移量才能获得name的地址,拿到地址才能获取name里面的值。

总结:对象的本质是结构体objc_object,对象都包含一个class类型的isa是继承自NSObjectclass的本质是结构体类型指针objc_class *

位域、联合体

了解位域之前先来看一段代码:

struct OCBike1 {
    BOOL front;
    BOOL back;
    BOOL left;
    BOOL right;
};

通过这个结构体实例化一个bike1,打印它的size大小为4,如下图: 结构体1.png bike1的大小为4字节,一个字节8位,4字节就是32位,结构体OCBike1中每个成员都是布尔类型,布尔类型不是0就是1,那真正的存储这个结构体OCBike1中每个成员,其实只需要4位就可以了,相当于半个字节,但没有半个字节还是要用到1个字节存储,剩下3个字节其实都是浪费空间了。

对上面的结构体OCBike1我们进行一点修改变成结构体OCBike2,代码如下:

struct OCBike1 {
    BOOL front;
    BOOL back;
    BOOL left;
    BOOL right;
};

struct OCBike2 {
    BOOL front: 1;
    BOOL back : 1;
    BOOL left :1 ;
    BOOL right : 1;
};

结构体OCBike2相对于结构体OCBike1,只是为每个成员设置了位域,都设置成1,代表1位,占用一个位置,设置位域值不能大于8,否则系统提示出错。我们再实例化结构体OCBike2,然后观察这两个结构体实例化后的size大小,打印输出如下: 结构体2size.png bike1占用4字节,而bike2占用1字节,bike2大大的减少了占用空间,这也是写代码时的一种优化方式。代码里面我们设计一辆车,同一时刻只有一个方向,向前就不能向后,向左就不会向右,这种设计就是互斥。

接下来我们来看另外一个结构体,代码如下:

struct OCProfessor1 {
    char *name;
    int age;
    double height;
};

实例化结构体OCProfessor1,给professor1的成员赋值,然后边运行边打印输出professor1对象,如下图: professor1.png 刚开始professor1对象里面的成员都为空,后面所有成员变量都赋值成功。这里我们引入一个词“联合体”,联合体和结构体有什么区别?我们先来看下联合体OCProfessor2的代码,如下:

union OCProfessor2 {
    char *name;
    int age;
    double height;
};

联合体OCProfessor2的成员和结构体OCProfessor1的成员设计成一样,看起来一样,但它们还是有区别的,结构体里面的成员是可以共存的,但联合体里面的成员是互斥的。我们用联合体OCProfessor2实例化一个professor2,给professor2的成员赋值,边运行边打印输出professor2,看看和结构体professor1的输出是否还一样,如下图: professor2name.png 代码运行到line77时,professor2中的成员还没赋值,我们打印输出p professor2nameageheight都赋值为空显示正常。继续运行到line78之后,我们再打印输出,name显示的是我们当前赋的值Jones,但是ageheight的值发生了变化,从原来的0变成了16296和2.12一长串的数值。之所以发生这样的变化,是因为变量ageheight所在的内存区域是脏内存,显示的是脏数据。继续运行到line79,我们再打印输出,name的值没有了,age为13,在这一时刻,nameage只能有一个被使用,共用了内存。

  • 结构体struct中所有变量都是共存的,缺点是结构体内存空间的分配是粗放的,不管用不用都会分配。
  • 联合体union中各变量是互斥的,优点是内存使用更精细灵活,节省了内存空间。一般联合体搭配位域一起使用。

联合体(也叫共用体)中的所有成员是共享一段内存的,因此每个成员的存放首地址相对于联合体变量的基地址的偏移量为0,即所有成员的首地址都是一样的。为了使得所有成员能够共享一段内存,因此该空间必须足够容纳这些成员中最宽的成员。

使用位域的主要目的是压缩存储,大致规则:如果相邻位域字段的类型相同,且其位宽之和小于类型的sizeof大小,则后面的字段将紧邻前一个字段存储,直到不能容纳为止;如果相邻位域字段的类型相同,但其位宽之和大于类型的sizeof大小,则后面的字段将从新的存储单元开始,其偏移量为其类型大小的整数倍。

isa

我们在探索alloc的流程的流程时,在_class_createInstanceFromZone函数中有用到initIsa,进入initIsa代码如下:

_class_createInstanceFromZone函数,堆内存申请的结构体指针和cls绑定在一起:

_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone,
                              int construct_flags = OBJECT_CONSTRUCT_NONE,
                              bool cxxConstruct = true,
                              size_t *outAllocatedSize = nil)
{
    ASSERT(cls->isRealized());

    // Read class's info bits all at once for performance
    bool hasCxxCtor = cxxConstruct && cls->hasCxxCtor();
    bool hasCxxDtor = cls->hasCxxDtor();
    bool fast = cls->canAllocNonpointer();
    size_t size;

    size = cls->instanceSize(extraBytes);
    if (outAllocatedSize) *outAllocatedSize = size;
    ...
    ...
    ...
    if (!zone && fast) {
        obj->initInstanceIsa(cls, hasCxxDtor);
    } else {
        // Use raw pointer isa on the assumption that they might be
        // doing something weird with the zone or RR.
        obj->initIsa(cls);
    }
    ...
    ...
    ...
}

进入到initIsa函数如下:

inline void 
objc_object::initIsa(Class cls)
{
    initIsa(cls, false, false);
}

继续进入到下层的initIsa,如下图: initIsa.png

上面的函数有一个isa_t,我们看下这个isa_t,如下图: isa_t.png 我们发现这个isa_t就是一个联合体,line81和line82就是isa_t的构造方法,bits是属性。

在表现类的地址过程中,经常会出现一个名词:nonPointerIsa,nonPointerIsa是什么呢?它不是一个简简单单的指针。类是一个对象,也是一个指针,类里面有很多的信息是可以存储的, 当前指针是8字节,8 * 8 = 64 位,这64位如果只是存一个指针,那这个空间就大大的浪费了,我们可以优化一些空间,因为每个类几乎都有一个isa,导致它很浪费,所以苹果就想优化一下这些空间。在isa中,我们经常关注的类,和类相关的变量等一些东西我们可以写在里面。比如是否正在释放、引用计数、weak、关联对象、析构函数(oc下层是c/c++,oc层的释放不是真正的释放,而是下层c/c++的释放,下层的函数不释放它就不会释放。)等等。这些信息都和类有关,可以把这些信息存到这64位里面,所以就出现了nonPointerIsa。

要看这个联合体isa_t里面存了什么东西,重点要看位域,接下来看一下ISA_BITFIELD,如下图: ISA_BITFIELD.png 这是x86_64架构下(macOS)的ISA_BITFIELD结构,也有arm64位架构下(iOS)的ISA_BITFIELD结构。

  • nonpointer是1位,表示是否对isa指针开启指针优化,0:表示纯isa指针,1:表示不止是类对象地址,isa中包含了类信息、对象的引用计数等。
  • has_assoc是关联对象标志位,0表示没有关联,1表示存在关联。
  • has_cxx_dtor该对象是否有c++或者objc的析构器,如果有析构函数,则需要做析构逻辑,如果没有,则可以更快的释放对象。
  • shiftcls是类的指针地址,存储类指针的值。开启指针优化的情况下,在x86_64架构中有44位来存储类指针,在arm64架构中真机是有33位来存储类指针。
  • magic用于调试器判断当前对象是真的对象还是没有初始化的空间。
  • weakly_referenced对象是否被指向或者曾经指向一个arc的弱变量,没有弱引用的对象可以更快释放。
  • unused是否没使用。
  • has_sidetable_rc散列表,当对象引用计数大于10时,则需要借用该变量存储进位。
  • extra_rc表示该对象的引用计数值,实际上是引用计数值减1。如果对象的引用计数为10,那么extra_rc为9。如果引用计数大于10,则需要用到has_sidetable_rc。 我们画了一个x86_64架构下的ISA_BITFIELD存储分布图,如下: 作业3截图.png

总共是64位。第0位存储nonpointer,第1位存储has_assoc,第2位存储has_cxx_dtor,第3位到第46位存储shiftcls,第47位到第52位存储magic,第53位存储weakly_referenced,第54位存储unused,第55位存储has_sidetable_rc,第56位到63位存储extra_rc。纯poniterIsa是只存储类的指针地址,默认创建的都是nonPointerIsa。通过nonPointerIsa我们最主要的是拿到shiftcls

从实际代码中来分析下这个isa,创建一个demo1,main.m文件中代码如下:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        OCPeople *p = [OCPeople alloc];
        NSLog(@"%@",p);
    }
    return 0;
}

断点运行起来,打印输出一下p,如图: 4gxp.png

把p的内存地址中的第一条16进制数据(isa)以二进制输出,以及打印输出OCPeople类的内存地址,如下图: pt.png 0x011d8001000080e9以二进制打印出来,可以看到它的64位是没有全部存满的,还有7位没有用到,浪费很多。当前对象p关联到了类OCPeople,那对象的地址是怎么和类的地址0x00000001000080e8关联的呢?这里就需要引入ISA_MASK了。

ISA_MASK

刚刚讲的x86_64架构下(macOS)的ISA_BITFIELD结构时,截图里面有个宏定义# define ISA_MASK 0x00007ffffffffff8ULLISA_MASK是面具,针对类的面具,将0x011d8001000080e9ISA_MASK进行与操作,就能得出我们想要的信息(类的地址0x00000001000080e8),如下图: 对象的地址与上isamask.png 对象p通过isa,这个isa是不纯的,通过掩码操作一下,得到了类cls。为什么可以这样操作呢?因为原来存就是这样存的,现在是反过来去取。正常的是通过类关联到对象,现在是反过来从对象到类。

接下来我们回到上面提到的initIsa函数,把objc的源码跑起来,其中main.m中的代码如下图:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        OCPeople *ocp = [OCPeople alloc];
        NSLog(@"%@",ocp);
        }
    return 0;
}

进入到initIsa函数: 截屏2021-06-21 上午12.21.37.png

此时的nonpointer为true,我们打印一下newisa,如下: 截屏2021-06-21 上午12.24.29.png

newisa是通过联合体isa_t声明的,此时newisa里面所有的变量都是0或者nil,代码继续运行,运行到line363,打印newisa,如图: 截屏2021-06-21 上午12.32.47.png 此时的newisa.bits是有值了,ISA_MAGIC_VALUE是宏定义,为 0x001d800000000001ULL,打印出来bits的值是8303511812964353,这个值等于ISA_MAGIC_VALUE,只不过一个是10进制一个是16进制。 给bits赋值后,cls的值也变化了,bits的值也赋值给了clsp/t 0x001d800000000001打印出0x001d800000000001的二进制为0b0000 0000 0001 1101 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0001,是64位的,其中的第0位1赋值给了nonpointer,表示不是纯的isa指针,它不仅包括类对象的地址信息,还包括了类信息和引用计数等信息。第47位到52位是111011p 0b111011刚好是59,赋值给了magic

代码继续运行,如图: 截屏2021-06-21 上午1.13.49.png

从这句newisa.setClass(cls, this);我们断点进入到setClass函数: 截屏2021-06-21 上午1.17.21.png 这里的if...else比较多,我采用的是最笨的方法,在分支语句处打断点,发现这个函数进来后,直接进入了line213这句。我们通过p/x newCls命令以16进制打印输出newCls的地址,再打印此地址右移3位的结果为536875229shiftcls就等于536875229

代码继续运行到line367,打印输出newisa,如图: 截屏2021-06-21 上午1.24.08.png shiftcls打印出来的值就是我们上面计算的536875229,一模一样。OCPeople类的地址,右移3位就是shiftcls的值。此时的newisa与类OCPeople已经关联,打印出来的cls等于了OCPeople

继续往下运行,到line376,打印输出newisa。此时的extra_rc也有了值为1。bits的值也发生了变化,bits = 80361110145894121。但shiftcls的值没变,在上一步的探索中已经关联成功。如下图: 截屏2021-06-21 下午3.18.19.png 总结一下:cls与isa关联就是isa指针中的shiftcls存储了类信息。

接下来,我们再回到demo1中,断点运行,x/4gx p打印输出p对象: 截屏2021-06-21 下午3.46.11.png 对象p的isa是0x011d8001000080e90x011d8001000080e9右移3位是0x0023b0002000101d0x0023b0002000101d左移20位是0x0002000101d000000x0002000101d00000右移17位是0x00000001000080e8。打印出类OCPeople的地址是0x00000001000080e8,和isa进行位移运算后最终的结果是一样的。可以看到对象p的isa已经关联了类OCPeople

isa位移运算过程图解,如下: 位移过程.png

总结一下isa与类关联:

  • 类的地址右移3位就是shiftcls的值,shiftcls是isa中的ISA_BITFIELD下的字段。
  • isa进行位移运算就是类的地址。
  • isa跟ISA_MASK进行与(&)操作也能得到类的地址。

关于对象的本质和isa就探究到这里,本文的创作参考了逻辑cooci大神的讲解和学员的博客,感谢他们的分享!文章如有错误,欢迎指正