底层 C++ 哈希表实现原理

有时候英文资料反而会更加高效

这大概是,全中文网站唯一一篇正确解释了 C++ STL 哈希表底层实现的文章。

如果你的编译器中对哈希表的实现不一样,请告知我。我这是 GCC 13.2.1。9.3.0 我也验证过了,它也是这么实现的。

Overview

很多博客中写的 STL 中的链表是这个样子的:

image

但是实际上是这样子的:

image

也就是说,实际上,整个哈希表都只有一个链表。

然后它没有redis、golang的渐进式rehash。

确实只有一个链表

写一段简短的代码

#include <iostream>
#include <unordered_set>
int main() {
  std::unordered_set<int> set;
  set.insert(1);
  set.insert(2);
  set.insert(3);
  set.insert(4);
  set.insert(5);
  for (auto i = set.begin(); i != set.end(); i++) {
    std::cout << *i;
  }
  return 0;
}

编译它,记得加上 -g 参数。然后修改你的 ~/.gdbinit,注释掉:

这一些是为了在调试中跳过所有标准库中的函数而写的配置。如果没有那就不用管。
#skip -gfile /usr/include/*
#skip -rfunction ^std::*

开始调试,执行到插入 5 的时候,step 进入 insert 函数,然后不停 step,直到_Hashtable::_M_insert_bucket_begin。这里给出当前的调用栈。我稍微化简了一点(因为太难看了)

#0  std::_Hashtable::_M_insert_bucket_begin
#1  std::_Hashtable::_M_insert_unique_node
#2  std::_Hashtable::_M_insert_unique
#3  std::_Hashtable::_M_insert_unique_aux
#4  std::_Hashtable::_M_insert
#5  std::__detail::_Insert::insert
#6  std::unordered_set::insert
#7  main

以及你应该看到的源代码。

image

执行到__node->_M_nxt = _M_before_begin._M_nxt;,输出 __node

image

这就是新插入到链表的节点。到此我们输出整个链表,注意里面每一个 __data

image

这里*(typeof(__node))_M_before_begin._M_nxt->_M_nxt->_M_nxt->_M_nxt中 typeof 的原因是 gcc 输出值的时候有一个 bug,有时候它无法确定一个指针指向的对象的正确的类型。

目前我还没有找到直接通过解地址的方式正确输出类型的方式。如果 typeof 不可用,这里你可以用 p *(std::__detail::_Hash_node<int, false>*)_M_before_begin

回到源代码。这里 if 里是处理哈希冲突,而 else 是为了处理当前桶为空的情况。if 里处理冲突的方式与传统拉链法没什么很大的区别,但是 else 都是对这整条链表做头插法。所以如果你遍历你的哈希表,且没有发生哈希冲突,那么你会发现你输出的顺序和你插入的顺序,刚好相反。

image

所以在遍历哈希表的时候,他做的是链表遍历。

补充一下,此处没有出现严重的哈系冲突。下图是输出的 _M_buckets 信息

image

设计目的

以下都是个人猜想。

  1. 在哈希表比较空的时候,遍历哈希表会很快,因为其时间复杂度不是 O(桶大小 + 链表节点总数),而是 O(链表节点总数)
  2. 在空间使用上,没有什么代价,除了多了一个 _M_before_begin 指针以外。

但是还有一定的代价,代价在于查找的时候的时间代价。

在查找 map[1] 的时候,需要遍历它指向的节点,但是什么时候结束遍历?到某个节点的哈希值,不等于 hash(1) 的时候,才可以确定该值是真的不存在。所以相比传统拉链法而言,它需要算更多次哈希值。

简单地看看是不是这样的。现在改用 unordered_multiset:

  set.insert(1);
  set.insert(2);
  set.insert(15); // 这里我找到了15会和2产生哈希冲突
  set.find(2);

执行到 find 里调用的这个函数

image

_M_bucket_index step,可以看到它最终还是调用了 _M_hash_code,所以除非到最后一个节点,并且遍历过程中没有找到值,它就会算 hash,而传统拉链法是不需要的。(不过我好像有一次看见了类似于 _M_hash_cache 这样的东西,说不定使用这个字段有一定的条件。)

image

到此的调用栈:

#0  std::__detail::_Hash_code_base::_M_bucket_index
#1  std::_Hashtable::_M_bucket_index
#2  std::_Hashtable::_M_find_before_node
#3  std::_Hashtable::_M_find_node
#4  std::_Hashtable::find
#5  std::unordered_multiset::find
#6  main () at test.cc:9

不难看出,unordered 系列哈希表都是使用了同一个哈希表,他们只是封装了一层而已。

确实没有渐进式rehash

可以从下面的代码中看出,它先分配了一个__new_buckets,然后遍历旧表的链表,再rehash到这个新表上,并且构件新的链表。

因为它会遍历完这个链表,所以STL的哈希表可能会造成较大延迟,所以在延迟较为敏感的服务上,请小心!

    void
    _Hashtable<_Key, _Value, _Alloc, _ExtractKey, _Equal,
	       _Hash, _RangeHash, _Unused, _RehashPolicy, _Traits>::
    _M_rehash(size_type __bkt_count, true_type /* __uks */)
    {
      __buckets_ptr __new_buckets = _M_allocate_buckets(__bkt_count);
      __node_ptr __p = _M_begin();
      _M_before_begin._M_nxt = nullptr;
      std::size_t __bbegin_bkt = 0;
      while (__p)
	{
	  __node_ptr __next = __p->_M_next();
	  std::size_t __bkt
	    = __hash_code_base::_M_bucket_index(*__p, __bkt_count);
	  if (!__new_buckets[__bkt])
	    {
	      __p->_M_nxt = _M_before_begin._M_nxt;
	      _M_before_begin._M_nxt = __p;
	      __new_buckets[__bkt] = &_M_before_begin;
	      if (__p->_M_nxt)
		__new_buckets[__bbegin_bkt] = __p;
	      __bbegin_bkt = __bkt;
	    }
	  else
	    {
	      __p->_M_nxt = __new_buckets[__bkt]->_M_nxt;
	      __new_buckets[__bkt]->_M_nxt = __p;
	    }

	  __p = __next;
	}

      _M_deallocate_buckets();
      _M_bucket_count = __bkt_count;
      _M_buckets = __new_buckets;
    }