相信大家在學習C語言的時候,malloc是最早遇到的幾個方法之一,這里就來深入的了解下,macOS/iOS中用戶空間的內存分配。
引言
首先,我們來看幾個有意思的例子,以下幾個在x86_64或者ARM64中的運行情況。
1
2
3
|
char *str = malloc(32);
free(str);
str[
0] = 'a';
|
1
2
3
|
char *str = malloc(32);
free(str);
str[
12] = 'a';
|
1
2
3
|
char *str = malloc(32);
free(str);
str[
18] = 'a';
|
這里先說一下結果,之后再來分析為什么,看看你有沒有猜對。
這里均不會在str[x] = 'a';
這一行崩潰,而可能在下次內存分配的時候崩潰。
第一個會報malloc: *** error for object 0x60000003cfa0: Invalid pointer dequeued from free list
。
第二個會觸發BAD_ACCESS
的錯誤。
第三個運行一切正常,不會崩潰。
內核內存申請
malloc
方法並不止提供了向內核申請內存(syscall)的功能,它還提供了一整套用戶態的內存管理。比如linux-2.3之后使用的ptmalloc
,FreeBSD使用的jemalloc
,以及macOS/iOS使用的malloc_zone
及libmalloc
。
向內核申請內存,觸發系統調用,比較通用的接口有sbrk
和mmap
。在mac上,sbrk
已經被廢棄,而所有內存申請的內核調用最終都會轉到
1
2
3
4
5
6
7
|
kern_return_t mach_vm_allocate
(
vm_map_t target,
mach_vm_address_t *address,
mach_vm_size_t size,
int flags
);
|
這個內核方法,我們可以通過vm_allocate
去間接的調用它。
有人建議使用系統自帶的malloc
來構建自己的內存管理程序,這樣就不用考慮不同平台的差異性;也有人認為在別人的管理系統上創建,不能達到更好的性能。這些還是具體情況具體分析吧,后面會簡單介紹下如何構建自己的內存管理系統。
回到內核內存,內核內存都是按頁管理的,你不可能向內核申請1byte的內存,所有的內存申請都需要經過round
,否則會導致申請內存失敗,其定義如下:
1
2
3
4
5
|
extern vm_size_t vm_page_size;
// These macros assume vm_page_size is a power-of-2.
|
用戶態內存申請
用戶態的內存管理方案實在太多了,這里主要說一下大家都比較通用的部分,以及libmalloc
的實現。
由於系統提供的內存,最少是一頁,那么程序如果申請小塊內存,特別像Objc這種含有大量小內存的情況,我們總不可能為一個指針分配一頁內存吧。
這里幾乎所有的內存分配庫都采用了相同的做法,即將內存分為不同大小來管理,某些地方稱為size class
,某些地方稱為chunk
,而mac中就是malloc_zone
了。
mac中的malloc_zone
大致分為以下幾種:
- nano zone. <256
- tiny zone. <同nano
- small zone. <1024 bytes (64-bit), <512 bytes (32-bit)
- large zone.
申請不同大小的內存將會被派分到對應的zone,而各自的zone會采取不同的策略,比如nano, tiny, small是在內存頁鏈表中尋找到一塊擁有足夠空閑空間的頁,在這個頁中分配該大小的內存;而large則是直接分配多個內存頁,銷毀的邏輯也完全不一樣。
這里看到nano和tiny是重合的,他們之間有什么區別呢?這個問題放到下面多線程中去詳細描述。
為什么需要將內存分配做這樣的切分呢。由於我們平時使用到的內存大部分為小內存(這個在之后我會給一個統計結果),特別像是Objc這種語言,由於所有對象存在都是heap中的,所以基本都是以小指針對象,可能會導致大量小內存的申請和銷毀,那么作為一個較為通用的內存分配器,那么肯定要考慮到優化小指針的分配效率。
這里再看一下Google的tcmalloc
的划分策略。
The size-classes are spaced so that small sizes are separated by 8 bytes, larger sizes by 16 bytes, even larger sizes by 32 bytes, and so forth.
可以看到它對size-class的划分更為細致,而且它會在運行時根據具體情況具體可能會調整這個粒度,同時不會在同一頁中分配任意size-class的內存,這樣做是為了避免碎片。更高細粒度的划分會讓程序在划分的時候更為簡單,從而增加了效率,但這樣也會增加緩沖內存的大小,個人覺得正是這個原因導致tcmalloc
並沒有考慮移動設備。
用戶態內存銷毀
以上說明了內存申請的方式,現在來看看如何銷毀內存的。
如果是大塊內存(large zone),那么視系統有沒有指定內存頁的緩存,否則就直接歸還給系統。
那么如果是小內存(nano除外),在調用free
之后
- 會先根據配置情況是否需要將內存重置為
0x55
,正常情況下不會執行這一步。 - 由於最小內存為
2 * sizeof(void *)
,所以會將第一個指針位置更新成為一個token。 - 合並旁邊的空閑內存。
- 將第二個指針位置更新為下一個空閑內存的地址或者NULL。
- 將當前空閑內存加入
free-list
緩存,當下次申請新內存的時候,會優先在緩存中尋找是否有適合的空閑內存段,沒有才會向系統申請新的內存頁。
這里和我們的理解上有些偏差了,free
並沒有第一時間把我們的內存還給系統,也就是說free
之后的內存其實還是在用戶空間的,我們有可能還是可以任意讀寫該段內存的。這也就是引言中的例子。
但是如果我們修改了小內存的第一個指針位置,會導致我們的token失效,結果在復用該free-list
中的緩存時候,會去校驗當前緩存的token,導致Invalid pointer dequeued from free list
錯誤。就如下所示:
1
2
3
4
|
typedef struct chained_block_s {
uintptr_t double_free_guard;
struct chained_block_s *next;
} *
chained_block_t;
|
1
2
3
4
5
|
void free(nanozone_t *nanozone, void *ptr) {
// ...
((
chained_block_t)ptr)->double_free_guard = (0xBADDC0DEDEADBEADULL ^ nanozone->cookie);
// ...
}
|
而如果我們修改的是第二個指針位置的數據,則會導致該指針非NULL
,導致查詢下一個空閑內存塊的時候內存訪問錯誤。
而如果我們去修改其他位置的數據,則不會有任何問題。
這里我們看到,一些非常奇怪的崩潰,有可能是由於這種寫入釋放后指針引起的。
tcmalloc
可以看到上面的free
過程中,是會有空閑內存的合並問題,這些當然也就會產生內存碎片。
1
2
3
|
| 64 | 64 | |
| null | 64 | |
| 48 | null | 64 | v
|
如上圖所示,中間的16byte可能就無法進行新的利用,好在我們的objc對象幾乎都是幾個指針的大小,加之malloc也會進行一次round,所以利用率還不錯。
那么tcmalloc
是怎么來進行優化的呢?由於tcmalloc
在設計之初就不存在一個chunk中存在多個size-class的情況,所以一旦free
,只需要將其丟進free-list
中就可以了,在需要的時候再進行GC
,將多余的空閑內存出讓給別人或者還給系統。這樣就避免了合並的性能開銷。
多線程安全問題
現在的應用都是多線程的,按照我們上面所述的,均沒有涉及到線程安全問題,那么最簡單的方法就是對所有內存申請及銷毀進行加鎖。但是鎖是一種相對比較耗資源的東西,普通鎖可能會涉及到系統調用,spinlock又可能會導致優先級反轉等問題,那么大家都是怎么解決這個問題的呢?
libmalloc
的解決方式比較傳統,也就是加鎖,但是在nano malloc
中會有特別的優化。
- 每個CPU都會分配一個屬於自己的分配器,也就是說每個CPU都有屬於自己的內存緩存。
- 內存划分和
tcmalloc
類似,一個slot(size-class)中只有一種大小的對象,這樣就不存在內存合並的問題了。 - 在修改
free-list
的時候采用的是原子操作,而不是傳統意義的鎖。 - 只在需要擴展堆,也就是增長空閑內存的時候,才使用真正的鎖。
- 64位系統才開始支持,因為需要指針長度達到64位。
- 所有的指針均有相同的開頭,比如
x86_64
上一定是0x00006nnnnnnnnnnn
,arm
上這個值會不一樣。 - 所有的slot(size-class)最大容量均為
0x20000
大小,而里面存在的對象個數會不一樣。 - 當申請對象個數超過對應slot的最大個數的時候(
slot_exhausted
),會fall through
進入scalable zone
進行申請。
造成以上幾個魔法數字的原因是nona分配器使用指針儲存了部分free-list
的信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
struct nano_blk_addr_s {
uint64_t
nano_offset:NANO_OFFSET_BITS,
// locates the block
nano_slot:NANO_SLOT_BITS,
// bucket of homogenous quanta-multiple blocks
nano_band:NANO_BAND_BITS,
nano_mag_index:NANO_MAG_BITS,
// the core that allocated this block
nano_signature:NANO_SIGNATURE_BITS;
// the address range devoted to us.
};
// 這個是指針,也是該內存對象的信息
typedef union {
uint64_t addr;
struct nano_blk_addr_s fields;
}
nano_blk_addr_t;
|
可以說nano_malloc_zone
是專門為了OC而優化的。
而tcmalloc
和部分其他分配器(jemalloc
),則是采取每一個線程上都獨立擁有一個分配器,那么在該線程上進行free-list
的操作時(申請內存的時候從緩存讀取,及釋放內存的時候直接加到緩存),就實現了無鎖。當然,增長緩存以及GC
等需要和其他線程交互的時候,還是需要鎖的。這么做也會減少空閑內存的利用率。
思考
之前看到過如何解決一些主線程大量釋放對象的問題,為了優化
釋放所消耗的時間,將所有釋放工作都放到子線程中,這是否真的是一種好的方案呢?
內存分配優化
根據我們上面的分析,可以看到這些分配器都是通用型分配器,它考慮了各種長度大小的性能,但是沒有考慮過一些對象的生命周期等。
在一些特殊的場景和應用中,比如音樂、視頻、人工智能、游戲等,可能會出現大量特定長度的對象,也可能會出現一些常駐內存,而這些對象會導致通用內存分配器的性能降低,以及重復利用率降低。
如果我們要做到極致性能的內存管理,那么我們就需要進行分析應用的內存分配情況,以及性能。然后根據需要自定義內存管理模塊,並與通用管理進行對比。
替換系統默認內存分配方式
替換默認malloc的方法很多,如果是使用的C++,替換new的方式也比較常見,鑒於默認new都是基於malloc實現的,這里只看替換malloc的方法。
define
1
|
|
這種方法很傻瓜,只能替換可以被宏替換的地方,在部分場景替換還是很方便。
alias
1
2
|
void my_malloc () { /* Do something. */; }
void malloc () __attribute__ ((alias ("my_malloc")));
|
利用編譯器進行符號的替換,這樣可以替換本身以及靜態庫中的malloc。得益於MachO文件的二級命名空間,並不會替換動態庫中的方法。
符號覆蓋
1
2
3
|
void *malloc(size) {
dlsym(RTLD_NEXT,
"malloc");
}
|
在項目內可以直接定義新的malloc方法,鏈接器會將自身和靜態庫的malloc鏈接到自己的方法,如果需要調用原本的方法,可以使用dlsym(RTLD_NEXT, "malloc")
。同樣無法替換動態庫的malloc。
mac上可行
1
|
__attribute__ ((section(
"__DATA, __interpose")))
|
iOS上被禁用的特性。
動態庫符號鏈接替換
fish_hook
提供了一種修改動態庫符號鏈接的方法,前提是替換的被替換的對象需要在動態庫中,也是只能替換映射到自身的malloc,無法替換動態庫的方法。
但是這種方式比較靈活,可以根據情況動態的打開關閉。
malloc_zone
影響面最大的就是替換malloc_default_zone
了,這樣動態庫的malloc也會使用新的內存管理。
系統並沒有公開方法給我們替換default_zone的方法,其實私有方法也沒有替換的方法,這里就用到了一個技巧,malloc_zone_unregister
的時候,會將unregister_zone和zone列表最后一個zone交換來填補zone數組,所以就可以用以下方式來替換。
1
2
3
4
|
malloc_zone_register(my_zone);
malloc_zone_t *default_zone = malloc_default_zone()
malloc_zone_unregister(default_zone);
malloc_zone_register(default_zone);
|
替換完以后必須把unregister的注冊回去,不然可能會導致某些對象釋放時找不到對應的zone。
同時這些方法之間無法保證線程安全,由於內部的鎖並未公開,所以這里需要在程序運行之前,也就是main函數開始時,或是更早進行替換。
這樣我們就得到了一個完全屬於自己的內存管理方案。
應用內內存使用分析
在進行替換之前,我們需要去分析當前內存使用狀況,以及性能狀態,從而才可以得知我們替換的內存管理方案有效。
為了做這個腳手架,也耗費了我相當長的時間。這里來看看如何去實現收集內存使用狀況。這里就不能使用task_info
,host_statistics
和sysctl
這樣粗略的統計方法了。
由於性能以及Objc對象無法完全擺脫malloc_zone
(會導致統計的死循環),所以這里使用C++來實現統計分析。
線程安全
首先,需要考慮到的是線程安全,這里可以使用鎖來簡單的解決這個問題,但是這樣同時也會大大影響性能,甚至可能會影響統計結果,所以這里采用ThreadLocal
的方案。
每一個線程都有自己獨立統計數據存放池,這樣在新增數據等操作的時候就不需要加鎖了,也盡量避免對性能有太大的影響。
malloc死循環
我們統計malloc,在生成統計數據的時候依然可能會調用到malloc,這樣我們就可能形成了一個死循環,那么我們需要解決這種循環有兩種方法。
- 在統計過程中修改標志位,統計結束重置該標志位,在這之間的malloc不進入統計。如果上面選擇使用的是鎖,那么這里也要加鎖,如果上面選擇的是
ThreadLocal
,那么這里每個線程也需要一個獨立的標志。 - 修改內存申請方式,不用malloc,使用系統底層實現
vm_allocate
。
這里,我選用第2中方案,為此需要C++的Allocator
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
template <class _Tp>
class VMAllocator : public std::allocator<_Tp> {
public:
typedef typename std::allocator<_Tp>::pointer pointer;
typedef typename std::allocator<_Tp>::size_type size_type;
pointer allocate(size_type __n, std::allocator<void>::const_pointer = 0)
{
size_type n = round_page(__n *
sizeof(_Tp));
vm_address_t addr;
kern_return_t rt = vm_allocate(mach_task_self(), &addr, n, VM_FLAGS_ANYWHERE);
if (rt != KERN_SUCCESS) {
throw std::bad_alloc();
}
vm_protect(mach_task_self(), addr, n,
true, VM_PROT_READ|VM_PROT_WRITE);
return reinterpret_cast<pointer>(addr);
}
void deallocate(pointer __p, size_type __n) noexcept
{
size_type n = round_page(__n);
kern_return_t rt = vm_deallocate(mach_task_self(), reinterpret_cast<vm_address_t>(__p), n);
if (rt != KERN_SUCCESS) {
}
}
};
|
獲取每一個內存申請數據
那么我們如何去獲取這樣詳細的統計數據呢?只能去hook malloc的方法了,這里我們需要去hook malloc_zone->malloc
的方法。
我們如何才能獲得malloc_zone的真正對象呢,其實這些對象都是有全局的名字的。
1
2
|
extern "C" malloc_zone_t **malloc_zones;
extern "C" int32_t malloc_num_zones;
|
其中malloc_zones[0]
就是default_zone
。
由於malloc_zone是readonly狀態,我們需要先修改權限才能繼續hook。同時由上面所說的,這些都是非線程安全的操作,所以需要在啟動的時候就完成,並且運行過程中不能修改。
1
2
3
|
mprotect(orig_zone_ptr_,
sizeof(malloc_zone_t), PROT_READ|PROT_WRITE);
orig_zone_ptr_->
malloc = Wrap::malloc;
mprotect(orig_zone_ptr_,
sizeof(malloc_zone_t), PROT_READ);
|
logger
其實系統也開放了兩個鈎子對象,分別給我們統計系統調用和malloc調用的情況:
1
2
3
4
5
6
7
8
9
|
// For logging VM allocation and deallocation, arg1 here
// is the mach_port_name_t of the target task in which the
// alloc or dealloc is occurring. For example, for mmap()
// that would be mach_task_self(), but for a cross-task-capable
// call such as mach_vm_map(), it is the target task.
typedef void (malloc_logger_t)(uint32_t type, uintptr_t arg1, uintptr_t arg2, uintptr_t arg3, uintptr_t result, uint32_t num_hot_frames_to_skip);
extern malloc_logger_t *__syscall_logger;
|
1
2
3
4
5
6
7
8
9
|
// We set malloc_logger to NULL to disable logging, if we encounter errors
// during file writing
typedef void(malloc_logger_t)(uint32_t type,
uintptr_t arg1,
uintptr_t arg2,
uintptr_t arg3,
uintptr_t result,
uint32_t num_hot_frames_to_skip);
extern malloc_logger_t *malloc_logger;
|
由於這里我們不需要統計malloc的數據(我們更關心OC對象),但是我們還是希望了解系統調用發生的次數(系統調用是一種比較慢的操作)。
啟動
這里我做了一個不完整的工具放在github,歡迎大家進行補充。只需要將動態庫導入,並在程序開始的時候配置就可以了。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
int main(int argc, char * argv[]) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (
int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
malloc_detector_show_inspector();
});
malloc_detector_attach_zone(
true);
malloc_detector_start();
@autoreleasepool {
return UIApplicationMain(argc, argv, NSStringFromClass([Application class]), NSStringFromClass([AppDelegate class]));
}
}
|
統計結果
下面就來看看我在我們app里面統計得到的結果。
設備iPhone 7。在此期間系統調用12898次。
這里是內存申請大小之和按照時間順序的情況,其中size作log2處理。
可以看到主線程都比較平穩,而ui線程則是和用戶行為相關,網絡更是和網絡請求密切相關。
下面是內存size-class的分布,這里粒度比較低(2^n),個數(/1000)
可以看出來,我們對於256 bytes
以下的對象占有絕對的比例,其中32 - 64 bytes
最多。每個線程的分布也不一致,說明特定的業務場景會擁有不同的內存需求。
下面是不同大小耗費時間的分布,時間的單位為time_t
。
可以看出來256 bytes一下的時間消耗具有優勢。
以上統計結果可能並不能代表所有,統計的樣本也不夠多,但也能代表部分真實狀況。
替換default_zone
本來想替換為tcmalloc
,但是它沒有支持iOS系統,所以這里轉而替換為jemalloc
,由於時間有限,我也沒有成功移植到arm上,所以這里看看模擬器的情況:
其中左邊為蘋果默認的分配器,右邊為jemalloc
。
在內存分布相近的情況,jemalloc
看似略微好於蘋果默認分配器,但這種差距似乎很小,可能在誤差之內。
最后
在移動應用中,內存的管理似乎並沒有起到非常重要的地位,也不可能出現服務器那樣的長時間運行,所以目前沒有人做過這方面的優化處理。但是從這些點可以了解內存分配的一些情況,給我們一些不同的視角,具體情況下可以做一些特殊的優化。