iOS之武功秘籍②:OC对象原理-中(内存对齐和malloc源码分析)

iOS之武功秘籍 文章汇总

写在前面

iOS之武功秘籍①:OC对象原理-上(alloc & init & new)一文中讲了底层对象创建的流程,那么本文将来探索下对象中的属性在内存中的排列 -- 内存对齐 和 malloc源码分析

本节可能用到的秘籍Demo

一、对象开辟内存的影响因素(补充)

通过上一篇文章,我们已经知道,创建一个对象,在经过_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调试命令等预备知识:
  • pop: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)
  • 第二段中0x0000001218 -- 对应age,而6261分别是baASCII编码
  • 第三段中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), nm 位置开始存储, 反之继续检查 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
  • 结构体成员CJStruct1CJStruct1是一个结构体,占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
  • 结构体成员CJStruct2CJStruct2是一个结构体,占16字节.根据内存对齐原则二,结构体成员要从其内部最大成员大小的整数倍开始存储,而CJStruct2中最大的成员大小为8,所以CJStruct2要从8的整数倍开始,当前是从8开始,符合要求,符合内存对齐原则,所以 8-23 存储 CJStruct2.
    因此CJStruct4需要的内存大小为 24 字节,而CJStruct4 中最大变量为CJStruct2, 其最大成员内存字节数为8,根据内存对齐原则,所以 CJStruct4 实际的内存大小必须是 8 的整数倍,24正好是8的整数倍,所以 sizeof(CJStruct4) 的结果是 24.

④ 内存优化(属性重排)

如果按照对象默认声明的属性顺序进行内存分配,在进行属性的8字节对齐时会浪费大量的内存空间,所以这里系统会把对象的属性重新排列,以此来最大化利用我们的内存空间

验证①:
CJStruct1CJStruct2而言,他们的成员属性一样,只是他们之间的属性排列位置不同,他们分别占用不同的内存.

验证②:
就前面 -- 如何查看对象属性在内存中的显示 中的例子也验证了这一点.
我们声明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

已知zonemalloc_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 字节对齐规避了风险和容错,有效的防止了访问溢出
    • 同时,也提高了寻址访问效率,也就是通常我们所说的空间换时间
  • 和谐学习,不急不躁.我还是我,颜色不一样的烟火.
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容