iOS底层-OC对象原理(下)

600 阅读13分钟

背景

  • 我们说在整个苹果的底层有太多内容,跟黑洞似的,这时候我们拿出对象最熟悉的陌生人作为一个案例开始分析iOS底层OC对象原理(上),但我们也不知道怎么分析,于是我们通过LLDB汇编符号断点,找到底层源码并LLDB调试,通过底层源码我们开始流程分析,得到alloc的流程分析图,后来我们通过探索alloc我们把注意力转移到了内存大小上,然后去验证对象的内存得出一些字节对齐的算法和一些原理,然而我们对对象真正的大小还一无所知。
  • 对象类型内存多大?
  • 对象实际内存大小和系统分配的内存大小是否一样?
  • 内存是怎么对齐的?
  • 对象的本质是什么?
  • isa是怎么和类关联的?

准备

  • objc4源码
  • malloc源码

内存对齐-alignStyle

案例

//ZMPerson声明了4个属性
@property (nonatomic,copy) NSString *name;
@property (nonatomic,copy) NSString *nickName;
@property (nonatomic) int age;
@property (nonatomic) double height;
ZMPerson *p1 = [[ZMPerson alloc] init];
ZMPerson *newP;
NSLog(@"p1:%@ - %lu - %lu - %lu", p1, sizeof(p1), class_getInstanceSize([p1 class]), malloc_size((__bridge const void*)(p1)));
NSLog(@"newP:%@ - %lu - %lu - %lu", newP, sizeof(newP), class_getInstanceSize([newP class]), malloc_size((__bridge const void*)(newP)));

输出结果:

p1:<ZMPerson: 0x6000012e0f00> - 8 - 40 - 48
newP:(null) - 8 - 0 - 0

结果分析:

  • 对象类型的内存大小(sizeof操作符,计算传进来的数据类型大小)p1和newP一样的都是8字节,这在编译期就已经确定了,因为它们本质上是结构体指针
  • newP的内存地址为null,实际内存大小和系统分配的内存大小都是0,因为newP只是声明一个变量,并没有开辟内存
  • p1开辟了内存,但实际内存大小(class_getInstanceSize由类的成员变量大小决定)系统分配大小(malloc_size按16字节对齐方式)却不一样

引申扩展:

  • class_getInstanceSize通过源码查找核心代码为:
define WORD_MASK 7UL//字节面具
//字节对齐算法
static inline size_t word_align(size_t x) {
    return (x + WORD_MASK) & ~WORD_MASK;
}

看核心代码实际内存是计算实际大小后并进行8字节对齐 案例中ZMPerson类定义了4个属性,再加上通过看核心代码4*8(4个属性变量)+8(对象类型)=40

  • malloc_size获取系统分配的内存大小,上篇文章iOS底层-OC对象原理(上)我们知道了在instanceSize计算好需要的内存后向系统calloc申请内存,首先在malloc库下载libSystem_malloc编译运行源码,通过探索malloc源码我们得到这样的逻辑图:

image.png 在上篇文章iOS底层-OC对象原理(上)我们以9为例子探索了16进制对齐算法(&~MASK算法) 这里我们探索一下位移算法 image.png

为什么需要内存对齐:

空间换取时间

CPU在读写数据时,是以块为单位,不是以字节为单位。频繁读写未对齐的数据,给CPU极大压力,降低CPU性能。字节对齐后,减少CPU的读写次数,降低开销。

提升安全、效率

CPU以块为单位的读写方式,操作未对齐的数据可能开始在上一个内存块,结束在另一个内存块。这样中间可能要经过复杂运算在合并在一起,降低了CPU效率还不安全。字节对齐后,提过CPU访问速率。

内存对齐三大原则:

1. 数据成员对⻬,以元素的整数倍为开始

结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小(只要该成员有子成员,比如说是数组,结构体等)的整数倍开始(比如char为1字节,min(当前开始位置为9)则要从9开始存储并存储在9。这时候后面又有个int为4字节,min(这时当前开始的位置为10)但这个int不能从10开始,而是从12(即4的整数倍)开始[12 13 14 15]

2. 结构体作为成员,以最大元素为倍数

如果一个结构里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储.(struct a里存有struct b,b里有char,int ,double等元素,那b应该从8的整数倍开始存储.)

3. 以最大成员整数倍收尾

结构体的总大小,也就是sizeof的结果,必须是其内部最大成员的整数倍,不足的要补⻬

常用类型内存所占字节表

image.png

内存对齐分析

结构体struct

  • 结构体(struct)中所有变量是“共存”的
  • 优点是“有容乃大”, 全面;
  • 缺点是struct内存空间的分配是粗放的,不管用不用,全分配 image.png
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSLog(@"------%lu--%lu--%lu",sizeof(ZMS1),sizeof(ZMS2),sizeof(ZMS3));  
    }
    return 0;
}

