NSObject
探索NSObject的本质可以有很多方法,查看objc4(本文使用版本:objc4-818.2)的源码,还可以通过编译器将OC代码转成C++代码,还可以通过断点的方式进入汇编一步一步跟,下面结合起来看一下NSObject的本质。
代码转译
- 前期准备,首先新建一个命令行工程,在
main.m添加一个Person类,代码如下: - 对代码进行转译,可以窥探一些内部实现,通过命令行进入当前
main.m所在文件,输入一下命令(命令详细内容不做陈述,可自行Google): xcrun --sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o arm64-main.mm 这样就会生成一个对应的arm64-main.mm文件。到此我们的转译就完成了。
探索本质
Objective-C中的对象本质是什么?
NSObject
在arm64-main.mm中搜索NSObject,找到了如下代码:
struct NSObject_IMPL {
Class isa;
};
struct 结构体,IMPL其实就是implementation的缩写,所以此处应该是NSObject的数据结构实现->结构体。
其实上面的结构体等同于objc_object的结构体,
/// Represents an instance of a class.
struct objc_object {
Class _Nonnull isa OBJC_ISA_AVAILABILITY;
};
Class
NSObject有一个Class类型的isa,Class就是:
/// An opaque type that represents an Objective-C class.
typedef struct objc_class *Class;
Class本质就是指向objc_class结构体的指针,在想查看objc_class是什么,就要查看objc4的源码了。
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
... 下面就是一些函数了
}
可以看到objc_class是一个继承自objc_object的结构体,也就是说Class也可以看成是一个OC对象,结构里面的delete是C++11里的特性,具体可查看c++11 类默认函数的控制:"=default" 和 "=delete"函数
自定义类
自定义的Person类被转译成了这样:
struct Person_IMPL {
struct NSObject_IMPL NSObject_IVARS;
};
也是一个结构体,里面存放了一个NSObject_IMPL的结构体,等同于下面的定义:
struct Person_IMPL {
Class isa;
};
到这里可以看到,OC对象是基于结构体来实现的。
其他
id
typedef struct objc_object *id;
id是objc_object结构体的指针,和NSObject还是有一定区别的。
SEL
typedef struct objc_selector *SEL;
对象创建的流程
对象创建流程中会有slowpath、fastpath的使用:
#define fastpath(x) (__builtin_expect(bool(x), 1))
#define slowpath(x) (__builtin_expect(bool(x), 0))
其原理可查看__builtin_expect 说明 这篇文章。
关键的方法
alloc
+ (id)alloc {
return _objc_rootAlloc(self);
}
NSObject的类方法alloc,会向下调用_objc_rootAlloc,并把当前类传入。
objc_alloc
// Calls [cls alloc].
id
objc_alloc(Class cls)
{
return callAlloc(cls, true/*checkNil*/, false/*allocWithZone*/);
}
方法直接调用callAlloc,查看此处传入的参数allocWithZone为false。
callAlloc
// Call [cls alloc] or [cls allocWithZone:nil], with appropriate
// shortcutting optimizations.
static ALWAYS_INLINE id
callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
{
#if __OBJC2__
if (slowpath(checkNil && !cls)) return nil;
if (fastpath(!cls->ISA()->hasCustomAWZ())) {
return _objc_rootAllocWithZone(cls, nil);
}
#endif
// No shortcuts available.
if (allocWithZone) {
return ((id(*)(id, SEL, struct _NSZone *))objc_msgSend)(cls, @selector(allocWithZone:), nil);
}
return ((id(*)(id, SEL))objc_msgSend)(cls, @selector(alloc));
}
这个方法对应的分支就比较多了:
- 第一个分支:
#if __OBJC2__,这个预预编译肯定会进,最新版的runtime 就是OBJC2版本,所以里面的代码肯定会执行; - 第二个分支:检查传入的类是否存在
- 存在:继续向下执行;
- 不存在:直接返回nil
- 第三个分支:查看当前类是否有
allocWithZone方法- 有:不进入代码块,继续向下执行;
- 没有:进入代码块,直接调用
_objc_rootAllocWithZone;
- 第四个分支:根据传入的
allocWithZone参数判断是否要通过objc_msgSend调用allocWithZone方法; - 第五个分支:通过
objc_msgSend调用类的alloc方法;
_objc_rootAllocWithZone
NEVER_INLINE
id
_objc_rootAllocWithZone(Class cls, malloc_zone_t *zone __unused)
{
// allocWithZone under __OBJC2__ ignores the zone parameter
return _class_createInstanceFromZone(cls, 0, nil,
OBJECT_CONSTRUCT_CALL_BADALLOC);
}
直接调用_class_createInstanceFromZone方法。
_class_createInstanceFromZone
/***********************************************************************
* class_createInstance
* fixme
* Locking: none
*
* Note: this function has been carefully written so that the fastpath
* takes no branch.
**********************************************************************/
static ALWAYS_INLINE id
_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;
id obj;
if (zone) {
obj = (id)malloc_zone_calloc((malloc_zone_t *)zone, 1, size);
} else {
obj = (id)calloc(1, size);
}
if (slowpath(!obj)) {
if (construct_flags & OBJECT_CONSTRUCT_CALL_BADALLOC) {
return _objc_callBadAllocHandler(cls);
}
return nil;
}
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);
}
if (fastpath(!hasCxxCtor)) {
return obj;
}
construct_flags |= OBJECT_CONSTRUCT_FREE_ONFAILURE;
return object_cxxConstructFromClass(obj, cls, construct_flags);
}
这个方法有3个步骤,计算类在内存中占用大小,根据大小进行开辟内存空间,在进行isa指针绑定,其他的是一些异常边界处理和C++扩展的处理。
创建流程
上面介绍了对象创建的几个关键方法,此处简单介绍一下对象创建流程,objc-4官网源码无法运行,所以使用了objc_debug。下面是创建流程走到最后一步_class_createInstanceFromZone的栈追踪。获取方式:在_class_createInstanceFromZone方法内加上断点,当代码运行到此断点时,在lldb调试输入 bt 即可获得。可以追溯代码运行过程。当然还可以通过断点调试一步一步查看。
* thread #1, queue = 'com.apple.main-thread', stop reason = step over
* frame #0: 0x00000001002fc124 libobjc.A.dylib`_objc_rootAllocWithZone [inlined] _class_createInstanceFromZone(cls=Person, extraBytes=0, zone=0x0000000000000000, construct_flags=2, cxxConstruct=true, outAllocatedSize=0x0000000000000000) at objc-runtime-new.mm:7986:9
frame #1: 0x00000001002fc040 libobjc.A.dylib`_objc_rootAllocWithZone(cls=Person, zone=0x0000000000000000) at objc-runtime-new.mm:8021
frame #2: 0x00000001002fbc5c libobjc.A.dylib`_objc_rootAlloc [inlined] callAlloc(cls=Person, checkNil=false, allocWithZone=true) at NSObject.mm:1932:16
frame #3: 0x00000001002fbbf0 libobjc.A.dylib`_objc_rootAlloc(cls=Person) at NSObject.mm:1949
frame #4: 0x000000010035b838 libobjc.A.dylib`+[NSObject alloc](self=Person, _cmd="alloc") at NSObject.mm:2547:12
frame #5: 0x0000000100359a1c libobjc.A.dylib`objc_alloc [inlined] callAlloc(cls=Person, checkNil=true, allocWithZone=false) at NSObject.mm:1940:12
frame #6: 0x000000010035996c libobjc.A.dylib`objc_alloc(cls=Person) at NSObject.mm:1956
frame #7: 0x0000000100003c64 KCObjcBuild`main(argc=1, argv=0x000000016fdff4a0) at main.m:24:28 [opt]
frame #8: 0x000000019e445430 libdyld.dylib`start + 4
简单介绍一些上面这些数据该如何解读,拿frame #6举例,当前要执行的是Person *person = [Person alloc];方法:
#7:后面0x000...是方法的内存地址,libobjc.A.dylib是方法所在库;objc_alloc是方法名,(`后面就是方法名)。(cls=Person),括号内是执行方法要传入的参数at后面是方法在源文件的位置,没有源码的话是没有该数据的。- [inlined],内联函数,优化频繁调用的函数大量消耗栈空间(栈内存)的问题。
我们要从
frame #6开始查看。
frame #6 - objc_alloc
- 调用方法:
objc_alloc - 传入参数:传入的是
Person类 - 调用说明:此处是编译器做了优化,直接从
[Person alloc]到了此处。
frame #5 - callAlloc
- 调用方法:
callAlloc - 传入参数:
- cls=Person, Person类
- checkNil=true, 执行判空处理
- allocWithZone=false, 不执行
allocWithZone:方法。
- 调用说明:这是第一次进入
callAlloc方法,根据传入的参数在看该方法内的执行逻辑,要么会执行_objc_rootAllocWithZone,要么会通过objc_msgSend调用alloc方法。通过下一帧frame #4可以看到,是走到alloc方法。
frame #4 - alloc
- 调用方法:
+[NSObject alloc] - 传入参数:(以下为OC方法默认追加参数,分别放在第一、第二位)
- self=Person,类。
- _cmd="alloc", 当前执行的方法。
- 调用说明:此处调用的是
+[NSObject alloc]方法,是_objc_rootAllocWithZone调起的。
frame #3 - _objc_rootAlloc
- 调用方法:
_objc_rootAlloc。 - 传入参数:传入的是
Person类。 - 调用说明:
alloc内直接调用该方法。
frame #2 - callAlloc
- 调用方法:
callAlloc - 传入参数:
- cls=Person Person类
- checkNil=false 不进行判空处理
- allocWithZone=true 需要执行
allocWithZone:方法
- 调用说明:此处是通过
_objc_rootAlloc内调起的callAlloc方法,这是第二次进入callAlloc方法,此处传入的参数与frame #5传入的参数有些区别,checkNil变成了false,这样可以快速跳过cls的异常处理。allocWithZone变成了true,但是最后并没有通过objc_msgSend调用alloc方法。(而是调用了_objc_rootAllocWithZone方法,传入参数:cls=Person, zone=0x0000000000000000。)
frame #1 - _objc_rootAllocWithZone
- 调用方法:
_objc_rootAllocWithZone - 传入参数:
- cls=Person Person类
- zone=0x0000000000000000 指向nil的内存地址
- 调用说明:方法内会直接调用
_class_createInstanceFromZone方法。
frame #0 - _class_createInstanceFromZone
- 调用方法:
_class_createInstanceFromZone - 传入参数:
- cls=Person, 类
- extraBytes=0, 是否需要申请额外的内存空间
- zone=0x0000000000000000, 是否需要通过malloc_zone_calloc方式开辟内存
- construct_flags=2, 创建失败的处理
- cxxConstruct=true, 是否需要调用C++的构造函数,还要依赖于当前类是否实现构造函数。
- outAllocatedSize=0x0000000000000000, 抛出当前对象创建所需要的内存空间大小。
- 调用说明:这是创建流程的最后一步,计算大小、开辟空间、绑定isa指针。
流程图
没有实现allocWithZone方法流程图如下:
疑问
- 为什么
callAlloc方法会执行两次? - 编译器是如何优化
alloc方法的(从alloc直接调用到了objc_alloc),目的是什么? llvm对alloc进行了hook,调用alloc是会先调用objc_alloc,此时会标记objc_alloc已经被调用过,接下来会走到callAlloc方法,里面会通过objc_msgSend调用alloc方法,由于之前的标记,此时不会在调用objc_alloc方法。
再此hook猜测应该是要做一些内存上的监控吧。
对象在内存中所占的大小
想要了解对象在内存中的大小,我们先要了解内存大小的单位,以及基本数据类型在内存中所占大小,以及对象的本质,是如何表示一个对象的。
位(bit):
计算机内最小的存储单元是位(bit),一位只能用0或者1来表示,即二进制(还有八进制、十六进制,一个十六进制包含4个二进制,也就是4bit)。如果用一个二进制表示十进制里的3,应该是11。
字节(byte):
一个字节包含8位,里面有8个bit,8个二进制数据。这里就可以引申一个问题,为什么int的取值范围是-2147483648(-2^31)~2147483647(2^31-1)?
这里解释的不太对,详细的可以查看下面的内容
一个int数据在内存中占有4个字节(32位),int存储的是整数,其中也包括负数,表示数据的正负需要一位,首位是0表示正数,首位是1表示负数,那么就只剩下31位去真正表示数据了。
- -2的二进制是 1001
- -1的二进制是 1111
- 0的二进制是 0000
- 1的二进制是 0001 这样算下来int的最大值就不是2^32,而需要减少一位了,也就是最大值为
2^31-1,最小值就是-2^32.还会有疑问为啥最大值要做-1操作,而最小值不用?- 最大值:二进制
0111(这里的首位0表示正整数)能表示的数是0~7,也就是0~2^3-1,这样31位二进制能表示的值就是0~2^31-1,所以最大值就是2^31-1(2147483647)。- 最小值:整数里的负数不是从0开始的,而是从-1开始的,-1的二进制是
10,此时最后一位二进制的0是表示1,那二进制1111(这里的首位1表示负整数)表示的数时-1~-8,也就是-1~-2^3,所以最小值也就是-2^31。 最后总结:整数的基本数据类型的取值范围要看是否有符号- 无符号:
0~2^n-1(因为是无符号,所以不需要首位表示正负数了)- 有符号:
-2^(n-1)~2^(n-1)-1其中n为该数据类型在内存中所占的bit总数。
基本数据类型
下表是基础数据类型在内存中占用大小(字节)
| 类型 | 32位 | 64位 |
|---|---|---|
| BOOL | 1 | 1 |
| char | 1 | 1 |
| unsigned char | 1 | 1 |
| short | 2 | 2 |
| unsigned short | 2 | 2 |
| int | 4 | 4 |
| unsigned int | 4 | 4 |
| long | 4 | 8 |
| unsigned long | 4 | 8 |
| long long | 8 | 8 |
| NSInteger | 4 | 8 |
| float | 4 | 4 |
| double | 8 | 8 |
| CGFloat | 4 | 8 |
| 指针 | 4 | 8 |
结构体
如果想要创建一个对象,首页我们要知道当前对象在内存中需要开辟多少空间,占用多少字节,上面已经介绍过了OC对象是基于结构体来实现的,想要知道一个对象占用多少空间,就要知道该对象转换后的结构体在内存中占用多少空间,结构体大小要遵循一个内存对齐原则:
-
结构体变量的起始地址能够被其最宽的成员大小整除;
-
结构体每个成员相对于起始地址的偏移能够被其自身大小整除,如果不能则在前一个成员后面补充字节;
-
结构体总体大小能够被最宽的成员的大小整除,如不能则在后面补充字节; 下面举例说明一下:
struct x1 { int a; char b; char c; } x1;假如内存从
0x00开始:int aint类型在内存中占用4个字节,成员变量a占用0x00到0x34个字节- 成员变量
char b和成员变量char c分别各占用1个字节,这样成员变量b占用0x04,成员变量c占用0x05, - 基于
规则3,需要在0x05后补充字节,补充到总体大小能被最宽成员变量a(4)整除,所以需要填充0x06、0x07两个字节 - 最后结构体
x1占用8个字节
struct x2 { char a; int b; char c; } x2;假如内存从
0x00开始:char achar类型在内存中占用1个字节,成员变量a占用0x00,int bint类型在内存中占用4个字节,基于规则2,当前成员变量b的起始地址的偏移为1,并不能被自身大小(4)整除,所以要在成员变量a后面填充0x01、0x02、0x03,从0x04开始距离起始地址的偏移为4,能被4整除,成员变量b就站用了0x04~0x07,- 最后成员变量
c占用0x08,基于规则3,需要填充后3个字节0x09~0x011。 - 最后结构体
x2占用12个字节
struct x3 { char a; char b; int c; } x3;假如内存从
0x00开始:- 成员变量
char a和成员变量char b分别各占用1个字节,成员变量a占用0x00,成员变量b占用0x01, - 基于
规则2,当前成员变量b的起始地址的偏移为2,并不能被自身大小(4)整除,所以要在成员变量b后面填充0x02、0x03 int cint类型在内存中占用4个字节,占用0x04~0x07- 最后结构体
x2占用8个字节根据上面的分析,不难得出上面例子三个结构体的内存布局如下:最后输出内容:
int main(int argc, const char * argv[]) { @autoreleasepool { NSLog(@"x1 is %lu",sizeof(x1)); // 输出8 NSLog(@"x2 is %lu",sizeof(x2)); // 输出12 NSLog(@"x3 is %lu",sizeof(x3)); // 输出8 } return 0; }在以后定义结构时,最优的方式是将内存占用最小的数据类型放在前面,这样可以较少一些内存开销。
OC对象
oc中有两个方法可以查看对象所占内存大小,一个是在runtime库中,一个是在malloc库中,方法分别是:
#import <objc/runtime.h>
/**
* Returns the size of instances of a class.
*
* @param cls A class object.
*
* @return The size in bytes of instances of the class \e cls, or \c 0 if \e cls is \c Nil.
*/
size_t class_getInstanceSize(Class _Nullable cls)
查看注释,当前方法需要传入一个Class,会返回当前类实例对象在内存中占用大小。
#import <malloc/malloc.h>
/* Returns size of given ptr */
extern size_t malloc_size(const void *ptr)
当前方法是传入一个指针,返回当前指针所指向的内容在内存中所占用大小。
下面看看NSObject对象占用内存大小:
结合
NSObject_IMPL结构体,结构体内就一个指向objc_class结构体的指针,指针在内存(64位)中占用8字节,而malloc_size返回的是16。
class_getInstanceSize
先看看objc4的源码是如何实现class_getInstanceSize方法的:
-
class_getInstanceSize()实现:size_t class_getInstanceSize(Class cls) { if (!cls) return 0; return cls->alignedInstanceSize(); }与注释描述的一样,如果传入一个nil,会返回0。
-
alignedInstanceSize()的实现:// Class's ivar size rounded up to a pointer-size boundary. uint32_t alignedInstanceSize() const { return word_align(unalignedInstanceSize()); }该方法是获取当前类里的成员变量的大小,并且做了字节对齐的操作。
-
unalignedInstanceSize()的实现:
// May be unaligned depending on class's ivars. uint32_t unalignedInstanceSize() const { ASSERT(isRealized()); return data()->ro()->instanceSize; }获取类成员变量的大小,内存是否对齐要依赖类里面的对象,如果成员变量只有1个
指针,加上类原有的的isa,那当前情况下是对齐的。如果只有一个int类型的数据,那就不是对齐的。 -
word_align()的实现:
// 64位下, // define WORD_MASK 7UL static inline uint32_t word_align(uint32_t x) { return (x + WORD_MASK) & ~WORD_MASK; }这是
WORD_MASK+1对齐的算法,比如当前是WORD_MASK为7,那就会进行8对齐,换算为8的倍数。 比如当前传入7,7 + 7 = 14; 14的二进制为 00001110 7的二进制为 00000111 在取反之后 11111000 在做&运算: 00001110 & 11111000 ---------- 00001000 最后结果的十进制为8.
-
class_getInstanceSize方法实质就是取当前类里的成员变量在内存中所占大小,并且要按8字节进行对齐。
可以进行以下测试,自定义一个类Person,里面加一个age的属性,最新的Xcode属性会自动生成成员变量、setter、getter,然后通过class_getInstanceSize方法获取大小,应该是16:
可以看到输出就是
16。
下面看看Person类的实例在内存中的布局,可通过两种方式查看,一种是通过lldb(lldb查看内存信息)命令查看,另一种是通过Xcode中Debug->Debug Workflow->View Memory查看。
代码如下:
- lldb命令:
类
Person创建的实例对象p在内存中占用16个字节,前8个字节存放的是对象的isa指针,指向的是Person的类对象(通过类对象内存地址0x1可以确定iOS中类对象是存放在全局区的, [iOS] 内存五大区),后8个字节存放的是成员变量age的值10。通过isa& 上一个mask可以获取类对象地址,可先自行搜索一下。x/4gx (isa&ISA_MASK)可以查看类对象的内存信息,可以自行尝试。 - View Memory:
iOS 设备的处理器是基于 ARM 架构的,默认是采用小端模式,所以读取数据的时候要从右向左读取。
isa指针就是:0x0100000100b5d4a1,而成员变量age就是:0x000000000000000a。 在这可以推测出,一个实例对象里面只存储了成员变量,实例对象的方法并没有存储到对象内,对象的方法是存储到了类对象内。
可以自行尝试在添加一个属性,查看内存大小以及存储会发生什么变化。
malloc_size
malloc_size是libmalloc提供的API,用于获取系统实际分配的内存大小。
oc中开辟内存方式:
使用的方式:
void *calloc(size_t __count, size_t __size);`
在内存中动态地分配`count`个长度为`size`的连续空间,
objc-4里的代码:
obj = (id)calloc(1, size);
传入的count是1,而size是一个计算出来的值。如果我们找到这个size是怎么算出来的,就可以了解NSObject实例对象大小了。
instanceSize
在对象创建过程中最后一步会走到_class_createInstanceFromZone,此处会通过instanceSize方法计算出对象开辟内存所占用的空间大小,在此,我们只要搞懂instanceSize里的代码就知道为什么NSObject在内存中要站用16个字节
了。具体实现代码如下:
inline size_t instanceSize(size_t extraBytes) const {
if (fastpath(cache.hasFastInstanceSize(extraBytes))) {
return cache.fastInstanceSize(extraBytes);
}
size_t size = alignedInstanceSize() + extraBytes;
// CF requires all objects be at least 16 bytes.
if (size < 16) size = 16;
return size;
}
首先会去从缓存(cache_t)中取InstanceSize,取不到在进行16字节补齐,
-
fastInstanceSize
size_t fastInstanceSize(size_t extra) const { ASSERT(hasFastInstanceSize(extra)); if (__builtin_constant_p(extra) && extra == 0) { return _flags & FAST_CACHE_ALLOC_MASK16; } else { size_t size = _flags & FAST_CACHE_ALLOC_MASK; // remove the FAST_CACHE_ALLOC_DELTA16 that was added // by setFastInstanceSize return align16(size + extra - FAST_CACHE_ALLOC_DELTA16); } }
暂时只看最后的return align16(size + extra - FAST_CACHE_ALLOC_DELTA16),最后是一个16对齐的算法。
-
align16
static inline size_t align16(size_t x) { return (x + size_t(15)) & ~size_t(15); }
和前面的word_align是一样的原理。
OC对象是16字节对齐
继承自NSObject的类,所生成的实例在内存占用大小是按照16字节对齐的。下面简单测试一下:
声明了一个Person类,自带一个isa指针,占用8字节,里面有一个name属性,指针类型,占用8字节,一个age属性,int类型,占用4字节,转换成结构体后,按照结构齐对齐原则应该是共占用24字节,也就是class_getInstanceSize获取到的大小,又根据runtime内存对齐原则,最后生成的对象应该是占用32字节,也就是malloc_size获取到的大小。
代码中
name、age属性都是没有进行赋值操作的,最后也会占用内存,如果一些Model或其他实体类内有大量属性的,可以通过其他方式进行存储减少一些内存占用。
查看libmalloc内的源码,在开辟内存时,里面也会进行16字节对齐。
疑问:
cache_t是如何关联InstanceSize的,还存储了哪些数据?- runtime有一个16字节对齐的计算,libmalloc内也有,这样不是重复计算了吗?
- 结构体、OC对象内部为什么要做内存对齐?是否可以理解这是一个典型的空间换时间的策略?下面参考资料里的内存对齐可以解答。