iOS底层原理探索— Runtime之isa的本质

探索底层原理,积累从点滴做起。大家好,我是Mars。

往期回顾

iOS底层原理探索—OC对象的本质
iOS底层原理探索—class的本质
iOS底层原理探索—KVO的本质
iOS底层原理探索— KVC的本质
iOS底层原理探索— Category的本质(一)
iOS底层原理探索— Category的本质(二)
iOS底层原理探索— 关联对象的本质
iOS底层原理探索— block的本质(一)
iOS底层原理探索— block的本质(二)

今天继续带领大家探索iOS之Runtime的本质。

前言

OC是一门动态性比较强的编程语言,它的动态性是基于RuntimeAPIRuntime在我们的实际开发中占据着重要的地位,在面试过程中也经常遇到Runtime相关的面试题,我们在之前几期的探索分析时也经常会到Runtime的底层源码中查看相关实现。Runtime对于iOS开发者的重要性不言而喻,想要学习和掌握Runtime的相关技术,就要从Runtime底层的一些常用数据结构入手。掌握了它的底层结构,我们学习起来也能达到事半功倍的效果。今天先学习isa

isa

我们在iOS底层原理探索—OC对象的本质一文中讲解OC对象本质的时候提到,每个OC对象的底层结构体中都包含一个isa指针:

struct NSObject_IMPL {
    Class isa;
};

arm64架构之前,isa仅是一个指针,保存着类对象(Class)或元类对象(Meta-Class)的内存地址,在arm64架构之后,苹果对isa进行了优化,变成了一个isa_t类型的共用体(union)结构,同时使用位域来存储更多的信息:

isa.h中objc_object部分代码.png

也就是说,我们之前熟知的OC对象的isa指针并不是直接指向类对象或者元类对象的内存地址,而是需要&ISA_MASK通过位运算才能获取到类对象或者元类对象的地址。

现在大家可能心存疑问,什么是共用体?什么是位域?位运算又是什么?不要着急,接下来一一为大家解答。

1、位域

位域是指信息在存储时,并不需要占用一个完整的字节, 而只需占一个或几个二进制位。例如生活中的电灯开关,它只有“开”、“关”两种状态,那我们就可以用 10 来分别代表这两种状态,这样我们就仅仅用了一个二进制位就保存了开关的状态。这样一来不仅节省存储空间,还使处理更加简便。

2、位运算符

在计算机语言中,除了加、减、乘、除等这样的算术运算符之外还有很多运算符,这里只为大家简单讲解一下位运算符。
位运算符用来对二进制位进行操作,当然,操作数只能为整型和字符型数据。C语言中六种位运算符: & 按位与、 | 按位或、 ^ 按位异或、 ~ 非、 << 左移和 >> 右移。
我们依旧引用上面的电灯开关论,只不过现在我们有两个开关,1代表开,0代表关。

1) 按位与 &

有0出0,全1为1。

按位与.png

我们可以理解为在按位与运算中,两个开关是串联的,如果我们想要灯亮,需要两个开关都打开灯才会亮,所以是1 & 1 = 1。如果任意一个开关没打开,灯都不会亮,所以其他运算都是0。

2) 按位或 |

有1出1,全0出0。


按位或.png

在按位或运算中,我们可以理解为两个开关是并联的,即一个开关开,灯就会亮。只有当两个开关都是关的,灯才不会亮。

3) 按位异或 ^

相同为0,不同为1。


按位异或.png

4) 非 ~

非运算即取反运算,在二进制中 1 变 0 ,0 变 1。例如110101进行非运算后为001010,即1010

5) 左移 <<

左移运算就是把<<左边的运算数的各二进位全部左移若干位,移动的位数即<<右边的数的数值,高位丢弃,低位补0。
左移n位就是乘以2的n次方。例如:a<<4是指把a的各二进位向左移动4位。如a=00000011(十进制3),左移4位后为00110000(十进制48)。

6) 右移 >>

