java

导航

 

本文主要简要分析了Java中和Redis中HashMap的实现,并且对比了两者的异同

1.Java的实现

下图表示了Java中一个HashMap的主要实现方式
因为大家对于Java中HashMap的实现方式,已经比较熟悉了,所以咱们只是简单的说一下.

基本结构

table是一个Entry[]数组类型,而Entry实际上就是一个单向链表。哈希表的"key-value键值对"都是存储在Entry数组中的。 size是HashMap的大小,它是HashMap保存的键值对的数量。 threshold是HashMap的阈值,用于判断是否需要调整HashMap的容量。threshold的值="容量*加载因子",当HashMap中存储数据的数量达到threshold时,就需要将HashMap的容量加倍。loadFactor就是加载因子。 modCount是用来实现fail-fast机制的。

计算Hash值和在数组中的位置
//length为Entry数组长度
static int indexFor(int h, int length) {  
  return h & (length - 1);
}
static int hash(int h) {
  h ^= (h >>> 20) ^ (h >>> 12); 
  return h ^ (h >>> 7) ^ (h >>> 4);
}
添加键值对时的操作(put)
// 将“key-value”添加到HashMap中
public V put(K key, V value) { 
// 若“key为null”,则将该键值对添加到table[0]中。  
if (key == null)
  return putForNullKey(value); 
// 若“key不为null”,则计算该key的哈希值,然后将其添加到该哈希值对应的链表中。  
int hash = hash(key.hashCode()); 
int i = indexFor(hash, table.length); 
for (Entry<K, V> e = table[i]; e != null; e = e.next) {
  Object k; 
  // 若“该key”对应的键值对已经存在,则用新的value取代旧的value。然后退出!  
   if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
    V oldValue = e.value;  e.value = value;  
    e.recordAccess(this); return oldValue;  
    }
  } 
  // 若“该key”对应的键值对不存在,则将“key-value”添加到table中 
  modCount++;  
  addEntry(hash, key, value, i); return null;}
解决Hash冲突的方式,扩容时机
// 新增Entry。将“key-value”插入指定位置,bucketIndex是位置索引。
void addEntry(int hash, K key, V value, int bucketIndex) { 
  // 保存“bucketIndex”位置的值到“e”中  
  Entry<K,V> e = table[bucketIndex];  
  // 设置“bucketIndex”位置的元素为“新Entry”, 
  // 设置“e”为“新Entry的下一个节点”  
  table[bucketIndex] = new Entry<K,V>(hash, key, value, e);  
  // 若HashMap的实际大小 不小于 “阈值”,则调整HashMap的大小  
  if (size++ >= threshold)resize(2 * table.length);
}
扩容过程
// 重新调整HashMap的大小,newCapacity是调整后的单位
void resize(int newCapacity) {
  Entry[] oldTable = table; 
  int oldCapacity = oldTable.length; 
  if (oldCapacity == MAXIMUM_CAPACITY) {
    threshold = Integer.MAX_VALUE; 
    return; 
  }
  // 新建一个HashMap,将“旧HashMap”的全部元素添加到“新HashMap”中,
  // 然后,将“新HashMap”赋值给“旧HashMap”。  
  Entry[] newTable = new Entry[newCapacity];  
  transfer(newTable);  
  table = newTable;  
  threshold = (int)(newCapacity * loadFactor);
}
// 将HashMap中的全部元素都添加到newTable中
void transfer(Entry[] newTable) {
  Entry[] src = table; 
  int newCapacity = newTable.length; 
  for (int j = 0; j < src.length; j++) {
    Entry<K,V> e = src[j]; 
    if (e != null) {
      src[j] = null; 
      do {
        Entry<K,V> next = e.next; 
        int i = indexFor(e.hash, newCapacity);  
        e.next = newTable[i];  
        newTable[i] = e;  
        e = next;  
      } while (e != null);  
    }
  }
}

2.Redis的实现

整个基本结构

