哈希表
哈希表还是列表
我们知道,哈希表和列表类似,都用于处理需要随机访问的数据结构, 但还有不同选择:
- 如果数据结构的输入和输出能一一对应,那么可以使用列表,
- 如果无法一一对应,那么就需要使用哈希表。
Rust 的哈希表
哈希表核心冲突
哈希表的核心特点与解决
哈希表最核心的特点就是:巨量的可能输入和有限的哈希表容量。
这就会引发哈希冲突,也就是两个或者多个输入的哈希被映射到了同一个位置,所以我们要能够处理哈希冲突。
要解决冲突,首先可以通过更好的、分布更均匀的哈希函数,以及使用更大的哈希表来缓解冲突,但无法完全解决,所以我们还需要使用冲突解决机制。
理论上,主要的冲突解决机制有链地址法(chaining)和开放寻址法(open addressing)。
解决冲突的方法
- 链地址法⛓️
就是把落在同一个哈希上的数据用单链表或者双链表连接起来。
这样在查找的时候:
- 先找到对应的哈希桶(hash bucket),
- 然后再在冲突链上挨个比较,直到找到匹配的项。
冲突链处理哈希冲突非常直观,很容易理解和撰写代码,但缺点是哈希表和冲突链使用了不同的内存,对缓存不友好。
- 开放寻址法
开放寻址法把整个哈希表看做一个大数组,不引入额外的内存,当冲突产生时,按照一定的规则把数据插入到其它空闲的位置。
- 线性探寻(linear probing)在出现哈希冲突时,不断往后探寻,直到找到空闲的位置插入。
- 而二次探查,理论上是在冲突发生时,不断探寻哈希位置加减 n 的二次方,找到空闲的位置插入.
我们看图,更容易理解:
图中示意是理论上的处理方法,实际为了性能会有很多不同的处理。
HashMap数据结构
深入Rust哈希表的数据结构:HashMap->hashbrown->RawTable
- HashMap数据结构
我们来看看 Rust 哈希表的数据结构是什么样子的,打开标准库的 源代码:
use hashbrown::hash_map as base; #[derive(Clone)] pub struct RandomState { k0: u64, k1: u64, } pub struct HashMap<K, V, S = RandomState> { base: base::HashMap<K, V, S>, }
可以看到,HashMap 有三个泛型参数:
- K 和 V 代表 key / value 的类型
- S 是哈希算法的状态,它默认是 RandomState,占两个 u64。
RandomState 使用 SipHash 作为缺省的哈希算法,它是一个加密安全的哈希函数(cryptographically secure hashing)。
- 从定义中还能看到,Rust 的 HashMap 复用了 hashbrown 的 HashMap:
hashbrown 是 Rust 下对 Google Swiss Table 的一个改进版实现,我们打开 hashbrown 的代码,看它的结构:
pub struct HashMap<K, V, S = DefaultHashBuilder, A: Allocator + Clone = Global> { pub(crate) hash_builder: S, pub(crate) table: RawTable<(K, V), A>, }
可以看到,HashMap 里有两个域:
- 一个是 hash_builder,类型是刚才我们提到的标准库使用的 RandomState
- 还有一个是具体的 RawTable:
- hashbrown的HashMap用到的RawTable
pub struct RawTable<T, A: Allocator + Clone = Global> { table: RawTableInner<A>, // Tell dropck that we own instances of T. marker: PhantomData<T>, } struct RawTableInner<A> { // Mask to get an index from a hash value. The value is one less than the // number of buckets in the table. bucket_mask: usize, // [Padding], T1, T2, ..., Tlast, C1, C2, ... // ^ points here ctrl: NonNull<u8>, // Number of elements that can be inserted before we need to grow the table growth_left: usize, // Number of elements in the table, only really used by len() items: usize, alloc: A, }
- RawTable 中,实际上有意义的数据结构是 RawTableInner:
前四个字段很重要:
- usize 的 bucket_mask,是哈希表中哈希桶的数量减一;
- 名字叫 ctrl 的指针,它指向哈希表堆内存末端的 ctrl 区;
这一点在介绍hashmap的内存结构时还会详细说明
- usize 的字段 growth_left,指哈希表在下次自动增长前还能存储多少数据;
- Usize 的 items,表明哈希表现在有多少数据。
这里最后的 alloc 字段,和 RawTable 的 marker 一样,只是一个用来占位的类型,它用来分配在堆上的内存。
HashMap 的基本使用方法
数据结构搞清楚,我们再看具体使用方法。
HashMap基本使用方法
Rust 哈希表的使用很简单,它提供了一系列很方便的方法,使用起来和其它语言非常类似,你只要看看文档,就很容易理解。
我们来写段代码,尝试一下(代码):
use std::collections::HashMap; fn main() { let mut map = HashMap::new(); explain("empty", &map); map.insert('a', 1); explain("added 1", &map); map.insert('b', 2); map.insert('c', 3); explain("added 3", &map); map.insert('d', 4); explain("added 4", &map); // get 时需要使用引用,并且也返回引用 assert_eq!(map.get(&'a'), Some(&1)); assert_eq!(map.get_key_value(&'b'), Some((&'b', &2))); map.remove(&'a'); // 删除后就找不到了 assert_eq!(map.contains_key(&'a'), false); assert_eq!(map.get(&'a'), None); explain("removed", &map); // shrink 后哈希表变小 map.shrink_to_fit(); explain("shrinked", &map); } fn explain<K, V>(name: &str, map: &HashMap<K, V>) { println!("{}: len: {}, cap: {}", name, map.len(), map.capacity()); }
- 可以看到,当 HashMap::new() 时,它并没有分配空间,容量为零
- 随着哈希表不断插入数据,它会以 2 的幂减一的方式增长,最小是 3。
- 当删除表中的数据时,原有的表大小不变,只有显式地调用 shrink_to_fit,才会让哈希表变小。
HashMap 的内存布局
通过 HashMap 的公开接口无法看到 HashMap 在内存中是如何布局,还是需要借助 std::mem::transmute 方法,来把数据结构打出来。
ctrl表的变化:借助std::mem::transmute查看内存布局
我们把刚才的代码改一改(代码):
use std::collections::HashMap; fn main() { let map = HashMap::new(); let mut map = explain("empty", map); map.insert('a', 1); let mut map = explain("added 1", map); map.insert('b', 2); map.insert('c', 3); let mut map = explain("added 3", map); map.insert('d', 4); let mut map = explain("added 4", map); map.remove(&'a'); explain("final", map); } // HashMap 结构有两个 u64 的 RandomState,然后是四个 usize, // 分别是 bucket_mask, ctrl, growth_left 和 items // 我们 transmute 打印之后,再 transmute 回去 fn explain<K, V>(name: &str, map: HashMap<K, V>) -> HashMap<K, V> { let arr: [usize; 6] = unsafe { std::mem::transmute(map) }; println!( "{}: bucket_mask 0x{:x}, ctrl 0x{:x}, growth_left: {}, items: {}", name, arr[2], arr[3], arr[4], arr[5] ); unsafe { std::mem::transmute(arr) } }
运行之后,可以看到:
empty: bucket_mask 0x0, ctrl 0x1056df820, growth_left: 0, items: 0
added 1: bucket_mask 0x3, ctrl 0x7fa0d1405e30, growth_left: 2, items: 1
added 3: bucket_mask 0x3, ctrl 0x7fa0d1405e30, growth_left: 0, items: 3
added 4: bucket_mask 0x7, ctrl 0x7fa0d1405e90, growth_left: 3, items: 4
final: bucket_mask 0x7, ctrl 0x7fa0d1405e90, growth_left: 4, items: 3
发现在运行的过程中,ctrl 对应的堆地址发生了改变。
- 在我的 OS X 下,一开始哈希表为空,ctrl 地址看上去是一个 TEXT/RODATA 段的地址,应该是指向了一个默认的空表地址;
- 插入第一个数据后,哈希表分配了 4 个 bucket,ctrl 地址发生改变;
- 在插入三个数据后,growth_left 为零
- 再插入时,哈希表重新分配,ctrl 地址继续改变。
在探索 HashMap 数据结构时,说过 ctrl 是一个指向哈希表堆地址末端 ctrl 区的地址,所以我们可以通过这个地址,计算出哈希表堆地址的起始地址。
- 因为哈希表有 8 个 bucket(0x7 + 1)
- 每个 bucket 大小是 key(char) + value(i32) 的大小,也就是 8 个字节,
- 所以一共是 64 个字节。
- 对于这个例子,通过 ctrl 地址减去 64,就可以得到哈希表的堆内存起始地址。
然后,我们可以用 rust-gdb / rust-lldb 来打印这个内存。
可以用 Linux 下的 rust-gdb 设置断点,依次查看哈希表有一个、三个、四个值,以及删除一个值的状态:
❯ rust-gdb ~/.target/debug/hashmap2
GNU gdb (Ubuntu 9.2-0ubuntu2) 9.2
...
(gdb) b hashmap2.rs:32
Breakpoint 1 at 0xa43e: file src/hashmap2.rs, line 32.
(gdb) r
Starting program: /home/tchen/.target/debug/hashmap2
...
# 最初的状态,哈希表为空
empty: bucket_mask 0x0, ctrl 0x555555597be0, growth_left: 0, items: 0
Breakpoint 1, hashmap2::explain (name=..., map=...) at src/hashmap2.rs:32
32 unsafe { std::mem::transmute(arr) }
(gdb) c
Continuing.
# 插入了一个元素后,bucket 有 4 个(0x3+1),堆地址起始位置 0x5555555a7af0 - 4*8(0x20)
added 1: bucket_mask 0x3, ctrl 0x5555555a7af0, growth_left: 2, items: 1
Breakpoint 1, hashmap2::explain (name=..., map=...) at src/hashmap2.rs:32
32 unsafe { std::mem::transmute(arr) }
(gdb) x /12x 0x5555555a7ad0
0x5555555a7ad0: 0x00000061 0x00000001 0x00000000 0x00000000
0x5555555a7ae0: 0x00000000 0x00000000 0x00000000 0x00000000
0x5555555a7af0: 0x0affffff 0xffffffff 0xffffffff 0xffffffff
(gdb) c
Continuing.
# 插入了三个元素后,哈希表没有剩余空间,堆地址起始位置不变 0x5555555a7af0 - 4*8(0x20)
added 3: bucket_mask 0x3, ctrl 0x5555555a7af0, growth_left: 0, items: 3
Breakpoint 1, hashmap2::explain (name=..., map=...) at src/hashmap2.rs:32
32 unsafe { std::mem::transmute(arr) }
(gdb) x /12x 0x5555555a7ad0
0x5555555a7ad0: 0x00000061 0x00000001 0x00000062 0x00000002
0x5555555a7ae0: 0x00000000 0x00000000 0x00000063 0x00000003
0x5555555a7af0: 0x0a72ff02 0xffffffff 0xffffffff 0xffffffff
(gdb) c
Continuing.
# 插入第四个元素后,哈希表扩容,堆地址起始位置变为 0x5555555a7b50 - 8*8(0x40)
added 4: bucket_mask 0x7, ctrl 0x5555555a7b50, growth_left: 3, items: 4
Breakpoint 1, hashmap2::explain (name=..., map=...) at src/hashmap2.rs:32
32 unsafe { std::mem::transmute(arr) }
(gdb) x /20x 0x5555555a7b10
0x5555555a7b10: 0x00000061 0x00000001 0x00000000 0x00000000
0x5555555a7b20: 0x00000064 0x00000004 0x00000063 0x00000003
0x5555555a7b30: 0x00000000 0x00000000 0x00000062 0x00000002
0x5555555a7b40: 0x00000000 0x00000000 0x00000000 0x00000000
0x5555555a7b50: 0xff72ffff 0x0aff6502 0xffffffff 0xffffffff
(gdb) c
Continuing.
# 删除 a 后,剩余 4 个位置。注意 ctrl bit 的变化,以及 0x61 0x1 并没有被清除
final: bucket_mask 0x7, ctrl 0x5555555a7b50, growth_left: 4, items: 3
Breakpoint 1, hashmap2::explain (name=..., map=...) at src/hashmap2.rs:32
32 unsafe { std::mem::transmute(arr) }
(gdb) x /20x 0x5555555a7b10
0x5555555a7b10: 0x00000061 0x00000001 0x00000000 0x00000000
0x5555555a7b20: 0x00000064 0x00000004 0x00000063 0x00000003
0x5555555a7b30: 0x00000000 0x00000000 0x00000062 0x00000002
0x5555555a7b40: 0x00000000 0x00000000 0x00000000 0x00000000
0x5555555a7b50: 0xff72ffff 0xffff6502 0xffffffff 0xffffffff
这段输出蕴藏了很多信息,结合示意图来仔细梳理。
- 首先,插入第一个元素 ‘a’: 1 后,哈希表的内存布局如下:
- key ‘a’ 的 hash 和 bucket_mask 0x3 运算后得到第 0 个位置插入。
- 同时,这个 hash 的头 7 位取出来,在 ctrl 表中对应的位置,也就是第 0 个字节,把这个值写入。
要理解这个步骤,关键就是要搞清楚这个 ctrl 表是什么。
ctrl 表
主要目的与设计
ctrl表的主要目的是什么?它如何设计实现这个目的?
ctrl 表的主要目的是快速查找。它的设计非常优雅,值得我们学习。
一张 ctrl 表里:
- 有若干个 128bit 或者说 16 个字节的分组(group)
- group 里的每个字节叫 ctrl byte,对应一个 bucket,那么一个 group 对应 16 个 bucket。
- 如果一个 bucket 对应的 ctrl byte 首位不为 1,就表示这个 ctrl byte 被使用;
- 如果所有位都是 1,或者说这个字节是 0xff,那么它是空闲的。
SIMD查表:
一组 control byte 的整个 128 bit 的数据,可以通过一条指令被加载进来,然后和某个值进行 mask,找到它所在的位置。这就是HashMap的 SIMD 查表。
查询效率高:
我们知道,现代 CPU 都支持单指令多数据集的操作,而 Rust 充分利用了 CPU 这种能力,一条指令可以让多个相关的数据载入到缓存中处理,大大加快查表的速度。所以,Rust 的哈希表查询的效率非常高。
通过ctrl表查询
具体怎么操作,我们来看 HashMap 是如何通过 ctrl 表来进行数据查询的。
假设这张表里已经添加了一些数据,我们现在要查找 key 为 ‘c’ 的数据:
- 把 h 跟 bucket_mask 做与,得到一个值,图中是 139;
- 拿着这个 139,找到对应的 ctrl group 的起始位置,因为 ctrl group 以 16 为一组,所以这里找到 128;
- 用 SIMD 指令加载从 128 对应地址开始的 16 个字节;
- 对 hash 取头 7 个 bit,然后和刚刚取出的 16 个字节一起做与,找到对应的匹配,如果找到了,它(们)很大概率是要找的值;
- 如果不是,那么以二次探查(以 16 的倍数不断累积)的方式往后查找,直到找到为止。
所以,当 HashMap 插入和删除数据,以及因此导致重新分配的时候,主要工作就是在维护这张 ctrl 表和数据的对应。
为何HashMap的结构中,堆内存指针直接指向ctrl表?
因为 ctrl 表是所有操作最先触及的内存,所以,在 HashMap 的结构中,堆内存的指针直接指向 ctrl 表,而不是指向堆内存的起始位置,这样可以减少一次内存的访问。
通过ctrl表新增:重新分配与增长
插入三个元素后没有剩余空间的哈希表,在加入 ‘d’: 4 时,hash map是如何增长的
在插入第一条数据后,哈希表只有 4 个 bucket,所以只有头 4 个字节的 ctrl 表有用。
随着哈希表的增长,bucket 不够,就会导致重新分配。由于 bucket_mask 永远比 bucket 数量少 1,所以插入三个元素后就会重新分配。
根据 rust-gdb 中得到的信息,我们看插入三个元素后没有剩余空间的哈希表,在加入 ‘d’: 4 时,是如何增长的:
- 首先,哈希表会按幂扩容,从 4 个 bucket 扩展到 8 个 bucket。
这会导致分配新的堆内存,然后原来的 ctrl table 和对应的 kv 数据会被移动到新的内存中。
- 这个例子里因为 char 和 i32 实现了 Copy trait,所以是拷贝;
- 如果 key 的类型是 String,那么只有 String 的 24 个字节 (ptr|cap|len) 的结构被移动,String 的实际内存不需要变动。
- 在移动的过程中,会涉及哈希的重分配。
- 从下图可以看到,‘a’ / ‘c’ 的相对位置和它们的 ctrl byte 没有变化,但重新做 hash 后,‘b’ 的 ctrl byte 和位置都发生了变化:
通过ctrl表删除一个值
哈希表删除时内存如何释放?
明白了哈希表是如何增长的,我们再来看删除的时候会发生什么。
当要在哈希表中删除一个值时,整个过程和查找类似:
- 先要找到要被删除的 key 所在的位置。
- 在找到具体位置后,并不需要实际清除内存,只需要将它的 ctrl byte 设回 0xff(或者标记成删除状态)。这样,这个 bucket 就可以被再次使用了
这里有一个问题,当 key/value 有额外的内存时,比如 String,它的内存不会立即回收,只有在下一次对应的 bucket 被使用时,让 HashMap 不再拥有这个 String 的所有权之后,这个 String 的内存才被回收。我们看下面的示意图:
一般来说,这并不会带来什么问题,顶多是内存占用率稍高一些。但某些极端情况下,比如在哈希表中添加大量内容,又删除大量内容后运行,这时你可以通过 shrink_to_fit / shrink_to 释放掉不需要的内存。
让自定义的数据结构做 Hash key
有时候,我们需要让自定义的数据结构成为 HashMap 的 key。此时,要使用到三个 trait:
不过这三个 trait 都可以通过派生宏自动生成。
自定义数据结构需要实现Hash、PartialEq、Eq这三个trait, 它们分别有什么用?
- 实现了 Hash ,可以让数据结构计算哈希;
- 实现了 PartialEq/Eq,可以让数据结构进行相等和不相等的比较:
- Eq 实现了比较的自反性(a == a)、对称性(a == b 则 b == a)以及传递性(a == b,b == c,则 a == c)
- PartialEq 没有实现自反性。
写个例子,看看自定义数据结构如何支持 HashMap:
use std::{ collections::{hash_map::DefaultHasher, HashMap}, hash::{Hash, Hasher}, }; // 如果要支持 Hash,可以用 #[derive(Hash)],前提是每个字段都实现了 Hash // 如果要能作为 HashMap 的 key,还需要 PartialEq 和 Eq #[derive(Debug, Hash, PartialEq, Eq)] struct Student<'a> { name: &'a str, age: u8, } impl<'a> Student<'a> { pub fn new(name: &'a str, age: u8) -> Self { Self { name, age } } } fn main() { let mut hasher = DefaultHasher::new(); let student = Student::new("Tyr", 18); // 实现了 Hash 的数据结构可以直接调用 hash 方法 student.hash(&mut hasher); let mut map = HashMap::new(); // 实现了 Hash / PartialEq / Eq 的数据结构可以作为 HashMap 的 key map.insert(student, vec!["Math", "Writing"]); println!("hash: 0x{:x}, map: {:?}", hasher.finish(), map); }
HashSet / BTreeMap / BTreeSet
HashSet只用于确认存在,存放无序集合
有时我们只需要简单确认元素是否在集合中,如果用 HashMap 就有些浪费空间了。
这时可以用 HashSet,它就是简化的 HashMap,可以用来存放无序的集合,定义直接是 HashMap<K, ()>:
use hashbrown::hash_set as base; pub struct HashSet<T, S = RandomState> { base: base::HashSet<T, S>, } pub struct HashSet<T, S = DefaultHashBuilder, A: Allocator + Clone = Global> { pub(crate) map: HashMap<T, (), S, A>, }
使用 HashSet 查看一个元素是否属于集合的效率非常高。
BTreeMap 是内部使用 B-tree 来组织哈希表的数据结构。
另外 BTreeSet 和 HashSet 类似,是 BTreeMap 的简化版,可以用来存放有序集合。
重点看下 BTreeMap
它的数据结构如下:
pub struct BTreeMap<K, V> { root: Option<Root<K, V>>, length: usize, } pub type Root<K, V> = NodeRef<marker::Owned, K, V, marker::LeafOrInternal>; pub struct NodeRef<BorrowType, K, V, Type> { height: usize, node: NonNull<LeafNode<K, V>>, _marker: PhantomData<(BorrowType, Type)>, } struct LeafNode<K, V> { parent: Option<NonNull<InternalNode<K, V>>>, parent_idx: MaybeUninit<u16>, len: u16, keys: [MaybeUninit<K>; CAPACITY], vals: [MaybeUninit<V>; CAPACITY], } struct InternalNode<K, V> { data: LeafNode<K, V>, edges: [MaybeUninit<BoxedNode<K, V>>; 2 * B], }
和 HashMap 不同的是,BTreeMap 是有序的。
我们看个例子(代码):
use std::collections::BTreeMap; fn main() { let map = BTreeMap::new(); let mut map = explain("empty", map); for i in 0..16usize { map.insert(format!("Tyr {}", i), i); } let mut map = explain("added", map); map.remove("Tyr 1"); let map = explain("remove 1", map); for item in map.iter() { println!("{:?}", item); } } // BTreeMap 结构有 height,node 和 length // 我们 transmute 打印之后,再 transmute 回去 fn explain<K, V>(name: &str, map: BTreeMap<K, V>) -> BTreeMap<K, V> { let arr: [usize; 3] = unsafe { std::mem::transmute(map) }; println!( "{}: height: {}, root node: 0x{:x}, len: 0x{:x}", name, arr[0], arr[1], arr[2] ); unsafe { std::mem::transmute(arr) } }
可以看到,在遍历时,BTreeMap 会按照 key 的顺序把值打印出来。如果你想让自定义的数据结构可以作为 BTreeMap 的 key,那么需要实现 PartialOrd 和 Ord,这两者的关系和 PartialEq / Eq 类似,PartialOrd 也没有实现自反性。同样的,PartialOrd 和 Ord 也可以通过派生宏来实现。
为什么 Rust 的 HashMap 要默认采用加密安全的哈希算法?
为了防备DoS攻击
我们知道哈希表在软件系统中的重要地位,但哈希表在最坏情况下,如果绝大多数 key 的 hash 都碰撞在一起,性能会到 O(n),这会极大拖累系统的效率。
比如 1M 大小的 session 表,正常情况下查表速度是 O(1),但极端情况下,需要比较 1M 个数据后才能找到,这样的系统就容易被 DoS 攻击。 所以如果不是加密安全的哈希函数,只要黑客知道哈希算法,就可以构造出大量的 key 产生足够多的哈希碰撞,造成目标系统 DoS。
SipHash 就是为了回应 DoS 攻击而创建的哈希算法
虽然和 sha2 这样的加密哈希不同(不要将 SipHash 用于加密!),但它可以提供类似等级的安全性。 把 SipHash 作为 HashMap 的缺省的哈希算法,Rust 可以避免开发者在不知情的情况下被 DoS,就像曾经在 Web 世界发生的那样。
当然,这一切的代价是性能损耗,虽然 SipHash 非常快,但它比 hashbrown 缺省使用的 Ahash 慢了不少。
如果你确定使用的 HashMap 不需要 DoS 防护(比如一个完全内部使用的 HashMap),那么可以用 Ahash 来替换。
你只需要使用 Ahash 提供的 RandomState 即可:
use ahash::{AHasher, RandomState}; use std::collections::HashMap; let mut map: HashMap<char, i32, RandomState> = HashMap::default(); map.insert('a', 1);