右移运算就是把>>左边的运算数的各二进位全部右移若干位,>>右边的数指定移动的位数。例如:设 a=15,a>>2 表示把00001111右移为00000011(十进制3)。

简单了解了位运算符后,下面为大家介绍位运算符的两种运用场景。

位运算符的运用

1、取值

可以利用按位与 & 运算取出指定位的值,具体操作是想取出哪一位的值就将那一位置为1,其它位都为0,然后同原数据进行按位与计算,即可取出特定的位。

例:0000 0011取出倒数第三位的值

// 想取出倒数第三位的值,就将倒数第三位的值置为1,其它位为0,跟原数据按位与运算
  0000 0011
& 0000 0100
------------
  0000 0000  // 得出按位与运算后的结果,即可拿到原数据中倒数第三位的值为0

上面的例子中,我们从0000 0011中取值,则0000 0011被称之为源码。进行按位与操作设定的0000 0100称之为掩码

2、设值

可以通过按位或 |运算符将某一位的值设为1或0。具体操作是:
想将某一位的值置为1的话,那么就将掩码中对应位的值设为1,掩码其它位为0,将源码与掩码进行按位或操作即可。

例:将0000 0011倒数第三位的值改为1

// 改变倒数第三位的值,就将掩码倒数第三位的值置为1,其它位为0,跟源码按位或运算
  0000 0011
| 0000 0100
------------
  0000 0111  // 即可将源码中倒数第三位的值改为1

想将某一位的值置为0的话,那么就将掩码中对应位的值设为0,掩码其它位为1,将源码与掩码进行按位或操作即可。

例:将0000 0011倒数第二位的值改为0

// 改变倒数第二位的值,就将掩码倒数第二位的值置为0,其它位为1,跟源码按位或运算
  0000 0011
| 1111 1101
------------
  0000 0001  // 即可将源码中倒数第二位的值改为0

到这里相信大家对位运算符有了一定的了解,下面我们通过OC代码的一个例子,来将位运算符运用到实际代码开发中。

我们声明一个Man类,类中有三个BOOL类型的属性,分别为tallrichhandsome,通过这三个属性来判断这个人是否高富帅。

Man类.png

然后我们查看一下一个Man类对象所占据的内存大小:
Man类对象占据内存.png

我们看到,一个Man类的对象占16个字节。其中包括一个isa指针和三个BOOL类型的属性,8+1+1+1=11,根据内存对齐原则所以一个Man类的对象占16个字节。

我们知道,BOOL值只有两种情况:01,占据一个字节的内存空间。而一个字节的内存空间中又有8个二进制位,并且二进制同样只有 01 ,那么我们完全可以使用1个二进制位来表示一个BOOL值。也就是说我们上面声明的三个BOOL值最终只使用3个二进制位就可以,这样就节省了内存空间。那我们如何实现呢?

想要实现三个BOOL值存放在一个字节中,我们可以通过char类型的成员变量来实现。char类型占一个字节内存空间,也就是8个二进制位。可以使用其中最后三个二进制位来存储3个BOOL值。

当然我们不能把char类型写成属性,因为一旦写成属性,系统会自动帮我们添加成员变量,自动实现setget方法。

@interface Man()
{
    char _tallRichHandsome;
}

如果我们赋值_tallRichHansome1,即0b 0000 0001 ,只使用8个二进制位中的最后3个分别用0或者1来代表tallrichhandsome的值。那么此时tallrichhandsome的状态为:

char类型含义.png

结合我们上文将的6中位运算符以及使用场景,我们可以分别声明tallrichhandsome的掩码,来方便我们进行下一步的位运算取值和赋值:

#define Tall_Mask 0b00000100 //此二进制数对应十进制数为 4
#define Rich_Mask 0b00000010 //此二进制数对应十进制数为 2
#define Handsome_Mask 0b00000001 //此二进制数对应十进制数为 1

通过对位运算符的左移 <<右移 >>的了解,我们可以将上面的代码优化成:

