面试题及其衍生知识点

面试题及其衍生知识点

  • 1、tcp拥塞控制具体怎么实现的
    • 1.1、网络拥塞
    • 1.2、拥塞控制
    • 1.3、网络拥塞的解决方法:
      • 1.3.1、慢开始算法
      • 1.3.2、拥塞避免算法
      • 1.3.3、慢开始和拥塞避免的工作原理
      • 1.3.4、快速重传
      • 1.3.5、快速恢复
  • 2、redis能充分利用cpu吗?为什么?
  • 3、mysql1000w数据,聚簇索引每个二层节点下挂大约多少子节点
  • 4、redis使用sds的全部理由,压缩列表和哈希表的对比
    • 4.1、redis使用sds的全部理由
      • 4.1.1、String简单介绍
      • 4.1.2、底层结构
        • 4.1.2.1、数据结构
        • 4.1.2.2、短串和长串的边界
        • 4.1.2.3、embstr 和 raw的区别
        • 4.1.2.4、为什么短串使用embstr,长串使用raw
      • 4.1.3、SDS的数据结构
        • 4.1.3.1、SDS的结构
        • 4.1.3.2、结构函数
        • 4.1.3.3、紧凑型头结构
        • 4.1.3.4、总结
      • 4.1.4、为什么Redis不使用C字符串
        • 4.1.4.1、避免内存溢出问题
        • 4.1.4.2、二进制安全
        • 4.1.4.3、SDS空间分配策略优化
        • 4.1.4.4、总结
    • 4.2、Redis数据类型
      • 4.2.1、Redis核心对象
      • 4.2.2、String
        • 4.2.2.1、存储方式
        • 4.2.2.2、应用场景
      • 4.2.3、Hash
        • 4.2.3.1、底层实现
          • 4.2.3.1.1、哈希表hashtable
            • 4.2.3.1.1.1、rehash
            • 4.2.3.1.1.2、渐进式rehash
          • 4.2.3.1.2、压缩列表
        • 4.2.3.2、应用场景
          • 4.2.3.2.1、存储用户数据
          • 4.2.3.2.2、分布式生成唯一ID
      • 4.2.4、List
        • 4.2.4.1、底层实现
        • 4.2.4.2、应用场景
      • 4.2.5、Set
        • 4.2.5.4、底层实现
        • 4.2.5.5、应用场景
      • 4.2.6、ZSet
        • 4.2.6.1、底层实现
        • 4.2.6.2、应用场景
      • 4.2.7、Bitmap
        • 4.2.7.1、底层实现
        • 4.2.7.2、应用场景
          • 4.2.7.2.1、签到统计
          • 4.2.7.2.2、判断用户登陆态
          • 4.2.7.2.3、连续签到用户总数
      • 4.2.8、HyperLogLog
        • 4.2.8.1、应用场景
      • 4.2.9、Geo
        • 4.2.9.1、底层实现
        • 4.2.9.2、应用场景
      • 4.2.10、Stream
        • 4.2.10.1、应用场景
          • 4.2.10.1.1、消息队列
      • 4.2.11、总结
  • 5、http1.0和2.0的区别
  • 6、nio和bio区别 epoll详细原理
    • 6.1、BIO
    • 6.2、NIO
    • 6.3、AIO
      • 6.3.1、select epoll
      • 6.3.2、select
    • 6.4、epoll
    • 6.5、mmap和零拷贝
      • 6.5.1、零拷贝senfile
      • 6.5.2、mmap
  • 7、类中有三个对象引用,创建一个空对象的占用内存情况
  • 8、volatile jmm及应检实现
  • 9、栈上分配对象和堆分配对象的区别
  • 10、两个树节点最近公共祖先
    • 10.1、代码实现

群里流传的得物面试题,顺手整理一下
面试题及其衍生知识点_第1张图片

1、tcp拥塞控制具体怎么实现的

1.1、网络拥塞

在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络性能就要变坏,这种情况就叫做网络拥塞。
如:在一个道路上,如果没有道路管制(如靠右行驶),这个道路上的来去的车一多而且都不遵守交通规则,或者后面的车辆已经看到了前面已经堵车了,但是还一个劲的往前挤,那么这个路就会越来越堵。

出现网络拥塞的条件: 对资源需求的总和 > 可用资源

如:一根网线的带宽是50M,但是网络中的所有人传输的带宽已经大于50M了就会出现堵塞

而流量控制是两台计算机之间调控自己的传输速度。和拥塞没有关系。

1.2、拥塞控制

网络中的所有使用TCP通讯的计算机,如果在传输过程中出现了丢包的情况,就会主动把发送速度降下来,避免堵住

拥塞控制起到的作用:
面试题及其衍生知识点_第2张图片
假设网络的最大吞吐量是50M。(可用理解为网线的带宽为50M)
理想的拥塞控制: 当网络上传输的数据包的流量超过了50M,路由器就会处理不过来大量的数据包时就会丢包,再传输过来的数据包就会直接扔掉。而且网络一点也不堵。
实际的拥塞控制: 为了避免出现绿线的情况。如果出现了丢包的情况,路由器就会自动减缓发送速度和接受速度。
无拥塞控制: 网络线路上传输的数据包如果没有拥塞控制,如果有丢包完全不考虑,还是该扔多少就扔多少。这样就会适得其反,扔了20M就出处理了10M,而有10M的数据包丢包堵住了。最后就会造成死锁(传输的数据越快,接收到的数据包最后就会为0)堵住。

1.3、网络拥塞的解决方法:

慢开始、拥塞避免、快速重传、快速恢复

发送方维持拥塞窗口(cwnd,就是可靠传输中的接收窗口 )的原则:
只要网络没有拥塞,拥塞窗口就增大一些,以便发送更多分组出去
只要网络出现拥塞,拥塞窗口就减小大一些,以减少网络中的分组数。

1.3.1、慢开始算法

开始的传输速率慢,到后面会越来越快,以2的指数的速率加快。
但是如果不控制拥塞窗口cwnd就会快速膨胀,为了防止因为拥塞窗口cwnd的过度增长引起的网络拥塞,需要使用慢开始门限ssthresh控制:
慢开始门限ssthresh默认值是16。会根据发生网络拥塞的峰值(下一次是峰值的一半,如下图)随时都会变化。

1.3.2、拥塞避免算法

通过慢开始门限的约束,让拥塞窗口cwnd缓慢地增大,即每经过一个往返时间RTT就把发送方的cwnd拥塞窗口cwnd加1cwnd,而不是加倍cwnd。这样拥塞窗口cwnd按线性规律缓慢增长,比慢开始算法的拥塞窗口增长速率缓慢得多。
拥塞避免算法不能完全避免拥塞,只是使网络不容易出现拥塞。

1.3.3、慢开始和拥塞避免的工作原理

先使用慢开始算法 然后 使用拥塞避免算法。
面试题及其衍生知识点_第3张图片
第一轮,cwnd=1,开始执行慢开始算法,因为不知道网络到底堵不堵,所以只发送1个数据包试探一下,如果在规定时间内接收到确认包就进行第二轮。
第二轮,cwnd=2,一次发送2个数据包,如果正常接收到确认包就进行第三轮。
第三轮,cwnd=4,一次发送4个数据包,如果正常接收到确认包就进行第四轮。
下面的都看右面的图。
cwnd=8,一次发送8个数据包,如果正常接收到确认包就进行第五轮。
cwnd=16,已经到等于了默认慢开始门限的值,一次发送16个数据包,正常发送数据包16个。
cwnd=17,因为默认慢开始门限就是16,执行拥塞避免算法,一次只增长一个cwnd,所以这一次不是32,而是17。
。。。
cwnd=24,到了该点发送24个cwnd的位置已经开始出现丢包现象,网络开始有点拥塞。
cwnd=25,由于网络出现了丢包,所以就要从0重新开始发送数据包,执行慢开始算法。倍数增长。但是此时的慢开始门限的值也会变为网络拥塞点的1/2个cwnd,也就是12。
cwnd=1,因为丢包了,所以重新开始执行慢开始算法。
。。。
cwnd=12,开始执行拥塞避免算法。之后重复上面的步骤。

