作者:@iOS成长指北,本文首发于公众号 iOS成长指北,欢迎各位前往指正
如有转载需求请联系我,记住一定要联系哦!!!
为什么系统给NSObject分配的内存大小为16字节,根本原因是什么?
本文源于一个讨论组中一个关于 iOS 基础的问题,为什么系统给 NSObject 分配的最小内存大小为 16 字节且分配的内存大小是 16 的倍数。
为了探究这部分,你至少需要具备以下知识:
- 了解 NSObject 在内存中的本质
- 了解如何获取一个实例对象的大小
- 了解什么字节对齐
基础知识
测试代码是在一个命令行项目中创建的一个 NSObject 对象
NSObject *object = [[NSObject alloc] init];
复制代码
NSObject 在内存中的本质
当我们使用 xrun
将 Objective-C 代码转换成 iphoneos
平台下的 arm64
64 位下的 C++ 代码
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main.cpp
复制代码
如果你需要在模拟器里面的中调试的话——即 Single APP,可以使用下面的命令进行转换
xcrun -sdk iphonesimulator14.4 clang -rewrite-objc -F /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks ViewController.m
复制代码
我们找到 C++ 代码中关于 NSObject 的结构体
struct NSObject_IMPL {
Class isa; //8 个字节
};
复制代码
可知,NSObject 的本质是一个包含 isa
成员变量的结构体。Class 是一个指向结构体的指针,其大小为 8 个字节。
获取实例对象大小的方法
在 iOS 中我们有三种方法来获取我们对象实例的大小,我们来了解一下
这里说的是在 OC 中的调试方法,在 Swift 中我们还有 Memory layout
class_getInstanceSize()
当我们在项目中打印 NSObject 类的实例大小时,我们可以通过 runtime
中的 class_getInstanceSize()
方法来获取
#import <objc/runtime.h>
...
NSLog(@"%zd", class_getInstanceSize([NSObject class])); // 8
复制代码
结果与我们已知的系统为 NSObject 实例所分配的 16 个字节大小不一致
malloc_size()
我们需要探究的是具体对象分配的大小,所以我们来探究一下其实例的 malloc_size
,也就是获取 object
所指向内存的大小
#import <malloc/malloc.h>
...
NSLog(@"%zd", malloc_size((__bridge const void*)object)); //16
复制代码
这个值与我们已知的大小是相符合的
sizeof()
我们还有 sizeof()
这个方法来获取 object
占多大内存。
NSLog(@"%zd", sizeof(object)); // 8
复制代码
值得注意的是,sizeof()
是一个运算符,并不是一个函数。sizeof()
传进来的是类型,用来计算这个类型占多大内存,这个在 编译器编译阶段
就会确定大小并直接转化成 8 、16 、24 这样的常数,而不是在运行时计算。参数可以是数组、指针、类型、对象、结构体、函数等。
字节对齐
在 struct 中,编译器为 struct 的每个成员按其自然边界(alignment)分配空间。各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构的地址相同。
一般来说字节对齐有两个的作用一是便于 CPU 快速访问,二是合理的利用字节对齐可以有效地节省存储空间
源码分析
源码分析需要用到两个开源项目:objc4[1] 和 libmalloc[2]
请使用最新的开源代码,不要抄网上的代码看,相差多个版本的话,部分实现是不一样的!!!
从 runtime 源码中我们可以找到关于 + (id)alloc
方法的调用轨迹:
-
当调用
alloc
方法时,其本质是调用[cls allocWithZone:nil]
方法来实现,注意,zone
的值为 nil+ (id)alloc { return _objc_rootAlloc(self); } ... // Base class implementation of +alloc. cls is not nil. // Calls [cls allocWithZone:nil]. id _objc_rootAlloc(Class cls) { return callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/); } ... // Replaced by ObjectAlloc + (id)allocWithZone:(struct _NSZone *)zone { return _objc_rootAllocWithZone(self, (malloc_zone_t *)zone); } 复制代码
-
实际上是调用
class_createInstance(cls, 0);
来创建对象的——为了行文大小,进行删减id _objc_rootAllocWithZone(Class cls, malloc_zone_t *zone) { id obj; ... obj = class_createInstance(cls, 0); ... } ... id class_createInstance(Class cls, size_t extraBytes) { return _class_createInstanceFromZone(cls, extraBytes, nil); } 复制代码
-
利用
cls->instanceSize(extraBytes);
方法获取值size
——为了行文大小,进行删减static __attribute__((always_inline)) id _class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, bool cxxConstruct = true, size_t *outAllocatedSize = nil) { ... size_t size = cls->instanceSize(extraBytes); ... obj = (id)calloc(1, size); ... return obj; } 复制代码
-
在 instanceSize 方法中会返回一个大小,这个大小经过字节对齐以后生成一个新的,并且这个大小会进行一个判断,判断不小于 16 个字节,然后我们调用
calloc
方法生成一个对象。size_t instanceSize(size_t extraBytes) { size_t size = alignedInstanceSize() + extraBytes; // CF requires all objects be at least 16 bytes. if (size < 16) size = 16; return size; } 复制代码
从源码上已经佐证了, NSObject 分配的最小内存大小为 16 字节。当然我们还可以查看内存,在 object
赋值之后加上断点,利用 View Memory of "object"
获取对象在内存中的字节
然后我们可以看到,的确分配了 16 个字节。
然后通过使用 calloc
方法创建一个对象实例。那我们来看看在 libmalloc
里面 calloc
里面做了什么吧
void *
calloc(size_t num_items, size_t size)
{
return _malloc_zone_calloc(default_zone, num_items, size, MZ_POSIX);
}
复制代码
其实现是调用了 _malloc_zone_calloc
函数,
static void *
_malloc_zone_calloc(malloc_zone_t *zone, size_t num_items, size_t size,
malloc_zone_options_t mzo) {
...
ptr = zone->calloc(zone, num_items, size); //调用 calloc 方法
...
return ptr;
}
复制代码
在探究源码我们发现,在 macOS/iOS
上,苹果采用了名为 malloc_zone
的内存分配方式,将内存块的分配按照尺寸拆成不同的 zone
来完成,一个 zone
可以看做是一系列内存分配相关 api
的组合结构,一个 _malloc_zone_t
结构体其中包含了大量的方法实现,便于展示做了删减:
typedef struct _malloc_zone_t {
...
size_t (* MALLOC_ZONE_FN_PTR(size))(struct _malloc_zone_t *zone, const void *ptr); /* returns the size of a block or 0 if not in this zone; must be fast, especially for negative answers */
void *(* MALLOC_ZONE_FN_PTR(malloc))(struct _malloc_zone_t *zone, size_t size);
void *(* MALLOC_ZONE_FN_PTR(calloc))(struct _malloc_zone_t *zone, size_t num_items, size_t size); /* same as malloc, but block returned is set to zero */
void *(* MALLOC_ZONE_FN_PTR(valloc))(struct _malloc_zone_t *zone, size_t size); /* same as malloc, but block returned is set to zero and is guaranteed to be page aligned */
void (* MALLOC_ZONE_FN_PTR(free))(struct _malloc_zone_t *zone, void *ptr);
void *(* MALLOC_ZONE_FN_PTR(realloc))(struct _malloc_zone_t *zone, void *ptr, size_t size);
void (* MALLOC_ZONE_FN_PTR(destroy))(struct _malloc_zone_t *zone); /* zone is destroyed and all memory reclaimed */
const char *zone_name;
...
} malloc_zone_t;
复制代码
最终的分配方法其实是根据不同的 zone
来实现的。
malloc_zone
会按照需要分配的内存大小依次分配为 nano
、 tiny
、 small
、 large
,一块最小的内存在是被分配在 nano
中的,其分配的内存大小是是 16 的倍数—— 16, 32, … ,256
在阿里云栖社区的《iOS 内存管理和 malloc 源码解读》[3]和泰晓科技的《内存分配奥义·malloc in OS X》[4] 两篇文章中都提及了关于在 64 位机器上 malloc
会默认使用 nano_zone
来进行低内存的分配
类型 | 处理 size 范围 | 粒度(QUANTA) |
---|---|---|
nano | 0(返回16B) – 256 B | 16B |
tiny | 0(返回 16B) – 63×16 B | 16B |
small | 1KB — 15KB(设备内存小于1GB) / 1KB — 127KB(大于等于1GB) | 512B |
large | 15+KB(<1GB) / 127+KB(>=1GB) | kernel page size(内核页的大小) |
这里需要注意粒度的概念
笔者在《让我们来调试 iOS 内存 – Memory Graph》一文中通过分析应用程序的 .memgraph
文件证明了内存中存在三种内存类型:tiny
、small
、large
,所以如果存在 nano
类型,为什么这个类型在.memgraph
文件中得不到体现呢?
如何证明 NanoZone 或 ScalableZone —— 1.0 测试
一个简单的证明方法是,调试 libmalloc[2] 源码
calloc(size_t __count, size_t __size)
复制代码
注意:源码与真实实现之间存在一定差别的。目前现有的编译方法都是去除代码中关于
nanov2
的实现。而且由于苹果 Open source 的代码是针对 OS X 的特定版本,具体细节可能与 iOS 上有所不同,如地址空间分布
调用 calloc
时传入两个不同的 __size
,我们可以依次调试来证明这部分:
#import <Foundation/Foundation.h>
#import <malloc/malloc.h>
int main(int argc, const char * argv[]) {
@autoreleasepool {
void *p = calloc(1, 8);
//void *p1 = calloc(1, 300);
}
return 0;
}
复制代码
最终我们是通过 malloc
方法进行内存分配的,所以我们在 malloc.c
文件中查找 zone->malloc()
方法,一个值得关注的方法为 _malloc_zone_malloc
,现在我们将断点设置在 ptr = zone->malloc(zone, size);
static void *
_malloc_zone_malloc(malloc_zone_t *zone, size_t size, malloc_zone_options_t mzo)
{
...
ptr = zone->malloc(zone, size); // if lite zone is passed in then we still call the lite methods
...
return ptr;
}
复制代码
这部分的代码分成三部分:安全条件检测 -> 通过 zone
申请内存 -> 日志记录输出,我们需要关注的是通过 zone
申请内存方法。
当我们运行测试程序的时候,我们得到一个方法链
calloc()
-> _malloc_zone_calloc()
-> _malloc_initialize()
->malloc_set_zone_name()
->_malloc_zone_malloc()
我们模拟的是首次调用时,初始化 default_zone
的情况。所以其会包含 initial_scalable_zone
和 initial_nano_zone
初始化的逻辑——即 _malloc_initialize()
初始化方法。
// 在 MALLOC_TARGET_64BIT 下,CONFIG_NANOZONE恒为1
#if MALLOC_TARGET_64BIT
#define CONFIG_NANOZONE 1
#define CONFIG_ASLR_INTERNAL 0
#else // MALLOC_TARGET_64BIT
#define CONFIG_NANOZONE 0
#define CONFIG_ASLR_INTERNAL 1
#endif // MALLOC_TARGET_64BIT
static void
_malloc_initialize(const char *apple[], const char *bootargs)
{
...
const uint32_t k_max_zones = 3;
malloc_zone_t *zone_stack[k_max_zones];
const char *name_stack[k_max_zones];
uint32_t num_zones = 0;
//初始化 initial_scalable_zone 和 initial_nano_zone ,以及当 64 位情况下将 initial_default_zone 赋值为 initial_nano_zone
initial_scalable_zone = create_scalable_zone(0, malloc_debug_flags);
zone_stack[num_zones] = initial_scalable_zone;
name_stack[num_zones] = DEFAULT_MALLOC_ZONE_STRING;
num_zones++;
#if CONFIG_NANOZONE
nano_common_configure();
malloc_zone_t *helper_zone = zone_stack[num_zones - 1];
malloc_zone_t *nano_zone = NULL;
nano_zone = nano_create_zone(helper_zone, malloc_debug_flags);
if (nano_zone) {
initial_nano_zone = nano_zone;
zone_stack[num_zones] = nano_zone;
name_stack[num_zones] = DEFAULT_MALLOC_ZONE_STRING;
name_stack[num_zones - 1] = MALLOC_HELPER_ZONE_STRING;
num_zones++;
}
#endif
MALLOC_ASSERT(num_zones <= k_max_zones);
initial_default_zone = zone_stack[num_zones - 1]; // initial_default_zone 值为 initial_nano_zone
// 2 separate loops: malloc_set_zone_name already requires a working allocator.
for (int i = num_zones - 1; i >= 0; i--) malloc_zone_register_while_locked(zone_stack[i]);
for (int i = num_zones - 1; i >= 0; i--)
malloc_set_zone_name(zone_stack[i], name_stack[i]);
...
}
复制代码
所以当我们初次调用 calloc()
方法时, malloc.c
文件中的_malloc_zone_malloc
实际上与内存的分配无关,其应该是 initial_nano_zone
和 create_scalable_zone
两个对象的初始化方法。
从测试上来说,当初始内存分配时,zone->malloc(zone, size)
会执行两到三次,这取决于 zone_stack
大小。所以根据 zone_stack
中不用zone
的区别,其 malloc
方法都会执行一次。
如何证明 NanoZone 或 ScalableZone —— 2.0 测试
为了避免这种重复的现象对于源码阅读的影响,笔者重新调整了测试的代码——关于 p8
和 p300
的实现。一般来说,我们分配的第一个对象,肯定是_malloc_initialize()
已经完成初始化的事情
#import <Foundation/Foundation.h>
#import <malloc/malloc.h>
int main(int argc, const char * argv[]) {
@autoreleasepool {
void *p = calloc(1, 8);
void *p8 = calloc(1, 8);
void *p300 = calloc(1, 300);
return 0;
}
复制代码
然后,我们查看_malloc_zone_calloc
方法,当default_zone
成功以后,返回出来的值就是 zone->calloc()
返回的值。
我们最终分配出来的内存地址,恒为 zone->calloc 方法返回的值
static void *
_malloc_zone_calloc(malloc_zone_t *zone, size_t num_items, size_t size,
malloc_zone_options_t mzo)
{
...
// 这个流程是有优化的 会和苹果原系统不一定完全一样
ptr = zone->calloc(zone, num_items, size);
...
return ptr;
}
复制代码
然后我们将聚焦在 nano_malloc.c
文件中的 nano_calloc
方法上。在第一次试验中,我们证明了默认的default_zone
初始化以后,其值应该是 initial_nano_zone
,当然我们也可以通过断点在 ptr = zone->calloc(zone, num_items, size);
时,使用两种方法:
- 断点的下一步
- 直接使用
po zone->calloc
定位到具体实现的位置,使用po zone->calloc
首先会定位到malloc.c
中的default_zone_calloc()
,然后继续使用po zone->calloc
定位到nano_calloc
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);
}
复制代码
当 size
小于 NANO_MAX_SIZE
时,首先调用_nano_malloc_check_clear
方法进行处理,如果失败,或者当size
大于 NANO_MAX_SIZE
时,会调用 nanozone
的 helper_zone
去执行这个内存分配的逻辑 calloc
。在1.0测试中我们证明了 nano_zone
初始化时 helper_zone
为 initial_scalable_zone
。
我们阅读关于 _nano_malloc_check_clear
的源码,这个方法才是内存分配的核心:
static void *
_nano_malloc_check_clear(nanozone_t *nanozone, size_t size, boolean_t cleared_requested)
{
MALLOC_TRACE(TRACE_nano_malloc, (uintptr_t)nanozone, size, cleared_requested, 0);
//获取cpu对应的index以及内存size对应的slot
void *ptr;
size_t slot_key;
size_t slot_bytes = segregated_size_to_fit(nanozone, size, &slot_key); // Note slot_key is set here
mag_index_t mag_index = nano_mag_index(nanozone);
//以链表的形式将这些区块存储起来。这些链表的头部放在 meta_data 数组中对应的[mag_index][slot_key]元素中
nano_meta_admin_t pMeta = &(nanozone->meta_data[mag_index][slot_key]);
//根据需要合并了内存 barriers,以允许线程安全地访问队列元素。用来检测检测最近释放的内存块是否存在可用的
ptr = OSAtomicDequeue(&(pMeta->slot_LIFO), offsetof(struct chained_block_s, next));
if (ptr) {
unsigned debug_flags = nanozone->debug_flags;
...
} else {
//进行新的内存分配
ptr = segregated_next_block(nanozone, pMeta, slot_bytes, mag_index);
}
//非初始化时,cleared_requested值恒为 1,
if (cleared_requested && ptr) {
memset(ptr, 0, slot_bytes); // TODO: Needs a memory barrier after memset to ensure zeroes land first?
}
return ptr;
}
复制代码
当我们创建的内存大小值小于NANO_MAX_SIZE
时,segregated_size_to_fit
会返回一个真正合适的大小并且生成一个 slot_key
#define NANO_REGIME_QUANTA_SIZE (1 << SHIFT_NANO_QUANTUM) // 16
#define NANO_MAX_SIZE 256 /* Buckets sized {16, 32, 48, ..., 256} */
#define SHIFT_NANO_QUANTUM 4
...
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;
}
复制代码
segregated_next_block
为我们提供了基于链表的内存分配能力,其值取决于 meta_data
这个二维数组,其分配方式是新分配一块内存。
segregated_next_block(nanozone_t *nanozone, nano_meta_admin_t pMeta, size_t slot_bytes, unsigned int mag_index)
{
while (1) {
uintptr_t theLimit = pMeta->slot_limit_addr; // Capture the slot limit that bounds slot_bump_addr right now
uintptr_t b = OSAtomicAdd64Barrier(slot_bytes, (volatile int64_t *)&(pMeta->slot_bump_addr));
b -= slot_bytes; // Atomic op returned addr of *next* free block. Subtract to get addr for *this* allocation.
//由 slot_bump_addr 起分配出一个内存块 (同时 slot_bump_addr 前进)
if (b < theLimit) { // Did we stay within the bound of the present slot allocation?
return (void *)b; // Yep, so the slot_bump_addr this thread incremented is good to go
} else {
//当前 slot 没有任何空闲的 bands
if (pMeta->slot_exhausted) { // exhausted all the bands availble for this slot?
pMeta->slot_bump_addr = theLimit;
return 0; // We're toast
} else {
// One thread will grow the heap, others will see its been grown and retry allocation
_malloc_lock_lock(&nanozone->band_resupply_lock[mag_index]);
// re-check state now that we've taken the lock
if (pMeta->slot_exhausted) {
_malloc_lock_unlock(&nanozone->band_resupply_lock[mag_index]);
return 0; // Toast
} else if (b < pMeta->slot_limit_addr) {
_malloc_lock_unlock(&nanozone->band_resupply_lock[mag_index]);
continue; // ... the slot was successfully grown by first-taker (not us). Now try again.
} else if (segregated_band_grow(nanozone, pMeta, slot_bytes, mag_index)) {
_malloc_lock_unlock(&nanozone->band_resupply_lock[mag_index]);
continue; // ... the slot has been successfully grown by us. Now try again.
} else {
pMeta->slot_exhausted = TRUE;
pMeta->slot_bump_addr = theLimit;
_malloc_lock_unlock(&nanozone->band_resupply_lock[mag_index]);
return 0;
}
}
}
}
}
复制代码
malloc
在首次调用时,初始化 default_zone
,在 64 位情况下,会初始化 default_zone
为 nano_zone
,同时初始化一个 scalable_zone
作为 helper _zone
,nano_zone
负责 nano
大小的分配,scalable_zone
则负责 tiny
、small
和 large
内存的分配
每次 malloc
时,根据传入的 size
参数,优先交给 nano_zone
做分配处理,如果大小不在 nano
范围,则转交给 helper_zone
处理。
推荐阅读
由于涉及到的函数过多且篇幅问题,想进一步了解分配规则,建议阅读源码。为了更容易的理解源码,可以借助阿里云栖社区[3]的图表辅助理解
当你阅读源码时,需要厘清一些在概念来辅助
-
nano_zone
在正常情况下进行无锁分配 -
nano_zone
OSX上 64 位情况下分配内存的地址空间范围是0x00006xxxxxxxxxxx -
nano_zone
将其地址空间划分为三个级别-
Magazine: 每个物理 CPU 核(非 SMT 虚拟 CPU),拥有一个属于它的地址空间范围。可以对应测试1.0的
_malloc_initialize()
方法// max_magazines may already be set from a boot argument. Make sure that it // is bounded by the number of CPUs. if (max_magazines) { max_magazines = MIN(max_magazines, logical_ncpus); } else { max_magazines = logical_ncpus; 复制代码
-
Band:耗尽一个 Band,用下一个 Band。一个 Band 大小为 2 MB。
-
Slot:每个 Band 中 128K 大小的范围,每个 Band 都分为 16 个 Slot,分别对应于 16B、32B、…256B 大小,支持它们的内存分配
-
-
分配时会从对应的空间级别获取一个空闲的资源用于分配
总结
当我们遇到 为什么系统给 NSObject 分配的内存大小为 16 字节,其根本原因是什么?
- 对于 Objective-C 来说,当我们调用
alloc
方法时,其最终是通过(id)calloc(1, size)
进行内存分配的,其中size
的最小值为16
, 当需要分配的内存大小小于16
时,runtime 中的instanceSize
方法,会强制其大小不小于16
字节; - 我们是通过
malloc.c
的中calloc
方法进行赋值时,其最终分配方法是根据不同zone
的calloc
方法实现的。在 64 位(iOS/MacOS)设备上而默认是通过nano_zone
进行分配的,无论何种大小的size
,都会执行一遍nano_calloc
方法,当需要分配的大小小于256
字节时,会执行_nano_malloc_check_clear
方法,其中的segregated_size_to_fit
方法会保证最终分配的内存大小不小于16
字节且大小是16
的倍数 - 一个值得相信的根本原因是由于在 64 机器上,系统的分配的最小的内存单位大小是 16 字节,但是并没有太多明确的证据证明
未解决的问题
-
未证明 Swift 中的分配情况
-
未证明 NanoV2 的实际情况
-
并未详细论述当内存不足时或者空间不足时的系统优化方法,即内存释放、内存整理等以及地址空间三个级别的具体信息
-
并未论述 Scalable_Zone 的分配情况——为说明 tiny、small、large 三种内存的分配情况
-
为什么在
.memgraph
文件并没有nano
部分
参考资料
objc4:opensource.apple.com/source/objc…
libmalloc:opensource.apple.com/source/libm…
iOS 内存管理和 malloc 源码解读:developer.aliyun.com/article/306…
内存分配奥义·malloc in OS X:tinylab.org/memory-allo…
如果你有任何问题,请直接评论,如果文章有任何不对的地方,请随意表达。如果你愿意,可以通过分享这篇文章来让更多的人发现它。
感谢你阅读本文! ?