剖析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...
相关文章
相关标签/搜索