剖析JS和Redis的數據結構設計:數組

語言的數據結構相通性

最近讀了Redis的原理實現,感覺到程序語言的相通性,只要你掌握了語言的共性,舉一反三其餘語言的開發就變得很是簡單了。javascript

整體來講,各類程序語言底層的設計思想是很是相通的,首先針對須要解決的問題和場景選擇不一樣的數據結構和算法,根據運行環境設計不一樣的架構和特性,根據做者的喜愛選擇開發的風格,根據應用場景開發對外的接口,根據程序員的實踐維護社區和bug反饋區。java

不要將某種數據結構固化成你理解的某種語言的一種實現方式,它們都只是一種方便理解的概念,有許多種實現它的方式,甚至徹底不一樣。node

咱們下面看下數組這種數據結構的設計思路。mysql

數據類型:數組

當咱們想要設計一種數組的數據結構時,最容易想到的就是排成一隊的學生,每一個學生就是一個元素,咱們能夠對他們進行增刪查改。他們牢牢相連,就像一塊連續的存儲空間。git

數據結構.png

當咱們能夠從頭至尾的看完全部學生信息(遍歷),也能夠從頭開始查找第4個學生(索引)。咱們能夠加入一個學生到任意位置(插入),也能夠將任意一位同窗移出隊列(刪除),但爲了保持緊密連續的隊列,咱們須要作一些額外的調整。程序員

這就是最經常使用的數據結構:數組。github

優點:算法

  1. 數據存儲連續緊密,佔用空間少。
  2. 遍歷數據時能夠充分利用磁盤連續空間,減小磁盤臂的移動,提升訪問速度。
  3. 在每一個元素佔用空間相同時,可以支持快速索引訪問。

缺點:sql

  1. 只有頭部指針,沒法得知當前數組有多少元素,只能所有遍歷後統計。
  2. 元素佔用空間不一樣時,缺少隨機讀寫的能力,必須從數組頭部順序訪問和查找。
  3. 若是中間元素出現增刪,後續元素的位置須要依次更新。

改進版1:支持總數查詢

在使用數組時,查詢元素的總數是常見的需求,遍歷元素獲取數組長度的方式很是低效,如mysql普通的查詢總行數,select count(*) from table_name,就會掃描全表。chrome

爲了支持總數快速查詢,咱們能夠看下javascript的數組實現方式,它經過增長一個字段length,在每次變動時更新這個數字,便可無需遍歷,直接讀取長度信息。

數據結構 (4).png

改進版2:支持下標的快速訪問

數組常常會進行遍歷,但也會使用下標獲取指定的元素,而典型的數組只能經過使用單獨的計數器來遍歷查找指定的元素,時間複雜度爲O(n),在元素不少時耗時好久。

方式一:元素長度固定

這種方式下,咱們就可使用(目標元素地址 = 數組頭部地址 + 元素長度 * 元素下標)的方式訪問指定元素。

可是缺點也很明顯,應用場景比較狹窄,由於全部元素佔用空間都相同的狀況很是少,在大部分場景下各個元素使用的空間不盡相同,這樣就會致使空間的浪費。因此基本不會使用這種方式。

方式二:使用Hash方式

數據結構 (2).png
在這種存儲方式中,咱們先使用一個指定長度l的連續數組做爲槽,這個長度就是hash的模值,咱們用數組元素的索引i對數組長度l取模,獲得槽的索引,而後用鏈表的方式進行存儲,這樣就可以進行快速的下標訪問。

可是缺點也很明顯,就是若是中間的元素增長或刪除,後面的全部元素都須要從新hash和排列,所以也比較低效。

改進版3: 無需後置元素依次更新

數據結構 (5).png
在原數組更新時,咱們能夠直接在原位置上進行重寫,而若是須要刪除元素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;

}

快數組、慢數組二者轉化的臨界點有兩種:

  1. if (index - capacity >= JSObject::kMaxGap) return true;
  2. 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(壓縮列表)來存儲,它是一塊連續的內存空間,元素緊密存儲,沒有空隙。

數據結構 (6).png

// 壓縮列表結構體
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

數據結構 (7).png

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的快速操做時不用再解壓縮改指針內容,而其餘元素的壓縮預計能夠節省一半的空間。

總結

在語言的數組設計中,咱們會發現幾個通性:

  1. 優先採用連續存儲的內存空間,提高操做的效率。
  2. 在新增元素時,採用連續內存空間複製的方式提高操做效率。
  3. 使用專用的變量來存儲數組長度,而不是經過遍歷。
  4. 在元素不少時,採用鏈表的方式存儲,減小大塊內存的申請和佔用。同時提高查詢效率。

參考資料

  1. Redis深度歷險-核心原理與應用實踐
  2. 探究V8引擎的數組底層實現:https://juejin.im/post/5d8091...
  3. 從Chrome源碼看JS Array的實現:https://www.yinchengli.com/20...
  4. V8源碼:https://github.com/v8/v8/tree...
相關文章
相關標籤/搜索