前言
在iOS探索底层-类的结构探索(上)一文中,我们探索了类的结构中很重要的两个指针,Class isa
和Class superclass
。在这我们继续探索类的结构。
类的数据存储区
WWDC 2020
在WWDC 2020
里面有一段是苹果介绍他对整个runtime的机制进行调整,从而优化了类在内存中的占用。具体地址如下:Advancements in the Objective-C runtime。下面我们针对其中关于类的结构的地方做一些分享。
clean memory & dirty memory
clean memory
clean memory
是指加载后不会发生改变的内存class_ro_t
属于clean memory
因为它是只读的clean memory
可以进行移除,节省更多的内存空间,当需要使用时再从磁盘加载
dirty memory
dirty memory
是指在进程运行时会发生改变的内存- 当类开始使用的时候,系统在运行时会为它分配一片额外的内存,这片内存就是
dirty memory
。 dirty memory
使用起来代价很高,只要进程在运行,它就必须一直存在
类的结构优化
在2020年之前,苹果设计的类的结构是下面这样的
我们看到每一个类,在使用后都会创建出一片内存用来存储他在运行时可能修改的数据,也就是dirty memory
。我们知道dirty memory
是非常昂贵的,苹果显然也意识到了这一点。在苹果的统计数据中,只有大约10%的类真正的修改了他们的方法。因此苹果将class_rw_t
这个结构中的一些数据进行拆分,将class_rw_t
中不是每个类必须存在的方法拆分到了class_rw_ext_t
这个结构中。
这样,整个class_rw_t
的结构大小大约就减少了一半,对于不需要那些额外信息的类就如图示下面这部分内存不需要申请可以直接节省下来。对于那些确实需要额外信息的类,则如下图的流程
class_data_bits_t
通过苹果在WWDC
中的介绍,我们大致的了解了类的结构,下面我们来看看代码
struct objc_class : objc_object {
objc_class(const objc_class&) = delete;
objc_class(objc_class&&) = delete;
void operator=(const objc_class&) = delete;
void operator=(objc_class&&) = delete;
// Class ISA;
Class superclass;
cache_t cache; // formerly cache pointer and vtable
class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags
……省略无关部分……
}
我们重点研究下class_data_bits_t bits
。
struct class_data_bits_t {
……省略部分……
class_rw_t* data() const {
return (class_rw_t *)(bits & FAST_DATA_MASK);
}
……省略部分……
}
其中最重要的方法就是这个data()
方法可以获取到一个class_rw_t *
的结构,也就是苹果介绍的记录类的方法、属性之类数据的地方,继续往里面看去
struct class_rw_t {
……省略部分……
class_rw_ext_t *ext() const {
return get_ro_or_rwe().dyn_cast<class_rw_ext_t *>(&ro_or_rw_ext);
}
const class_ro_t *ro() const {
auto v = get_ro_or_rwe();
if (slowpath(v.is<class_rw_ext_t *>())) {
return v.get<class_rw_ext_t *>(&ro_or_rw_ext)->ro;
}
return v.get<const class_ro_t *>(&ro_or_rw_ext);
}
void set_ro(const class_ro_t *ro) {
auto v = get_ro_or_rwe();
if (v.is<class_rw_ext_t *>()) {
v.get<class_rw_ext_t *>(&ro_or_rw_ext)->ro = ro;
} else {
set_ro_or_rwe(ro);
}
}
const method_array_t methods() const {
auto v = get_ro_or_rwe();
if (v.is<class_rw_ext_t *>()) {
return v.get<class_rw_ext_t *>(&ro_or_rw_ext)->methods;
} else {
return method_array_t{v.get<const class_ro_t *>(&ro_or_rw_ext)->baseMethods()};
}
}
const property_array_t properties() const {
auto v = get_ro_or_rwe();
if (v.is<class_rw_ext_t *>()) {
return v.get<class_rw_ext_t *>(&ro_or_rw_ext)->properties;
} else {
return property_array_t{v.get<const class_ro_t *>(&ro_or_rw_ext)->baseProperties};
}
}
const protocol_array_t protocols() const {
auto v = get_ro_or_rwe();
if (v.is<class_rw_ext_t *>()) {
return v.get<class_rw_ext_t *>(&ro_or_rw_ext)->protocols;
} else {
return protocol_array_t{v.get<const class_ro_t *>(&ro_or_rw_ext)->baseProtocols};
}
}
……省略部分……
}
可以看到里面如苹果介绍一样,含有class_rw_ext_t *ext
,class_ro_t *ro
等结构,还有存储方法列表、属性列表、协议列表的地方。
通过LLDB分析类的数据结构
从源码我们可以看到,类的结构确实如苹果介绍的那样,现在我们换一个方向,通过LLDB来验证一下,首先我们准备一个类
@interface DMPerson : NSObject {
NSString *_action;
}
@property (nonatomic, copy) NSString *name;
@property (nonatomic, strong) NSString *nickname;
- (void)sayHello;
+ (void)sayBye;
@end
@implementation DMPerson
- (void)sayHello {
NSLog(@"hello");
}
+ (void)sayBye {
NSLog(@"bye");
}
@end
然后开始我们的探索之旅,首先我们需要先找到class_data_bits_t
这个结构在哪,那么从哪里入手呢?我们能够获取到类的地址,也就是类的首地址
,接下来就是确定class_data_bits_t
相对于首地址需要偏移
多少才能找到他,我们再来看下类的结构
我们知道 Class isa
和Class superclass
都是结构体指针的类型,也就是他们一共占用了16
个字节,接下来在我们只需要知道cache_t cache
占用多少字节,就可以知道需要偏移多少了
可以看到cache_t cache
结构中主要包含两个,一个是unsigned long
类型的泛型,占用8
个字节,下面是一个联合体
,里面分别是一个结构体和一个preopt_cache_t *
类型的泛型,其中preopt_cache_t *
类型的泛型占用8
个字节。在来看看联合体中的结构体,mask_t
类型的泛型占用4
个字节,_flags
和_occupied
都是uint16_t
分别占用2
个字节,因此整个联合体占用8
个字节,而cache_t
就一共占用8+8=16
字节。所以最终我们需要偏移16 + 16 = 32
字节的位置,就能找到我们的class_data_bits_t
在上面的源代码中,我们了解到,class_rw_t
的结构中,会存储我们的方法、属性、协议等,下面我们就分别进行探索
属性的获取
调用properties()
方法,能够获取到类中的属性列表
可以看到这是一个list_array_tt
的结构,我们可以理解为一个数组,继续往下面寻找
我们找到了一个叫做property_list_t
的结构,打印他的内容看看
发现property_list_t
中存储的也是一个数组,而且中间含有两个元素,那么简单了,获取这各两个元素看看分别是什么
就是我们在类中定义的属性name
和nickname
。
方法的获取
按照属性的获取的思路,我们对方法的获取进行同样的操作
method_list_t
中储存了6个元素,我们获取他们看看
发现打印出来的是个空,并不能看到我们想要找到的方法名,似乎按照属性的获取思路行不通了。那么我们来看看method_t
和property_t
有什么区别。
struct property_t {
const char *name;
const char *attributes;
};
struct method_t {
……省略部分……
struct big {
SEL name;
const char *types;
MethodListIMP imp;
};
struct small {
// The name field either refers to a selector (in the shared
// cache) or a selref (everywhere else).
RelativePointer<const void *> name;
RelativePointer<const char *> types;
RelativePointer<IMP> imp;
bool inSharedCache() const {
return (CONFIG_SHARED_CACHE_RELATIVE_DIRECT_SELECTORS &&
objc::inSharedCache((uintptr_t)this));
}
};
big &big() const {
ASSERT(!isSmall());
return *(struct big *)this;
}
objc_method_description *getDescription() const {
return isSmall() ? getSmallDescription() : (struct objc_method_description *)this;
}
……省略部分……
}
可以看到,property_t
内部直接就是name
和attributes
,因此我们能够直接打印输出出来,而method_t
则没有直接打印输出的地方,我们发现我们想要知道的内容都在一个叫big
的结构体中,那么我们就使用方法去获取他。
打印后我们发现了我们写的实例方法
和系统自动生成的seter&geter
方法,以及系统生成的析构函数
在M1的电脑中,由于CPU架构不一样,调用
big()
方法会报错,提示isSmall = true
会直接走断言。因此我们需要调用getDescription()
方法来实现打印,具体如下
成员变量的获取
在之前属性的获取中我们发现,没有我们设置的成员变量_action
的存在,那么成员变量存储在哪呢?在苹果的介绍中,我们发现在class_ro_t
的结构中有一个ivars的东西,猜想是不是就在其中呢?
同样按照获取属性的流程,我们轻松的就找到了成员变量所在的地方。
类方法的获取
同样的,我们在刚刚的methods()
中,并没有找到类方法的存在,那么类方法会存储在哪里呢?我们知道,实例方法都是类的实例调用的,而实例方法却在类中可以找到。我们同样知道,类也是一个对象,他是元类的实例对象,那么类方法是不是就存储在元类里面呢?我们继续我们的探索
通过方法的获取流程,我们在元类中找到了类方法
的存在。
实际上在C/C++的底层函数中,并不存在实例方法
和类方法
的区别,统一称为函数
。而在OC
中,我们将他们区分了开来,但是如果他们都储存在类中,如果有同名的方法,那么就冲突了,在调用时底层并不知道要调用哪个方法。元类
就是为了解决这个问题,而诞生的一个结构,这也就回答了在上一篇文章中我们的问题,为什么会有元类
的存在。
seter方法和geter方法在底层的实现
我们知道,属性(property)和成员变量之间的区别(ivar)就在于
property = _ivar + seter + geter
下面我们就来探索探索seter和getter方法在底层的具体实现
seter方法在底层的实现
依旧是我们的DMPerson
类,这次我们通过iOS底层探索——对象的本质&isa分析中介绍的方法,将main函数转换成C/C++编写的底层代码。然后搜索DMPerson
我们看到了下面的情况
很明显的看到,setName
在底层调用的是objc_setProperty
方法去进行赋值,而setNickname
却不是,它使用的是内存偏移的方式,为什么呢?objc_setProperty
又是什么呢?我们在回头来看看name
和nickname
的区别
@property (nonatomic, copy) NSString *name;
@property (nonatomic, strong) NSString *nickname;
他们之间只有修饰符的区别,那么他们底层实现不同的原因到底是不是这个修饰符造成的呢?因为只直接通过clang
编译看到的底层代码,所以我们直接去LLVM
中寻找他们之间的关系。
LLVM分析objc_setProperty方法
在LLVM
代码中搜索关键字objc_setProperty
,我们发现了下面这行代码
很明显,这是编译器在关联objc_setProperty
。顺着这个线索,我们找谁调用了这个方法
这是一个中间层方法,那么继续往上找
我们在一个swich
的分支中找到了我们需要的东西,那么很简单,既然有swich
那么区分他们的strategy.getKind()
这个值就是我们接下来继续寻找的线索,而getKind()
是strategy
里面的一个get方法,重点关注PropertyImplStrategy
这个结构
StrategyKind getKind() const { return StrategyKind(Kind); }
PropertyImplStrategy
结构中的这行代码告诉我们,Kind
的赋值的地方就是我们要寻找的地方,那么继续往下找
苹果的注释告诉了我们,如果修饰符是copy
那么我们通常会使用setProperty
来给属性赋值。由于retain
在arc
中几乎已经不使用了,所以我们忽略,直接去找strong
发现当用strong
修饰的时候,Kind
会返回CopyStruct
与Copy
修饰返回的GetSetProperty
不同。到这我们就知道了,使用objc_setProperty
方法,确实与copy
修饰符有关系。
属性的修饰符与objc_setProperty的关系
我们知道一个属性除了copy
,strong
这两个修饰符以外,还有nonatomic
和atomic
这个,接下来我们就用实验的方式来测试并验证刚刚的猜想
@property (nonatomic, copy) NSString *a;
@property (atomic, copy) NSString *b;
@property (nonatomic, strong) NSString *c;
@property (atomic, strong) NSString *d;
重新定义4个属性并用不同的修饰符来修饰,然后查看底层代码如下
我们发现,是否使用objc_setProperty
方法只与属性的修饰符是否是copy
有关(Ps.由于ARC环境,我们暂时忽略retain
)。
geter方法在底层的实现
实际上,在上面的过程中,我们看到不只是objc_setProperty
,还有objc_getProperty
这个方法,在调用getter方法的时候,也存在跟setter方法类似的情况,有调用方法和使用内存偏移两种方式来获取值,那么是不是也是值跟copy
修饰符有关呢?我们继续去LLVM
中寻找答案
除了上面的那种方法以外,我们还找到了另外一个方法
我们可以看到,在C/C++底层中,如果需要调用某个方法的话,会先引入这个方法
而在LLVM
中,这里的Getr
的值就是上图中红框中的代码,那么我们可以认为,只要进到了这个方法里面,那么就会调用objc_getProperty
方法。而调用的条件是什么呢?
bool GenGetProperty =
!(Attributes & ObjCPropertyAttribute::kind_nonatomic) &&
(Attributes & (ObjCPropertyAttribute::kind_retain |
ObjCPropertyAttribute::kind_copy));
看到这里我们就能得出结论
当属性以
copy
或者retain
修饰并且不以nonatomic
修饰的时候,使用objc_getProperty
方法来赋值,否则使用内存偏移的方式赋值。
而上面a,b,c,d的getter
方法底层代码,也验证了这一结论。
补充
关于 IMP,SEL之间的关系
SEL
: 类成员方法的指针,但不同于C语言中的函数指针,函数指针直接保存了方法的地址,但SEL
只是方法编号。IMP
:一个函数指针,保存了方法的地址
SEL
和IMP
的关系就可以解释为:
SEL
就相当于书本的⽬录标题IMP
就是书本的⻚码- 函数就是具体页码对应的内容
编码相关的内容
在使用lldb
查找类methods()
的时候,我们在打印的结果中经常会看到方法的types那个属性的值是类似v24@0:8@16
这样的。那么这到底是什么呢?经过查阅,我们发现,原来这是苹果为了方便使用的一套编码格式,其中v
代表void
返回值,@
代表objcet
也就是对象,:
代表的是一个SEL
。苹果在他的官方文档里面有一个对照表
现在我们知道了符号代表的含义,那么每个符号后面跟的数字是什么含义呢?
- 首先第一个数字
24
,表示的是整个方法在内存中占用的空间是24
字节 - 第二个数字
0
代表@
这个参数是从0号位
开始存储,一个id
占用8个字节 - 第三个数字
8
代表:
从8号位
开始存储,一个SEL也是一个指针,占用8字节 - 第三个数字
16
代表@
从16号位
开始存储,一个id
占用8个字节 - 总共占用
8+8+8 = 24个字节
类相关的面试题
我们看看下面这个很经典的面试题
BOOL re1 = [(id)[NSObject class] isKindOfClass:[NSObject class]];
BOOL re2 = [(id)[NSObject class] isMemberOfClass:[NSObject class]];
BOOL re3 = [(id)[DMPerson class] isKindOfClass:[DMPerson class]];
BOOL re4 = [(id)[DMPerson class] isMemberOfClass:[DMPerson class]];
NSLog(@" re1 :%hhd\n re2 :%hhd\n re3 :%hhd\n re4 :%hhd\n,%hhd,%hhd",re1,re2,re3,re4,re9,re10);
BOOL re5 = [(id)[NSObject alloc] isKindOfClass:[NSObject class]];
BOOL re6 = [(id)[NSObject alloc] isMemberOfClass:[NSObject class]];
BOOL re7 = [(id)[DMPerson alloc] isKindOfClass:[DMPerson class]];
BOOL re8 = [(id)[DMPerson alloc] isMemberOfClass:[DMPerson class]];
NSLog(@" re5 :%hhd\n re6 :%hhd\n re7 :%hhd\n re8 :%hhd\n",re5,re6,re7,re8);
而输出的结果如下
re1 :1
re2 :0
re3 :0
re4 :0
re5 :1
re6 :1
re7 :1
re8 :1
为了探究为什么是这个结果,我们在开源的源码中,找到了isKindOfClass
和isMemberOfClass
的实现,并打上了断点,开始调试。
神奇的事情发生了,isKindOfClass
并没有走我们的断点,整个项目直接断在了isMemberOfClass
方法里。那么抛开isKindOfClass
方法,我们先来分析isMemberOfClass
方法
isMemberOfClass
isMemberOfClass
的类方法,是将获取调用方的元类与传入的类进行比较,如果相等,则返回true
,否则返回false
isMemberOfClass
的实例方法,是获取调用方的类对象与传入的类进行比较,如果相等,则返回true
,否则返回false
我们来看上面的代码
- re2是
NSObject
类调用类方法isMemberOfClass
与NSObject
类比较,很明显,NSObject
的元类与NSObject
本身并不相等,所以返回false
. - re4是
DMPerson
类调用类方法isMemberOfClass
与DMPerson
类比较,DMPerson
的元类与DMPerson
本身并不相等,所以返回false
. - re6是
NSObject
的实例调用实例方法isMemberOfClass
与NSObject
类比较,明显的他们是相同的,所以返回true
- re8是
DMPerson
的实例调用实例方法isMemberOfClass
与DMPerson
类比较,明显的他们是相同的,所以返回true
isKindOfClass
分析完isMemberOfClass
,我们再来研究下isKindOfClass
,为什么没有进断点呢?肯定是有原因的,于是我们打开汇编
我们在汇编中看到,苹果在编译阶段又给我们重定向了,isKindOfClass
并没有走源码中的方法,而是走的objc_opt_isKindOfClass
,到源码中找到该方法,并且打上断点
发现确实走的是这方法,接下来我们开始分析他。
objc_opt_isKindOfClass
在上面的探索中,我们知道,类实际上也是一个对象,我们称之为类对象
。所以不管我们在OC层调用的是类方法还是实例方法,最后都会重定向到objc_opt_isKindOfClass
这个方法中来。简单的分析下他
- 如果是类,那么获取他的元类与传入的类比较,相等返回
true
- 否则获取元类的父类与传入的类比较,相等返回
true
- 依次循环直到获取到的父类为nil,依旧没有找到则返回
false
- 如果是实例,则获取他的类与传入的类进行比较,其他步骤与类调用相同
接下来看上面的代码
- re1 传入的是
NSObject
的类,获取元类与NSObject
不等,继续寻找获取元类的父类为NSObject
与传入的值相等,返回true
- re3 传入的是
DMPerson
的类,获取元类与DMPerson
不等,继续寻找获取元类的父类为NSObject
的元类,与传入的值依旧不等,继续往上NSObject
元类的父类为NSObject
依旧不等,再往上就是nil,最后返回false
- re5 传入的是
NSObject
的实例,获取对象的类,与NSObject
相等,返回true
- re7 传入的是
DMPerson
的实例,获取对象的类,与DMPerson
相等,返回true
看到这里,我们知道苹果有时候会挖坑给我们,所以实践才是唯一的真理。