1.3.4、快速重传

快重传和快恢复是配套使用的。
快速重传:要求接收方在收到一个失序的报文段后就立即发出重复确认(为的是使发送方及早知道有报文段没有到达对方),而不要等到自己发送数据时捎带确认。
如:A给B发数据,假设B每接收到5个数据包就向A确认,当B收到了1、2后,但是直接收到了4,B会认为3已经丢了,立即向A发送3个连续确认(都是3个),A收到3个连续确认,让A发第3个,B就不等到5个再确认,让A重新发送3的数据包。 为了防止A重发后面B已经收到的数据包。
然后又从cwnd=1开始重新发送数据包,执行快速恢复。

1.3.5、快速恢复

快速恢复的工作机制:
面试题及其衍生知识点_第4张图片
(1)当发送方连续收到三个重复确认,就执行“乘法减小”算法,立刻把慢开始门限ssthresh减半为12。这是为了预防网络发生拥塞。然后立即重传丢失的报文段,并将cwnd设置为新的ssthresh(如:12。减半后的ssthresh)
注意:接下来不执行慢开始算法

有些快重传实现是把开始时的拥塞窗口cwnd值再增大一点,即等于 ssthresh + 3 * MSS 。这样做的理由是:既然发送方收到三个重复的确认,就表明有三个分组已经离开了网络。这三个分组不再消耗网络资源而是停留在接收方的缓存中。可见现在网络中并不是堆积了分组而是减少了三个分组。因此可以适当把拥塞窗口扩大了些。

(2)因为发送方收到了3个连续的数据包,所以发送方现在认为网络很可能没有发生拥塞,因此与慢开始不同之处是现在不执行慢开始算法(即拥塞窗口cwnd现在不设置为1),而是把cwnd值设置为慢开始门限ssthresh减半后的数值(如:12),然后开始执行拥塞避免算法(“加法增大”),使拥塞窗口缓慢地线性增大。

(3)每次收到一个重复的确认时,设置cwnd=cwnd+SMSS(拥塞窗口cwnd加1).此时发送端可以发送新的TCP报文段

(4)当收到新数据的确认时,设置cwnd=ssthresh(如:12。ssthresh是新的慢启动门限值,由第一步计算得到)
原因是因为该ack确认了新的数据,说明从重复ACK时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免算法。

快速重传和快速恢复完成之后,拥塞控制将恢复到拥塞避免阶段。

2、redis能充分利用cpu吗?为什么?

不能
redis它是基于内存的。CPU一般有三级缓存,L1、L2、L3,还有内存,CPU之间是用总线连接的,在三级缓存中,一般L3常用于和内存的数据交换,而Redis是用单线程、多路复用io来实现高性能的内存数据服,所以Redis最大的问题不是CPU问题,而是内存和网络,由于要经常做到和内存的信息交互,所以最好的方法就是建立一个集群,然后让redis实例和CPU内核进行绑定
首先,我们可以肯定的说,Redis不需要提升CPU利用率,
因为Redis的操作基本都是基于内存的,CPU资源根本就不是Redis的性能瓶颈。
所以,通过多线程技术来提升Redis的CPU利用率这一点是完全没必要的。
底层Redis采用Linux的IO模型:epoll多路复用
查看文章:Redis不是号称单线程效率也很高吗,为什么又采用多线程了?

3、mysql1000w数据,聚簇索引每个二层节点下挂大约多少子节点

Innodb的数据组织的最小单位是page,默认情况下,page的大小为16K。
Innodb中一个指针是6字节长度。所以[主键ID+指针]总共就占14字节。
所以一个16K大小的节点可以存下的[主键ID+指针]个数=16K/14=16384/14=1170,
deep=2的B+树可以放下1170个叶子节点,即1170个用于存放行数据的page,
即可以存放的行数据的大小=1170*16K=18720K=1.8M, 准确的说这是树上的,还有很多不在树上的,
deep=2
1000 14000W/Byte 1170000
1 14byte 1170

以实际能放下的数据不止1.8M
ps:一个page内还有一些其他的数据,如next指针,LSN等,所以说一个page的16K不完全都拿来存行数据的。
deep=3,即有两层非叶子节点的B+树,能存放多少数据。根节点的16k的page可以存放16k/14=1170个[主键ID+指针],即第二层就可以有1170个page。所以总共树上可以放的叶子节点的个数=11701170=1368900,所以能放下的数据=136890016K=21902400K=21G。同理,因为不是所有的行数据都在树上,所以高度=3的B+树不止放下21G的数据的。
deep=4的,那么树上可以存下的叶子节点=117011701170=1601613000个,所以能存下的数据=1601613000*16K=25.6T。同理,实际存下的数据是可以不止这个量的。

4、redis使用sds的全部理由,压缩列表和哈希表的对比

4.1、redis使用sds的全部理由

4.1.1、String简单介绍

String 是最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value其实不仅是字符串, 也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是 512M。
String 类型的底层的数据结构实现主要是 int 和 SDS(简单动态字符串),SDS 和我们认识的 C 字符串不太一样,下面第三节会有解释。

4.1.2、底层结构

4.1.2.1、数据结构

对于不同类型的数据我们可能有不同的编码方式,除了int单独编码单独存储外,短串的话,我们采用embstr方式,长串的话我们采用raw方式
面试题及其衍生知识点_第5张图片

4.1.2.2、短串和长串的边界

embstr 编码和 raw 编码的边界在 redis 不同版本中是不一样的:

  • redis 2.+ 是 32 字节
  • redis 3.0 - 4.0 是 39 字节
  • redis 5.0 是 44 字节
4.1.2.3、embstr 和 raw的区别

在这里插入图片描述

embstr

面试题及其衍生知识点_第6张图片

raw
如图所示,embstr和raw编码都会使用SDS来保存值,但不同之处在于embstr会通过一次内存分配函数来分配一块连续的内存空间来保存redisObject和SDS,而raw编码会通过调用两次内存分配函数来分别分配两块空间来保存redisObject和SDS。

embstr

优点:

  • 由于redisObject和SDS内存连续,分配和销毁只需要一次,性能较低
  • 空间连续更利于Cpu的使用
  • 查找速度更快

缺点 :

  • 如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,所以embstr编码的字符串对象实际上是只读的,redis没有为embstr编码的字符串对象编写任何相应的修改程序。当我们对embstr编码的字符串对象执行任何修改命令(例如append)时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令
4.1.2.4、为什么短串使用embstr,长串使用raw

embstr看作为短字符串的优化,可以看到很多好处,长串用embstr并不影响embstr的优缺点,并不是长串用embstr,embstr的优点就没了,那么必然存在一些点使长串不能用embstr肯定是转embstr有坏处。

  • 如果长串也用embstr,那么修改的时候需要重新找一份内存空间转换为sds,然后才可以进行修改
  • 长串的缩短,基于free字段的惰性空间释放性能更高 ,对于字符串变短的情况,短串由于Redisobject和sds连续,直接删除修改成本也低,但是长串缩短成本比较高,而基于sds的free我们对长串可以做到惰性删除;

4.1.3、SDS的数据结构

4.1.3.1、SDS的结构

面试题及其衍生知识点_第7张图片
如上图所示SDS并没有直接用C字符串,而是以Struct的形式构造了一个SDS的抽象类型。

struct sdshdr{
    //int 记录buf数组中未使用字节的数量 如上图free为0代表未使用字节的数量为0
    int free;
    //int 记录buf数组中已使用字节的数量即sds的长度 如上图len为5代表未使用字节的数量为5
    int len;
    //字节数组用于保存字符串 sds遵循了c字符串以空字符结尾的惯例目的是为了重用c字符串函数库里的函数
    char buf[];
}

