1. OC对象内存对齐探究
前面的文章我们已经详细探究了OC对象alloc方法的底层原理,紧接着我们就来探究一下alloc方法调用流程中是如何计算内存大小的,也就是探究(cls->instanceSize(extraBytes);)这行代码中的instanceSize这个方法是如何计算对象所需内存大小的。
首先,定义一个LGPerson类,如下所示:
//LGPerson.h
@interface LGPerson : NSObject
@end
//LGPerson.m
@implementation LGPerson
@end
然后在mian函数中编写如下代码,并在40行打上断点:
然后编译运行程序,程序执行到断点后,再在_class_createInstanceFromZone方法中设置如下断点,并继续执行程序:
查看一下instanceSize方法代码,在instanceSize方法中设置如下断点,继续执行程序:
我们可以发现在这个方法中,因为有缓存,因此又调用了cashe(cache_t类型)的fastInstanceSize方法,并且extraBytes值为0,但是如果没有缓存的话,实际上首次运行时应该是执行如下所示红框中的代码:
因此我们先来探究一下alignedInstanceSize这个方法,如下图所示:
这个方法中又调用了word_align方法,并传入未对齐时实例的内存大小作为参数,因此我们查看一下word_align方法是如何进行内存对齐的,这个方法的代码如下所示:
//7UL实际上就是unsigned long类型的数7
define WORD_MASK 7UL
static inline uint32_t word_align(uint32_t x) {
return (x + WORD_MASK) & ~WORD_MASK;
}
那么代码中的 (x + WORD_MASK) & ~WORD_MASK;到底是什么意思呢?我们可以假定x为某个数来验证一下这句代码的意思:
假设x为7
(7 + 7)& ~7
uint32_t 7的二进制表示为: 0000 0000 0000 0000 0000 0000 0000 0111
加上7之后的二进制表示为: 0000 0000 0000 0000 0000 0000 0000 1110
~7的二进制表示为: 1111 1111 1111 1111 1111 1111 1111 1000
第二步的结果&~7二进制表示为: 0000 0000 0000 0000 0000 0000 0000 1000
结果为8
其实这个算法的意思等同于(x + 7) >> 3 << 3,实际上无论x为何值,最后得到的结果都是8的整数倍。
那么fastInstanceSize方法中逻辑又是如何的呢?其代码如下所示:
#define FAST_CACHE_ALLOC_MASK16 0x1ff0
#define FAST_CACHE_ALLOC_DELTA16 0x0008
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);
}
}
设置如下断点,我们会发现程序运行到了如下代码:
其中size为_flags&FAST_CACHE_ALLOC_MASK这个宏的值,大小为16,那么我们知道在我们定义LGPerson这个类的时候,并没有定义任何的属性或方法,那么为什么它的初始大小会是16呢?因为LGPerson是继承自NSObject,而NSObject中就有一个成员变量为isa,如下图所示:
而Class实际上是objc_class *类型的别名,如下图所示:
而objc_class实际上又是继承自结构体objc_object的结构体类型,如下图所示:
我们都知道一个指针变量大小就是8字节大小,那为什么这里获取到的大小为16字节大小呢?原因就在于在没有缓存之前实际上执行的是word_align这个方法进行内存对齐操作,而通过这个方法传入8字节计算出的内存大小就是16字节,而且就算小于16字节大小,经过后面的代码判断,当对象的大小小于16字节时,最少也会分配16字节,当获取到size大小为16之后系统又是如何进行内存对齐的呢?我们发现紧接着又调用了align16这个方法,我们查看一下这个方法中的代码,如下图所示:
也就是说,当size为16时,减去FAST_CACHE_ALLOC_DELTA16(值为8)与extra(此时值为0)值之后大小为8,而我们发现这个方法的代码与上面的方法word_align中的代码逻辑如出一辙,实际上就是取大于x值的16的整数倍的整数,计算出来就是16,那么系统为什么会如此设计呢?因为这样设计对象与对象之间很大概率存在着间隔,不容易产生内存访问错误,增大了容错率,能够有效减少野指针产生的几率。
2. 影响OC对象分配内存大小的因素
经过上面的探究我们知道了内存对齐原则是以16字节的整数倍为大小给对象分配内存的,那么我们再来探究一下系统为OC对象分配内存大小的影响因素是什么?
首先,我们猜测系统为OC对象分配内存大小的影响因素可能是属性,因此在LGPerson中添加几个属性,再来查看一下分配内存大小的变化,代码如下所示:
//LGPerson.h文件
@interface LGPerson : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) int age;
@end
//LGPerson.m文件
@implementation LGPerson
@end
运行程序,我们在fastInstanceSize方法中打上断点,查看其size大小,如下图所示:
我们得出结论,系统为OC对象分配内存大小的影响因素就是其属性大小以及数量,我们再来添加一个成员变量,再来看看分配内存大小的变化,如下所示在LGPerson.h中添加如下代码:
@interface LGPerson : NSObject {
NSString* name;
}
添加成员变量是会增加OC对象内存分配大小的,那么添加方法会不会影响呢?分别添加一个对象方法和一个类方法,代码如下:
//LGPerson.h文件
@interface LGPerson : NSObject {
NSString* name;
}
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) int age;
- (void)sayHello;
+ (void)sayWorld;
@end
//LGPerson.m文件
@implementation LGPerson
- (void)sayHello {
NSLog(@"hello");
}
+ (void)sayWorld{
NSLog(@"world");
}
@end
查看内存分配情况,如下图所示:
经过以上探究我们发现其实系统为OC对象内存分配大小的影响因素与这个对象所包含的成员变量的大小以及成员变量的数量有关。
3. OC对象字节对齐原理
我们想要探究OC对象字节对齐原理,首先应该先明白结构体中成员变量之间内存是如何对齐的,因为OC类的底层实现其实就是objc_object结构体类型。
想要明白结构体中成员变量之间字节是如何对齐的,我们必须了解下面的三个结构体成员变量对齐原则。
- 数据成员对齐规则:结构体(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小(只要改成员有子成员,比如说:数组、结构体等)的整数倍开始(比如int为4字节,则要从4的整数倍地址开始存储)
- 结构体作为成员:如果一个结构体中有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储(例如:struct a 里中存有struct b,b中有char,int,double等元素,那么b应该从8的整数倍开始存储)。
-
收尾工作:结构体的总大小,也就是sizeof的结果,必须是其内部最大成员的整数倍,不足的要补齐。
然后再来看一下C语言、OC语言中不同数据类型所占字节大小。
接下来我们就做几个练习题,分别算一下下面三个结构体变量在内存中所占字节大小
- 结构体LGStudent1
struct LGStudent1 {
double a;
char b;
int c;
short d;
} s1;
在内存中布局如下图所示:
根据原则3,结构体分配内存大小必须为其最大成员也就是8的整数倍,不足要补齐,因此,结构体LGStudent1最终所占内存大小为24字节。
- 结构体LGStudent2
struct LGStudent2 {
double a;
int b;
char c;
short d;
} s2;
在内存中布局如下图所示:
结构体LGStudent1所占内存分配的总大小为16字节。
-- 结构体LGStudent3
struct LGStudent3 {
double a;
int b;
char c;
short d;
int e;
struct LGStudent1 stu;
} s3;
在内存中布局如下图所示:
结构体LGStudent3所占内存分配的总大小为48字节。
我们再来验证一下答案是否正确,如下所示:
-- 结构体LGStudent4
struct LGStudent4 {
double a;
int b;
char c;
short d;
char e;
struct LGStudent2 stu1;
int f;
struct LGStudent1 stu2;
} s4;
在内存中布局如下图所示:
结构体LGStudent3所占内存分配的总大小为72字节。
探究完结构体字节对齐原理之后,我们再来探究以下OC对象的成员变量字节对齐是如何的,定义一个LGPerson类,其代码如下:
//LGPerson.h文件
@interface LGPerson : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) int age;
@property (nonatomic, assign) BOOL isHappy;
@property (nonatomic, assign) char c;
@property (nonatomic, copy) NSString *hobby;
@property (nonatomic, assign) BOOL isFun;
@property (nonatomic, assign) double height;
- (void)sayHello;
+ (void)sayWorld;
@end
//LGPerson.m文件
@implementation LGPerson
- (void)sayHello {
NSLog(@"hello");
}
+ (void)sayWorld{
NSLog(@"world");
}
@end
main函数中代码如下:
int main(int argc, const char * argv[]) {
@autoreleasepool {
LGPerson *p = [LGPerson alloc];
p.name = @"sss";
p.age = 20;
p.isHappy = true;
p.c = 'k';
p.hobby = @"play";
p.isFun = true;
p.height = 183.5;
}
return 0;
}
如果按照结构体字节对齐原则计算p这个对象所占内存大小,计算结果应该为48字节大小,但是实际情况真的是这样的吗?我们在return之前打上断点,运行程序,查看一下LGPerson类实际内存大小,如下图所示:
实际大小为40字节,这到底是怎么回事呢?我们再来查看一下p对象的内存布局,首先使用x/6gx命令打印一下p对象的内存数据,命令使用说明如下图所示:
p对象的内存布局所下图所示:
上图中每个红框中的数据都为8字节大小,一共5个8字节,正好是p对象所分配内存的大小,我们来分别分析一下这5个标号处的数据
标号1:这个实际上是isa指针的值,将这个标号1处的数据0x011d8001000085f9&ISA_MASK(0x007ffffffffffff8ULL)就可以得到class的值,为0x001d8001000085f8
-
标号2:这个实际上是age(前四个字节)、isHappy(第六字节)、c(第七字节)、isFun(第八字节)这四个属性的值,我们可以打印一下这几个数据,如下所示:
-标号3:这个是属性name的值,打印结果如下:
-标号4:这个是属性hobby的值,打印结果如下:
-标号5:这个是属性height的值,打印结果如下:
实际上,苹果底层对结构体属性在内存中的存储顺序进行了优化,并不是不按照结构体内存对齐原则存储数据,而是以存储空间最小化的原则对结构体属性的存储顺序进行调整。尽量去节省内存空间。
我们现在已经对内存对齐原则有了很清楚的认识,那么系统为什么要使用内存对齐原则来存储结构体数据呢?
首先,我们需要知道的一点是,尽管内存是以字节为单位,但是大部分处理器并不是按字节块来存取内存的,它一般会以2字节,4字节,8字节,16字节甚至32字节为单位来存取内存(以8字节为单位的处理器只能从8的整数倍的地址开始读取数据),我们将上述这些存取单位称为内存存取粒度,现在我们假设处理器是以4字节为单位存取数据的,如果有一个结构体变量a,它分别有如下的成员变量:int a, char b,short c,short d,如果没有内存对齐机制,数据可以任意存放,成员变量a不从为0的地址存储,而是从地址为1的地方连续进行存储,假设其存储结构如下所示:
当CPU读取这个结构体变量的数据时,首先从0地址开始读取数据,并且以4字节大小读取成员变量a的值,但是需要剔除掉0地址中的数据,然后向后从4字节地址读取数据,得到地址4这个字节的数据,剔除掉5、6、7地址中的数据,然后将两次获取的数据合并放入寄存器,读取成员变量b时,需要剔除掉地址4、6、7地址中的数据,读取成员变量c时,需要剔除4、5地址中的数据,然后读取移动到地址8读取成员变量d的数据,这样的过程很繁琐,效率很低,那么我们按照字节对齐原则进行读取又是怎样的过程呢?
如果按照内存对齐原则进行存储,其数据存储结构如下所示:
处理器直接从地址0开始读取成员变量a的数据,然后后移4字节,依次读取成员变量b与c的数据,再次后移读取成员变量d的数据,相比于没有内存对齐原则,这种方式虽然浪费了部分的内存空间,但是处理器读取数据的效率却提高了很多,但是对于现在的计算机硬件发展情况来说,这种处理完全是利大于弊的,这也是以空间换取时间的典型例子。
4. OC对象申请内存大小与系统实际分配内存大小
4.1 查看OC对象申请内存大小与系统实际分配内存大小
分析完对象内存对齐原则以及为什么要进行内存对齐的原因后,我们再来看看OC对象申请内存大小与系统实际分配内存大小是否一样,首先思考一下以下代码中打印输出的数据是怎样的(LGPerson类结构未发生变化):
int main(int argc, const char * argv[]) {
@autoreleasepool {
LGPerson *p = [LGPerson alloc];
p.name = @"sss";
p.age = 20;
p.isHappy = true;
p.c = 'k';
p.hobby = @"play";
p.isFun = true;
p.height = 183.5;
NSLog(@"sizeof: %ld, 申请内存大小:%ld, 系统实际开辟内存大小:%ld", sizeof(p), class_getInstanceSize([LGPerson class]), malloc_size(p));
}
return 0;
}
分析:
sizeof:sizeof是一个运算符,它会计算类型的大小或者传入的参数的类型的大小,p实际上是一个指针,而指针在64位系统中的大小为8字节。
class_getInstanceSize:这个函数是获取类的对象在创建时所申请的内存的大小。
malloc_size:这个函数是获取对象创建后系统实际上所开辟给对象的内存大小。
4.2 系统实际开辟给对象的内存大小的计算原理
我们如果想要知道系统实际开辟给对象内存大小是如何计算的,就需要对calloc函数进行探索,但是我们在工程中只能得到calloc函数的如下信息:
malloc_size这个函数是属于malloc库中的函数,因此,如果想要 深入探究malloc_size这个函数,还需要在苹果开源网站下载malloc库的源码
下载完源码,可以运行后,在main函数中编写如下代码:
#import <Foundation/Foundation.h>
#import <malloc/malloc.h>
int main(int argc, const char * argv[]) {
@autoreleasepool {
void *p = calloc(1, 40);
NSLog(@"%lu",malloc_size(p));
NSLog(@"Hello, World!");
}
return 0;
}
首先查看calloc函数代码实现,如下所示:
void *
calloc(size_t num_items, size_t size)
{
return _malloc_zone_calloc(default_zone, num_items, size, MZ_POSIX);
}
接着查看_malloc_zone_calloc函数实现
MALLOC_NOINLINE
static void *
_malloc_zone_calloc(malloc_zone_t *zone, size_t num_items, size_t size,
malloc_zone_options_t mzo)
{
MALLOC_TRACE(TRACE_calloc | DBG_FUNC_START, (uintptr_t)zone, num_items, size, 0);
void *ptr;
if (malloc_check_start) {
internal_check();
}
ptr = zone->calloc(zone, num_items, size);
if (os_unlikely(malloc_logger)) {
malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE | MALLOC_LOG_TYPE_CLEARED, (uintptr_t)zone,
(uintptr_t)(num_items * size), 0, (uintptr_t)ptr, 0);
}
MALLOC_TRACE(TRACE_calloc | DBG_FUNC_END, (uintptr_t)zone, num_items, size, (uintptr_t)ptr);
if (os_unlikely(ptr == NULL)) {
malloc_set_errno_fast(mzo, ENOMEM);
}
return ptr;
}
可以发现函数的返回值是ptr,而这个函数中是通过zone->calloc的调用对ptr指针进行赋值的,因此我们应该重点探究zone->calloc是如何实现的,在这行代码打上断点,编译运行程序:
通过打印可以发现实际上调用的是default_zone_calloc方法,而这个方法代码实现如下:
static void *
default_zone_calloc(malloc_zone_t *zone, size_t num_items, size_t size)
{
zone = runtime_default_zone();
return zone->calloc(zone, num_items, size);
}
通过打印可以发现实际上调用的是nano_calloc方法,而这个方法代码实现如下:
static void *
nano_calloc(nanozone_t *nanozone, size_t num_items, size_t size)
{
size_t total_bytes;
if (calloc_get_size(num_items, size, 0, &total_bytes)) {
return NULL;
}
if (total_bytes <= NANO_MAX_SIZE) {
void *p = _nano_malloc_check_clear(nanozone, total_bytes, 1);
if (p) {
return p;
} else {
/* FALLTHROUGH to helper zone */
}
}
malloc_zone_t *zone = (malloc_zone_t *)(nanozone->helper_zone);
return zone->calloc(zone, 1, total_bytes);
}
而这个方法中我们探究的重点应该是_nano_malloc_check_clear方法,这个方法中的代码如下:
而这个方法中我们探究的重点应该是segregated_size_to_fit这个方法,其代码如下:
#define SHIFT_NANO_QUANTUM 4
#define NANO_REGIME_QUANTA_SIZE (1 << SHIFT_NANO_QUANTUM) // 16
static MALLOC_INLINE size_t
segregated_size_to_fit(nanozone_t *nanozone, size_t size, size_t *pKey)
{
size_t k, slot_bytes;
if (0 == size) {
size = NANO_REGIME_QUANTA_SIZE; // Historical behavior
}
k = (size + NANO_REGIME_QUANTA_SIZE - 1) >> SHIFT_NANO_QUANTUM; // round up and shift for number of quanta
slot_bytes = k << SHIFT_NANO_QUANTUM; // multiply by power of two quanta size
*pKey = k - 1; // Zero-based!
return slot_bytes;
}
这个方法的意思实际上与最开始我们讲的内存对齐原理中的计算大于size并且最接近16的整数倍的数的算法是一致的。