阅读 406

iOS底层原理探索 一 类结构分析

欢迎阅读iOS底层原理探索系列篇章

一、章前复习

通过前面篇章的探索,我们已成功的从对象过渡到类了.但在探索类之前,还需要补充一下我们在前面篇章中没有细讲的一些小细节.

1.1 alloc的一个小细节

我们在iOS底层原理探索 — alloc&init探索一文中留下了一个细节没有细说,就是在分析alloc源码分析流程的时候,在调用callAlloc方法时,我们只是简单的说了:此方法内部有一系列的判断条件,其中由于方法canAllocFast()的内部调用了bits.canAllocFast(),其返回值为固定值false,所以可以确定之后创建对象只会走class_createInstance方法.即:callAllocif (fastpath(cls->canAllocFast()))方法不走直接走的else后面的代码.那么为什么会这样呢?来看源码:

static ALWAYS_INLINE id
callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
{
    if (slowpath(checkNil && !cls)) return nil;

#if __OBJC2__ //这个表示object-c 2.0 版本才有的功能
    /*
        这里的hasDefaultAWZ()方法是用来判断当前class是否有默认的allocWithZone。
        if (fastpath(!cls->ISA()->hasCustomAWZ())):
        意思就是如果该类实现了allocWithZone方法,那么就不会走if里的逻辑,直接走以下逻辑
        if (allocWithZone) return [cls allocWithZone:nil];
     */
    if (fastpath(!cls->ISA()->hasCustomAWZ())) {
        if (fastpath(cls->canAllocFast())) {
            // No ctors, raw isa, etc. Go straight to the metal.
            bool dtor = cls->hasCxxDtor();
            id obj = (id)calloc(1, cls->bits.fastInstanceSize());
            if (slowpath(!obj)) return callBadAllocHandler(cls);
            obj->initInstanceIsa(cls, dtor);//initInstanceIsa 里面是初始化 isa 指针的操作。
            return obj;
        }
        else {
            id obj = class_createInstance(cls, 0);
            if (slowpath(!obj)) return callBadAllocHandler(cls);
            return obj;
        }
    }
#endif

    if (allocWithZone) return [cls allocWithZone:nil];
    return [cls alloc];
}
复制代码
  • 第一个判断fastpath(!cls->ISA()->hasCustomAWZ())的决定条件就是你是否有重写allocWithZone的方法:即if (fastpath(!cls->ISA()->hasCustomAWZ())):意思就是如果该类实现了allocWithZone方法,那么就不会走if里的逻辑,直接走if (allocWithZone) return [cls allocWithZone:nil];
  • 第二个判断fastpath(cls->canAllocFast())就是关于宏定义的设置:我们沿着源码点进去可以看到:
      bool canAllocFast() {
          assert(!isFuture());
          return bits.canAllocFast();
      }
    复制代码
    顺着bits.canAllocFast();点进去可以看到:
    #if FAST_ALLOC
      size_t fastInstanceSize() 
      {
          assert(bits & FAST_ALLOC);
          return (bits >> FAST_SHIFTED_SIZE_SHIFT) * 16;
      }
      void setFastInstanceSize(size_t newSize) 
      {
          // Set during realization or construction only. No locking needed.
          assert(data()->flags & RW_REALIZING);
    
          // Round up to 16-byte boundary, then divide to get 16-byte units
          newSize = ((newSize + 15) & ~15) / 16;
          
          uintptr_t newBits = newSize << FAST_SHIFTED_SIZE_SHIFT;
          if ((newBits >> FAST_SHIFTED_SIZE_SHIFT) == newSize) {
              int shift = WORD_BITS - FAST_SHIFTED_SIZE_SHIFT;
              uintptr_t oldBits = (bits << shift) >> shift;
              if ((oldBits & FAST_ALLOC_MASK) == FAST_ALLOC_VALUE) {
                  newBits |= FAST_ALLOC;
              }
              bits = oldBits | newBits;
          }
      }
    
      bool canAllocFast() {
          return bits & FAST_ALLOC;
      }
      #else // 一般都会走这里
      size_t fastInstanceSize() {
          abort();
      }
      void setFastInstanceSize(size_t) {
          // nothing
      }
      // 一般流程都会走这个false的返回
      bool canAllocFast() {
          return false;
      }
      #endif
    复制代码
    一般都会走#else后面的代码,也就是bool canAllocFast(){return false}.为什么会这样呢?,这就要去看条件控制:#if FAST_ALLOC这个宏定义的走向了. 在全局搜索宏定义FAST_ALLOC,发现#define FAST_ALLOC (1UL<<2)而这个宏定义外面还加了一层条件判断:
   #if !__LP64__
   ...
   #elif 1
   ...
   #else
   ...
   #define FAST_ALLOC              (1UL<<2)
   #endif