打印结果:

------16--24--40

图例:

image.png

内存优化

案例

//ZMPerson定义4个属性
@property (nonatomic) NSString *name
@property (nonatomic) NSString *nickName
@property (nonatomic) int age;
@property (nonatomic) int height;

//实现
ZMPerson *p1 = [[ZMPerson alloc] init];
p1.name = @"moon";
p1.nickName = @"jdt";
p1.age = 19;
p1.height = 179;
NSLog(@"p1:%@ - %lu", p1, class_getInstanceSize(p1.class));

结果

image.png

内存优化分析

理论上定义4个属性,在计算内存时应该是 4 * 8 + 8 = 40字节,为什么实际打印却只有32字节,说明内存对齐又制定了一套规则,目的是提高cpu的存取效率和安全的访问。字节对齐可能浪费了部分内存,但是同时进行内存优化尽可能的降低了内存的浪费,即保证了存取的速率,又减少了内存的浪费

对象的本质

联合体-union

  • 各变量是“互斥”的;
  • 修改联合体中某个变量会覆盖其他变量的值
  • 缺点是不够“包容”;
  • 优点是内存使用更为精细灵活,也节省了内存空间

示例代码

//定义一个联合体
union ZMPerson {
    int a;  //4
    short b;//2
    char c; //1
};
//实现
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        union ZMPerson p;
        p.a = 18;
        NSLog(@"a:%d - b:%d - c:%c", p.a, p.b, p.c);
        p.c = 'f';
        NSLog(@"a:%d - b:%d - c:%c", p.a, p.b, p.c);
        p.b =  3;
        NSLog(@"a:%d - b:%d - c:%c", p.a, p.b, p.c); 
        NSLog(@"%lu---%lu",sizeof(p),sizeof(union ZMPerson));
    }
    return 0;
}

结果分析

a:18 - b:18 - c:
a:102 - b:102 - c:f
a:3 - b:3 - c:
4---4
  • 修改c的值,覆盖了a和b
  • 修改b的值,覆盖了a和c
  • 联合体的内存大小由其中最大的成员的大小决定
  • 所有变量公用一块内存,变量之间互斥

位域-Bit field

  • 优化内存,减少内存空间浪费

示例代码

//普通结构体
struct ZMStruct {
    BOOL top;
    BOOL bottom;
    BOOL left;
    BOOL right;
};
//结构体位域
struct ZMBitfStruct {
    BOOL top    : 1;
    BOOL bottom : 1;
    BOOL left   : 1;
    BOOL right  : 1
};
//实现
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        struct ZMStruct zms;
        struct ZMBitfStruct zmbitfs;
        NSLog(@"-- %lu - %lu", sizeof(zms), sizeof(zmbitfs));
    }
    return 0;
}

结果分析

image.png 从上面我们的常用类型内存所占字节表知道bool类型占一个字节 结构体ZMStruct里分配4字节内存,但ZMBitfStruct发现内存只占1个字节,1字节=8bit(位),ZMBitfStruct在1字节里按位存储,因为macOS为小端模式,bit里从右往左依次为 0000 1111:top、bottom、left、right

clang(main.cpp)

clang知识集

  • Clang是一个C语言、C++、Objective-C语言的轻量级编译器。源代码发布于BSD协议下。 Clang将支持其普通lambda表达式、返回类型的简化处理以及更好的处理constexpr关键字。

  • Clang是一个由Apple主导编写,基于LLVM的C/C++/Objective-C编译器

  • 2013年4月,Clang已经全面支持C++11标准,并开始实现C++1y特性(也就是C++14,这是 C++的下一个小更新版本)。Clang将支持其普通lambda表达式、返回类型的简化处理以及更 好的处理constexpr关键字。

  • Clang是一个C++编写、基于LLVM、发布于LLVM BSD许可证下的C/C++/Objective-C/ Objective-C++编译器。它与GNU C语言规范几乎完全兼容(当然,也有部分不兼容的内容, 包括编译命令选项也会有点差异),并在此基础上增加了额外的语法特性,比如C函数重载 (通过__attribute__((overloadable))来修饰函数),其目标(之一)就是超越GCC。

  • 在这里我们探索的主题:用Clang 把源文件编译成底层文件,比如把main.m 文件编译成main.cpp(也可编译成main.o或者可执行文件)。便于观察底层的逻辑结构,便于我们探究底层。 终端找到工程main函数位置并执行

