从Hash到散列表到HashMap

Hash

Hash 哈希、散列,通常我们讲的都是hash函数,是将任意长度的数据映射到有限长度的域上,作为这段数据的特征(指纹)。

什么是哈希算法,比较常见的有MDx系列(MD5等)、SHA-xxx系列(SHA-256等),对于哈希算法,一般需要满足两点:

  • 抗碰撞能力:对于任意两个不同的数据块,其hash值相同的可能性极小;对于一个给定的数据块,找到和它hash值相同的数据块极为困难。
  • 抗篡改能力:对于一个数据块,哪怕只改动其一个比特位,其hash值的改动也会非常大。

最直接,则对应于jdk中的hashCode()函数,以String.hashCode()为例

public int hashCode() {
    int h = hash; // default 0
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

很简洁的一个乘加迭代运算,将字符串映射成为一个int值

Hash Table

Hash Table 散列表(也叫哈希表),是根据键值(Key value)而直接进行访问的数据结构。也就是说,它通过把键值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数便是hash函数,存放数据的数组则是散列表。

散列函数

hash的作用便是将键值映射为散列表的一个具体位置,即散列表数组的下标。散列表的长度是固定的,如散列表长度为10,则其下标只能为 0, 1, 2, ... , 8, 9 ,而一般常见hash函数并不能将任意的键值均映射到0~9之间。

为了到达此效果,有很多构造散列值的算法,如除留余数法、平方取中法、折叠法、随机数法、数学分析法等。我们以最常见的除留余数法,就是把键值通过一个固定的算法函数既所谓的hash函数转换成一个整型数字,然后将该数字对散列表长度进行取余,取余结果就当作散列表的下标(散列值)。

还以上为例,散列表长度为10,则计算散列值的hash函数可以设计为

static int hash(String s, int len) {
    return (s.hashCode() & 0x7fffffff) % len;
}

依次计算ManerFan Maner-Fan Maner·Fan对应的散列值

System.out.println(hash("ManerFan", 10)); // out 2
System.out.println(hash("Maner-Fan", 10)); // out 1
System.out.println(hash("Maner·Fan", 10)); // out 9

总的来讲,要为键值实现一个优秀的散列方法需要满足三个条件:

  • 一致性:等价的键必然产生相等的散列值
  • 高效性:计算简便
  • 均匀性:均匀的散列所有的键

碰撞处理

对于有限长度的散列表,hash碰撞在所难免(不同键值通过hash计算出来的散列值相同)
解决碰撞的方法有很多种,如线性探测发、拉链法等等,这里介绍一下拉链法

一种直接的办法是,将散列表的每个元素指向一条链表,链表的每个节点都存储了散列值为该元素的索引的键值对,这样,发生冲突的元素都被存储在一条链表中。

盗一张百科的图
从Hash到散列表到HashMap

左侧数组为散列表,散列表每个元素都对应一条链表。数组寻址容易、修改困难,链表寻址困难、修改简单,这样便以较好的性能解决了散列表的查询、插入与删除动作。

HashMap

关于HashMap的源码已经有很多博文讲解的非常详尽,这里不再对源码做过多的解析
推荐一篇文章 Java7/8 中的 HashMap 和 ConcurrentHashMap 全解析 (以下插图均来自此文章)

HashMap的实现便是散列表,只是在其基础上做了一些扩容等方面的优化

从Hash到散列表到HashMap

容量(2的整数次幂)

HashMap的默认初始容量为2^4(=16),即使创建时指定初始容量,HashMap内部也会计算一个不小于指定容量的、最接近的且为2的整数次幂的一个值作为初始容量

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

为什么必须是2的整数次幂?
以上在介绍散列表时,提出了使用除留余数法来计算某值对应的散列表下标。但直接取余操作效率不高,HashMap采用了位与运算 (length - 1) & hash,但这跟2的整数次幂又有什么关系?

优先应该明确,2的整数次幂减1所对应的二进制表示,是一串连续的1,比如16-1为1111,8-1为0111,等等。任何数字与(2^n - 1)相与,其值只能在[0, 2^n - 1]之间,且是连续的,这正好满足散列表求下标的条件。

为什么说以上计算结果是连续的,比如使用14 - 1,其二进制表示为1101,任何数字与1101相与,其值只能在[0, 13]之间,且不包含2、3、6、7、10、11,这样极大地浪费了散列表的节点空间。

节点结构(链表 | 红黑树)

以上在介绍碰撞处理时,提出了拉链法,即将发生冲突的元素存储在一条链表中。

我们知道,链表具有存储、修改速度快等优点,但检索速度较慢。如果存在大量key,且在其进行(length - 1) & hash(key)后的值均相同,将其全部放入HashMap中,则HashMap将会退化成一条链表,此时如果进行大量查询操作,则有可能占用大部分CPU时间而造成拒绝服务攻击。

红黑树(平衡二叉树)平衡了存储、修改及查询的复杂度。java8中,当散列表某一entry上的节点数量大于8时,会将该entry的结构从链表升级为红黑树,反之如果某一entry上的节点数量降到6以下时,会将该entry的结构从红黑树恢复为链表。

从Hash到散列表到HashMap

扩容

以上,已经介绍了HashMap的容量capacity,其总是为2的整数次幂。
如果HashMap的容量大小总是保持不变,则随着存放在HashMap中的key-value越来越多,每个entry下的节点数量则会越来越大,不论对于链表还是红黑树,不论对于修改还是查询,效率都将成为一个问题。

这里引入另外两个参数:
loadFactor:负载因子,默认为 0.75。
threshold:扩容的阈值,等于 capacity * loadFactor

当HashMap存储的节点数量大于阈值threshold时会进行扩容操作,将当前enties数量扩容到当前的两倍 capacity*2
负载因子loadFactor的作用在于,HashMap并不会等到节点数量到达总容量capacity之后再进行扩容,而是在“快要达到”总容量时便进行扩容

关于更多HashMap的细节,可以阅读jdk源码或者各种技术博客

相关推荐