#define Tall_Mask (1<<2) // 0b00000100
#define Rich_Mask (1<<1) // 0b00000010
#define Handsome_Mask (1<<0) // 0b00000001

自定义的set方法如下:

- (void)setTall:(BOOL)tall
{
    if (tall) { // 如果需要将值置为1,将源码和掩码进行按位或运算
        _tallRichHandsome |= Tall_Mask;
    }else{ // 如果需要将值置为0 // 将源码和按位取反后的掩码进行按位与运算
        _tallRichHandsome &= ~Tall_Mask;
    }
}
- (void)setRich:(BOOL)rich
{
    if (rich) {
        _tallRichHandsome |= Rich_Mask;
    }else{
        _tallRichHandsome &= ~Rich_Mask;
    }
}
- (void)setHandsome:(BOOL)handsome
{
    if (handsome) {
        _tallRichHandsome |= Handsome_Mask;
    }else{
        _tallRichHandsome &= ~Handsome_Mask;
    }
}

自定义的get方法如下:

- (BOOL)isTall
{
    return !!(_tallRichHandsome & Tall_Mask);
}
- (BOOL)isRich
{
    return !!(_tallRichHandsome & Rich_Mask);
}
- (BOOL)isHandsome
{
    return !!(_tallRichHandsome & Handsome_Mask);
}

此处需要注意的是,代码中!为逻辑运算符,因为_tallRichHandsome & Tall_Mask代码执行后,返回的肯定是一个整型数,如当tallYES时,说明二进制数为0b 0000 0100,对应的十进制数为4,那么进行一次逻辑非运算后,!(4)的值为0,对0再进行一次逻辑非运算!(0),结果就成了1,那么正好跟tallYES对应。所以此处进行两次逻辑非运算,!!

当然,还要实现初始化方法:

- (instancetype)init
{
    if (self = [super init]) {
        _tallRichHandsome = 0b00000100;
    }
    return self;
}

通过测试验证,我们完成了取值和赋值:


测试代码.png

使用结构体位域优化代码

我们上文讲到了位域的概念,那么我们就可以使用结构体位域来优化一下我们的代码。这样就不用再额外声明上面代码中的掩码部分。位域声明的格式是位域名 : 位域长度
在使用位域的过程中需要注意以下几点:

1.如果一个字节所剩空间不够存放另一位域时,应从下一单元起存放该位域。
2.位域的长度不能大于数据类型本身的长度,比如int类型就不能超过32位二进位。
3.位域可以无位域名,这时它只用来作填充或调整位置。无名的位域是不能使用的。

使用位域优化以后:

使用位域优化后的代码.png

测试看一下是否正确,这次我们将tall设为YESrich设为NOhandsome设为YES
优化后测试.png

依旧完成赋值和取值。
但是代码这样优化后我们去掉了掩码和初始化的代码,可读性很差,我们继续使用共用体进行优化:

使用共用体优化代码

我们可以使用比较高效的位运算来进行赋值和取值,使用union共用体来对数据进行存储。这样不仅可以增加读取效率,还可以增强代码可读性。

#define Tall_Mask (1<<2) // 0b00000100
#define Rich_Mask (1<<1) // 0b00000010
#define Handsome_Mask (1<<0) // 0b00000001

@interface Man()
{
    union {
        char bits;
       // 结构体仅仅是为了增强代码可读性
        struct {
            char tall : 1;
            char rich : 1;
            char handsome : 1;
        };
    }_tallRichHandsome;
}
@end

@implementation Man