clang -rewrite-objc main.m -o main.cpp

image.png

案例

@interface ZMPersons : NSObject
{
    NSString *height;
}
@property (nonatomic) NSString *name;
//@property (nonatomic) NSString *nickName;
@property (nonatomic) int age;
//@property (nonatomic) int height;
@end
@implementation ZMPersons

@end

int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        // Setup code that might create autoreleased objects goes here
//        appDelegateClassName = NSStringFromClass([AppDelegate class])
    }
    return 0;
//    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

在main.cpp文件的相关代码:

image.png image.png

image.png

分析

  • 从main.cpp文件里我们看到ZMPersons底层就是个结构体
  • 我们自定义了height、_age、_name,再加上NSObject_IVARS(就是isa),总4个变量
  • NSObject_IMPL里的Class是个指针类型
  • 代码中我们自定义了一个局部变量height,但是底层代码仅添加了一个变量。而定义的属性,底层自动添加了带_变量以及getset方法的实现。

总结

  • 对象本质就是结构体
  • 对象的isa是继承NSObject的isa
  • NSObjct只有一个成员变量就是isa

isa探索

  • 通过iOS底层-OC对象原理(上)我们引入了isa是为了与类关联
  • calloc系统分配内存时多了8字节的内存也是isa指针
  • 而在上面对象的本质时,我们看到对象底层唯一的成员变量也是isa

源码案例

inline void 
objc_object::initIsa(Class cls, bool nonpointer, bool hasCxxDtor) 
{
    ASSERT(!isTaggedPointer());
    if (!nonpointer) {
        isa = isa_t((uintptr_t)cls)
    } else 
        ASSERT(!DisableNonpointerIsa);
        ASSERT(!cls->instancesRequireRawIsa())
        isa_t newisa(0);
        
#if SUPPORT_INDEXED_IS
        ASSERT(cls->classArrayIndex() > 0);
        newisa.bits = ISA_INDEX_MAGIC_VALUE;
        // isa.magic is part of ISA_MAGIC_VALUE
        // isa.nonpointer is part of ISA_MAGIC_VALUE
        newisa.has_cxx_dtor = hasCxxDtor;
        newisa.indexcls = (uintptr_t)cls->classArrayIndex();
#else
        newisa.bits = ISA_MAGIC_VALUE
        // isa.magic is part of ISA_MAGIC_VALU
        // isa.nonpointer is part of ISA_MAGIC_VALUE
        newisa.has_cxx_dtor = hasCxxDtor
        newisa.shiftcls = (uintptr_t)cls >> 3;
#endif
        // This write must be performed in a single store in some cases
        // (for example when realizing a class because other threads
        // may simultaneously try to use the class).
        // fixme use atomics here to guarantee single-store and to
        // guarantee memory order w.r.t. the class index tabl
        // ...but not too atomic because we don't want to hurt instantiation
        isa = newisa;
    }
}
  • 代码中 newisa(0)bits进行赋值,bits里面所有的变量都是0
  • 进到isa_timage.png

源码分析

  • isa_t联合体,并且有两个变量 一个是bits,一个是cls
  • 通过上面分析联合体互斥的,那就意味着初始化isa有两种方式
  • bits被赋值,cls 没有值或者被覆盖
  • cls 被赋值,bits没有值或者被覆盖
  • isa_t中还有一个宏定义的结构体成员变量ISA_BITFIELD

ISA_BITFIELDisa的位域信息

image.png

