【轉】深入理解內存分配


相信大家在學習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_zonelibmalloc

向內核申請內存,觸發系統調用,比較通用的接口有sbrkmmap。在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.
#define trunc_page(x) ((x) & (~(vm_page_size - 1)))
#define round_page(x) trunc_page((x) + (vm_page_size - 1))

用戶態內存申請

用戶態的內存管理方案實在太多了,這里主要說一下大家都比較通用的部分,以及libmalloc的實現。

由於系統提供的內存,最少是一頁,那么程序如果申請小塊內存,特別像Objc這種含有大量小內存的情況,我們總不可能為一個指針分配一頁內存吧。

這里幾乎所有的內存分配庫都采用了相同的做法,即將內存分為不同大小來管理,某些地方稱為size class,某些地方稱為chunk,而mac中就是malloc_zone了。

mac中的malloc_zone大致分為以下幾種:

  1. nano zone. <256
  2. tiny zone. <同nano
  3. small zone. <1024 bytes (64-bit), <512 bytes (32-bit)
  4. 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之后

  1. 會先根據配置情況是否需要將內存重置為0x55,正常情況下不會執行這一步。
  2. 由於最小內存為2 * sizeof(void *),所以會將第一個指針位置更新成為一個token。
  3. 合並旁邊的空閑內存。
  4. 將第二個指針位置更新為下一個空閑內存的地址或者NULL。
  5. 將當前空閑內存加入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中會有特別的優化。

  1. 每個CPU都會分配一個屬於自己的分配器,也就是說每個CPU都有屬於自己的內存緩存。
  2. 內存划分和tcmalloc類似,一個slot(size-class)中只有一種大小的對象,這樣就不存在內存合並的問題了。
  3. 在修改free-list的時候采用的是原子操作,而不是傳統意義的鎖。
  4. 只在需要擴展堆,也就是增長空閑內存的時候,才使用真正的鎖。
  5. 64位系統才開始支持,因為需要指針長度達到64位。
  6. 所有的指針均有相同的開頭,比如x86_64上一定是0x00006nnnnnnnnnnnarm上這個值會不一樣。
  7. 所有的slot(size-class)最大容量均為0x20000大小,而里面存在的對象個數會不一樣。
  8. 當申請對象個數超過對應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
#define malloc(size) my_malloc(size)

這種方法很傻瓜,只能替換可以被宏替換的地方,在部分場景替換還是很方便。

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_infohost_statisticssysctl這樣粗略的統計方法了。

由於性能以及Objc對象無法完全擺脫malloc_zone(會導致統計的死循環),所以這里使用C++來實現統計分析。

線程安全

首先,需要考慮到的是線程安全,這里可以使用鎖來簡單的解決這個問題,但是這樣同時也會大大影響性能,甚至可能會影響統計結果,所以這里采用ThreadLocal的方案。

每一個線程都有自己獨立統計數據存放池,這樣在新增數據等操作的時候就不需要加鎖了,也盡量避免對性能有太大的影響。

malloc死循環

我們統計malloc,在生成統計數據的時候依然可能會調用到malloc,這樣我們就可能形成了一個死循環,那么我們需要解決這種循環有兩種方法。

  1. 在統計過程中修改標志位,統計結束重置該標志位,在這之間的malloc不進入統計。如果上面選擇使用的是鎖,那么這里也要加鎖,如果上面選擇的是ThreadLocal,那么這里每個線程也需要一個獨立的標志。
  2. 修改內存申請方式,不用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
#import <MallocDetector/MallocDetector.h>
 
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看似略微好於蘋果默認分配器,但這種差距似乎很小,可能在誤差之內。

最后

在移動應用中,內存的管理似乎並沒有起到非常重要的地位,也不可能出現服務器那樣的長時間運行,所以目前沒有人做過這方面的優化處理。但是從這些點可以了解內存分配的一些情況,給我們一些不同的視角,具體情況下可以做一些特殊的優化。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM