第二章:简单动态字符串 Simple Dynamic String
struct sdshdr {
int len; // 已使用的字节数量
int free; // 未使用的字节数量
char buf[]; // 字节数组,保存二进制数据
}
SDS与C原生字符串(string.h)的区别
1.通过len可O(1)获取字符串的长度。
2.扩容时有判断,不会造成缓冲区溢出。
3.内存分配次数少:空间预分配 + 惰性释放内存。
空间预分配:当free不够用的时候,扩容后进行预分配,free = x < 1MB ? x:1MB
惰性释放内存:新增时如果free里空间足够则不用新申请内存,同样的释放时先把多的放在free里
4.可保存文本或者二进制数据:因为C标准是以空字符作为空字符串的结尾,所以原生C字符串无法保存二进制数据,但是SDS可以通过len来判断是否结尾,所以可保存二进制数据。
5.尽可能兼容string.h。(联想:Guava提供的新集合框架,也是对原生JDK集合框架的兼容)
(联想:如何扩容的?详见结尾)
第三章:链表
typedef stuct list {
listNode * head; // 表头结点
listNode *tail; // 表尾结点
unsigned long len; // 结点数量
void *(*dup)(void *ptr); // 结点复制
void (*free)(void *ptr); // 结点释放
int (*match)(void *ptr, void *key); // 结点值对比
}
typedef struct listNode {
struct listNode * prev; // 前置结点
struct listNode *next; // 后置结点
void *value; // 值
}
特点:双向、无环、多态
第四章:字典
typedef struct dict {
dictType *type; // 类型特定函数
void *privdata; // 私有数据
dictht ht[2]; // 哈希表
int trehashidx; // rehash状态标识,开始为0,每次rehash一个结点+1,结束为-1
}
typedef struct dictht {
dictEntry **table; // 数组
unsigned long size; // 大小
unsigned long sizemask; // 掩码,用于计算索引值,总是等于size-1
unsigned long used; // 已有结点数量
}
typedef struct dictEntry {
void *key; // 键
union{ // 值
void *val;
uint64_t u64;
int64_t s64;
} v;
struct dictEntry *next; // 指向下一个结点,用于解决hash碰撞
}
hash函数:MurmurHash Austin Appleby,优点是即便输入的键很规律,也能给出一个很好的随机分布,计算量也不大
hash碰撞:链地址法,即冲突的结点形成了一个单向链表(联想:处理冲突的方法和JDK1.7一样,JDK1.8后进一步优化了,如果某个链表的结点数大于8,将该链表转为红黑树。Redis是否也应该做这个优化呢?这里我的想法是Redis的hash函数已经很棒了,hash碰撞的概率不高,所以这个优化可能不会带来特别明显的效果)
rehash:
ht[0]用来使用,ht[1]用来rehash。load_factor = ht[0].used / ht[0].size(联想:注意这里负载因子是算出来的,和Java里直接设置不一样,Java里HashMap默认大小16,负载因子0.75,也可调用带参的构造函数进行赋值)
如果当前没有执行 BGSAVE / BGREWRITEAOF命令 且负载因子大于等于1 或者 当前正在执行上述命令且负载因子大于等于5,则进行扩容。这两个命令会导致Redis创建当前服务器进程的子进程,而大多数操作系统都采用写时复制技术来优化子进程的使用效率,所以这个时候要减少内存的开销。
如果是扩容,则ht[1].size 为大于等于 ht[0].used*2的第一个2的n次方;如果是缩容,ht[1].size为第一个大于等于ht[0].used的2的n次方。
渐进式rehash:
hashmap的rehash时一个很耗时的操作,因为需要将之前的数据取出来,重新hash到新的地址。Java里的HashMap我们可以通过提前预估数据量的大小,调用传了数组大小的构造函数,从而避免rehash;但Redis没有办法这样做,并且作为一个高可用的数据库,不可能让它停止服务来进行rehash,所以Redis采用了渐进式rehash的方式,非常的机智,在我们对hashmap进行增删改查的时候,它都会偷偷rehash一个过去。
(联想:这个解决rehash的思路和JDK11里ZGC对原有G1的优化有异曲同工之妙,G1里因为要遍历堆,所以性能始终和堆大小有关,无可避免的要Stop The World,而ZGC里通过彩色指针、负载屏障,虽然增加了原有对象指针的逻辑,但却因此无需再Stop The World;我们这里的rehash原本就需要Stop The World,但是通过增加了原有增删改查的逻辑,将这个时间平摊到了每一步操作中,从而就不需要再Stop The World了;这样解决问题的思路,我猜想在其他框架里也会遇到,但在业务场景中可能就不大适用了,当我们遇到一个很耗性能的业务场景时,应该不会去考虑将它和其它业务场景耦合)
第五章:跳跃表
通常,跳跃表的性能与平衡树相当,但其实现更简单,所有不少程序都是用跳跃表来代替平衡树。
(联想:跳表增删查的时间复杂度平均为log2N,最坏为N;而平衡树例如B-tree,平均和最坏都是log2N)
typedef struct zskiplist {
structz skiplistNode *header, *tail; // 表头结点 表尾结点
unsigned long length; // 结点数量
int level; // 最高层的层数
}
typedef struct zskiplistNode {
struct zskiplistNode *backward; // 后退指针
double score; // 分值 结点按照其从小到大排列,如果相等则按照成员对象排
robj *obj; // 成员对象 指向一个字符串对象
struct zskiplistLevel { // 层 创建结点时随机生成 (1,32) 数字越大概率越小
struct zskiplistNode *forward; // 前进指针 可用于向后遍历,为null是则已到表尾
unsigned int span; // 跨度 可用于计算排位 即结点的位置
}
}
第六章:整数集合
可保存int16_t、int32_t、int64_t类型的整数值,并且集合里元素不重复
typedef struct intset {
uint32_t encoding; // 编码方式
uint32_t length; // 元素数量
int8_t cotents[]; // 元素,有序不重复,大小等于位数*length
}
升级:扩展数组长度,将所有元素类型转为新元素类型并放到新位置,将新元素加入到数组。例如已有1,2,3,加入65535。
实现机制的好处:
1.提升了灵活性。C语言是静态类型语言,保持所有元素的类型相同,可避免类型错误。
2.节约内存。
不支持降级。
(联想:如何扩容的?详见结尾)
第七章:压缩链表
为节约内存而开发,是由一系列特殊编码的连续内存块组成的顺序型数据结构。
zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend |
zlbytes:记录整个表的内存字节数。在内存重分配或计算zlend位置时使用。
zltail:表尾结点距离起始地址的记录。
zllen:结点数量,如果等于UINT16_MAX(65535),则实际值需要进行遍历才能得出。
zlend:特殊值0xFF,末端标记。
每个结点保存一个字节数组或整数值。
previous_entry_length | encoding | content |
previous_entry_length:记录了前一个结点的长度。可用来计算前一个结点的起始地址,从而实现逆向遍历。该属性占用的内存 = 上一结点长度 < 254字节 ? 1字节 : 5字节
连锁更新:新增、删除操作可能会造成连锁更新。例如当前结点全部介于250~253字节,此时新增一个大于等于254,每个结点的 previous_entry_length 都会变化。
第八章:对象
typedef struct redisObject{
unsigned type : 4; // 类型
unsigned encoding : 4; // 编码方式,即底层数据结构
void *ptr; // 指向底层数据结构的指针
int refcount; // 引用计数(联想:引用计数法是一个非常好实现的GC算法,但其缺点是无法解决互相引用的问题;这里由于该GC只在Redis自己的代码里使用,所以只要开发者避开互相引用就OK了。猜测是手动规避+IDE扫描检查有无互相引用,同时测试的时候测一下GC有没有无法回收对象的情况就OK了。)
unsigned lru : 22; // 最后一次被命令访问的时间 可用来计算空窗时间 GC回收等等
.......
}
类型 | 编码 | 备注 |
REDIS_STRING | REDIS_ENCODING_INT | 只包含long类型的整数时使用 |
REDIS_STRING | REDIS_ENCODING_EMBSTR | 保存短字符串的优化方式;通过一次内存分配,将redisObject和sdshdr放在一块连续的空间,缓存带来的效益更高。 只读的,一旦对其进行修改,将转为RAW编码。 |
REDIS_STRING | REDIS_ENCODING_RAW | 无 |
REDIS_LIST | REDIS_ENCODING_ZIPLIST | 元素个数小于512并且每个元素小于64字节时使用 |
REDIS_LIST | REDIS_ENCODING_LINKEDLIST | 无 |
REDIS_HASH | REDIS_ENCODING_ZIPLIST | 元素个数小于512并且每个元素小于64字节时使用 |
REDIS_HASH | REDIS_ENCODING_HT | 无 |
REDIS_SET | REDIS_ENCODING_INTSET | 元素个数小于512并且每个元素都是整数值时使用 |
REDIS_SET | REDIS_ENCODING_HT | 无 |
REDIS_ZSET | REDIS_ENCODING_ZIPLIST | 元素个数小于128并且每个元素小于64字节时使用 |
REDIS_ZSET | REDIS_ENCODING_SKIPLIST | 同时使用跳跃表和字典,获得了两种数据结构的优势;并且两种数据结构通过指针共享元素,不会浪费内存; |
关于各个数据结构:平时我们谈起能够兼顾读写性能的数据结构,最先想到的就是平衡树和Hash,Redis里没有树,不过有SKIPLIST。整体看下来最棒的是在ZSET,它同时使用SKIPLIST和Hash,功能强大性能优秀,但实际业务场景中还是需要综合考量,具体的我想之后要阅读的《Redis实战》、《Redis开发与运维》应该能给出不错的建议。
类型检查与多态,无需多说。
对象共享机制:0~9999的字符串对象都是共享的。为什么只共享数字?因为字符串判断相等O(N),而数字O(1)。
(联想:这个共享并不陌生了,Java里的int也是有缓存的,源码如下)
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high){
return IntegerCache.cache[i + (-IntegerCache.low)];
}
return new Integer(i);
}
1.SDS、整数集合的扩容方式?与Java里String、StringBuffer、StringBuilder对比有什么区别吗?(JDK1.7)
首先我们来看看SDS的初始化和拼接操作。初始化支持空或具体大小;拼接操作支持拼接SDS或char数组,逐步深入我们会发现,当free不够的时候,数组长度会扩展到(SDS.length + 参数.length)* 2,当然这里还有阀值1024*1024,也就是我们最开始提到的SDS的空间预分配策略。
举个栗子:SDS length=8 free=2,拼接的SDS.length=3,那么就需要扩展到(8+3)*2=22,此时新SDS.length=11,free=11
/*
* 创建一个只包含空字符串 "" 的 sds
*
* T = O(N)
*/
sds sdsempty(void) {
// O(N)
return sdsnewlen("",0);
}
/*
* 根据给定初始化值 init ,创建 sds
* 如果 init 为 NULL ,那么创建一个 buf 内只包含 \0 终结符的 sds
*
* T = O(N)
*/
sds sdsnew(const char *init) {
size_t initlen = (init == NULL) ? 0 : strlen(init);
return sdsnewlen(init, initlen);
}
/*
* 创建一个指定长度的 sds
* 如果给定了初始化值 init 的话,那么将 init 复制到 sds 的 buf 当中
*
* T = O(N)
*/
sds sdsnewlen(const void *init, size_t initlen) {
struct sdshdr *sh;
// 有 init ?
// O(N)
if (init) {
sh = zmalloc(sizeof(struct sdshdr)+initlen+1);
} else {
sh = zcalloc(sizeof(struct sdshdr)+initlen+1);
}
// 内存不足,分配失败
if (sh == NULL) return NULL;
sh->len = initlen;
sh->free = 0;
// 如果给定了 init 且 initlen 不为 0 的话
// 那么将 init 的内容复制至 sds buf
// O(N)
if (initlen && init)
memcpy(sh->buf, init, initlen);
// 加上终结符
sh->buf[initlen] = '\0';
// 返回 buf 而不是整个 sdshdr
return (char*)sh->buf;
}
/*
* 将一个 char 数组拼接到 sds 末尾
*
* T = O(N)
*/
sds sdscat(sds s, const char *t) {
return sdscatlen(s, t, strlen(t));
}
/*
* 拼接两个 sds
*
* T = O(N)
*/
sds sdscatsds(sds s, const sds t) {
return sdscatlen(s, t, sdslen(t));
}
/*
* 按长度 len 扩展 sds ,并将 t 拼接到 sds 的末尾
*
* T = O(N)
*/
sds sdscatlen(sds s, const void *t, size_t len) {
struct sdshdr *sh;
size_t curlen = sdslen(s);
// O(N)
s = sdsMakeRoomFor(s,len);
if (s == NULL) return NULL;
// 复制
// O(N)
memcpy(s+curlen, t, len);
// 更新 len 和 free 属性
// O(1)
sh = (void*) (s-(sizeof(struct sdshdr)));
sh->len = curlen+len;
sh->free = sh->free-len;
// 终结符
// O(1)
s[curlen+len] = '\0';
return s;
}
/*
* 对 sds 的 buf 进行扩展,扩展的长度不少于 addlen 。
*
* T = O(N)
*/
sds sdsMakeRoomFor(
sds s,
size_t addlen // 需要增加的空间长度
)
{
struct sdshdr *sh, *newsh;
size_t free = sdsavail(s);
size_t len, newlen;
// 剩余空间可以满足需求,无须扩展
if (free >= addlen) return s;
sh = (void*) (s-(sizeof(struct sdshdr)));
// 目前 buf 长度
len = sdslen(s);
// 新 buf 长度
newlen = (len+addlen);
// 如果新 buf 长度小于 SDS_MAX_PREALLOC 长度
// 那么将 buf 的长度设为新 buf 长度的两倍
// #define SDS_MAX_PREALLOC (1024*1024)
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
// 扩展长度
newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);
if (newsh == NULL) return NULL;
newsh->free = newlen - len;
return newsh->buf;
}
其次是intset。intset只提供了一个空的构造函数;新增和删除修改操作都会进行resize,并且没有复杂的扩容技巧,只是加一或减一。
/*
* 创建一个空的 intset
*
* T = theta(1)
*/
intset *intsetNew(void) {
intset *is = zmalloc(sizeof(intset));
is->encoding = intrev32ifbe(INTSET_ENC_INT16);
is->length = 0;
return is;
}
/*
* 将 value 添加到集合中
*
* 如果元素已经存在, *success 被设置为 0 ,
* 如果元素添加成功, *success 被设置为 1 。
*
* T = O(n)
*/
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
uint8_t valenc = _intsetValueEncoding(value);
uint32_t pos;
if (success) *success = 1;
// 如果有需要,进行升级并插入新值
if (valenc > intrev32ifbe(is->encoding)) {
return intsetUpgradeAndAdd(is,value);
} else {
// 如果值已经存在,那么直接返回
// 如果不存在,那么设置 *pos 设置为新元素添加的位置
if (intsetSearch(is,value,&pos)) {
if (success) *success = 0;
return is;
}
// 扩张 is ,准备添加新元素
is = intsetResize(is,intrev32ifbe(is->length)+1);
// 如果 pos 不是数组中最后一个位置,
// 那么对数组中的原有元素进行移动
if (pos < intrev32ifbe(is->length)) intsetMoveTail(is,pos,pos+1);
}
// 添加新元素
_intsetSet(is,pos,value);
// 更新元素数量
is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
return is;
}
/*
* 把 value 从 intset 中移除
*
* 移除成功将 *success 设置为 1 ,失败则设置为 0 。
*
* T = O(n)
*/
intset *intsetRemove(intset *is, int64_t value, int *success) {
uint8_t valenc = _intsetValueEncoding(value);
uint32_t pos;
if (success) *success = 0;
if (valenc <= intrev32ifbe(is->encoding) && // 编码方式匹配
intsetSearch(is,value,&pos)) // 将位置保存到 pos
{
uint32_t len = intrev32ifbe(is->length);
if (success) *success = 1;
// 如果 pos 不是 is 的最末尾,那么显式地删除它
// (如果 pos = (len-1) ,那么紧缩空间时值就会自动被『抹除掉』)
if (pos < (len-1)) intsetMoveTail(is,pos+1,pos);
// 紧缩空间,并更新数量计数器
is = intsetResize(is,len-1);
is->length = intrev32ifbe(len-1);
}
return is;
}
/*
* 调整 intset 的大小
*
* T = O(n)
*/
static intset *intsetResize(intset *is, uint32_t len) {
uint32_t size = len*intrev32ifbe(is->encoding);
is = zrealloc(is,sizeof(intset)+size);
return is;
}
现在,我们再来回顾一下Java里String、StringBuffer、StringBuilder的初始化、拼接流程。
构造函数就不用说了,三个类都提供了丰富的构造方式,值得一提的是String,我们平时使用String a = "a",实际上调用的是 String.valueOf(Object obj),和 Integer a = 1 一样。
拼接流程。String的+拼接,底层实现全是通过StringBuffer来来实现的,先调用StringBuffer(String str),然后调用append函数,最后调用toString函数。StringBuffer、StringBuilder的append都是调用AbstractStringBuilder的append。这个时候数组希望能扩到2倍再加2,如果不够,则等于最小可用值。
char[] value;
int count;
public AbstractStringBuilder append(String str) {
if (str == null) str = "null";
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}
private void ensureCapacityInternal(int minimumCapacity) {
if (minimumCapacity - value.length > 0)
expandCapacity(minimumCapacity);
}
void expandCapacity(int minimumCapacity) {
int newCapacity = value.length * 2 + 2;
if (newCapacity - minimumCapacity < 0){
newCapacity = minimumCapacity;
}
if (newCapacity < 0) {
if (minimumCapacity < 0){
throw new OutOfMemoryError();
}
newCapacity = Integer.MAX_VALUE;
}
value = Arrays.copyOf(value, newCapacity);
}
综上所述:intset是效率最低的,每次修改都是O(N),但其是有序的。SDS和StringBuffer比起来不分伯仲,虽然SDS有空间预分配,惰性释放,但这些StringBuffer也是有的。
当然,更重要的是我们要使用StringBuffer,如果嫌麻烦用String去拼接的话,性能就会大打折扣了,特别是在循环里拼接。