v8記憶體分配淺談

pagecao發表於2018-10-12

前言

本文會通過V8中對String物件的記憶體分配開始分析,對中間出現的原始碼進行解讀,v8博大精深,確實有很多東西我也只能根據一些資訊推測,有不對的地方還請指正。對heap記憶體的新生代分配和老生代記憶體分配的過程解讀。首先,我們來看一張流程圖,該流程圖給出整個分配過程中的前期流程圖,其中省略了一些步驟,只給出了關鍵的步驟。

image1

從String::NewFromUtf8開始

我們從String::NewFromUtf8這個函式開始,首先我們來看一下使用,從samples/hello-world.cc中我們可以看到

Local<String> source =
    String::NewFromUtf8(isolate, "'Hello' + ', World!'",
                        NewStringType::kNormal).ToLocalChecked();
複製程式碼

這裡出現了一個isolate的指標,是通過

Isolate* isolate = Isolate::New(create_params);
複製程式碼

語句產生的,這個isolate在v8中是非常重要的一個物件,我感覺類似於控制程式碼的作用,一個v8執行例項幾乎所有資訊都在上面,包括heap,threadData等等重要資訊,這裡主要不講這個就先跳過,接下來的第二個引數,就是一個字串,第三個參數列示的是String的型別,在原始碼中分為kNormal和kInternalized兩個型別,等會讓我們還會講到他們。

NewFromUtf8方法在src/api.cc中定義如下:

NEW_STRING(isolate, String, NewFromUtf8, char, data, type, length);
複製程式碼

NEW_STRING的巨集定義就在上面程式碼的上方,主要流程程式碼如下:

i::Handle<i::String> handle_result =                                   \
    NewString(i_isolate->factory(), type,                              \
              i::Vector<const Char>(data, length))                     \
        .ToHandleChecked();                                            \
result = Utils::ToLocal(handle_result);      
複製程式碼

在這裡我們可以看到函式是通過NewString方法來獲取到string的handle,其中isolate->factory()返回的i::Factory物件在程式碼中的註釋

Interface for handle based allocation.
複製程式碼

由此我們可見這個物件包含了所有分配記憶體並生成對應handle物件的方法。下面是NewString的程式碼:

if (type == v8::NewStringType::kInternalized) {
	return factory->InternalizeUtf8String(string);
}
return factory->NewStringFromUtf8(string);
複製程式碼

這裡出現了我們之前提起過的StringType,從註釋裡,我們可以發現

kNormal:Create a new string, always allocating new storage memory.
kInternalized:Acts as a hint that the string should be created in the old generation heap space and be deduplicated if an identical string already exists.
複製程式碼

kNormal的情況下會建立一個新的string,並且一定會分配記憶體。而kInternalized則先在老生代(不懂新生代老生代的可以參考這篇文章淺談V8引擎中的垃圾回收機制)的StringTable(存在Heap中,通過factory物件取得)中搜尋是否已經有entry存在,如果存在則直接返回,如果不存在就在老生代中分配記憶體生成。而KNormal的情況是呼叫方法NewStringFromUtf8,該方法的原型如下:

MaybeHandle<String> NewStringFromOneByte(
  Vector<const uint8_t> str, PretenureFlag pretenure = NOT_TENURED)
複製程式碼

該原型中可知,PretnureFlag為 NOT_TENURED,我們可以看看這個flag的的註釋:

A flag that indicates whether objects should be pretenured when allocated (allocated directly into the old generation) or not (allocated in the young generation if the object size and type allows).
複製程式碼

從註釋裡和flag的名字中我們都可以判斷出,這是一個判斷分配記憶體是 長存的(TENURED)還是非長存(NOT_TENURED)的,也就是新生代中還是老生代中。後面的方法Heap::SelectSpace中很明確的說明了這一點:

static AllocationSpace SelectSpace(PretenureFlag pretenure) {
	return (pretenure == TENURED) ? OLD_SPACE : NEW_SPACE;
}
複製程式碼

所以我們可以看出,在kNormal的情況下新建立的string物件都是在新生代的記憶體區中。這裡我們直接從NewStringFromOneByte中的程式碼流程來說明分配記憶體的機制,忽略InternalizeUtf8String方法。因為後面其實是殊途同歸,不過InternalizeUtf8String中有很大一部分程式碼是在StringTable中尋找是否已經存在了這個String,所以不太適合我們主題。

Factory::NewStringFromUtf8初現端倪