SDS存储数据还包含了元信息也就是一个头信息见下,以下是按照redis5.0.8版本来说明的。
面试题及其衍生知识点_第8张图片
SDS数据有两个重要的结构,sds辅助操作结构和带头部真实数据结构sdshdr。虽说sds是辅助但它扮演者举足轻重的角色,很多传参和返回值都是sds字符串,因为可以通过C语言指针偏移指快速访问到数据结构。

sds结构是char *类型指针,只是定义了一个有意义的别名。

typedef char *sds;

sdshdr结构特点见下,下面以8字节为例。

struct sdshdr {
    uint8_t len; /* 当前字符串大小(单位字节)*/
    uint8_t alloc; /* 内存分配大小(单位字节) */
    unsigned char flags; /* 头类型分配8,16,32,64字节四种类型(其中5字节这种类型被弃用) */
    char buf[];
};
4.1.3.2、结构函数

介绍完结构后接下来看看sds定义的字符串操作函数,以下是sds字符串长度len,alloc获取和设置。

// 获取长度
static inline size_t sdslen(const sds s) {}
// 获取可用长度
static inline size_t sdsavail(const sds s) {}
// 设置长度
static inline void sdssetlen(sds s, size_t newlen) {}
// 增加长度
static inline void sdsinclen(sds s, size_t inc) {}
// 获取分配空间大小
static inline size_t sdsalloc(const sds s) {}
// 设置分配空间大小
static inline void sdssetalloc(sds s, size_t newlen){}

常用操作函数如下,这里列举一些和C字符串相同基本功能的函数。

SDS字符串功能见下。

// 字符串比较
int sdscmp(const sds s1, const sds s2);
// 字符串端截取
sds sdstrim(sds s, const char *cset);
// 字符串追加sds字串
sds sdscatsds(sds s, const sds t);
// 字符串赋值字串
sds sdscpy(sds s, const char *t);
// 字符串重复
sds sdsdup(const sds s);

C语言字符串功能见下。

char	*strcat(char *__s1, const char *__s2);
int	 strcmp(const char *__s1, const char *__s2);
char	*strcpy(char *__dst, const char *__src);
size_t	 strlen(const char *__s);
int	 strerror_r(int __errnum, char *__strerrbuf, size_t __buflen);
char	*strdup(const char *__s1);

其中sds创建有专门封装函数处理。

sds sdsnewlen(const void *init, size_t initlen);
sds sdsnew(const char *init);

sdsnewlen这个函数是创建新字符串,需要指定指针和长度,除了sdsnewlen还有一些重要函数例如sdsMakeRoomFor(扩展原sds字符串的空间),下面以sdsnewlen函数为例,展示了sds和sdshdr使用。它会将sdshdr结构体转化为字符指针,通过指针偏移操作结构体,节省内存空间,这也是为什么比较推崇使用紧凑型字符串结构,由此可见SDS的头信息和数据占内存连续空间的。

// 创建新字符串,指定了指针和长度
sds sdsnewlen(const void *init, size_t initlen) {
    void *sh;  //指向SDS结构体的指针
    sds s;     //sds类型变量,即char*字符数组
 
    ...
    sh = s_malloc(hdrlen+initlen+1);   //新建SDS结构,并分配内存空间
    ...
    s = (char*)sh+hdrlen;              //sds类型变量指向SDS结构体中的buf数组,sh指向SDS结构体起始位置,hdrlen是SDS结构体中元数据的长度
    ...
 
    switch(type) {
        ...
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s);      // char类型的s强转为sdshdr8
            sh->len = initlen;     // 设置sh结构字符长度
            sh->alloc = initlen;    // 设置sh结构空间分配长度
            *fp = type;
            break;
        }
        ...
    }
    if (initlen && init)
        memcpy(s, init, initlen);    //将要传入的字符串拷贝给sds变量s
    s[initlen] = '\0';               //变量s末尾增加\0,表示字符串结束
    return s;
}

以下演示了结构体和char类型转化并通过char偏移访问结构体方式。

#include 
#include 
#include 
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; 
    uint8_t alloc; 
    unsigned char flags;
    char  buf[];
};
int main(){
        char *sds;
        struct sdshdr8 *sh;
        int hdrlen = sizeof(struct sdshdr8);
        sh = (struct sdshdr8*)malloc(hdrlen+12);
        sds = (char*)sh+hdrlen;
        sh->len = (uint8_t)12;
        memcpy(sds,"hello world" , 11);
        printf("sds->%s;sh->buf->%s;sds[-3]=%d\n", sds,sh->buf,sds[-3]);
        return 0;
}

sdsMakeRoomFor是给sds字符串增加新的空间,存在扩容情况。

sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    size_t avail = sdsavail(s);
    size_t len, newlen;
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;
 
    
    if (avail >= addlen) return s;     // avail空间足够直接返回,avail=sds->alloc - sds->len
 
    len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    newlen = (len+addlen);             // 动态字符串新长度
    if (newlen < SDS_MAX_PREALLOC)     // 小于1m以2倍扩容(其中SDS_MAX_PREALLOC=1024 * 1024)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;   // 大于1m以1m大小扩容
 
    ...
 
    sdssetalloc(s, newlen);            // 设置分配空间大小
    return s;
}
4.1.3.3、紧凑型头结构

redis 在定义结构时增加了__attribute__ ((__ packed__)) ,该关键字作用是申明以紧凑结构分配内存。因为通常默认结构体在占内存时,是以4或8字节对齐的,若加上该关键字,编译器会按照结构体实际大小占据空间。有两个好处,1节约空间;2通过指针偏移访问。

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

例如以sdshdr16为例子,没有__attribute__ ((__ packed__))需要占用6字节,有__attribute__ ((__ packed__))则只占用5字节。

4.1.3.4、总结

SDS通过自定义结构,能在O(1)时间复杂度访问长度;通过实现自己处理函数,维护字符长度和采用指针偏移方式访问数据,对任意二进制数据操作;通过连续空间的预分配减少系统调用和内存碎片。

4.1.4、为什么Redis不使用C字符串

4.1.4.1、避免内存溢出问题

面试题及其衍生知识点_第9张图片
C字符串,如果程序员在字符串修改的时候如果忘记给字符串重新分配足够的空间,那么就会发生内存溢出,如上图所示,忘记给s1分配足够的内存空间, s1的数据就会溢出到s2的空间, 导致s2的内容被修改.。而Redis提供的SDS其内置的空间分配策略则可以完全杜绝这种事情的发生。当API需要对SDS进行修改时, API会首先会检查SDS的空间是否满足条件, 如果不满足, API会自动对它动态扩展, 然后再进行修改。
面试题及其衍生知识点_第10张图片

注意。这里有个Redis的优化,空间预分配 待会讲

减少修改字符串时带来的内存重分配次数

4.1.4.2、二进制安全
  • C字符串以\0空字符结尾标识一个字符串结束,所以字符串里边是不能包含\0的,不然就会被误认是多个。由于这种限制,使得C字符串只能保存文本数据,像音视频、图片等二进制格式的数据是无法存储的。
  • SDS的buf字节数组不是在保存字符,而是一系列二进制数组,SDS API都会以二进制的方式来处理buf数组里的数据,使用len属性的值而不是空字符来判断字符串是否结束。
4.1.4.3、SDS空间分配策略优化

