OC底层原理04-OC对象内存优化
本文将结合我的另外一篇文章 Object-C底层原理03-结构体内存对齐 来讲讲 OC对象的内存对齐和内存分配规则.
在开始之前, 我们先来了解一下获取内存大小的三种方式 :
sizeof
class_getInstanceSize
malloc_size
sizeof
-
sizeof
是一个操作符,不是函数. - 用于获取
数据类型
占用内存的大小. - 在编译阶段就能确定所传入的
数据类型
占用内存大小.
基本数据类型如
sizeof 获取内存int
,char
,double
...... , 我想不用多说了,
比较特殊的是指针
, 占用 8 个字节.
class_getInstanceSize
class_getInstanceSize
是 runtime 提供的 api,用于获取 类的实例对象
所需要的内存大小.
类的本质就是结构体, 实际就是这个结构体的大小, 我们知道结构体的大小和成员的顺序有直接关系, 为了节约内存, OC 底层对这个结构体做了重排, 以达到所需内存最小.
malloc_size
malloc_size
函数是获取系统实际分配的内存大小.
malloc_size 获取实际分配到的内存大小
注意 :
- 对象的所需的最小内存是 8 字节.
- 对象被分配到的最小内存是 16 字节.
接下来我们举例说明 OC 对象的内存对齐和分配.
创建一个 GFPerson
类, 并打印对象大小
@interface GFPerson : NSObject
// isa // 8 0 ~ 7
@property(nonatomic, copy) NSString *name; // 8 8 ~ 15
@property(nonatomic, copy) NSString *nick; // 8 16 ~ 23
@property(nonatomic) char c1; // 1 24
@property(nonatomic, assign) int age; // 4 28 ~ 31
@property(nonatomic) char c2; // 1 32
@property(nonatomic, assign) long *height; // 8 33 ~ 40 共41字节, 最大 8, 所需 48
@end
@implementation GFPerson
@end
int main(int argc, char * argv[]) {
@autoreleasepool {
GFPerson *obj = [[GFPerson alloc] init];
NSLog(@"obj 类型所需的内存: %lu", class_getInstanceSize([obj class]));
NSLog(@"obj 实际分配的内存: %lu", malloc_size((__bridge const void*)(obj)));
}
return 0;
}
按照结构体的内存计算规则, 指针类型占 8 字节, int
占 4 字节, char
占 1 字节, 最终得到 这个类的对象至少需要 48 字节内存空间.
注意: isa 是继承自 NSObject 的, 就是我们上面据说的最小内存为 8
打印结果如下图 :
这与我们计算的结果对不上, 不但没有超过, 41, 还比41小, 这显然是底层动了手脚. 我们知道结构体成员的顺序直接影响了结构体的大小,
猜测: 底层对成员的顺序进行了重排, 这个例子比较简单, 我们自己也可以重排一下, 再计算, 来验证
@interface GFPerson : NSObject
// isa // 8 0 ~ 7
@property(nonatomic, copy) NSString *name; // 8 8 ~ 15
@property(nonatomic, copy) NSString *nick; // 8 16 ~ 23
@property(nonatomic, assign) int age; // 4 24 ~ 27
@property(nonatomic) char c1; // 1 28
@property(nonatomic) char c2; // 1 29
@property(nonatomic, assign) long *height; // 8 32 ~ 39 共40字节, 最大 8, 所需 40
@end
因此我们有理由相信底层对类的成员顺序做了一定的调整, 以达到节约内存, 提高效率的目的.
那么问题来了, GFPerson
这个类需要 40 个字节, 为什么系统给分配了 48 个字节呢, 从 alloc 源码分析可以知道, 系统在分配内存时采用的是 16 字节对齐方式, 因此被分配了 48 个字节的内存, 而计算类本身大小时, 采用的是 8 字节对齐.
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;
}
以上这些我们都是从类的本质 - 结构体的角度出发去分析的. 接下来我们从 内存的实际情况来看一下到底是不是这样的.
在 main
函数中给 obj
的属性赋值,
GFPerson *obj = [[GFPerson alloc] init];
obj.name = @"Andy";
obj.nick = @"shuaige";
obj.age = 18;
obj.c1 = 'a';
obj.c2 = 'b';
断点调试 obj
,根据 GFPerson
的对象地址,查找出属性的值.
通过地址找到 name
和 nick
的值:
当我们想通过地址 0x0000001200006261
找出 age 等数据时,发现是乱码,这里无法找出值的原因是苹果中针对 age
, c1
, c2
属性的内存进行了重排,因为age
类型占4个字节,c1
和c2
类型 char
分别占1个字节,通过4+1+1的方式,按照8字节对齐,不足补齐的方式存储在同一块内存中,
age
的读取通过0x00000012
c1
的读取通过0x61
c2
的读取通过0x62
为什么 c1
, c2
输出的是 97 , 98 呢,
十六进制 0x61 转换为 十进制是 97, 就是 'a' 的 ASCII 码值.
十六进制 0x62 转换为 十进制是 98, 就是 'a' 的 ASCII 码值.
注意:
属性没有赋值的地址都是 0x0000000000000000
这里可以总结下苹果中的内存对齐思想:
大部分的内存都是通过固定的内存块进行读取,
尽管我们在内存中采用了内存对齐的方式,但并不是所有的内存都可以进行浪费的,苹果会自动对属性进行重排,以此来优化内存
字节对齐到底采用多少字节对齐?
到目前为止,我们在前文既提到了8字节对齐,也提及了16字节对齐,那我们到底采用哪种字节对齐呢?
我们可以通过objc4中 class_getInstanceSize
的源码来进行分析,
/**
* 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.
*/
OBJC_EXPORT size_t
class_getInstanceSize(Class _Nullable cls)
OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);
size_t class_getInstanceSize(Class cls)
{
if (!cls) return 0;
return cls->alignedInstanceSize();
}
// Class's ivar size rounded up to a pointer-size boundary.
uint32_t alignedInstanceSize() const {
return word_align(unalignedInstanceSize());
}
static inline uint32_t word_align(uint32_t x) {
//x+7 & (~7) --> 8字节对齐
return (x + WORD_MASK) & ~WORD_MASK;
}
# define WORD_MASK 7UL
内存优化总结 :
- 按照 8 字节内存对齐的方式对属性顺序进行重排, 当然也是按照 8 字节内存对齐方式计算需要内存大小, 这样可以最大限度利用内存, 减少内存浪费.
- 在实际分配内存, 开辟内存空间时, 按照 16 字节内存对齐方式进行分配, 这样 cpu 在读取数据时就可以一次读取 16 个字节, 比一次读取 8 个字节效率要高的多, 然而一个对象最多只是多开辟 8 个字节的内存空间. 以空间换时间.
- 具有一定的容错功能, 如果是刚刚好, 那么当出错时可能访问到其他对象的内容.