我們首先來看一下NewStringFromUtf8的原始碼:

// 首先檢視字串是否為一個全是ASCII符號的字串
....
if (non_ascii_start >= length) {
	//如果字串為ASCII字串,我們就不需要將UTF8字串轉為ASCII字串
	return NewStringFromOneByte(Vector<const uint8_t>::cast(string), pretenure);
}
//非ASCII字串則需要轉義
Access<UnicodeCache::Utf8Decoder>
  decoder(isolate()->unicode_cache()->utf8_decoder());
  ...
  ASSIGN_RETURN_ON_EXCEPTION(
  isolate(), result,
  NewRawTwoByteString(non_ascii_start + utf16_length, pretenure),
  String);
  ...
複製程式碼

從註釋中我們可以看出,在分配記憶體時會檢查是否為ASCII字串,如果不是,則需要生成一個decoder的物件來處理,在生成了decoder物件後將utf8的字串轉化成ASCII,然後再生成了Handle物件後再轉化回UTF8字串。不過,不管是什麼型別在記憶體分配方面則是一樣的,所以我們直接挑選NewStringFromOneByte來繼續深入分析。

NewStringFromOneByte

在NewStringFromOneByte方法中,程式碼如下,我只列出了最重要的兩個方法:

//處理length為0和為1的情況
...
//為handle分配記憶體
Handle<SeqOneByteString> result;
ASSIGN_RETURN_ON_EXCEPTION(
	isolate(),
	result,
	NewRawOneByteString(string.length(), pretenure),
	String);
...
//將字串複製進分配的記憶體
CopyChars(SeqOneByteString::cast(*result)->GetChars(),
        string.start(),
        length);
return result;
複製程式碼

從上面的程式碼和註釋中我們可以看出整個流程,首先通過巨集定義如下:

do {                                                               \
	if (!(call).ToHandle(&dst)) {                                    \
	  DCHECK((isolate)->has_pending_exception());                    \
	  return value;                                                  \
	}                                                                \
} while (false)
複製程式碼

可以看出,這個中主要的邏輯在call裡面,也就是NewRawOneByteString函式。而下面對生成的result賦值,下面的copyChars函式就是把string中的字元複製進分配出來的記憶體,很簡單也跟我們主題無關就略過不表了。我們主要來看上面的函式NewRawOneByteString,這個函式的原始碼如下:

//檢查length是否超過最大閾值,檢查length是否大於0
...
//分配記憶體巨集方法
CALL_HEAP_FUNCTION(
	isolate(),
	isolate()->heap()->AllocateRawOneByteString(length, pretenure),
	SeqOneByteString);
複製程式碼

首先我們來看CALL_HEAP_FUNCTION這個巨集,原始碼太長我們就不貼了,它主要是執行isolate()->heap()->AllocateRawOneByteString(length, pretenure)這個FUNCTION_CALL,在執行完了以後如果未分配成功,則進行垃圾回收,然後再呼叫FUNCTION_CALL,在未成功的時候會再重試一次,這裡為什麼會是嘗試兩次呢,註釋中給出瞭解釋:

Two GCs before panicking.  In newspace will almost always succeed.
複製程式碼

在恐慌之前先做兩次GC,這樣在新生代中基本都會成功。這個恐慌我覺得用的很有意思,因為在兩次失敗以後,系統會做呼叫一個函式CollectAllAvailableGarbage,之前的兩次GC呼叫的是CollectGarbage函式,從這兩個函式的名字我們就可以看出,第二個函式做的操作應該是比較大的一次GC,他內部主要是呼叫CollectGarbage對老生代進行GC,就會涉及Mark和Compact,從之前的文章中我們可以意識到,這樣的操作,甚至會造成工作執行緒的暫停,所以恐慌一詞用在這裡很傳神。 GC方面的程式碼不是我們這次工作的主要內容,所以這裡簡要的敘述一下,我們接下來看這次的主題函式FUNCTION_CALL,也就是Heap::AllocateRawOneByteString函式。

從Heap::AllocateRawOneByteString進入分配核心邏輯

首先我們來看流程圖:

image2
從上圖我們可以看出,首先通過之前給出的string的length引數算出應該分配的記憶體空間大小,SizeFor的程式碼如下:

return OBJECT_POINTER_ALIGN(kHeaderSize + length * kCharSize);
複製程式碼