对于Redis这种具有高性能要求的内存数据库,如果每次修改字符串都要进行内存重分配,无疑是巨大的性能损失。而Redis的SDS提供了两种空间分配策略来解决这个问题。

  • 预分配
    我们知道在数组进行扩容的时候,往往会申请一个更大的数组,然后把数组复制过去。为了提升性能,我们在分配空间的时候并不是分配一个刚刚好的空间,而是分配一个更大的空间。Redis同样基于这种策略提供了空间预分配。当执行字符串增长操作并且需要扩展内存时,程序不仅仅会给SDS分配必需的空间还会分配额外的未使用空间,其长度存到free属性中。其分配策略如下:
    • 如果修改后len长度将小于1M,这时分配给free的大小和len一样,例如修改过后为10字节, 那么给free也是10字节,buf实际长度变成了10+10+1 = 21byte
    • 如果修改后len长度将大于等于1M,这时分配给free的长度为1M,例如修改过后为30M,那么给free是1M.buf实际长度变成了30M+1M+1byte
  • 惰性空间分配
    惰性空间释放用于字符串缩短的操作。当字符串缩短是,程序并不是立即使用内存重分配来回收缩短出来的字节,而是使用free属性记录起来,并等待将来使用。
    面试题及其衍生知识点_第11张图片
    Redis通过空间预分配和惰性空间释放策略在字符串操作中一定程度上减少了内存重分配的次数。但这种策略同样会造成一定的内存浪费,因此Redis SDS API提供相应的API让我们在有需要的时候真正的释放SDS的未使用空间。
4.1.4.4、总结

通过以上分析,我们可以得到,SDS这种数据结构相对于C字符串有以下优点:

  • 杜绝缓冲区溢出
  • 减少字符串操作中的内存重分配次数
  • 二进制安全
  • 由于SDS遵循以空字符结尾的惯例,因此兼容部门C字符串函数,这也是为什么SDS保留\0的原因

Redis定位于一个高性能的内存数据库,其面向的就是大数据量,大并发,频繁读写,高响应速度的业务。因此在保证安全稳定的情况下,性能的提升非常重要。而SDS这种数据结构屏蔽了C字符串的一些缺点,可以提供安全高性能的字符串操作。

4.2、Redis数据类型

Redis提供了丰富的数据类型,常见的有五种:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。

随着Redis版本的更新,后面又支持了四种数据类型:BitMap(2.2版新增)、HyperLogLog(2.8版新增)、GEO(3.2版新增)、Stream(5.0版新增)。

4.2.1、Redis核心对象

在Redis中有一个核心的对象叫做redisObject,是用来表示所有的key和value的,用redisObject结构体来表示String、Hash、List、Set、ZSet五种数据类型。

redisObject的源代码在redis.h中,使用c语言写的,表示redisObject的结构如下所示:
面试题及其衍生知识点_第12张图片
在redisObject中type表示所属数据类型,encoding表示该对象编码,也就是底层实现该数据类型的存储方式。那么encoding中的存储类型又分别表示什么意思呢?具体数据类型所表示的含义,如下图所示:
面试题及其衍生知识点_第13张图片
这张图只是让你找到每种中数据结构对应的储存类型有哪些,举一个简单的例子,在Redis中设置一个字符串key 234,然后查看这个字符串的存储类型就会看到为int类型,非整数型的使用的是embstr储存类型,具体操作如下图所示:

127.0.0.1:6379> set key 234
OK
127.0.0.1:6379> object encoding key
"int"
127.0.0.1:6379> set k2 3.200
OK
127.0.0.1:6379> object encoding k2
"embstr"

4.2.2、String

String是Redis最基本的数据类型,这是最简单的类型,就是普通的set和get,做简单的KV缓存。一个键最大能存储512M。

内部的实现是通过SDS(Simple Dynamic String)来存储的。SDS类似于Java中的ArrayList,可以通过预分配冗余空间的方式来减少内存的频繁分配。Redis是用c语言开发的。但是Redis中的字符串和c语言中的字符串类型却是有明显的区别。

4.2.2.1、存储方式

String类型的底层的数据结构实现主要是int和SDS(简单动态字符串)。内部编码(encoding)有三种int、raw、embstr。
int
Redis中规定假如存储的是整数型值,比如set num 123这样的类型,就会使用int的存储方式进行存储,在redisObject的「ptr属性」中就会保存该值。
面试题及其衍生知识点_第14张图片
SDS
存储的字符串对象是一个字符串值就会使用SDS方式进行存储。

SDS称为简单动态字符串,对于SDS中的定义在Redis的源码中有的三个属性int len、int free、char buf[]。len保存了字符串的长度,free表示buf数组中未使用的字节数量,buf数组则是保存字符串的每一个字符元素。

  • 如果存储的字符串值长度大于32个字节,对象编码设置为raw。
    面试题及其衍生知识点_第15张图片

  • 如果字符串长度小于等于32个字节,对象编码设置为embstr。
    在这里插入图片描述
    raw和embstr区别

  • embstr会通过一次内存分配函数来分配一块连续的内存空间来保存redisObject和SDS,

  • raw编码会通过调用两次内存分配函数来分别分配两块空间来保存redisObject和SDS。

embstr优缺点

  • 优点:
    • embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次;
    • 释放embstr编码的字符串对象同样只需要调用一次内存释放函数;
    • 因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面可以更好的利用CPU缓存提升性能。
  • 缺点:
    • 如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,所以embstr编码的字符串对象实际上是只读的,redis没有为embstr编码的字符串对象编写任何相应的修改程序。当我们对embstr编码的字符串对象执行任何修改命令(例如append)时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令。

SDS与c语言字符串对比

Redis使用SDS作为存储字符串的类型肯定是有自己的优势,SDS与c语言的字符串相比,SDS对c语言的字符串做了自己的设计和优化,具体优势有以下几点:

  • (1)c语言中的字符串并不会记录自己的长度,因此每次获取字符串的长度都会遍历得到,时间的复杂度是O(n),而Redis中获取字符串只要读取len的值就可,时间复杂度变为O(1)。
  • (2)c语言中两个字符串拼接,若是没有分配足够长度的内存空间就会出现缓冲区溢出的情况;而SDS会先根据len属性判断空间是否满足要求,若是空间不够,就会进行相应的空间扩展,所以不会出现缓冲区溢出的情况。
  • (3)SDS还提供空间预分配和惰性空间释放两种策略。在为字符串分配空间时,分配的空间比实际要多,这样就能减少连续的执行字符串增长带来内存重新分配的次数。当字符串被缩短的时候,SDS也不会立即回收不适用的空间,而是通过free属性将不使用的空间记录下来,等后面使用的时候再释放。具体的空间预分配原则是:当修改字符串后的长度len小于1MB,就会预分配和len一样长度的空间,即len=free;若是len大于1MB,free分配的空间大小就为1MB。
  • (4)SDS是二进制安全的,除了可以储存字符串以外还可以储存二进制文件(如图片、音频,视频等文件的二进制数据);而c语言中的字符串是以空字符串作为结束符,一些图片中含有结束符,因此不是二进制安全的。

为了方便易懂,做了一个c语言的字符串和SDS进行对比的表格,如下所示:

C语言字符串 SDS
获取长度的时间复杂度为O(n) 获取长度的时间复杂度为O(1)
不是二进制安全的 是二进制安全的
只能保存字符串 还可以保存二进制数据
n次增长字符串必然会带来n次的内存分配 n次增长字符串内存分配的次数<=n
4.2.2.2、应用场景
  • String存储图片案例实现。
    (1)首先要把上传得图片进行编码,这里写了一个工具类把图片处理成了Base64得编码形式,具体得实现代码如下:
/**
 * 将图片内容处理成Base64编码格式
 * @param file
 * @return
 */
public static String encodeImg(MultipartFile file) {
    byte[] imgBytes = null;
    try {
        imgBytes = file.getBytes();
    } catch (IOException e) {
        e.printStackTrace();
    }
    BASE64Encoder encoder = new BASE64Encoder();
    return imgBytes == null ? null : encoder.encode(imgBytes);
}

(2)第二步就是把处理后的图片字符串格式存储进Redis中,实现的代码如下所示:

/**
 * Redis存储图片
 * @param file
 * @return
 */
public void uploadImageServiceImpl(MultipartFile image) {
    String imgId = UUID.randomUUID().toString();
    String imgStr= ImageUtils.encodeImg(image);
    redisUtils.set(imgId , imgStr);
    // 后续操作可以把imgId存进数据库对应的字段
    // 如果需要从redis中取出,只要获取到这个字段后从redis中取出即可。
}

