深入理解sds及初始化、扩容、缩容
sds是什么?
- sds是redis里面自定义的结构体(),有 5 个 sdshdr 结构体(5 个 sds 分别用来存储不同长度的字符串)
- C语言中int有多种长度的:uint5_t、uint8_t、uint16_t、uint32_t、uint64_t
如uint8_t 就是占 8 位的无符号 int 值,能表示的最大值就是 2^8-1
-__attribute__ ((__packed__)):被它修饰的实例对象存储不是内存对齐的(没有对齐填充),内部对象是紧密相邻的
-内存对齐好处-->方便访问实例(个人理解:这个和机器有关,如果机器每次读n个指定字节的数据,实例对象地址都是
n的倍数。访问时不会出现读取一半而需要二次读取)。
-对齐能方便实例的好处,为啥redis定义的时候指定不内存对齐呢?-->因为对齐填充了,就失去了指针滑动访问对象的能力。
(指针+或者- 对象对应字节长度的方式 访问 对象-->有可能访问到填充部分)
Redis 里面 Key 都是字符串,Key 小于 32 个字节的时候,会用 sdshdr5;value 的话,即使小于 32 字节
,也会用 sdshdr8(主要是sdshdr5太小了,可能会频繁扩容)。
有 5 个 sdshdr 结构体如下
SDS结构逻辑图如下
sds:是一个指针,指向buff数组的首地址
flags:一个字节,低三位存储此实例 对应sdshdr具体类型
len:字符串存储的实际字节长度(考虑到二进制安全,buff里面存的是字节信息,里没有做字符转换)
alloc:buff数组实际的长度
uint5_t 从上图定义可以知道,是没有len与alloc的,并没有再单独搞个 len 字段,而是用了 flags 字段的高 5 位来存了
len 字段,也就是字符串的使用长度。它里面也没有再搞个 alloc 字段出来,总之,就是为了省内存。
注意:放在结构体最后的这个数组,比较特殊,被叫作柔性数组。它是不占内存空间的,只有真正使用的时候,才会开辟内存空间。我们在 sdshdr 的结构体里面看到的 buf 字段,也是柔性数组,这个稍微注意一下就好。
- 初始化字符串
- [1] sdsReqType(initlen):根据字符串长度,选择应该用sdshdr具体类型
- [2] sdsHdrSize(type):计算除柔性数组外,所有字段占的字节长度
- [3] trymalloc? s_trymalloc_usable(hdrlen+initlen+1, &usable) : s_malloc_usable(hdrlen+initlen+1, &usable);开辟指定大小内存
- [4]s = (char*)sh+hdrlen;fp = ((unsigned char*)s)-1;//根据首地址计算buff首地址与flags字段首地址
- [5]非buff之外属性设置
- [6] memcpy(s, init, initlen);//buff数值设置初始值
总结:根据initlen选择合适类型-->mollc开辟内存-->计算出sh、s、fp-->非buff之外属性设置-->buff数值设置初始化
sds _sdsnewlen(const void *init, size_t initlen, int trymalloc) {
void *sh;
sds s;
// 根据initlen这个长度决定使用哪个sdshdr结构体
// 我们跳进去看看,里面就是找一个能装下initlen的最小sdshdr类型
char type = sdsReqType(initlen);
// 如果是sdshdr5,这里直接专转成sdshdr8类型,因为sdshdr5的alloc太小了,
// 很容易就要扩容,还不如直接使用sdshdr8
// 那为什么还需要定义sdshdr5呢?这个后面再说
if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
// 这里选定sdshdr类型之后,就要开始创建sdshdr的实例了。
// 先用sizeof算一下sdshdr在内存里面占多少个字节,
int hdrlen = sdsHdrSize(type);
unsigned char *fp;
size_t usable;
assert(initlen + hdrlen + 1 > initlen);
// 根据前面计算的sdshdr分配内存,此时sh指针指向了sdshdr实例的第一个字节
// usable是分配出来的实际空间大小
sh = trymalloc?
s_trymalloc_usable(hdrlen+initlen+1, &usable) :
s_malloc_usable(hdrlen+initlen+1, &usable);
if (sh == NULL) return NULL;
if (init==SDS_NOINIT)
init = NULL;
else if (!init)
memset(sh, 0, hdrlen+initlen+1);
// s指向的是buf的第一个字节
s = (char*)sh+hdrlen;
// fp是s向前移动一个字节,也就是指向了sds里面的flags字段
fp = ((unsigned char*)s)-1;
usable = usable-hdrlen-1;
// 如果一次分配的空间太多了,超过了当前用的sdshdr的上限值,就修改一下
if (usable > sdsTypeMaxSize(type))
usable = sdsTypeMaxSize(type);
switch(type) {
case SDS_TYPE_5: {
...
break;
}
// 如果是用了sdshdr8结构体,就走这个分支
case SDS_TYPE_8: {
// s向前移动,指向sdshdr8的首字节
SDS_HDR_VAR(8,s);
sh->len = initlen; // 把len设置成initlen
sh->alloc = usable; // alloc设置成usable
*fp = type; // 设置flags字段值
break;
}
case SDS_TYPE_16: {
... //省略
break;
}
case SDS_TYPE_32: {
... //省略
break;
}
case SDS_TYPE_64: {
... //省略
break;
}
}
if (initlen && init)
// memcpy()函数是init字符串的内容,拷贝到s往后
memcpy(s, init, initlen);
s[initlen] = '\0'; // 字符串的最后添加'\0'
return s;
}
字符串扩容:sdsMakeRoomFor()
-[1]avail = sdsavail(s);计算预留空间-->空间够就不扩容
-[2]if (newlen < SDS_MAX_PREALLOC) newlen *= 2; else newlen += SDS_MAX_PREALLOC;计算出newlen是否小于1M,小于成倍扩容;不小于每次扩容1M
-[3] type = sdsReqType(newlen); // 根据扩容之后的长度(包含预留字段),确认sds类型
-[4] oldtype==type,扩容后对象sds类型不变,调用s_realloc_usable(sh, hdrlen+newlen+1, &usable);在原对象buff后面扩容,实例首地址不变,但是len,alloc,buff首地址需要重新设置。
-[5]oldtype!=type-->s_malloc_usable(hdrlen+newlen+1, &usable);新开辟出实例,并将就字符串copy到新的实例里。计算设置,首地址sh、len、alloc、fp、s。释放旧实例
总结:先判断预留空间是否足够-->计算扩容后需要的newlen-->扩容机制,添加预留字段(不足1M*2;超过+1M)-->根据newlen(包含预留字段)选择type-->type不变在原来实例上扩容;改变新建实例,释放旧实例。
sds sdsMakeRoomFor(sds s, size_t addlen) {
void *sh, *newsh;
// 计算剩余空间,点进去看看,就是alloc-len
size_t avail = sdsavail(s);
size_t len, newlen, reqlen;
char type, oldtype = s[-1] & SDS_TYPE_MASK;
int hdrlen;
size_t usable;
// 分支一:检查sds剩余可用空间要是足够的话,就不用扩容了
if (avail >= addlen) return s;
// 要是剩余空间不够,就要开始走下面的扩容逻辑了。
// 扩容之后,sds里面还是要预留一些空闲buf的,我们先根据要追加的字节数,
// 确认如何扩容:要是扩容之后小于1M,就两倍的扩容,要是超过1M,就每次扩容1M
len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype);
reqlen = newlen = (len+addlen);
assert(newlen > len); /* Catch size_t overflow */
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
// 下面的逻辑其实和初始化字符串的逻辑类似了
type = sdsReqType(newlen); // 根据扩容之后的长度,确认sds类型
if (type == SDS_TYPE_5) type = SDS_TYPE_8;
hdrlen = sdsHdrSize(type);
assert(hdrlen + newlen + 1 > reqlen);
if (oldtype==type) {
// sds类型没变的话,sds里面原有的字符串是不用进行拷贝的,这里直接走realloc,扩大buf的长度就行了
// 这个newsh返回的还是sds的首地址,其实和sh是一样的
newsh = s_realloc_usable(sh, hdrlen+newlen+1, &usable);
if (newsh == NULL) return NULL;
// s指向buf的首地址
s = (char*)newsh+hdrlen;
} else {
// 要是sds的类型发生了变化,那len、alloc的长度就变了,这个时候buf里面的字符就前后移动,
// 这里就要走malloc分配一个新的sds实例,然后把原来buf里面的数据拷贝过去
newsh = s_malloc_usable(hdrlen+newlen+1, &usable);
if (newsh == NULL) return NULL;
// 拷贝数据buf里面原有的数据
memcpy((char*)newsh+hdrlen, s, len+1);
// 释放原sds实例
s_free(sh);
// s指向了新sds实例的buf
s = (char*)newsh+hdrlen;
s[-1] = type; // 设置新sds实例的flags字段
sdssetlen(s, len); // 设置新sds实例的len字段
}
// 设置新sds实例的alloc或者是扩大原sds的alloc字段
usable = usable-hdrlen-1;
if (usable > sdsTypeMaxSize(type))
usable = sdsTypeMaxSize(type);
sdssetalloc(s, usable);
return s;
}
缩容:是直接修改 len 值实现的。在缩容过程中需要释放无用内存。sdsRemoveFreeSpace()
函数是释放空间的
-[1]len = sdslen(s);获取当前len,用来计算可用空间。如果无可用空间-->buff存的有用信息,且是满的,无需缩容
-[2]type = sdsReqType(len);//根据len计算type
-[3]if (oldtype==type || type > SDS_TYPE_8)if (oldtype==type || type > SDS_TYPE_8)-->在原实例对象上使用realloc,缩容buf就行.对于 sdshdr8 以上的缩容,不做 sds 类型上面的变更
-[4]如果上述条件不满足,就新建一个实例,会把旧实例释放掉。
sds sdsRemoveFreeSpace(sds s) {
void *sh, *newsh;
// 看这里,s本来是个指针吧,C语言里面指针和数组其实是一样的,这里可以用s[-1]来表示指针向前移动
// 然后,算一下当前sdshdr的类型
char type, oldtype = s[-1] & SDS_TYPE_MASK;
int hdrlen, oldhdrlen = sdsHdrSize(oldtype);
// 再算一下当前的len和空闲字节数
size_t len = sdslen(s);
size_t avail = sdsavail(s);
sh = (char*)s-oldhdrlen; // 前移指针,拿到当前sdshdr首地址
if (avail == 0) return s; // 要是没有剩余空间,就不用缩容buf了
// 算算要是存储当前len长度的字符串,需要用什么类型的sdshdr
type = sdsReqType(len);
hdrlen = sdsHdrSize(type);
// 要是sdshdr类型没发生变化,或者是大sdshdr之间变来变去,就没必要更新sdshdr,
// 毕竟有效负载全在buf那里,更新sdshdr节省的那几个字节带来收益微乎其微,
// 但是付出的代价是要拷贝一边巨大的buf,所以这里使用realloc,缩容buf就行了
if (oldtype==type || type > SDS_TYPE_8) {
newsh = s_realloc(sh, oldhdrlen+len+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+oldhdrlen;
} else {
// 如果是sdshdr缩到的比较厉害,比如缩到了sdshdr8,这个时候就需要通过malloc
// 新申请一块内存区域,然后拷贝buf,这个时候buf比较小,拷贝的代价不大,
// 而且sdshdr缩小的那几个字节,可以提高整个sdshdr的有效负载
newsh = s_malloc(hdrlen+len+1);
if (newsh == NULL) return NULL;
memcpy((char*)newsh+hdrlen, s, len+1);
s_free(sh);
s = (char*)newsh+hdrlen;
s[-1] = type;
sdssetlen(s, len);
}
sdssetalloc(s, len); // 最后更新一下alloc字段
return s;
}
-
这主要是因为 sdshdr8 以上的字符串,有效负载几乎全在 buf 上。比如说,从 sdshdr64 更新成 sdshdr16,len、alloc 各自节省了 6 个字节,总共节省了 12 字节,但是呢,重新申请空间要拷贝的数据量是多少?至少是 2^8 吧,256 个字节,这是 sdshdr16 的存储下限,字节数再少,就要用 sdshdr8 存储了。而且一般 sdshdr16 的字符串长度不会恰好卡在下限,都要比 256 字节多。这样的话,节省空间带来的收益微乎其微,但是付出的代价是要拷贝一遍大 buff
-
如果是字符串长度缩得比较厉害,比如缩到了 sdshdr8,这个时候就要通过 malloc 新申请一块内存区域,然后拷贝 buf 里面的数据,这个时候 buf 比较小,拷贝的代价不大,而且 sdshdr 类型的变化,会缩小几个字节,可以提高整个 sdshdr 的有效负载