复制代码

因为我们的环境都是在64位环境下,所以可以判断上面的判断只会走#elif 1里面的代码,而#define FAST_ALLOC的定义是在#else里面,即FAST_ALLOC永远都不会define了.即只会走bool canAllocFast(){return false},进而就有callAllocif (fastpath(cls->canAllocFast()))方法不走,直接走的else{}里面的代码.即走的下面红框里面的代码

1.2 联合体互斥

我们在iOS底层原理探索 一 isa原理与对象的本质一文中有分析到,isa的结构其实是一个联合体,而联合体有一大特性,就是其内部属性是共享同一片内存的,也就是说属性之间都是互斥的.

union isa_t {
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }

    Class cls;
    uintptr_t bits;
#if defined(ISA_BITFIELD)
    struct {
        ISA_BITFIELD;  // defined in isa.h
    };
#endif
};
复制代码

因此也就能解释初始化isa的时候,一个分支是赋值cls属性,一个分支是赋值bits属性了.

inline void 
objc_object::initIsa(Class cls, bool nonpointer, bool hasCxxDtor) 
{ 
    assert(!isTaggedPointer()); 
    
    if (!nonpointer) {
        isa.cls = cls;
    } else {
        assert(!DisableNonpointerIsa);
        assert(!cls->instancesRequireRawIsa());

        isa_t newisa(0);

#if SUPPORT_INDEXED_ISA
        assert(cls->classArrayIndex() > 0);
        newisa.bits = ISA_INDEX_MAGIC_VALUE;
        newisa.has_cxx_dtor = hasCxxDtor;
        newisa.indexcls = (uintptr_t)cls->classArrayIndex();
#else
        newisa.bits = ISA_MAGIC_VALUE;
        newisa.has_cxx_dtor = hasCxxDtor;
        newisa.shiftcls = (uintptr_t)cls >> 3;
#endif
        isa = newisa;
    }
}
复制代码

二、类和元类的创建时机

我们在探索类和元类的时候,对于其创建时机还不是很清楚,这里我们先抛出结论:类和元类是在编译期创建的,即在进行alloc操作之前,类和元类就已经被编译器创建出来了. 那么如何来证明呢,我们有两种方式来证明:

2.1 通过LLDB指令打印类和元类指针

我们在main函数开始之前打上断点,也就没有来到TCJPerson *obj = [TCJPerson alloc];,但是我们通过LLDB能打印出TCJPerson的类和元类.这就证明了,类和元类的创建时机是在编译期.

2.2 通过MachoView软件辅助证明:

MachoView 密码:kx8c 编译项目后,使用MachoView打开程序二进制可执行文件查看:

通过上面两种方式证明了:类和元类的创建时机是在编译期.

三、指针内存偏移

3.1 普通指针 - 值拷贝

我们观察上面的代码,虽然整型变量ab都是被赋值为10,但是ab内存地址是不一样的,这种方式被称为值拷贝.

3.2 对象 - 指针拷贝或引用拷贝

通过运行结果,可以知道obj1obj2对象不光自身内存地址不一样,连指向的对象的内存地址也不一样,这种方式被称为指针拷贝引用拷贝.

我们可以用一幅图来总结上面的两个例子:

3.3 用数组指针引出 - 内存偏移

通过运行结果可以看到:

  • &a&a[0]的地址是相同的.即首地址就代表数组的第一个元素的地址.
  • 第一个元素地址0x7ffeefbff400和第二个元素地址0x7ffeefbff404相差4个字节,也就是int的所占的4字节.
  • dd+1d+2这个地方的指针相加就是偏移地址.地址加1就是偏移,偏移一个位数所在元素的大小.
  • 可以通过地址,取出对应地址的值.

四、类的结构分析

OC中的类其实也是一种对象,怎么来证明呢,很简单,我们只需要用clang命令重写我们的OC代码将其转化为C++代码看其底层即可.

4.1 创建TCJPerson对象,并获取到TCJPerson的类,然后利用LLDB指令查看