這個OBJECT_POINTER_ALIGN的巨集定義是將分配大小校準,這個手段在類似Linux核心中經常會用到,比如不足一頁大小的記憶體,擴充套件到一頁,這樣可以方便cpu快取的存取,增加存取速度。接下來的方法SelectSpace我們之前已經聊過了,這裡就不再贅述了。這個方法中最核心方法(也是跟我們主題關聯最緊的方法)就是Heap::AllocateRaw方法,這個我們等會兒來說。先說最後初始化這個Handle的方法,程式碼如下:

//部分初始化需要的object
result->set_map_after_allocation(one_byte_string_map(), SKIP_WRITE_BARRIER);
String::cast(result)->set_length(length);
String::cast(result)->set_hash_field(String::kEmptyHashField);
複製程式碼

第一個set_map函式是把one_byte_string_map將map加入到物件中,這個Map應該就是String物件的HiddenClass,裡面儲存了String物件的方法和屬性以及其偏移,這樣做最大的好處就是String物件基本都一樣,物件變動的機會不大,很容易利用到InlineCache的優化(HiddenClass以及InlineCache的文章Hidden Classes)。後面兩個set_length方法和set_hash方法就基本跟他的名字一樣,就不必贅言了。 我們現在來重點討論一下Heap::AllocateRaw,首先從流程圖中我們可以看到有五種情況,對應著四個函式,這裡我們主要講新生代和老生代的AllocateRaw方法。

分配核心之NewSpace::AllocateRaw

我們首先來講新生代的記憶體分配方法,下面是NewSpace::AllocateRaw的流程圖:

image3

這裡在32位機器且alignment是kDoubleAligned的情況下會使用函式NewSpace::AllocateRawAlign,這個函式跟NewSpace::AllocateRawUnaligned其實是差不多的,不過會計算一下需要多少大小來填充物件實現Align的過程,所以我們重點分析NewSpace::AllocateRawUnaligned就行了,在NewSpace::AllocateRawUnaligned方法中最重要的函式就是NewSpace::EnsureAllocation方法了,他是確保新生代的記憶體中有足夠的記憶體來分配給object,至於後面兩個方法一個是從當年的頂部生成heapObject,另外一個是將當前的新生代的頂部重置,重置為 當前頂部+這次需要分配的大小,所以我們可以看出整個新生代的記憶體模型就是不停的在頂部分配記憶體給物件,是個比較標準的堆記憶體分配。我們重點來講解一下 NewSpace::EnsureAllocation的邏輯,下面我們看一下這個方法的主要邏輯:

Address old_top = allocation_info_.top();
Address high = to_space_.page_high();
int filler_size = Heap::GetFillToAlign(old_top, alignment);
int aligned_size_in_bytes = size_in_bytes + filler_size;

if (old_top + aligned_size_in_bytes > high) {
//沒有足夠的記憶體page了,增加一個
	if (!AddFreshPage()) {
		return false;
	}
	//讓一些allocation observers做統計的方法
	InlineAllocationStep(old_top, allocation_info_.top(), nullptr, 0);
	//重置引數		
	old_top = allocation_info_.top();
	high = to_space_.page_high();
	filler_size = Heap::GetFillToAlign(old_top, alignment);
}
複製程式碼

從上面我們可以看出,當前的top+需要分配的記憶體大於目前空間最大值時,會選擇新增一個新的page到新生代的to_space空間中,具體邏輯如下:

Address top = allocation_info_.top();
DCHECK(!Page::IsAtObjectStart(top));
if (!to_space_.AdvancePage()) {
// 沒有更多的page了
	return false;
}

// 清除現有頁中的殘留記憶體
Address limit = Page::FromAllocationAreaAddress(top)->area_end();
int remaining_in_page = static_cast<int>(limit - top);
heap()->CreateFillerObjectAt(top, remaining_in_page, ClearRecordedSlots::kNo);
UpdateAllocationInfo();
return true;
複製程式碼

上面程式碼中有兩個函式是最核心的,第一個是SemiSpace::AdvancePage,這個函式將Space中的當前頁變成下一頁來增加空間,如果沒有頁了或是到達最大頁了就會返回false,另外一個函式則是NewSpace::UpdateAllocationInfo函式,這個函式最核心的程式碼就是這一句:

allocation_info_.Reset(to_space_.page_low(), to_space_.page_high());
複製程式碼

他將AllocationInfo物件的top設定為新頁的page_low並將high設定為新頁的page_high,所以剛剛NewSpace::AddFreshPage的註釋中有一個建立一個fillerObject到之前頁剩餘的空間中的操作。