其他String的实际应用场景有:

  • 缓存功能:String字符串是最常用的数据类型,不仅仅是Redis,各个语言都是最基本类型,因此利用Redis作为缓存,配合其它数据库作为存储层,利用Redis支持高并发的特点,可以大大加快系统的读写速度、以及降低后端数据库的压力。
  • 计数器:许多系统都会使用Redis作为系统的实时计数器,可以快速实现计数和查询的功能。而且最终的数据结果可以按照特定的时间落地到数据库或者其它存储介质当中进行永久保存。
  • 共享用户Session:用户重新刷新一次界面,可能需要访问一下数据进行重新登录,或者访问页面缓存Cookie,但是可以利用Redis将用户的Session集中管理,在这种模式只需要保证Redis的高可用,每次用户Session的更新和获取都可以快速完成。大大提高效率。
  • 分布式锁:SET命令有个NX参数可以实现「key不存在才插入」,可以用它来实现分布式锁。

4.2.3、Hash

Hash是一个键值对(key-value)集合,其中value的形式入:value=[{field1,value1},…{fieldN,valueN}]。Hash特别适合用于存储对象。

Hash与String对象的区别如下图所示:
面试题及其衍生知识点_第16张图片

4.2.3.1、底层实现

Hash对象的实现方式有两种分别是压缩列表(ziplist)和哈希表(hashtable),其中hashtable的存储方式key是String类型的,value也是以键值对(key-value)的形式进行存储。

  • 如果哈希类型元素个数小于512个(默认值,可由hash-max-ziplist-entries配置),所有值小于64字节(默认值,可由hash-max-ziplist-value配置)的话,Redis会使用压缩列表作为Hash类型的底层数据结构;
  • 如果哈希类型元素不满足上面条件,Redis会使用哈希表作为Hash类型的底层数据结构。
    字典类型的底层就是hashtable实现的,明白了字典的底层实现原理也就是明白了hashtable的实现原理,hashtable的实现原理可以与HashMap的是底层原理相似。
4.2.3.1.1、哈希表hashtable

两者在新增时都会通过key计算出数组下标,不同的是计算法方式不同,HashMap中是以hash函数的方式,而hashtable中计算出hash值后,还要通过sizemask属性和哈希值再次得到数组下标。

我们知道hash表最大的问题就是hash冲突,为了解决hash冲突,假如hashtable中不同的key通过计算得到同一个index,就会形成单向链表(链地址法),如下图所示:
面试题及其衍生知识点_第17张图片

4.2.3.1.1.1、rehash

在字典的底层实现中,value对象以每一个dictEntry的对象进行存储,当hash表中的存放的键值对不断的增加或者减少时,需要对hash表进行一个扩展或者收缩。

这里就会和HashMap一样也会就进行rehash操作,进行重新散列排布。从上图中可以看到有ht[0]和ht[1]两个对象,先来看看对象中的属性是干嘛用的。

在hash表结构定义中有四个属性分别是dictEntry table、unsigned long size、unsigned long sizemask、unsigned long used,分别表示的含义就是哈希表数组、hash表大小、用于计算索引值,总是等于size-1、hash表中已有的节点数。

ht[0]是用来最开始存储数据的,当要进行扩展或者收缩时,ht[0]的大小就决定了ht[1]的大小,ht[0]中的所有的键值对就会重新散列到ht[1]中。

扩展操作:ht[1]扩展的大小是比当前ht[0].used值的二倍大的第一个2的整数幂;收缩操作:ht[0].used的第一个大于等于的2的整数幂。

当ht[0]上的所有的键值对都rehash到ht[1]中,会重新计算所有的数组下标值,当数据迁移完后ht[0]就会被释放,然后将ht[1]改为ht[0],并新创建ht[1],为下一次的扩展和收缩做准备。

4.2.3.1.1.2、渐进式rehash

假如在rehash的过程中数据量非常大,Redis不是一次性把全部数据rehash成功,这样会导致Redis对外服务停止,Redis内部为了处理这种情况采用渐进式的rehash。Redis将所有的rehash的操作分成多步进行,直到都rehash完成,具体的实现与对象中的rehashindex属性相关,若是rehashindex表示为-1表示没有rehash操作。当rehash操作开始时会将该值改成0,在渐进式rehash的过程更新、删除、查询会在ht[0]和ht[1]中都进行,比如更新一个值先更新ht[0],然后再更新ht[1]。而新增操作直接就新增到ht[1]表中,ht[0]不会新增任何的数据,这样保证ht[0]只减不增,直到最后的某一个时刻变成空表,这样rehash操作完成。上面就是字典的底层hashtable的实现原理,说完了hashtable的实现原理,我们再来看看Hash数据结构的两一种存储方式ziplist(压缩列表)

4.2.3.1.2、压缩列表

ziplist(压缩列表)是一组连续内存块组成的顺序的数据结构,压缩列表能够节省空间,压缩列表中使用多个节点来存储数据。压缩列表是列表键和哈希键底层实现的原理之一,压缩列表并不是以某种压缩算法进行压缩存储数据,而是它表示一组连续的内存空间的使用,节省空间,压缩列表的内存结构图如下:
在这里插入图片描述
压缩列表中每一个节点表示的含义如下所示:

  • zlbytes:4个字节的大小,记录压缩列表占用内存的字节数。
  • zltail:4个字节大小,记录表尾节点距离起始地址的偏移量,用于快速定位到尾节点的地址。
  • zllen:2个字节的大小,记录压缩列表中的节点数。
  • entry:表示列表中的每一个节点。
  • zlend:表示压缩列表的特殊结束符号’0xFF’。

再压缩列表中每一个entry节点又有三部分组成,包括previous_entry_ength、encoding、content。

  • previous_entry_ength表示前一个节点entry的长度,可用于计算前一个节点的其实地址,因为他们的地址是连续的。
  • encoding:这里保存的是content的内容类型和长度。
  • content:content保存的是每一个节点的内容。
    面试题及其衍生知识点_第18张图片
    在Redis 7.0中,压缩列表数据结构已经废弃了,交由listpack数据结构来实现了。
4.2.3.2、应用场景

哈希表相对于String类型存储信息更加直观,存储更加方便,经常会用来做用户数据的管理,存储用户的信息。hash也可以用作高并发场景下使用Redis生成唯一的id。下面我们就以这两种场景用作案例编码实现。

4.2.3.2.1、存储用户数据

第一个场景比如我们要储存用户信息,一般使用用户的ID作为key值,保持唯一性,用户的其他信息(地址、年龄、生日、电话号码等)作为value值存储。

若是传统的实现就是将用户的信息封装成为一个对象,通过序列化存储数据,当需要获取用户信息的时候,就会通过反序列化得到用户信息。
面试题及其衍生知识点_第19张图片
但是这样必然会造成序列化和反序列化的性能的开销,并且若是只修改其中的一个属性值,就需要把整个对象序列化出来,操作的动作太大,造成不必要的性能开销。

若是使用Redis的hash来存储用户数据,就会将原来的value值又看成了一个kv形式的存储容器,这样就不会带来序列化的性能开销的问题。

4.2.3.2.2、分布式生成唯一ID

第二个场景就是生成分布式的唯一ID,这个场景下就是把redis封装成了一个工具类进行实现,实现的代码如下:

// offset表示的是id的递增梯度值
public Long getId(String key,String hashKey,Long offset) throws BusinessException {
    try {
        if (null == offset) {
            offset=1L;
        }
        // 生成唯一id
        return redisUtil.increment(key, hashKey, offset);
    } catch (Exception e) {
        //若是出现异常就是用uuid来生成唯一的id值
        int randNo=UUID.randomUUID().toString().hashCode();
        if (randNo < 0) {
            randNo=-randNo;
        }
        return Long.valueOf(String.format("%16d", randNo));
    }
}

4.2.4、List

List列表是简单的字符串列表,按照插入顺序排序,可以从头部或尾部向List列表添加元素。