- (void)setTall:(BOOL)tall
{
    if (tall) {
        _tallRichHandsome.bits |= Tall_Mask;
    }else{
        _tallRichHandsome.bits &= ~Tall_Mask;
    }
}
- (void)setRich:(BOOL)rich
{
    if (rich) {
        _tallRichHandsome.bits |= Rich_Mask;
    }else{
        _tallRichHandsome.bits &= ~Rich_Mask;
    }
}
- (void)setHandsome:(BOOL)handsome
{
    if (handsome) {
        _tallRichHandsome.bits |= Handsome_Mask;
    }else{
        _tallRichHandsome.bits &= ~Handsome_Mask;
    }
}
- (BOOL)isTall
{
    return !!(_tallRichHandsome.bits & Tall_Mask);
}
- (BOOL)isRich
{
    return !!(_tallRichHandsome.bits & Rich_Mask);
}
- (BOOL)isHandsome
{
    return !!(_tallRichHandsome.bits & Handsome_Mask);
}

其中_tallRichHandsome共用体只占用一个字节,因为结构体中tallrichhandsome都只占一位二进制空间,所以结构体只占一个字节,而char类型的bits也只占一个字节,他们都在共用体中,因此共用一个字节的内存即可。
而且我们在setget方法中的赋值和取值通过使用掩码进行位运算来增加效率,整体逻辑也就很清晰了。

但是,如果我们在日常开发中这样写代码的话,很可能会被同事打死。虽然代码已经很清晰了,但是整体阅读起来很是很吃力的。我们在这里学习位运算以及共用体这些知识,更多的是为了方便我们阅读OC底层的代码。下面我们就回到本文主题,查看一下isa_t共用体的源码。

isa_t共用体

isa_t源码.png

我们发现在isa_t共用体内用宏ISA_BITFIELD定义了位域,我们进入位域内查看源码:
ISA_BITFIELD内部源码.png

我们看到,在内部分别定义了arm64位架构和x86_64架构的掩码和位域。我们只分析arm64位架构下的部分内容(红色标注部分)。
可以清楚看到ISA_BITFIELD位域的内容以及掩码ISA_MASK的值:0x0000000ffffffff8ULL
我们重点看一下uintptr_t shiftcls : 33;,在shiftcls中存储着类对象和元类对象的内存地址信息,我们上文讲到,对象的isa指针需要同ISA_MASK经过一次按位与运算才能得出真正的类对象地址。那么我们将ISA_MASK的值0x0000000ffffffff8ULL转化为二进制数分析一下:
ISA_MASK转化为二进制.png

从图中可以看到ISA_MASK的值转化为二进制中有33位都为1,上文讲到按位与运算是可以取出这33位中的值。那么就说明同ISA_MASK进行按位与运算就可以取出类对象和元类对象的内存地址信息。

我们继续分析一下结构体位域中其他的内容代表的含义:

struct {
    // 0代表普通的指针,存储着类对象、元类对象的内存地址。
    // 1代表优化后的使用位域存储更多的信息。
    uintptr_t nonpointer        : 1; 

   // 是否有设置过关联对象,如果没有,释放时会更快
    uintptr_t has_assoc         : 1;

    // 是否有C++析构函数,如果没有,释放时会更快
    uintptr_t has_cxx_dtor      : 1;

    // 存储着类对象、元类对象对象的内存地址信息
    uintptr_t shiftcls          : 33; 

    // 用于在调试时分辨对象是否未完成初始化
    uintptr_t magic             : 6;

    // 是否有被弱引用指向过。
    uintptr_t weakly_referenced : 1;

    // 对象是否正在释放
    uintptr_t deallocating      : 1;

    // 引用计数器是否过大无法存储在isa中
    // 如果为1,那么引用计数会存储在一个叫SideTable的类的属性中
    uintptr_t has_sidetable_rc  : 1;

    // 里面存储的值是引用计数器减1
    uintptr_t extra_rc          : 19;
};

至此我们已经对isa指针有了新的认识,__arm64__架构之后,isa指针不单单只存储了类对象和元类对象的内存地址,而是使用共用体的方式存储了更多信息,其中shiftcls存储了类对象和元类对象的内存地址,需要同ISA_MASK进行按位与 &运算才可以取出其内存地址值。

更多技术知识请关注公众号
iOS进阶


iOS进阶.jpg
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容