(其實這裡我有一個疑惑,按照這個邏輯那在新生代中生成的物件都是出於to_space中的,但是關於新時代的記憶體回收文章都寫得是新的分配是在from_space中,而在做記憶體回收時將from_space中不需要回收的放到to_space中去。而且在Heap::Scavenge方法中有這樣一段註釋和方法

// Flip the semispaces.  After flipping, to space is empty, from space has live objects.
new_space_->Flip();
new_space_->ResetAllocationInfo();
複製程式碼

在這裡會清空所有to_space裡的物件,並將存活的直接放入from_space,然後在後面又將沒有升入老生代的object又從新分配進to_space,這個邏輯跟關於V8新生代記憶體回收的文章中講的都不太一樣,要複雜很多。不過這個跟當下主題也不太符合,只是延伸說一下,下次有時間會再仔細說下這裡面的邏輯。)

說回主題來,NewSpace::UpdateAllocationInfo中還有一個方法我們要講到,因為等會兒的邏輯就是從這裡來的,就是設定AllocationInfo的limit的方法NewSpace::UpdateInlineAllocationLimit,這裡會根據三種情況設定不同的limit,正常的情況下,也就是在allocation_observers_paused的情況下,limit是等於high的,也就是當前頁的末尾;在增量標記的情況下,limit的值為:

new_limit = new_top + GetNextInlineAllocationStepSize() - 1;
複製程式碼

最小的limit值是線上性分配被禁用的情況下,就是new_top的值。分析完了NewSpace::AddFreshPage方法後,我們再回到NewSpace::EnsureAllocation方法,新增了新頁以後的操作直接在註釋中可以看到就不多分析了,我們著重說一下下面的程式碼:

if (allocation_info_.limit() < high) {
	Address new_top = old_top + aligned_size_in_bytes;
	Address soon_object = old_top + filler_size;
	InlineAllocationStep(new_top, new_top, soon_object, size_in_bytes);
	UpdateInlineAllocationLimit(aligned_size_in_bytes);
}
複製程式碼

這段程式碼其實有一段註釋,大概是說limit<high會在三種情況下發生:

1.線性分配被禁用時

2.在開啟增量標記情況下,希望能夠分配記憶體的時候做標記

3.idle scavenge希望能夠記錄新分配的記憶體並在大於一個閾值的情況下執行

所以我們可以看出,設定limit的原因最主要是為了為一些allocate observers在allocate發生時執行他們各自對應的AllocationStep方法(比如profile裡面的SamplingHeapProfiler用來收集每個函式分配記憶體大小的監控器就是通過這個呼叫的)。

到這裡新生代的記憶體分配以及生成object的過程就已經講完了,接下來我們來說一下老生代的記憶體分配方法。

###分配核心之OldSpace::AllocateRaw

因為OldSpace中的AllocateRaw方法並沒有重寫,是直接使用的父類PagedSpace中的AllocateRaw方法,所以在之前圖中,我們直接使用了PagedSpace::AllocateRaw方法來表示。接下來老規矩,我們先看一下老生代記憶體分配方面的方法流程圖:

image4

從這個圖我們就可以看出,對於老生代的分配邏輯上覆雜了許多,因為在之前我們在CALL_HEAP_FUNCTION中的註釋說了,分配失敗以後重試兩次GC基本就能讓new_space中出現足夠的空間分配,但是old_space方面就要複雜的多,讓我們來慢慢分析一下其中經歷的過程吧。

首先從圖中我們可以看出,跟new_space的情況一下,存在著AllocateRawAlign和AllocateRawUnalign的方法,跟之前一樣我們直接來分析Paged::AllocateRawUnalign方法,在AllocateRawUnalign程式碼中先是嘗試使用PagedSpace::AllocateLinearly的方法,這個方法跟之前我們在new_space中的方法類似:

Address current_top = allocation_info_.top();
Address new_top = current_top + size_in_bytes;
if (new_top > allocation_info_.limit()) return NULL;

allocation_info_.set_top(new_top);
return HeapObject::FromAddress(current_top);
複製程式碼

可以看一下程式碼,如果new_top沒有大於allocation_info_的limit,那麼就能直接從top生成物件即可。如果現有的空間不夠,那麼就會進行第一次邏輯上稍微簡單些的FreeList::Allocate,這裡我們涉及到一個在old_space中出現的資料結構,FreeList物件,他是old_space中主要的管理頁和分配的函式之一,我們先用一張圖來展示它和其他物件的關係,這裡面的物件在以後都會遇到:

image5

這裡多說兩句來解釋一下 Page,FreeListCategory以及FreeSpace的關係類似於Linux記憶體中三層頁表的關係,Page物件其實起到一個全域性頁目錄的作用,而FreeListCategory則是一個二級頁目錄,而FreeSpace是一個HeapObject的派生類,實際就是我們要儲存資訊的記憶體位置,可以理解為一個直接頁表項的關係,這裡V8的記憶體結構裡比較巧妙,他通過將FreeListCategory按型別分類,然後對不同的型別的FreeListCategory物件中的FreeSpace的記憶體大小是不一樣的,有點類似於控制二級頁目錄的位數來控制最後頁表項大小的方式。

講完了這些物件的關係,我們就開始說起函式FreeList::Allocate的過程了。

FreeList::Allocate

這段程式碼前,有一段註釋能說明為什麼oldSpace的分配記憶體會複雜

該函式會做一個分配在oldSpace的freeList上,如果成功則直接使用liner allocation將物件返回並設定allocationInfo的top和limit。如果失敗,則需要通過GC的方式來回收記憶體再分配,或者直接擴充一個新的Page給oldSpace;
複製程式碼

結合我們的流程圖大家就可以看到,這是第一次分配未成功的情況,後面兩次一次是通過GC的方式來做,第二次則是直接expand一個page。在FreeList::Allocate方法中首先執行兩個函式PagedSpace::EmptyAllocationInfo以及Heap::StartIncrementalMarkingIfAllocationLimitIsReached,簡單介紹一下就行,PagedSpace::EmptyAllocationInfo方法是將剛剛liner allocation方法中不夠用但是又剩餘的記憶體返回到free_list中,並且標記為未使用的空間,這樣在GC掃描時會忽略這段記憶體。至於Heap::StartIncrementalMarkingIfAllocationLimitIsReached函式則是在判斷了當前Heap的達到IncrementalMarkingLimit以後判斷是立刻開始增量標記的GC任務,還是將該任務加入排程式列,還是不進行。這個函式的判斷涉及很多Heap物件裡的屬性,具體是如何判定我也沒有完全搞清楚,但是根據後面的PagedSpace::SlowAllocateRaw方法中使用GC來分配記憶體的行為,所以這個函式我個人判斷是為這次分配失敗以後做準備的。接下來的FreeLIst::FindNodeFor是這個函式的核心操作了,我們重點來看一下這個函式,因為沒有在圖中表示這個函式的內部流程,我們先貼出他的程式碼來講解:

//先通過快速分配來找到適合這個大小的最小FreeListCategory
//這個操作只需要花費常數時間
FreeListCategoryType type =
SelectFastAllocationFreeListCategoryType(size_in_bytes);
for (int i = type; i < kHuge; i++) {
	node = FindNodeIn(static_cast<FreeListCategoryType>(i), node_size);
	if (node != nullptr) return node;
}

// 如果上面的沒找到,則通過找Huge類別的FreeListCategory 
// 該時間是線性增加的,取決於huge element的數量
node = SearchForNodeInList(kHuge, node_size, size_in_bytes);
if (node != nullptr) {
	DCHECK(IsVeryLong() || Available() == SumFreeLists());
	return node;
}

//如果分配的大小需要huge型別的FreeListCategory來分配,但是卻找不到,直接返回null
if (type == kHuge) return nullptr;

// 否則找尋最適合該大小的FreeListCategory.
type = SelectFreeListCategoryType(size_in_bytes);
node = TryFindNodeIn(type, node_size, size_in_bytes);

DCHECK(IsVeryLong() || Available() == SumFreeLists());
return node;
複製程式碼

之前我們講到過FreeListCategory,他有不同的型別,我們可以從原始碼中看到:

enum FreeListCategoryType {
	kTiniest,
	kTiny,
	kSmall,
	kMedium,
	kLarge,
	kHuge,

	kFirstCategory = kTiniest,
	kLastCategory = kHuge,
	kNumberOfCategories = kLastCategory + 1,
	kInvalidCategory
};
複製程式碼

不同的型別之間對應了不同的FreeSpace大小,而FreeList::SelectFastAllocationFreeListCategoryType則通過需要分配的size來確定從哪個FreeListCategory開始尋找足夠空間的Node,我們接著來看FreeList::FindNodeIn函式,首先來看他的原始碼:

FreeListCategoryIterator it(this, type);
FreeSpace* node = nullptr;
while (it.HasNext()) {
	FreeListCategory* current = it.Next();
	node = current->PickNodeFromList(node_size);
	if (node != nullptr) {
		Page::FromAddress(node->address())
			->remove_available_in_free_list(*node_size);
		DCHECK(IsVeryLong() || Available() == SumFreeLists());
		return node;
	}
	RemoveCategory(current);
}
return node;
複製程式碼

從程式碼中我們可以看到,FreeList會對當前type的FreeListCategory生成一個Iterator的迭代器,會迭代在該FreeList的不同page的該型別的FreeListCategory,直到通過FreeListCategory::PickNodeFromList找到一個可用的FreeSpace為止,如果找到了該node則會減去該頁上node的size大小的可用空間,而如果整個FreeListCategory都找不到可用空間,則直接從FreeList的該FreeListCategory連結串列中去掉該項。從FreeListCategory::PickNodeFromList的程式碼中:

DCHECK(page()->CanAllocate());
FreeSpace* node = top();
if (node == nullptr) return nullptr;
set_top(node->next());
*node_size = node->Size();
available_ -= *node_size;
return node;
複製程式碼

我們可以看出,在FreeListCategory中,FreeSpace也是一個連結串列,而top則是最近一次分配出來的FreeSpace,所以在分配成功後,除了在該FreeListCategory減去可用的記憶體大小以外,還要重新set_top。

當type為Huge或者之前type<Huge中都沒有找到可用的node,則會使用FreeList::SearchForNodeInList在kHuge中去查詢kHuge中可用的FreeSpace,從函式的程式碼中我們可以看到:

FreeListCategoryIterator it(this, type);
FreeSpace* node = nullptr;
while (it.HasNext()) {
	FreeListCategory* current = it.Next();
	node = current->SearchForNodeInList(minimum_size, node_size);
	if (node != nullptr) {
		Page::FromAddress(node->address())
			->remove_available_in_free_list(*node_size);
		DCHECK(IsVeryLong() || Available() == SumFreeLists());
		return node;
	}
	if (current->is_empty()) {
		RemoveCategory(current);
	}
}
return node;
複製程式碼

這個函式的大概邏輯跟之前其實是比較像的,只是多了一個minimum_size,這個大小是真正需要的大小,而node_size則是huge型別的FreeListCategory的node大小,之所以需要保留這個大小,主要是避免大量空間的浪費,後面我們會說到。再來看這個函式中的核心函式FreeListCategory::SearchForNodeInList:

FreeSpace* prev_non_evac_node = nullptr;
for (FreeSpace* cur_node = top(); cur_node != nullptr;
	cur_node = cur_node->next()) {
	size_t size = cur_node->size();
	if (size >= minimum_size) {
		DCHECK_GE(available_, size);
		available_ -= size;
		if (cur_node == top()) {
			set_top(cur_node->next());
		}
		if (prev_non_evac_node != nullptr) {
			prev_non_evac_node->set_next(cur_node->next());
		}
		*node_size = size;
		return cur_node;
	}

	prev_non_evac_node = cur_node;
}
return nullptr;
複製程式碼

通過程式碼我們可以看到,在通常情況下,物件需要size一般是小於huge型別的FreeListCategory的物件size的所以邏輯和之前的一樣。但是如果超出了大小,則會繼續往下尋找,一直找到合適大小的node然後將這個node從連結串列中排除,而不改變top。所以我們回想之前在FreeList::FindNodeFor中的註釋,就可以知道為什麼說之前那些type的查詢為O(1),而這個type為O(n)了。如果這樣並不能查出合適的node,那麼就會通過FreeList::SelectFreeListCategoryType方法定位更準確的type,總的來說就是定位一些需要記憶體更小的物件,至於FreeList::TryFindNodeIn方法跟之前的方法類似,最後一步其實使用的也是FreeListCategory::PickNodeFromList函式,O(1)查詢方法,所以也不需贅言。