列表的最大长度为2^32 - 1,也即每个列表支持超过40亿个元素。

4.2.4.1、底层实现

Redis中的列表在3.2之前的版本是使用ziplist和linkedlist进行实现的。

  • 如果列表的元素个数小于512个(默认值,可由list-max-ziplist-entries配置),列表每个元素的值都小于64字节(默认值,可由list-max-ziplist-value配置),Redis会使用压缩列表作为List类型的底层数据结构;

  • 如果列表的元素不满足上面的条件,Redis会使用双向链表作为List类型的底层数据结构;

在3.2之后的版本就是引入了quicklist。linkedlist是一个双向链表,普通的链表一样都是由指向前后节点的指针。插入、修改、更新的时间复杂度尾O(1),但是查询的时间复杂度确实O(n)。linkedlist和quicklist的底层实现是采用链表进行实现,在c语言中并没有内置的链表这种数据结构,Redis实现了自己的链表结构。
面试题及其衍生知识点_第20张图片
Redis中链表的特性:

  • 每一个节点都有指向前一个节点和后一个节点的指针。
  • 头节点和尾节点的prev和next指针指向为null,所以链表是无环的。
  • 链表有自己长度的信息,获取长度的时间复杂度为O(1)。
4.2.4.2、应用场景
  • 消息队列:Redis的链表结构,可以轻松实现阻塞队列,可以使用左进右出的命令组成来完成队列的设计。比如:数据的生产者可以通过Lpush命令从左边插入数据,多个数据消费者,可以使用BRpop命令阻塞的“抢”列表尾部的数据。
  • 文章列表或者数据分页展示的应用。比如,我们常用的博客网站的文章列表,当用户量越来越多时,而且每一个用户都有自己的文章列表,而且当文章多时,都需要分页展示,这时可以考虑使用Redis的列表,列表不但有序同时还支持按照范围内获取元素,可以完美解决分页查询功能,大大提高查询效率。

4.2.5、Set

Redis中列表和集合都可以用来存储字符串,但是Set是不可重复的集合,而List列表可以存储相同的字符串,Set集合是无序的这个和后面讲的ZSet有序集合相对。

Set类型和List类型的区别如下:

  • List可以存储重复元素,Set只能存储非重复元素;
  • List是按照元素的先后顺序存储元素的,而Set则是无序方式存储元素的。
4.2.5.4、底层实现

Set的底层实现是ht(哈希表)和intset(整数集合)。

  • 如果集合中的元素都是整数且元素个数小于512(默认值,set-maxintset-entries配置)个,Redis会使用整数集合作为Set类型的底层数据结构;
  • 如果集合中的元素不满足上面条件,则Redis使用哈希表作为Set类型的底层数据结构。

ht前面已经详细了解过,下面我们来看看inset类型的存储结构。

inset也叫做整数集合,用于保存整数值的数据结构类型,它可以保存int16_tint32_t或者int64_t的整数值。在整数集合中,有三个属性值encoding、length、contents[],分别表示编码方式、整数集合的长度、以及元素内容,length就是记录contents里面的大小。

在整数集合新增元素的时候,若是超出了原集合的长度大小,就会对集合进行升级,具体的升级过程如下:

  • 首先扩展底层数组的大小,并且数组的类型为新元素的类型。
  • 然后将原来的数组中的元素转为新元素的类型,并放到扩展后数组对应的位置。
  • 整数集合升级后就不会再降级,编码会一直保持升级后的状态。
4.2.5.5、应用场景

Set集合的应用场景可以用来去重、点赞、抽奖、共同好友、二度好友等业务类型。接下来模拟一个添加好友的案例实现:

@RequestMapping(value = "/addFriend", method = RequestMethod.POST)
public Long addFriend(User user, String friend) {
    String currentKey = null;
    // 判断是否是当前用户的好友
    if (AppContext.getCurrentUser().getId().equals(user.getId)) {
        currentKey = user.getId.toString();
    }
    //若是返回0则表示不是该用户好友
    return currentKey==null?0l:setOperations.add(currentKey, friend);
}

假如两个用户A和B都是用上上面的这个接口添加了很多的自己的好友,那么有一个需求就是要实现获取A和B的共同好友,那么可以进行如下操作:

public Set intersectFriend(User userA, User userB) {
    return setOperations.intersect(userA.getId.toString(), userB.getId.toString());
}

举一反三,还可以实现A用户自己的好友,或者B用户自己的好友等,都可以进行实现。

4.2.6、ZSet

Zset(Sorted set)是有序集合类型,相比于Set类型多了一个排序属性score(分值),对于有序集合ZSet来说,每个存储元素相当于有两个值组成的,一个是有序结合的元素值,一个是排序值。

有序集合保留了集合不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。

4.2.6.1、底层实现

Zset的底层实现是ziplist(压缩列表)和skiplist(跳表)实现的。

  • 如果有序集合的元素个数小于128个,并且每个元素的值小于64字节时,Redis会使用压缩列表作为Zset类型的底层数据结构;
  • 如果有序集合的元素不满足上面的条件,Redis会使用跳表作为Zset类型的底层数据结构;

skiplist(跳跃表)是一种有序的数据结构,它通过每一个节点维持多个指向其它节点的指针,从而达到快速访问的目的。

skiplist有如下几个特点:

  • 有很多层组成,由上到下节点数逐渐密集,最上层的节点最稀疏,跨度也最大。
  • 每一层都是一个有序链表,至少包含两个节点,头节点和尾节点。
  • 每一层的每一个每一个节点都含有指向同一层下一个节点和下一层同一个位置节点的指针。
  • 如果一个节点在某一层出现,那么该以下的所有链表同一个位置都会出现该节点。

具体实现的结构图如下所示
面试题及其衍生知识点_第21张图片
在跳跃表的结构中有head和tail表示指向头节点和尾节点的指针,能快速的实现定位。level表示层数,len表示跳跃表的长度,BW表示后退指针,在从尾向前遍历的时候使用。BW下面还有两个值分别表示分值(score)和成员对象(各个节点保存的成员对象)。跳跃表的实现中,除了最底层的一层保存的是原始链表的完整数据,上层的节点数会越来越少,并且跨度会越来越大。跳跃表的上面层就相当于索引层,都是为了找到最后的数据而服务的,数据量越大,条表所体现的查询的效率就越高,和平衡树的查询效率相差无几。

4.2.6.2、应用场景

zset的使用场景与set类似,但是set集合不是自动有序的,而Sorted set可以利用分数进行成员间的排序,而且是插入时就排序好。所以当你需要一个有序且不重复的集合列表时,就可以选择Sorted set数据结构作为选择方案。

  • 排行榜:有序集合经典使用场景。例如视频网站需要对用户上传的视频做排行榜,榜单维护可能是多方面:按照时间、按照播放量、按照获得的赞数等。
  • 用ZSets来做带权重的队列,比如普通消息的score为1,重要消息的score为2,然后工作线程可以选择按score的倒序来获取工作任务。让重要的任务优先执行。

微博热搜榜,就是有个后面的热度值,前面就是名称。因为ZSet是有序的集合,因此ZSet在实现排序类型的业务是比较常见的,比如在首页推荐10个最热门的帖子,也就是阅读量由高到低,排行榜的实现等业务。下面就选用获取排行榜前前10名的选手作为案例实现,实现的代码如下所示:

/**
 * 获取前10排名
 * @return
 */
public static List<levelVO > getZset(String key, long baseNum, LevelService levelService){
    ZSetOperations<Serializable, Object> operations = redisTemplate.opsForZSet();
    // 根据score分数值获取前10名的数据
    Set<ZSetOperations.TypedTuple<Object>> set = operations.reverseRangeWithScores(key,0,9);
    List<LevelVO> list= new ArrayList<LevelVO>();
    int i=1;
    for (ZSetOperations.TypedTuple<Object> o:set){
        int uid = (int) o.getValue();
        LevelCache levelCache = levelService.getLevelCache(uid);
        LevelVO levelVO = levelCache.getLevelVO();
        long score = (o.getScore().longValue() - baseNum + levelVO .getCtime())/CommonUtil.multiplier;
        levelVO .setScore(score);
        levelVO .setRank(i);
        list.add( levelVO );
        i++;
    }
    return list;
}