哈希表
typedef struct dictht { 
  // 哈希表数组 
  dictEntry **table; 
  // 哈希表大小(相当于Java中的capacity) 
  unsigned long size; 
  // 哈希表大小掩码,用于计算索引值 
  // 总是等于 size - 1 
  unsigned long sizemask; 
  // 该哈希表已有节点的数量(相当于Java中的size) 
  unsigned long used;
} dictht;
键值对
typedef struct dictEntry { 
  // 键 
  void *key; 
  // 值 
  union { 
    void *val; 
    uint64_t u64; 
    int64_t s64; 
  } v; 
  // 指向下个哈希表节点,形成链表 
   struct dictEntry *next;
} dictEntry;
哈希结构
typedef struct dict { 
  // 类型特定函数 
  dictType *type; 
  // 私有数据 
  void *privdata; 
  // 哈希表 
  dictht ht[2]; 
  // rehash 索引 
  // 当 rehash 不在进行时,值为 -1 
  int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;

简单例子:

Paste_Image.png

添加键值对时的操作(dictAdd)

计算 hash 和 数组位置
# 使用字典设置的哈希函数,计算键 key 的哈希值(相当于hash())
hash = dict->type->hashFunction(key);
# 使用哈希表的 sizemask 属性和哈希值,计算出索引值(相当于indexFor())
index = hash & dict->ht[x].sizemask;

先计算 key的哈希值 在将 该哈希值&(数组长度-1)确定下标(与Java极为相似)
注:Redis 使用 MurmurHash2 算法来计算键的哈希值;这种算法的优点在于, 即使输入的键是有规律的, 算法仍能给出一个很好的随机分布性, 并且算法的计算速度也非常快。关于 MurmurHash 算法的更多信息可以参考该算法的主页: http://code.google.com/p/smhasher/ 。

解决hash冲突

Paste_Image.png

用拉链法解决hash冲突,将旧entry链表插进新entry尾部(与Java极为相似)

扩容时机

当以下条件中的任意一个被满足时, 程序会自动开始对哈希表执行扩展操作:

  1. 服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 1 ;
  2. 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 5;

注:该值不可通过配置来修改,要变必须改源码。
其中哈希表的负载因子可以通过公式:

# 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size

该值和Java不同,Java默认值为0.75,相比之下Java扩容更加积极。
根据 BGSAVE 命令或 BGREWRITEAOF 命令是否正在执行, 服务器执行扩展操作所需的负载因子并不相同, 这是因为在执行 BGSAVE 命令或 BGREWRITEAOF 命令的过程中, Redis 需要创建当前服务器进程的子进程, 而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率, 所以在子进程存在期间, 服务器会提高执行扩展操作所需的负载因子, 从而尽可能地避免在子进程存在期间进行哈希表扩展操作, 这可以避免不必要的内存写入操作, 最大限度地节约内存。

缩容时机(Java中不会自动缩容)

当哈希表的负载因子小于 0.1
 时, 程序自动开始对哈希表执行收缩操作
因为Java中HashMap不会自动缩容,所以在在大量put后,再大量remove,并且还持有该引用的话,会浪费很多内存
变容过程

扩容前

扩容中

扩容后

渐进式转移

扩展或收缩哈希表需要将 ht[0]里面的所有键值对 rehash 到 ht[1]里面, 但是, 这个 rehash 动作并不是一次性、集中式地完成的, 而是分多次、渐进式地完成的。
这样做的原因在于, 如果 ht[0]里只保存着四个键值对, 那么服务器可以在瞬间就将这些键值对全部 rehash 到 ht[1]; 但是, 如果哈希表里保存的键值对数量不是四个, 而是四百万、四千万甚至四亿个键值对, 那么要一次性将这些键值对全部 rehash 到 ht[1]的话, 庞大的计算量可能会导致服务器在一段时间内停止服务。
因此, 为了避免 rehash 对服务器性能造成影响, 服务器不是一次性将 ht[0]里面的所有键值对全部 rehash 到 ht[1], 而是分多次、渐进式地将 ht[0]里面的键值对慢慢地 rehash 到 ht[1]。

渐进式转移中

因为在进行渐进式 rehash 的过程中, 字典会同时使用 ht[0]和 ht[1]两个哈希表, 所以在渐进式 rehash 进行期间, 字典的删除(delete)、查(find)、更新(update)等操作会在两个哈希表上进行: 比如说, 要在字典里面查找一个键的话, 程序会先在ht[0]里面进行查找, 如果没找到的话, 就会继续到 ht[1]里面进行查找, 诸如此类。
另外, 在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1]里面, 而 ht[0]则不再进行任何添加操作: 这一措施保证了 ht[0]包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表。

3.对比两者的异同

Java Redis
基本结构
两者都是键值对数组,键值对是链表
计算哈希值和数组位置

通过自身hash函数,计算hash值,与数组长度&,确定数组下标
解决Hash冲突的方式
拉链法

容量变化时机
默认值为0.75,更加消极,有缩容 默认值为1,更加积极,只可变大,不可变小
容量变化过程
一起完成 分次完成(渐进式)

posted on 2016-11-09 21:01  滕瀚斯  阅读(3082)  评论(0编辑  收藏  举报