在分配完成後,如果分配成功則會通過PagedSpace::AccountAllocatedBytes方法將老生代中分配狀態資訊中的已用size加上剛分配的物件大小,但是在禁用線性分配的情況下,會通過PagedSpace::Free釋放掉沒有使用的大小,並且將老生代allocationInfo的top和limit都設定為top加上需要分配物件的大小。而在剩餘大小大於一個閾值IncrementalMarking::kAllocatedThreshold且增量標記未完成的情況下,也會通過PagedSpace::Free釋放掉沒有使用的大小,不過這裡會多留出一個liner_size的大小,並且設定allocationInfo的top為top加上需要分配物件的大小,而limit為當前的top再加上liner_size。除了以上兩種情況,allocationInfo的top為top加上需要分配物件的大小,而limit為top加上分配的整個物件的大小。

而如果未分配成功的話,從流程圖我們可以知曉,接下來會呼叫函式PagedSpace::SlowAllocateRaw,不過PagedSpace::SlowAllocateRaw函式主要是一個過渡函式,將當前的heap的VMState設定為GC狀態,並開始對馬上要執行的函式設定一個timer來計時,而馬上要執行的函式PagedSpace::RawSlowAllocateRaw才是我們真正需要了解的函式。

PagedSpace::RawSlowAllocateRaw

從流程圖我們可以看出函式的第一步,如果判斷當前的heap在做sweep操作且不是在CompactionSpace中且sweeper本身已經沒有task在執行了,則通過MarkCompactCollector::EnsureSweepingCompleted函式等待sweep操作結束再進行下面的操作。在結束了sweep以後,會重新裝填FreeList的page,因為此時sweeper執行緒已經釋放了一些object了,這個操作由PagedSpace::RefillFreeList來完成,其程式碼如下:

if (identity() != OLD_SPACE && identity() != CODE_SPACE &&
	identity() != MAP_SPACE) {
	return;
}
MarkCompactCollector* collector = heap()->mark_compact_collector();
intptr_t added = 0;
{
	Page* p = nullptr;
	while ((p = collector->sweeper().GetSweptPageSafe(this)) != nullptr) {
		if (is_local() && (p->owner() != this)) {
			base::LockGuard<base::Mutex> guard(
				reinterpret_cast<PagedSpace*>(p->owner())->mutex());
			p->Unlink();
			p->set_owner(this);
			p->InsertAfter(anchor_.prev_page());
		}
		added += RelinkFreeListCategories(p);
		added += p->wasted_memory();
		if (is_local() && (added > kCompactionMemoryWanted)) break;
	}
}
accounting_stats_.IncreaseCapacity(added);
複製程式碼

第一行的判斷比較簡單就不用多說了,我們主要看while迴圈中的邏輯,Sweeper::GetSweptPageSafe可以得到已經sweep過的page,然後後面的邏輯中先判斷是否為CompactionSpace且該page不屬於該space,因為我們現在是在OldSpace中,所以這個邏輯我們直接忽略,我們來看下面的邏輯,首先是PagedSpace::RelinkFreeListCategoriesf方法,其執行的程式碼如下:

DCHECK_EQ(this, page->owner());
intptr_t added = 0;
page->ForAllFreeListCategories([&added](FreeListCategory* category) {
	added += category->available();
	category->Relink();
});
DCHECK_EQ(page->AvailableInFreeList(), page->available_in_free_list());
return added;
複製程式碼

其中通過Page::ForAllFreeListCategories方法,將遍歷Page中的每個可用的FreeListCategory並執行後面的匿名函式(C++的lambda函式):

[&added](FreeListCategory* category) {
	added += category->available();
	category->Relink();
}
複製程式碼

其中先通過added來加上page中所有FreeListCategory上能用的大小,又通過FreeListCategory::Relink將當前Page所屬的PagedSpace的FreeList設定為該FreeListCategory的owner,完成了對FreeListCategory的操作後,再加上page中浪費了的記憶體量。掃描完所有的sweptPage以後,將最後的add值增加到oldSpace的容量中。在做完這一系列操作以後,會再一次重試通過我們之前講過的FreeList::Allocate方法再一次分配,如果依然沒有成功,這個時候就會呼叫Sweeper::ParallelSweepSpace,這個函式作用在名字中已經表明了,這是一個做並行sweep PagedSpace的函式,程式碼如下:

int max_freed = 0;
int pages_freed = 0;
Page* page = nullptr;
while ((page = GetSweepingPageSafe(identity)) != nullptr) {
	int freed = ParallelSweepPage(page, identity);
	pages_freed += 1;
	DCHECK_GE(freed, 0);
	max_freed = Max(max_freed, freed);
	if ((required_freed_bytes) > 0 && (max_freed >= required_freed_bytes))
		return max_freed;
	if ((max_pages > 0) && (pages_freed >= max_pages)) return max_freed;
}
return max_freed;
複製程式碼