以上的代码实现大致逻辑就是根据score分数值获取前10名的数据,然后封装成lawyerVO对象的列表进行返回。

4.2.7、Bitmap

Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1的设置,表示某个元素的值或者状态,时间复杂度为O(1)。

由于bit是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景。
在这里插入图片描述

4.2.7.1、底层实现

Bitmap本身是用String类型作为底层数据结构实现的一种统计二值状态的数据类型。

String类型是会保存为二进制的字节数组,所以,Redis就把字节数组的每个bit位利用起来,用来表示一个元素的二值状态,你可以把Bitmap看作是一个bit数组。

4.2.7.2、应用场景

Bitmap类型非常适合二值状态统计的场景,这里的二值状态就是指集合元素的取值就只有0和1两种,在记录海量数据时,Bitmap能够有效地节省内存空间。

4.2.7.2.1、签到统计

在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态。

签到统计时,每个用户一天的签到用1个bit位就能表示,一个月(假设是31天)的签到情况用31个bit位就可以,而一年的签到也只需要用365个bit位,根本不用太复杂的集合类型。

4.2.7.2.2、判断用户登陆态

Bitmap提供了GETBIT、SETBIT操作,通过一个偏移值offset对bit数组的offset位置的bit位进行读写操作,需要注意的是offset从0开始。

只需要一个key=login_status表示存储用户登陆状态集合数据,将用户ID作为offset,在线就设置为1,下线设置0。通过GETBIT判断对应的用户是否在线。50000万用户只需要6MB的空间。

4.2.7.2.3、连续签到用户总数

把每天的日期作为Bitmap的key,userId作为offset,若是打卡则将offset位置的bit设置成1。

key对应的集合的每个bit位的数据则是一个用户在该日期的打卡记录。

一共有7个这样的Bitmap,如果我们能对这7个Bitmap的对应的bit位做『与』运算。同样的UserID offset都是一样的,当一个userID在7个Bitmap对应对应的offset位置的bit=1就说明该用户7天连续打卡。

结果保存到一个新Bitmap中,我们再通过BITCOUNT统计bit=1的个数便得到了连续打卡3天的用户总数了。

4.2.8、HyperLogLog

HyperLogLog是Redis 2.8.9版本新增的数据类型,是一种用于「统计基数」的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,HyperLogLog是统计规则是基于概率完成的,不是非常准确,标准误算率是0.81%。

所以,简单来说HyperLogLog提供不精确的去重计数。

HyperLogLog的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的内存空间总是固定的、并且是很小的。

在Redis里面,每个HyperLogLog键只需要花费12KB内存,就可以计算接近2^64个不同元素的基数,和元素越多就越耗费内存的Set和Hash类型相比,HyperLogLog就非常节省空间。

这什么概念?举个例子给大家对比一下。

用Java语言来说,一般long类型占用8字节,而1字节有8位,即:1byte = 8bit,即long数据类型最大可以表示的数是:263−1。对应上面的264个数,假设此时有263−1这么多个数,从0∼263−1,按照long以及1k=1024字节的规则来计算内存总数,就是:((263−1)∗8/1024)K,这是很庞大的一个数,存储空间远远超过12K,而HyperLogLog却可以用12K就能统计完。

4.2.8.1、应用场景

百万级网页UV计数:HyperLogLog优势在于只需要花费12KB内存,就可以计算接近264个元素的基数,和元素越多就越耗费内存的Set和Hash类型相比,HyperLogLog就非常节省空间。

所以,非常适合统计百万级以上的网页UV的场景。

4.2.9、Geo

GEO是Redis 3.2版本新增的数据类型,主要用于存储地理位置信息,并对存储的信息进行操作。

在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based Service,LBS)的应用。LBS应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO就非常适合应用在LBS服务的场景中。

4.2.9.1、底层实现

GEO本身并没有设计新的底层数据结构,而是直接使用了SortedSet集合类型。

GEO类型使用GeoHash编码方法实现了经纬度到Sorted Set中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为Sorted Set元素的权重分数。

这样一来,我们就可以把经纬度保存到Sorted Set中,利用Sorted Set提供的“按权重进行有序范围查找”的特性,实现LBS服务中频繁使用的“搜索附近”的需求。

4.2.9.2、应用场景

这里以滴滴叫车的场景为例,介绍下具体如何使用GEO命令:GEOADD和GEORADIUS这两个命令。

假设车辆ID是33,经纬度位置是(116.034579,39.030452),我们可以用一个GEO集合保存所有车辆的经纬度,集合key是cars:locations。

当用户想要寻找自己附近的网约车时,LBS应用就可以使用GEORADIUS命令。

例如,LBS应用执行下面的命令时,Redis会根据输入的用户的经纬度信息(116.054579,39.030452),查找以这个经纬度为中心的5公里内的车辆信息,并返回给LBS应用。

可以用来保存地理位置,并作位置距离计算或者根据半径计算位置等。有没有想过用Redis来实现附近的人?或者计算最优地图路径?这三个其实也可以算作一种数据结构,不知道还有多少朋友记得,我在梦开始的地方,Redis基础中提到过,你如果只知道五种基础类型那只能拿60分,如果你能讲出高级用法,那就觉得你有点东西。

4.2.10、Stream

Stream是Redis 5.0版本新增加的数据类型,Redis专门为消息队列设计的数据类型。

在Redis 5.0 Stream没出来之前,消息队列的实现方式都有着各自的缺陷,例如:

  • 发布订阅模式,不能持久化也就无法可靠的保存消息,并且对于离线重连的客户端不能读取历史消息的缺陷;
  • List实现消息队列的方式不能重复消费,一个消息消费完就会被删除,而且生产者需要自行实现全局唯一ID。

基于以上问题,Redis 5.0便推出了Stream类型也是此版本最重要的功能,用于完美地实现消息队列,它支持消息的持久化、支持自动生成全局唯一ID、支持ack确认消息的模式、支持消费组模式等,让消息队列更加的稳定和可靠。

4.2.10.1、应用场景
4.2.10.1.1、消息队列

生产者通过XADD命令插入一条消息,消费者通过XREAD命令从消息队列中读取消息时,可以指定一个消息ID,并从这个消息ID的下一条消息开始进行读取

4.2.11、总结

Redis常见的五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)及Zset(sorted set:有序集合)。

这五种数据类型都由多种数据结构实现的,主要是出于时间和空间的考虑,当数据量小的时候使用更简单的数据结构,有利于节省内存,提高性能。

Redis五种数据类型的应用场景:

  • String类型的应用场景:缓存对象、常规计数、分布式锁、共享session信息等。
  • List类型的应用场景:消息队列(有两个问题:1.生产者需要自行实现全局唯一ID;2.不能以消费组形式消费数据)等。
  • Hash类型:缓存对象、购物车等。
  • Set类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
  • Zset类型:排序场景,比如排行榜、电话和姓名排序等。

