java 字典_极限Redis (5) 字典

916ebad66d834ba23bf84bce2d30bdeb.png

字典是Redis中的一个非常重要的底层数据结构,其应用相当广泛。Redis数据库就是使用字典实现的,对数据库的增、删、查、改都是建立在对字典的操作上。此外,字典还是Redis中哈希键的底层实现,当一个哈希键包含的键值对比较多,或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。

Redis中的字典采用哈希表作为底层实现,在Redis源码文件中,字典的实现代码在dict.c和dict.h文件中。

Redis定义了dictEntry,dictType,dictht和dict四个结构体来实现字典结构,下面来分别介绍这四个结构体。这点与Java中的HashMap有点类似,1.7中每个节点命名为Entry,1.8中改名为Node。

某种意义上,字典就是Hash Table。

typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next; // 这里能看出使用了拉链法解决Hash冲突,与java类似
} dictEntry;
/* dict hash table */
typedef struct dictht {
dictEntry **table; // 哈希表数组
unsigned long size; // 表大小
unsigned long sizemask; // 掩码算索引
unsigned long used; //使用了几个
} dictht;
typedef struct dict {
dictType *type; // 一些用于操作特定类型键值对的函数
void *privdata; // 私有数据
dictht ht[2]; // 一个dict有两个hash table
long rehashidx; /* rehash索引,不进行rehash时其值为-1 */
unsigned long iterators; /* 当前正在使用的迭代器数量,这个字段由int扩展为long */
} dict;
typedef struct dictType {
uint64_t (*hashFunction)(const void *key);
void *(*keyDup)(void *privdata, const void *key);
void *(*valDup)(void *privdata, const void *obj);
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
void (*keyDestructor)(void *privdata, void *key);
void (*valDestructor)(void *privdata, void *obj);
} dictType;

当往字典中添加键值对时,需要根据键的大小计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。

h = dictHashKey(d, key);

一般来说,对于哈希表的实现,最需要关注的就是扩容方式和计算哈希数值。先看一看Redis计算哈希的方式,

Redis的作者使用的siphash算法,而memcached作者使用jenkins_hash或者murmur3_hash。衡量一个hash算法好坏的依据就是,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。当然这种复杂的hash算法属于数学问题,不该由Redis作者研究,通常使用现成的开源hash算法一样。

uint64_t siphash(const uint8_t *in, const size_t inlen, const uint8_t *k) {
#ifndef UNALIGNED_LE_CPU
uint64_t hash;
uint8_t *out = (uint8_t*) &hash;
#endif
uint64_t v0 = 0x736f6d6570736575ULL;
uint64_t v1 = 0x646f72616e646f6dULL;
uint64_t v2 = 0x6c7967656e657261ULL;
uint64_t v3 = 0x7465646279746573ULL;
uint64_t k0 = U8TO64_LE(k);
uint64_t k1 = U8TO64_LE(k + 8);
uint64_t m;
const uint8_t *end = in + inlen - (inlen % sizeof(uint64_t));
const int left = inlen & 7;
uint64_t b = ((uint64_t)inlen) << 56;
v3 ^= k1;
v2 ^= k0;
v1 ^= k1;
v0 ^= k0;
for (; in != end; in += 8) {
m = U8TO64_LE(in);
v3 ^= m;
SIPROUND;
v0 ^= m;
}
switch (left) {
case 7: b |= ((uint64_t)in[6]) << 48; /* fall-thru */
case 6: b |= ((uint64_t)in[5]) << 40; /* fall-thru */
case 5: b |= ((uint64_t)in[4]) << 32; /* fall-thru */
case 4: b |= ((uint64_t)in[3]) << 24; /* fall-thru */
case 3: b |= ((uint64_t)in[2]) << 16; /* fall-thru */
case 2: b |= ((uint64_t)in[1]) << 8; /* fall-thru */
case 1: b |= ((uint64_t)in[0]); break;
case 0: break;
}
v3 ^= b;
SIPROUND;
v0 ^= b;
v2 ^= 0xff;
SIPROUND;
SIPROUND;
b = v0 ^ v1 ^ v2 ^ v3;
#ifndef UNALIGNED_LE_CPU
U64TO8_LE(out, b);
return hash;
#else
return b;
#endif
}

这个算法的本质上就是尽可能避免hash攻击,让hash表退化成一个巨大的单链表。Java选择的是退化成红黑树,很明显,这二者分别是基于时间和空间的不同考量。

下面这个是对于long类型的计算hash的方式,对于字符串的计算hash的方式甚至就是直接调用而已。

unsigned int dictKeyHash(const void *keyp) {
unsigned long key = (unsigned long)keyp;
key = dictGenHashFunction(&key,sizeof(key));
key += ~(key << 15);
key ^= (key >> 10);
key += (key << 3);
key ^= (key >> 6);
key += ~(key << 11);
key ^= (key >> 16);
return key;
}

