写在前面
在iOS之武功秘籍①:OC对象原理-上(alloc & init & new)一文中讲了底层对象创建的流程,那么本文将来探索下对象中的属性在内存中的排列 -- 内存对齐 和 malloc源码分析
一、对象开辟内存的影响因素(补充)
通过上一篇文章,我们已经知道,创建一个对象,在经过_class_createInstanceFromZone
方法时,其内部的size = cls->instanceSize(extraBytes)
能计算出创建这个对象所需的内存空间大小.那么影响对象开辟内存的因素是什么呢?
① 对于对象来说影响其内存开辟的因素(影响对象字节对齐的因素) -- 对象的属性(更确切的说应该是对象的成员变量)
举个🌰:-
当我们的
TCJPerson
对象没有其他属性的时候,只有一个从父类NSObject
继承过来的isa
时,此时创建TCJPerson
对象所需的开辟的内存空间大小为16字节. -
当我们增加一个
name
属性时,此时的size
大小还是 16(if (size < 16) size = 16
).
-
接着我们在增加一个
nickName
属性,此时需要的size
大小为 32 (对象的字节对齐为16字节,开辟对象的内存大小必须是16的倍数)
② 如何查看对象属性在内存中的显示
测试代码:注:如果对象创建了没去赋值属性——它会是内存假地址
我们应先给对应的属性赋值,不然的话他们在内存中就是假的地址.因为内存是连续的,如果没去用的话,在内存中就是野指针.
②.1 第一种方式LLDB指令 -- 查看对象属性在内存中的显示
LLDB调试命令等预备知识:
-
po
与p
:p表示"expression"——打印对象指针;而po是"expression -O"——打印对象本身
-
x 对象
表示以16进制打印对象内存地址(x表示memory read)因为iOS是小端模式(数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中——反过来存放数据)所以要倒着读数据
-
x/8gx 对象
表示输出8个16进制的8字节地址空间(x表示16进制,8表示8个,g表示8字节为单位,等同于x/8xg 对象
)
根据我们的计算机基础和LLDB指令,可以发现
- 第一段是
isa
(从64位开始,isa
需要进行一个位运算& ISA_MASK
操作,而x86环境下,ISA_MASK
的值为0x0000000ffffffff8ULL
) - 第二段中
0x00000012
是18
-- 对应age
,而62
、61
分别是b
、a
的ASCII
编码 - 第三段中po出来是
TCJ
--对应name
- 第四段中po出来是
CJ
-- 对应nickName
- 第五段是po出来是
185
-- 对应height
查看控制台输出:
去掉声明属性查看控制台输出:
即TCJPerson
类中不声明任何属性:
提出问题
Q1:为什么成员变量的顺序和我们声明属性的顺序不同?!Q2:sizeof、class_getInstanceSize、malloc_size分别是什么?
后面讲解....
Q3:不是说对象最少为16字节,为什么class_getInstanceSize还能输出8字节?
后面讲解...
②.2 第二种方式:实时查看内存状况Debug->Debug Workflow->View Memory(shift + Command +M)
一般不推荐用第二种方式.
二、字节对齐
① sizeof、class_getInstanceSize、malloc_size
-
sizeof()
:是一个运算符,不是函数.传入数据类型,输出内存大小,在编译时确定.只与数据类型相关,与具体数值无关。(如:bool
2字节,int
4字节,对象(指针)8字节) -
class_getInstanceSize
:依赖于<objc/runtime.h>
,是runtime
提供的api
,用于获取类的实例对象所占用的内存大小
,并返回具体的字节数,其本质就是获取实例对象中成员变量的内存大小
(8字节对齐) -
malloc_size
:依赖于<malloc/malloc.h>
,返回系统实际分配的内存大小
(16字节对齐)
前面的打印也就得以验证.
在来总结一波:
-
sizeof
:计算类型占用的内存大小
,其中可以放基本数据类型
、对象
、指针
- 对于类似于
int
这样的基本数据而言,sizeof
获取的就是数据类型占用的内存大小,不同的数据类型所占用的内存大小是不一样的 - 而对于类似于
NSObject
定义的实例对象而言,其对象类型的本质就是一个结构体(即 struct objc_object)的指针
,所以sizeof(objc)
打印的是对象objc的指针大小
,我们知道一个指针的内存大小是8字节,所以sizeof(objc)
打印是 8.注意:这里的8字节与isa
指针一点关系都没有!!! - 对于指针而言,
sizeof
打印的就是8,因为一个指针的内存大小是8字节.
- 对于类似于
class_getInstanceSize
:计算对象实际占用的内存大小,这个需要依据类的属性而变化,如果自定义类没有自定义属性,仅仅只是继承自NSObject
,则类的实例对象实际占用的内存大小是8,遵循8字节对齐.malloc_size
:计算对象实际分配的内存大小,这个是由系统完成的.可以从上面的打印结果看出,实际分配的和实际占用的内存大小并不相等.
② 对象的内存对齐
我们知道就对象整体而言,苹果系统采用16字节对齐开辟内存大小,提高系统存取性能。
那么对于对象内部呢?
- 对象的本质是结构体,这个在后续篇章中我们会详细讲解.所以研究对象内部的内存,就是研究结构体的内存布局.
- 内存对齐目的:最大程度提高资源利用率.
③ 结构体内存对齐
搞个🌰瞧瞧:输出结果: CJStruct1-24 CJStruct2-16 CJStruct3-32 CJStruct4-24
.
从打印结果我们可以看出一个问题,两个结构体乍一看,没什么区别,其中定义的变量 和 变量类型都是一致的,唯一的区别只是在于定义变量的顺序不一致
,那为什么他们做占用的内存大小不相等呢?结构体内部的元素排序影响内存大小.其实这就是iOS中的内存字节对齐现象.
结构体内存对齐规则
每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数).程序员可以通过预编译命令#pragma pack(n)
,n=1,2,4,8,16来改变这一系数,其中的n就是你要指定的“对齐系数”.在iOS
中,Xcode
默认为#pragma pack(8)
,即8字节对齐
注意:这里的8字节对齐是结构体内部对齐规则,对象在系统中对外实际分配的空间是遵循16字节对齐原则。
【三条内存对齐规则】:
- 数据成员的对齐规则可以理解为
min(m, n)
的公式, 其中m
表示当前成员的开始位置,n
表示当前成员所需位数.如果满足条件m
整除n
(即m % n == 0
),n
从m
位置开始存储, 反之继续检查m+1
能否整除n
, 直到可以整除, 从而就确定了当前成员的开始位置. - 数据成员为结构体:当结构体嵌套了结构体时,作为数据成员的结构体的自身长度作为外部结构体的最大成员的内存大小(即在确定复合类型成员的偏移位置时则是将复合类型作为整体看待),且结构体成员要从其内部最大元素大小的整数倍地址开始存储.比如结构体a嵌套结构体b,b中有char、int、double等,那b应该从8的整数倍开始存储.
- 最后结构体的内存大小必须是结构体中最大成员内存大小的整数倍,不足的需要补齐.
iOS基础数据类型占用的字节数表
利用结构体对齐规则来分析前面的🌰
结构体CJStruct1
内存大小计算:
- 变量a:占8个字节,从0开始,此时min(0,8),即 0-7 存储 a
- 变量b:占1个字节,从8开始,此时min(8,1),8能整除1,,即 8 存储 b
- 变量c:占4个字节,从9开始,此时min(9,4),9不能整除4,继续往后移动,直到min(12,4),从12开始即 12-15 存储 c
- 变量d:占2个字节,从16开始,此时min(16, 2),16可以整除2,即16-17 存储 d
因此CJStruct1
的需要的内存大小为 18 字节,而CJStruct1
中最大变量的字节数为8,所以CJStruct1
实际的内存大小必须是 8 的整数倍,18向上取整到24,主要是因为24是8的整数倍,所以sizeof(CJStruct1)
的结果是 24.
结构体CJStruct2
内存大小计算:
- 变量a:占8个字节,从0开始,此时min(0,8),即 0-7 存储 b
- 变量b:占4个字节,从8开始,此时min(8,4),8可以整除4,即 8-11 存储 c
- 变量c:占1个字节,从12开始,此时min(12, 1),12可以整除1,即12 存储 d
- 变量d:占2个字节,从13开始,此时min(13,2),13不能整除2,继续往后移动,直到min(14,2),从14开始即 14-15 存储 c
因此CJStruct2
的需要的内存大小为 16字节,而CJStruct2
中最大变量的字节数为8,所以CJStruct2
实际的内存大小必须是 8 的整数倍,而 16 正好是 8 的整数倍,所以sizeof(CJStruct2)
的结果是 16.
结构体CJStruct3
内存大小计算:
- 变量e:占4个字节,从0开始,此时min(0,4),即 0-3 存储 e
- 结构体成员
CJStruct1
:CJStruct1
是一个结构体,占24字节.根据内存对齐原则二,结构体成员要从其内部最大成员大小的整数倍开始存储,而CJStruct1中最大的成员大小为8,所以CJStruct1要从8的整数倍开始,当前是从4开始,所以不符合要求,需要往后移动到8,8是8的整数倍,符合内存对齐原则,所以 8-31 存储 CJStruct1.
因此CJStruct3需要的内存大小为 32 字节,而CJStruct3 中最大变量为CJStruct1, 其最大成员内存字节数为8,根据内存对齐原则,所以 CJStruct3 实际的内存大小必须是 8 的整数倍,32正好是8的整数倍,所以 sizeof(CJStruct3) 的结果是 32.
结构体CJStruct4
内存大小计算:
- 变量e:占8个字节,从0开始,此时min(0,8),即 0-7 存储 e
- 结构体成员
CJStruct2
:CJStruct2
是一个结构体,占16字节.根据内存对齐原则二,结构体成员要从其内部最大成员大小的整数倍开始存储,而CJStruct2
中最大的成员大小为8,所以CJStruct2
要从8的整数倍开始,当前是从8开始,符合要求,符合内存对齐原则,所以 8-23 存储 CJStruct2.
因此CJStruct4
需要的内存大小为 24 字节,而CJStruct4
中最大变量为CJStruct2
, 其最大成员内存字节数为8,根据内存对齐原则,所以CJStruct4
实际的内存大小必须是 8 的整数倍,24正好是8的整数倍,所以sizeof(CJStruct4)
的结果是 24.
④ 内存优化(属性重排)
如果按照对象默认声明的属性顺序进行内存分配,在进行属性的8字节对齐时会浪费大量的内存空间,所以这里系统会把对象的属性重新排列,以此来最大化利用我们的内存空间
验证①:
就CJStruct1
与CJStruct2
而言,他们的成员属性一样,只是他们之间的属性排列位置不同,他们分别占用不同的内存.
验证②:
就前面 -- 如何查看对象属性在内存中的显示 中的例子也验证了这一点.
我们声明TCJPerson
的属性的顺序是:isa(继承NSObject) -> name(NSString) -> nickName(NSString) ->age(int) -> height(long) -> c1(char) -> c2(char);
而实际分配内存时的属性顺序是:isa(继承NSObject) ->age(int) -> c2(char) -> c1(char) -> nickName(NSString) -> name(NSString)-> height(long),并且将age 和 c1 及 c2 存放在了一个块区. 这就是苹果的内存优化的体现.
⑤小彩蛋
- 对于对象之间,系统面对的对象太多,系统为了防止容错,采用的是16字节对齐的内存,给对象留足够间距,避免越界访问(所以malloc_size读取的都是16的倍数)
- 但为了避免浪费太多内存空间,系统会在每个对象内部进行属性重排,并使用8字节对齐,使单个对象占用的资源尽可能小.(所以class_getInstanceSize读取的都是8的倍数)
三、malloc源码辅助分析
通过上一篇文章,我们已经知道,创建一个对象,在经过_class_createInstanceFromZone
方法时,其内部的obj = (id)calloc(1, size)
方法是根据计算好的空间大小size
(如size = 40
),去系统申请空间,并返回地址指针的.
我们发现点击calloc
进入内部,只能看到calloc
声明.无法再继续前进了
我们可以看到calloc
的声明是在malloc源码中.
打开我为你们准备好的可编译的malloc源码.
① calloc
在libmalloc
源码中新建target
,按照objc
源码中的调用方式操作:
② malloc_zone_calloc
之后进入calloc
流程,进行具体的内存开辟,在使用calloc
申请内存的过程中,首先调用malloc_zone_calloc
方法
根据return ptr
可知ptr
是重点,但是ptr = zone->calloc(zone, num_items, size)
;跟进去会看到让人一串摸不到头脑的代码,而且到此源码还无法继续跟进了:
③ default_zone_calloc
那么重点来了!!!想要继续跟进源码,可以通过以下方法:
方法一: —— 分析zone
已知zone
是malloc_zone_t
类型的,在第二步中retval = malloc_zone_calloc(default_zone, num_items, size)
;中传递的第一个参数zone
又是default_zone
,跟踪进去会发现它是一个静态变量
static malloc_zone_t *default_zone = &virtual_default_zone.malloc_zone;
static virtual_default_zone_t virtual_default_zone
__attribute__((section("__DATA,__v_zone")))
__attribute__((aligned(PAGE_MAX_SIZE))) = {
NULL,
NULL,
default_zone_size,
default_zone_malloc,
default_zone_calloc,
default_zone_valloc,
default_zone_free,
default_zone_realloc,
default_zone_destroy,
DEFAULT_MALLOC_ZONE_STRING,
default_zone_batch_malloc,
default_zone_batch_free,
&default_zone_introspect,
10,
default_zone_memalign,
default_zone_free_definite_size,
default_zone_pressure_relief,
default_zone_malloc_claimed_address,
};
初步推测zone->alloc是default_zone_calloc
方法二: —— 控制台打印
有时候打印也是阅读源码的一种方法——由打印可知实际调用default_zone_calloc
方法三: —— 按住control + step into,进入calloc的源码
加上图中的断点,来到断点后:按住control + step into
同样也会来到:
这其中有两个非常重要的操作:
- 创建真正的
zone
,即runtime_default_zone
方法 - 使用真正的
zone
进行calloc
④ nano_calloc
继续打印zone->calloc
,得到提示nano_calloc
:
在malloc
的源码中搜索nano_calloc
,于nano_malloc.c
文件中找到该方法,其中的核心代码_nano_malloc_check_clear
,进行内存申请,并且返回一个成熟的指针ptr.
⑤ _nano_malloc_check_clear
分析:这个方法中有三个return和一句注释/* FALLTHROUGH to helper zone */——进入辅助区域,即正常情况下走if判断(如果要开辟的空间小于 NANO_MAX_SIZE 则进行nanozone_t的malloc)NANO_MAX_SIZE=256
⑥ segregated_size_to_fit
进入_nano_malloc_check_clear
,此时此刻看到这么长的一段代码也不用慌张,if-else
只走其一.再仔细想想,我们是带着目的来看源码的——malloc_size
中的48是怎么来的.这里有多个size_t
类,断点调试看了下的size
是我们传进来的40,而slot_bytes
刚好是我们的目标48,那我们就来看下40->48是怎么来的,将error
的异常判断分支折叠起来,查看主流程:
- 其中
segregated_next_block
就是指针内存开辟算法,目的是找到合适的内存并返回(不断递归去寻找合适的内存空间) -
slot_bytes
是加密算法的盐(其目的是为了让加密算法更加安全,本质就是一串自定义的数字)
⑦ 16字节对齐
分析:size 是 40,在经过 (40 + 16 - 1) >> 4 << 4 操作后,结果为48,也就是16的整数倍——即16字节对齐.
写在后面
总结:
- 对象的属性是按照8字节进行对齐的
- 对象本身则是按照16字节进行对齐的
- 因为内存是连续的,通过 16 字节对齐规避了风险和容错,有效的防止了访问溢出
- 同时,也提高了寻址访问效率,也就是通常我们所说的空间换时间
- 和谐学习,不急不躁.我还是我,颜色不一样的烟火.