Redis后续版本又支持四种数据类型,它们的应用场景如下:

  • BitMap(2.2版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
  • HyperLogLog(2.8版新增):海量数据基数统计的场景,比如百万级网页UV计数等;
  • GEO(3.2版新增):存储地理位置信息的场景,比如滴滴叫车;
  • Stream(5.0版新增):消息队列,相比于基于List类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。

5、http1.0和2.0的区别

HTTP2.0新的二级制格式而非文本格式
HTTP2.0是完全多路复用,只需要一个TCP连接就可以实现并行,而非有序并阻塞的
使用header压缩,HTTP2.0降低了请求成本
服务端推送,服务端可以主动把响应主动push到客户端

6、nio和bio区别 epoll详细原理

6.1、BIO

面试题及其衍生知识点_第22张图片
操作系统内核提供 read(系统调用),读文件描述符
一个client连接就是一个文件描述符fd
socket为阻塞的,socket产生的文件描述符,如左边的fd8,当数据包没到的时候,上面左边read不能返回,阻塞着。
即有一个client连接,就需要开一个进程(或者线程),读这个连接,有数据就处理,没数据就阻塞着。

问题:几个连接几个进程(线程),一个cpu在某一时间片上,只能一个进程(线程)处理,如果自己的数据还没到,就算另外一个数据到了,也没办法处理。造成cpu资源浪费,没办法时刻处理到达的数据。而且开这么多进程(线程)是有成本的。

6.2、NIO

linux内核提供的socket可以是非阻塞的。通过man 2 socket。查看帮助文档。int socket(int domain, int type, int protocol)。在type的参数中设置SOCK_NONBLOCK标志,代表非阻塞。
面试题及其衍生知识点_第23张图片

既然socket不阻塞了,那么一个进程(线程)就够了。在进程里面写循环,即一个个问fd有没数据,即轮询,发生在用户空间。
遍历,取出来自己处理,这为同步非阻塞。

问题:如果有很多fd(假设1000个),代表用户进程需要轮询调用1000次内核(查一次文件描述符就需要一次系统调用)。这带来成本问题,因为用户态与内核态反复切换(cpu保护现场恢复现场)。当然了,如果连接数少的情况,这个开销就不大。

解决:引入后面select poll epoll。减少系统调用,这在用户空间实现是实现不了的,所以解决方式是在内核解决。

6.3、AIO

Linux 上目前没有像 IOCP(windows) 这样的成熟异步 IO 实现。目前来看,windows才有真正AIO。

6.3.1、select epoll

面试题及其衍生知识点_第24张图片

6.3.2、select

假设1000个fd,进程统一把1000个fd传select,内核监控这些,发现哪些fd准备好,则返回fd。然后进程拿准备好的fd再调用read。即多路复用,选择谁数据有了,直接执行。减少用户态和内核态切换。

问题: 每次需要把1000个fd传,再返回。用户态和内核需要拷来拷去fd。可优化!
解决:引入 epoll
延伸–> mmap
共享空间,内核<–>用户,即把1000个fd写入共享空间

6.4、epoll

epoll是一个整体,包含epoll_create 、epoll_ctl、epoll_wait三个系统调用。
共享空间,进程把fd存放红黑树,内核通过红黑树拿fd去查哪个io数据到达,把到达的放到链表里。然后进程从链表取对应的fd。

大致过程如下:

  • 进程先调用epoll的create,创建一个epoll文件描述符;
    epoll通过mmap开辟一块共享空间,增删改由内核完成,查询则内核和用户进程都可以
    这块共享空间中有一个红黑树和一个链表
  • 进程调用epoll的ctl add/delete sfd,把新来的链接放入红黑树中,
    • 进程调用wait(),等待事件(事件驱动)
  • 当红黑树中的fd有数据到了,就把它放入一个链表中并维护该数据可写还是可读,wait返回;
  • 上层用户空间(通过epoll)从链表中取出fd,然后调用read/write读写数据.

6.5、mmap和零拷贝

6.5.1、零拷贝senfile

比如读文件然后通过网卡把数据传出去
网卡到内核 socket io,file到内核 文件io,2个io,2个fd。那么需要先read 文件的fd,然后再write 把文件写出去,2个系统调用,发生在内核态。即文件数据先要到内核buffer缓冲区,然后read系统调用拷贝到用户空间,然后再系统调用把内容拷到内核空间。有了senfile后,就不用多了拷贝,即直接内核读缓冲区数据直接通过网卡写出去。

6.5.2、mmap

用户空间和内核空间是相互独立的,mmap用于把文件映射到内存空间中,简单说mmap就是把一个文件的内容在内存里面做一个映射。映射成功后,用户对这段内存区域的修改可以直接反映到内核空间,同样,内核空间对这段区域的修改也直接反映用户空间。

7、类中有三个对象引用,创建一个空对象的占用内存情况

「Java面试」一个空Object对象的占多大空间?这道题你答的上来吗
一个对象分别是由:对象头 + 实例数据 + 对齐填充构成
对象头在32位系统上占用8bytes,64位系统上占用16bytes。
因此,一个空的对象,在开启压缩指针的情况下,占16个字节其中Markword占8个字节、 类元指针占4个字节, 对齐填充占4个字节。

8、volatile jmm及应检实现

Java并发编程学习笔记

9、栈上分配对象和堆分配对象的区别

  • 栈由系统自动分配,速度较快。但程序员是无法控制的。
  • 栈空间的分配是在程序调度方法的时候,执行引擎执行方法的时候分配的内存空间。
    这部分空间,记录了对象和执行方法的关系,方法中基础数据类型分配表的关系。栈空间执行方法
    栈帧的时候,会出栈,立即销毁,所以不需要垃圾回收,而堆需要慢慢去整理和淘汰无用的对象。
  • 堆是由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便.存在垃圾管理
  • 在内存中分配两块空置,是一种平衡和制约的关系,可以起到协调和管理起来更加方便的目的

10、两个树节点最近公共祖先

给定二叉树(不是二叉搜索树)和两个节点 n1 和 n2,编程实现找到二者的最近公共祖先(Lowest Common Ancestor,LCA)。
LCA定义:最近公共祖先是两个节点所有公共祖先中离根节点最远的节点。从根开始遍历树,如果任一给定节点(n1和n2)与根匹配,则根为 LCA。如果根与任何节点都不匹配,重复左右子树中寻找节点 n1 和 n2。如果在其左子树中存在一个节点而在右子树中存在的另一个节点,则此节点即为 LCA。如果两个节点都位于左子树中,则 LCA 也位于左子树中,否则 LCA 位于右子树中。由此,找到该树中两个指定节点的最近公共祖先,有三种情况,如图:
面试题及其衍生知识点_第25张图片
为了确定树中节点之间距离:从 n1 节点到 n2 节点的距离,可以计算从根到 n1 的距离加上从根到 n2 的距离,减去从根到它们最近共同祖先的距离的两倍。
面试题及其衍生知识点_第26张图片

10.1、代码实现

import java.util.*;
import java.util.Queue;
//下面使用的遍历方法均为前序遍历
class Node{
    public char val;
    public Node left;//左孩子
    public Node right;//右孩子
    public Node(char val){
        this.val=val;
    }
}
public class BinaryTree {
    // 1、构造二叉树
    public Node buildTree(){
        Node A=new Node('A');
        Node B=new Node('B');
        Node C=new Node('C');
        Node D=new Node('D');
        Node E=new Node('E');
        Node F=new Node('F');
        Node G=new Node('G');
        Node H=new Node('H');
        A.left=B;A.right=C;B.left=D;B.right=E;//这里是构造二叉树的关键
        C.left=F;C.right=G;E.right=H;
        return A;
    }
    //找两个结点最近的公共祖先
    public Node lowstCommonAncestor(Node root,Node p,Node q){
        if(root==null){
            return null;
        }
        if(root==p||root==q){
            return root;
        }
        Node left=lowstCommonAncestor(root.left,p,q);
        Node right=lowstCommonAncestor(root.right,p,q);
        if(left!=null&&right!=null){
            return root;
        }else if(left!=null){
            return left;
        }else{
            return right;
        }
    }
    public static void main(String[] args) {
        BinaryTree binaryTree=new BinaryTree();
        Node root=binaryTree.buildTree();

        System.out.println("找两个结点最近的公共祖先:"+binaryTree.lowstCommonAncestor(root,root.left,root.right.left));//这里打印出的是地址,可推理该公共祖先是A
        System.out.println(root);//这个是根即A的地址,两者比较会发现是一致的
    }
}

你可能感兴趣的:(面试题,知识点总结,java,网络,tcp/ip,网络协议)