很明顯,程式碼會去拉取space中將要被sweep的頁,然後呼叫Sweeper::ParallelSweepPage方法直接做page的sweep,得到的單頁釋放記憶體大於需要的記憶體時或釋放頁數到達規定頁數時則會返回。從流程圖中我們可以看出在Sweeper::ParallelSweepPage最重要的就是Sweeper::RawSweep函式,在sweep未完成的時候會呼叫。Sweeper::RawSweep自然是sweepPage的核心,不過這個函式比較複雜,我們只能根據流程圖並提取關鍵程式碼大概講一下邏輯。首先,該函式會通過程式碼:

const MarkingState state = MarkingState::Internal(p);
複製程式碼

獲取頁中所有物件標記情況的bitmap,接著通過該bitmap執行函式:

ArrayBufferTracker::FreeDead(p, state);
複製程式碼

這個函式的作用是根據所給page的bitmap將page中已經dead的JSArrayBuffers所有backing store(這裡為什麼free掉的只有JSArrayBuffer?ArrayBufferTracker中的array_buffers_屬性的註釋中說這個集合中包含了這個頁上被GC移除的raw heap pointers,為什麼不直接使用heapObject?猜測是因為JSArrayBuffer每個索引對應HeapObject的一個位元組,在Free的時候通過它的特性比較方便,所以GC把需要移除的HeapObject轉化為JSArrayBuffer)。 在成功的釋放了page中一定的空間以後,會再通過page的bitmap找出所有存活的object,通過這些存活的object找到空閒的記憶體區間,並通過PagedSpace::UnaccountedFree方法將該空閒記憶體返回到對應他記憶體大小的FreeListCategory中去,而在存在有物件在新生代但是引用他的物件在老生代的的情況下,則需要通過RememberedSet<OLD_TO_NEW>::RemoveRange方法來清理掉這個引用的記錄(就是之前關於V8 GC文章中提到的寫屏障insert的記錄)。

在做完了Sweeper::ParallelSweepPage操作後,又得到了新的swept page,所以再一次執行函式PagedSpace::RefillFreeList,執行完成後又一次執行FreeList: Allocate嘗試分配。而如果這次失敗,程式就會判斷可能是頁真的不夠了,在通過Heap::ShouldExpandOldGenerationOnSlowAllocation判斷能擴充套件頁以後,會呼叫PagedSpace::Expand來擴充套件頁,從流程圖中可知PagedSpace::Expand的步驟,先是通過MemoryAllocator::AllocatePage分配出頁,接著呼叫PagedSpace::AccountCommit更新space中的committed_屬性,最後將生成的頁插入到space中,再增加頁後繼續嘗試FreeList: Allocate,如果這次依然失敗(可能是已經到達老生代記憶體的瓶頸了)則會呼叫PagedSpace::SweepAndRetryAllocation方法,這個方法相對簡單,程式碼如下,就不講解了:

if (collector->sweeping_in_progress()) {
	collector->EnsureSweepingCompleted();
	return free_list_.Allocate(size_in_bytes);
}
複製程式碼

基本都是之前講過的,這裡只是做最後一次嘗試。

如果PagedSpace::SlowAllocateRaw分配成功,則需要對分配成功的區域進行一個標記,標記該記憶體段是剛分配的不需要清理,使用Page::CreateBlackArea來完成。在以上操作成功的返回Object後,就會呼叫函式PagedSpace::AllocationStep,這個函式我們也不陌生,在新生代中使用過,他會通知space中的各個allocation observers呼叫各自的AllocationStep方法,做一些統計方面的工作。

總結

以上就是整個V8記憶體分配中的過程,該過程非常複雜,還涉及了很多GC相關的東西,但是V8程式碼確實是一個精湛的工藝品,裡面的函式名都取的讓人知道是做什麼的,裡面的註釋也恰到好處,很多時候我陷入困惑的時候總能從一些註釋中得到線索,慢慢又順藤摸瓜的搞出答案,當然這個只是系統的一部分,而且整個系統異常的精密,很多東西不聯絡其他模組上也無法知道,所以上面也存了一些疑惑的地方。下次有時間,我會再來一探GC的究竟,當然這塊更是一個硬骨頭,希望能夠搞懂~

相關文章