本质上,Redis认为当它使用了SipHash算法的时候,就不会出现极为严重的hash碰撞了。

接下来,就要考虑另外一个大问题,就是扩容。对于Java来说,扩容是一个十分耗费时间的操作,所以其默认大小设置为了16。那Redis是怎么考虑这个问题的呢?

扩容本身和简单,关键是rehash是一个非常漫长的计算过程。

Redis使用了一种非常巧妙的空间换时间的方式去实现,也就是前面说的一个dict中存在两个hash table。

通常情况下,字典的键值对数据都存放在ht[0]里面,如果此时需要对字典进行rehash,会进行如下步骤:

  • 为ht[1]哈希表分配空间,空间的大小取决于要执行的操作和字典中键值对的个数
  • 将保存在ht[0]中的键值对重新计算哈希值和索引,然后存放到ht[1]中。
  • 当ht[0]中的数据全部迁移到ht[1]之后,将ht[1]设为ht[0],并为ht[1]新创建一个空白哈希表,为下一次rehash做准备。

考虑HashMap的rehash操作,它是通过原地重申请一个new Table 来实现的。

// 执行N步渐进式的rehash操作,如果仍存在旧表中的数据迁移到新表,则返回1,反之返回0
// 每一步操作移动一个索引值下的键值对到新表
int dictRehash(dict *d, int n) {
    int empty_visits = n*10; // 最大允许访问的空桶值,也就是该索引下没有键值对
    if (!dictIsRehashing(d)) return 0;

    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;

        // rehashidx不能大于哈希表的大小
        assert(d->ht[0].size > (unsigned long)d->rehashidx);
        while(d->ht[0].table[d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        // 获取需要rehash的索引值下的链表
        de = d->ht[0].table[d->rehashidx];
        // 将该索引下的键值对全部转移到新表
        while(de) {
            unsigned int h;

            nextde = de->next;
            // 计算该键值对在新表中的索引值
            h = dictHashKey(d, de->key) & d->ht[1].sizemask;
            de->next = d->ht[1].table[h];
            d->ht[1].table[h] = de;
            d->ht[0].used--;
            d->ht[1].used++;
            de = nextde;
        }
        d->ht[0].table[d->rehashidx] = NULL;
        d->rehashidx++;
    }

    // 键值是否整个表都迁移完成
    if (d->ht[0].used == 0) {
        // 清除ht[0]
        zfree(d->ht[0].table);
        // 将ht[1]转移到ht[0]
        d->ht[0] = d->ht[1];
        // 重置ht[1]为空哈希表
        _dictReset(&d->ht[1]);
        // 完成rehash,-1代表没有进行rehash操作
        d->rehashidx = -1;
        return 0;
    }

    // 如果没有完成则返回1
    return 1;
}



Redis定义了一个负载因子dict_force_resize_ratio,该因子的初始值为5,如果满足一下条件,则需要进行rehash操作。同时,两个表其实意味着我的扩容操作不影响原来的表。所以扩容就可以分步骤来做了。
比如按照索引个数渐进,比如按照时间定时循环。

不止如此,Redis的双表结构还有额外的好处,因为这两个表的数据都是一样的,只不过是数量多少的问题,那么可以在查找的时候双表同时查找,万一在表0里它在链表上,在表1里它在节点上,那么它在表1节点上就可以快速查询到了。
非常神奇的是,即使在查询key的时候,它还见缝插针的进行了rehash的渐进,不多,就走一步。
dictEntry *dictFind(dict *d, const void *key) { dictEntry *he; unsigned int h, idx, table; // 字典为空,返回NULL if (d->ht[0].used + d->ht[1].used == 0) return NULL; // 如果正在进行rehash,则执行rehash操作 if (dictIsRehashing(d)) _dictRehashStep(d); // 计算哈希值 h = dictHashKey(d, key); // 在两个表中查找对应的键值对 for (table = 0; table <= 1; table++) { // 根据掩码来计算索引值 idx = h & d->ht[table].sizemask; // 得到该索引值下的存放的键值对链表 he = d->ht[table].table[idx]; while(he) { // 如果找到该key直接返回 if (key==he->key || dictCompareKeys(d, key, he->key)) return he; // 找下一个 he = he->next; } // 如果没有进行rehash,则直接返回 if (!dictIsRehashing(d)) return NULL; } return NULL; }
当然了,代价也是有的。
  • 如果此时没有进行rehash操作,直接计算出索引添加到ht[0]中
  • 如果此刻正在进行rehash操作,则根据ht[1]的参数计算出索引值,添加到ht[1]中
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值