通过上面结构可以得知:

  • 输出第二个内存地址得到NSObject,继续输出第三个发现输出不了.
  • 通过前面iOS底层原理探索 一 isa原理与对象的本质一文的分析,我们知道第二个内存地址存储的是Class superclass,它代表的是继承关系,也即证明了TCJPerson是继承自NSObject的.

4.2 将OC代码转化为C++代码帮助分析

原文件main.c:

    #import <Foundation/Foundation.h>
    #import <objc/runtime.h>

    @interface TCJPerson : NSObject

    @end

    @implementation TCJPerson

    @end

    int main(int argc, const char * argv[]) {
        @autoreleasepool {

            TCJPerson *obj = [TCJPerson alloc];
            Class objClass = object_getClass(obj);
            NSLog(@"%@ - %p", obj, objClass); //0x00007ffffffffff8ULL
        }
        return 0;
    }
复制代码

在终端执行clang指令:

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main.cpp
复制代码

即可将OC原文件main.c转化为C++文件mian.cpp文件后可看到:

至此,我们可以得出一个结论,Class类型在底层是一个结构体类型的指针,这个结构体类型为objc_classs. 我们再在libObjc的源码中可以找到objc_classs的详细定义:

通过objc_classs的定义,我们可以知道,objc_classs是继承于objc_object的.这就证明了万物皆对象,也从本质上说明类是一种对象,并且第一个属性是从objc_object上继承而来的isa. 除了isa,类还包含了superclass父类:表达继承关系;cache:方法缓存重要结构体;bits:存储数据的结构体.

至此我们可以总结得出:

  • 类是一种对象,并且帮我们定义了一些属性和方法.

  • OC是对C的底层封装,进而有下面的关系:

    C OC
    objc_object NSObject
    objc_class NSObject(Class)

最后我们知道Class的基本结构类型为:

到这有一个疑问:为什么在外面isaClass?

  • 万物皆对象,isa是可以由Class接收的.
  • 早期调用isa是用来返回类的,后面是通过nonpointer区分纯净isa和优化的isa.
  • 用源码查看有:return (Class)(isa.bits & ISA_MASK),进行了Class类型强转.

五、类的属性存储探索

OC中的类都会有属性及成员变量,那么它们究竟是怎么存在于类里面的呢?

5.1 类结构的isa、superclass、cache属性

这里我们需要对类的内存结构有一个比较清晰的认识:

类的内存结构 大小(字节)
isa 8
superclass 8
cache 16

前面两个的大小很好理解,因为isasuperclass都是结构体指针,而在arm64环境下,一个结构体指针的内存占用大小为8字节.而第三个属性cache则需要我们进行抽丝剥茧了. 来看源码:

从上面的代码我们可以看出,cache属性其实是cache_t类型的结构体,其内部有一个8字节的结构体指针,有2个各为4字节的mask_t.所以加起来就是16个字节,也就是说前三个属性总共的内存偏移量为 8 + 8 + 16 = 32 个字节,32 是 10 进制的表示,在 16 进制下就是 20.

5.2 bits属性结合上文提到的内存偏移一起探索

利用LLDB命令来探索类结构的第四个属性bits.

我们为了得到bits的指针地址,就需要进行指针偏移,这里进行一下16进制下的地址偏移计算:

0x100001200 + 0x20 = 0x100001220
复制代码

我们继续打印这个地址有:

通过输出结果,得知bits并不是一个对象,而是一个结构体,这里需要进行强转一下:
又由objc_class源码可知,其内部有data()方法:

所以接着调用data()方法拿到class_rw_t:

接着我们继续查看libObjc中关于class_rw_t的源码:得知class_rw_t也是一个结构体.

由源码推测出相关的属性应该存放在properties里面,我们在打印一下:

接着打印properties:

咦,居然为空.为什么会这样呢?因为这里我们漏掉了一个重要的线索就是const class_ro_t *ro;.我们来到其源码:
可以看到ro的类型是class_ro_t结构体,它包含了baseMethodListbaseProtocolsivarsbaseProperties 等属性.我们刚才在 class_rw_t 中没有找到我们声明在 TCJPerson类中的实例变量 titleStr 和属性 helloName,那么希望就在 class_ro_t身上了,我们接着打印看看它的内容:
通过打印结果,我们猜测,属性应该存在baseProperties里面,我们接着打印看看:
嗯哼,还有谁?我们的属性helloName被找到了,就存放在 class_ro_tbaseProperites 里面.咦,怎么没有看到我们的实例变量titleStr?我们从$10count 为 1 可以得知肯定不在 baseProperites 里面根.据名称我们猜测应该是在$8ivars里面.那我们接着打印:
嗯哼,实例变量titleStr也找到了,那为什么这里的count是2呢?我们接着打印第二个元素看看:
结果为 _helloName.这一结果证实了编译器会帮助我们给属性 helloName 生成一个带下划线前缀的实例变量 _helloName. 至此,我们可以到处一下结论:

  • class_rw_t 是可以在运行时来拓展类的一些属性、方法和协议等内容.
  • class_ro_t 是在编译时就已经确定了的,存储的是类的成员变量、属性、方法和协议等内容.

六、类的方法存储探索

研究完了类的属性是怎么存储的,我们再来看看类的方法又是怎么存储的. 在TCJPerson类里面增加一个readBook的实例方法和一个writeBook的类方法.

按照前面的思路,我们直接读取 class_ro_t 中的 baseMethodList 的内容:
嗯哼,readBook方法被找出来了,这说明baseMethodList就是存储实例方法的地方.我们接着打印剩下的内容:
可以看到baseMethodList中除了我们的实例方法readBook外,还有属性 helloNamegettersetter 方法以及一个 C++ 析构方法.而我们的类方法 writeBook 并没有被打印出来.那么类方法存储在哪呢?

七、类的类方法存储探索

我们上面已经得到了属性,实例方法是怎么样存储的了,但是还留下了一个疑问点,就是类方法是怎么存储的,接下来我们用 RuntimeAPI 来实际测试一下.

首先 testInstanceMethod_classToMetaclass 方法测试的是分别从类和元类去获取实例方法、类方法的结果.由打印结果我们可以知道:

  • 对于类对象来说,readBook 是实例方法,存储于类对象的内存中,不存在于元类对象中.而 writeBook 是类方法,存储于元类对象的内存中,不存在于类对象中.
  • 对于元类对象来说,readBook 是类对象的实例方法,跟元类没关系;writeBook 是元类对象的实例方法,所以存在元类中. 我们再测试另外的一个方法:
    从结果我们可以看出,对于类对象来说,通过 class_getClassMethod 获取writeBook是有值的,而获取 readBook 是没有值的;对于元类对象来说,通过 class_getClassMethod 获取writeBook也是有值的,而获取 readBook 是没有值的.这里第一点很好理解,但是第二点会有点让人糊涂,不是说类方法在元类中是体现为对象方法的吗?怎么通过 class_getClassMethod 从元类中也能拿到 writeBook,我们进入到 class_getClassMethod 方法内部可以解开这个疑惑:

可以很清楚的看到,class_getClassMethod 方法底层其实调用的是 class_getInstanceMethod,而 cls->getMeta() 方法底层的判断逻辑是如果已经是元类就返回,如果不是就返回类的 isa.这也就解释了上面的 writeBook 为什么会出现在最后的打印中了. 除了上面的这种方式,我们还可以通过 isa 的方式来验证类方法存放在元类中.

  • 通过 isa 在类对象中找到元类.

  • 打印元类的 baseMethodsList. 我们也来验证一下: 首先我们获取objClass的内存段:

    接着通过 & ISA_MASK拿到其元类,并且打印其内存段:
    接着按照上面类的属性存储探索的思路,进行指针偏移,获取bits属性:这里进行一下16进制下的地址偏移计算:

    0x100001280 + 0x20 = 0x1000012a0
    复制代码

    查找步骤都在图中标明了.这也验证了类方法存放在元类中.

章后总结

  • 类和元类创建于编译时,可以通过 LLDB 来打印类和元类的指针,或者用 MachOView软件查看二进制可执行文件
  • 万物皆对象:类的本质就是对象
  • 类在 class_ro_t 结构中存储了编译时确定的属性、成员变量、方法和协议等内容,并且对于属性helloName:底层编译会生成相应的settergetter方法,且帮我们转化为_helloName,对于成员变量titleStr:底层编译不会生成相应的settergetter方法,且没有转化为_titleStr
  • 实例方法存放在类中
  • 类方法存放在元类中

在这一章中我们完成了对 iOS 中类的结构的探索,下一章我们将对类的缓存进行探索,敬请期待~

关注下面的标签,发现更多相似文章
评论