語言的資料結構相通性
最近讀了Redis
的原理實現,感受到程式語言的相通性,只要你掌握了語言的共性,觸類旁通其他語言的開發就變得非常簡單了。
總體來說,各種程式語言底層的設計思想是非常相通的,首先針對需要解決的問題和場景選擇不同的資料結構和演算法,根據執行環境設計不同的架構和特性,根據作者的喜好選擇開發的風格,根據應用場景開發對外的介面,根據程式設計師的實踐維護社群和bug反饋區。
不要將某種資料結構固化成你理解的某種語言的一種實現方式,它們都只是一種方便理解的概念,有許多種實現它的方式,甚至完全不同。
我們下面看下陣列這種資料結構的設計思路。
資料型別:陣列
當我們想要設計一種陣列的資料結構時,最容易想到的就是排成一隊的學生,每個學生就是一個元素,我們可以對他們進行增刪查改。他們緊緊相連,就像一塊連續的儲存空間。
當我們可以從頭到尾的看完所有學生資訊(遍歷),也可以從頭開始查詢第4個學生(索引)。我們可以加入一個學生到任意位置(插入),也可以將任意一位同學移出佇列(刪除),但為了保持緊密連續的佇列,我們需要做一些額外的調整。
這就是最常用的資料結構:陣列。
優勢:
- 資料儲存連續緊密,佔用空間少。
- 遍歷資料時可以充分利用磁碟連續空間,減少磁碟臂的移動,提高訪問速度。
- 在每個元素佔用空間相同時,能夠支援快速索引訪問。
缺點:
- 只有頭部指標,無法得知當前陣列有多少元素,只能全部遍歷後統計。
- 元素佔用空間不同時,缺乏隨機讀寫的能力,必須從陣列頭部順序訪問和查詢。
- 如果中間元素出現增刪,後續元素的位置需要依次更新。
改進版1:支援總數查詢
在使用陣列時,查詢元素的總數是常見的需求,遍歷元素獲取陣列長度的方式非常低效,如mysql
普通的查詢總行數,select count(*) from table_name
,就會掃描全表。
為了支援總數快速查詢,我們可以看下javascript
的陣列實現方式,它通過增加一個欄位length
,在每次變更時更新這個數字,即可無需遍歷,直接讀取長度資訊。
改進版2:支援下標的快速訪問
陣列經常會進行遍歷,但也會使用下標獲取指定的元素,而典型的陣列只能通過使用單獨的計數器來遍歷查詢指定的元素,時間複雜度為O(n)
,在元素很多時耗時很久。
方式一:元素長度固定
這種方式下,我們就可以使用(目標元素地址 = 陣列頭部地址 + 元素長度 * 元素下標)的方式訪問指定元素。
但是缺點也很明顯,應用場景比較狹窄,因為所有元素佔用空間都相同的情況非常少,在大部分場景下各個元素使用的空間不盡相同,這樣就會導致空間的浪費。所以基本不會使用這種方式。
方式二:使用Hash方式
在這種儲存方式中,我們先使用一個指定長度l
的連續陣列作為槽,這個長度就是hash
的模值,我們用陣列元素的索引i
對陣列長度l
取模,得到槽的索引,然後用連結串列的方式進行儲存,這樣就能夠進行快速的下標訪問。
但是缺點也很明顯,就是如果中間的元素增加或刪除,後面的所有元素都需要重新hash和排列,因此也比較低效。
改進版3: 無需後置元素依次更新
在原陣列更新時,我們可以直接在原位置上進行重寫,而如果需要刪除元素2,我們可以直接申請一塊記憶體空間,將元素2之前和之後的連續記憶體空間直接拷貝到新空間中,就完成了陣列的縮容。
擴容也是一樣的,新增了元素5,我們同樣重新申請一塊記憶體空間,然後將元素5之前的拷貝到新空間,寫入元素5,再將元素5之後的連續記憶體空間進行批量拷貝。
JS陣列實現
// The JSArray describes JavaScript Arrays
// Such an array can be in one of two modes:
// - fast, backing storage is a FixedArray and length <= elements.length();
// Please note: push and pop can be used to grow and shrink the array.
// - slow, backing storage is a HashTable with numbers as keys.
class JSArray: public JSObject {
public:
// [length]: The length property.
DECL\_ACCESSORS(length, Object)
首先看原始碼實現,會發現JS中陣列是基於物件的,根據陣列狀態不同,元素屬性分為固定長度的快陣列,和hashTable
儲存的慢陣列。
快陣列和慢陣列
快陣列和慢陣列最大的區別就是儲存使用的資料結構不同,快陣列採用連續空間的方式儲存,慢陣列採用hashTable
的連結串列方式儲存。
// Constants for heuristics controlling conversion of fast elements
// to slow elements.
// Maximal gap that can be introduced by adding an element beyond
// the current elements length.
static const uint32\_t kMaxGap = 1024;
// JSObjects prefer dictionary elements if the dictionary saves this much
// memory compared to a fast elements backing store.
static const uint32\_t kPreferFastElementsSizeFactor = 3;
檢視快慢陣列轉換原始碼:
static inline bool ShouldConvertToSlowElements(JSObject object,
uint32\_t capacity,
uint32\_t index,
uint32\_t\* new\_capacity) {
STATIC\_ASSERT(JSObject::kMaxUncheckedOldFastElementsLength <=
JSObject::kMaxUncheckedFastElementsLength);
if (index < capacity) {
\*new\_capacity = capacity;
return false;
}
if (index - capacity >= JSObject::kMaxGap) return true;
\*new\_capacity = JSObject::NewElementsCapacity(index + 1);
DCHECK\_LT(index, \*new\_capacity);
// TODO(ulan): Check if it works with young large objects.
if (\*new\_capacity <= JSObject::kMaxUncheckedOldFastElementsLength ||
(\*new\_capacity <= JSObject::kMaxUncheckedFastElementsLength &&
ObjectInYoungGeneration(object))) {
return false;
}
// If the fast-case backing storage takes up much more memory than a
// dictionary backing storage would, the object should have slow elements.
int used\_elements = object->GetFastElementsUsage();
uint32\_t size\_threshold = NumberDictionary::kPreferFastElementsSizeFactor \*
NumberDictionary::ComputeCapacity(used\_elements) \*
NumberDictionary::kEntrySize;
return size\_threshold <= \*new\_capacity;
}
快陣列、慢陣列兩者轉化的臨界點有兩種:
if (index - capacity >= JSObject::kMaxGap) return true;
return size\_threshold <= \*new\_capacity;
其中kEntrySize根據陣列儲存的內容不同,會在1|2|3
中選擇一個作為係數,當為陣列索引時一般為2。
根據程式碼可知,也就是空洞元素大於1024個,或者新容量 > 3*舊容量*2
時,會將快陣列轉化為慢陣列。
所謂的空洞就是未初始化的索引值,如
const a = [1,2];
a[1030] = 1;
此時就會產生1028個空洞產生,會直接使用滿陣列來儲存,這樣能夠節省大量的儲存空間。
總之,在JS V8
引擎中,陣列使用快慢兩種方式設計,快陣列提高操作效率,慢陣列節省空間。
陣列的操作
陣列的常用push/pop
是通過直接在記憶體尾部追加或刪除,一般申請記憶體時會留有冗餘,空間不夠時再次申請。
// Number of element slots to pre-allocate for an empty array.
static const int kPreallocatedArrayElements \= 4;
};
從上面的程式碼中可以看到,初次申請就會分配4個元素槽位置。
static const uint32\_t kMinAddedElementsCapacity = 16;
// Computes the new capacity when expanding the elements of a JSObject.
static uint32\_t NewElementsCapacity(uint32\_t old\_capacity) {
// (old\_capacity + 50%) + kMinAddedElementsCapacity
return old\_capacity + (old\_capacity >> 1) + kMinAddedElementsCapacity;
}
當空間不夠用時,就會申請新的空間,新空間容量=原空間+原空間/2+16
。
然後根據需要變動的位置分為前後兩塊,直接按照連續記憶體空間的長度一次性拷貝到新記憶體地址上,效率是很高的。
Redis陣列實現
Redis(Remote Dictionary Service, 遠端字典服務)
是使用最為廣泛的儲存中介軟體,由於其超高的效能和豐富的客戶端支援,常常用於快取服務,當然它也可以用於持久化儲存服務。
Redis陣列常用來儲存任務佇列,使用佇列或者棧的方式,進行任務分發和處理。
ziplist
壓縮列表
Redis在陣列元素較少時,使用ziplist
(壓縮列表)來儲存,它是一塊連續的記憶體空間,元素緊密儲存,沒有空隙。
// 壓縮列表結構體
struct ziplist<T> {
int32 zlbytes; // 整個壓縮列表佔用位元組數
int32 zltail_offset; // 最後一個元素的偏移量
int16 zllength; // 元素個數
T[] entries; // 元素內容列表
int8 zlend; // 結束標誌位,值為0xFF
}
// 壓縮列表元素結構體
struct entry {
int<var> prevlen; // 前一個entry的位元組長度
int<var> encoding; // 元素型別編碼
optional byte[] content; // 元素內容
}
因此通過zltail_offset
我們可以快速定位到最後一個元素,通過prevlen
可以支援雙向遍歷,通過zllength
屬性我們可以不用遍歷就能支援整個陣列的元素個數。
由於ziplist
採取緊湊儲存,因此沒有空間冗餘,導致每次插入新元素時,我們都需要申請新的記憶體空間進行擴充套件,然後將原記憶體地址空間直接拷貝到新空間中。由於Redis
是單執行緒,因此如果壓縮列表的容量過大,就會導致服務卡頓,因此不適合儲存過大空間的內容。當更新資料時,如果內容是減少的或者沒有超過已佔用的指定位元組數閾值,就可以原地更新。
quicklist
快速列表
由於ziplist
不適合大容量儲存,因此在陣列元素較多時,我們結合linkedlist
(連結串列)的方式設計了quicklist
。
struct quicklist {
quicklistNode* head; // 頭部指標
quicklistNode* tail; // 尾部指標
long count; // 元素總數
int nodes; // ziplist節點個數
int compressDepth; // LZF壓縮演算法深度
}
struct quicklistNode {
quicklistNode* prev; // 前節點指標
quicklistNode* next; // 後節點指標
ziplist* zl; // ziplist指標
int32 size; // ziplist位元組總數
int16 count; // ziplist元素總數
int2 encoding; // 儲存形式:原生陣列|LZF壓縮陣列
}
一般每個ziplist的空間上限為8KB
,超過就會建立新的節點,這樣保證每個節點在更新時不會操作過大的空間進行復制,同時在檢索時也大大提高了效率。每個節點的空間限制可以由list-max-ziplist-size
引數配置。
在該結構體中,為了進一步壓縮空間佔用,可以使用LZF演算法進行壓縮,壓縮深度為0|1|2
三種,0就是不壓縮,1就是首尾的前兩個元素不壓縮,其餘都壓縮,2就是首尾的一個元素不壓縮,其餘都壓縮。
首尾元素不壓縮是為了保證push/pop
的快速操作時不用再解壓縮改指標內容,而其他元素的壓縮預計可以節省一半的空間。
總結
在語言的陣列設計中,我們會發現幾個通性:
- 優先採用連續儲存的記憶體空間,提升操作的效率。
- 在新增元素時,採用連續記憶體空間複製的方式提升操作效率。
- 使用專用的變數來儲存陣列長度,而不是通過遍歷。
- 在元素很多時,採用連結串列的方式儲存,減少大塊記憶體的申請和佔用。同時提升查詢效率。
參考資料
- Redis深度歷險-核心原理與應用實踐
- 探究V8引擎的陣列底層實現:https://juejin.im/post/5d8091...
- 從Chrome原始碼看JS Array的實現:https://www.yinchengli.com/20...
- V8原始碼:https://github.com/v8/v8/tree...