各变量的含义:

  • nonpointer:表示是否对isa指针进行优化,0表示纯指针,1表示不止是类对象的地址,isa中包含了类信息、对象、引用计数等
  • has_assoc:关联对象标志位,0表示未关联,1表示关联
  • has_cxx_dtor:该对象是否C ++ 或者Objc的析构器,如果有析构函数,则需要做析构逻辑,没有,则释放对象
  • shiftcls:储存类指针的值,开启指针优化的情况下,在arm64架构中有33位用来存储类指针,x86_64架构中占44
  • magic:用于调试器判断当前对象是真的对象还是没有初始化的空间
  • weakly_referenced:指对象是否被指向或者曾经指向一个ARC的弱变量,没有弱引用的对象可以更快释放
  • deallocating:标志对象是否正在释放
  • has_sidetable_rc:当对象引用计数大于10时,则需要借用该变量存储进位
  • hextra_rcextra_rc:表示该对象的引用计数值,实际上引用计数值减1,例如,如果对象的引用计数为10,那么extra_rc9,如果大于10,就需要用到上面的has_sidetable_rc

isa位域我们举例macOS端,如下图: image.png

  • 由于macOS的小端模式,在读取时要从后往前读
  • ULL表示类型,NULL

isa与类关联(obj->initInstanceIsa)

  • 通过iOS底层-OC对象原理(上)alloc流程 alloc --> ... --> _class_createInstanceFromZone,断点到 obj->initInstanceIsa,进入obj->initInstanceIsa->initIsa

image.png 图中显示 newisa.bits = ISA_MAGIC_VALUE 是一个宏 ISA_MAGIC_VALUE = 0x001d800000000001,变量值改变的有bits = 8303511812964353cls = 0x001d800000000001nonpointer = 1margic = 59

  • cls = 0x001d800000000001是因为给bits赋值的时候覆盖了clsisa_t联合体
  • 0位的1等于 nonpointer = 1
  • margic的值按位计算从47位开始,长度是6位 结果就是111011,从第0个位置开始算11101110进制就是59

继续走newisa.setClass到setClass里面 做了一次右移>>3 image.png

image.png

  • shiftcls = 536875162 与上面的算法shiftcls=(uintptr_t)newCls >> 3 得到的结果是一样的。
  • LGPerson的类地址>>3进行10进制转换赋值给shiftcls。此时isa已经关联LGPerson类 ,cls变量被覆盖 cls = LGPerson

(uintptr_t)newCls >> 3 为什么需要右移3

  • MACH_VM_MAX_ADDRESS表示虚拟内存最大寻址空间,在arm64中MACH_VM_MAX_ADDRESS = 0x1000000000 虚拟内存最大寻址空间是36位。在x86_64MACH_VM_MAX_ADDRESS=0x7fffffe00000 虚拟内存最大寻址空间是47
  • 字节对齐是8字节对齐,也就是说指针的地址只能是8的倍数,那么指针地址的后3位只能是0,比如0x80x180x30转换成二进制后3位都是0`。

为了节省内存空间,把后3位0抹去。在__arm64__shiftcls33位,__x86_64__shiftcls44位。因此需要将类地址右移3位,即(uintptr_t)newCls >> 3。可以说isa做到了极致内存优化。

image.png

总结

  • isa 是 (union + bitfield)的方式存储信息,这样可节省大量内存空间

  • 一切皆对象对象就有isa,isa占用很大内存,union联合体的互斥特性可使公用一块儿内存节省内存,而bitfield位域更是在节省的基础上,让isa指针的内存得到充分使用

  • isacls关联,实质是isa指针中shiftcls储了类信息,访问的时候经过偏移量访问

  • 让内卷来得再猛烈一些吧

进阶

isa核心

  • shiftcls =(uintptr_t)newCls >> 3
  • isa&ISA_MASK
  • isa位算法(字节对齐算法)

使用gdb查看内存表

格式: x /nfu <addr>

说明:

  • x 是 examine 的缩写
  • n 表示要显示的内存单元的个数
  • f 表示显示方式
  • u 表示一个地址单元的长度 |显示方式(f)|注释| | --- | --- | |x |按十六进制格式显示变量。| |d |按十进制格式显示变量。| |u |按十进制格式显示无符号整型。| |o |按八进制格式显示变量。| |t |按二进制格式显示变量。| |a |按十六进制格式显示变量。| |i |指令地址格式| |c |按字符格式显示变量。| |f |按浮点数格式显示变量。|
地址单元长度(u)注释
b表示单字节
h表示双字节
w表示四字节
g表示八字节