浅入深地全面讲解哈希表实现原理和源码分析 满篇精髓 你值得一看

HashMap是JDK中非常重要的容器,采用 数组 + 链表 的方式实现,理想情况下能支持 O(1) 时间复杂度的增删改查操作。本文将由浅入深地讲解哈希表的实现原理,并对HashMap的部分源码进行分析。

1. 从数组说起

数组应该是我们最先学习的数据结构,它是内存中一块连续的存储单元,因此计算机可以根据数组起始地址、元素长度和下标,计算出我们要访问的元素的地址,时间复杂度为 O(1) 。

以下代码定义了一个简单的 Student 类,假如我们要存储 20 个 Student 对象,我们希望能够在 O(1) 时间复杂度内,根据 studentID 找到相应的对象。


<code>public class Student { public int studentID; public String name; public Student(int studentID, String name) { this.studentID = studentID; this.name = name; }} 复制代码/<code>

如果我们要存储的 20 个 Student 对象的 studentID 刚好就是从 0 到 19,我们自然可以新建一个长度为 20 的 Student 数组 students,然后将对象的 studentID 作为数组下标,放到对应的 slot 里面,如下图所示。这样的话,如果我们想找 studentID 为 15 的对象,我们就可以直接访问 students[15]。


<code>Student[] students = new Student[20]; Student stu0 = new Student(0, "stu0");Student stu19 = new Student(19, "stu19"); students[stu0.studentID] = stu0;students[stu19.studentID] = stu19; 复制代码/<code>

为了表述方便,我们用 key 表示查找关键字,在这里指的 studentID,用 value 表示查找内容,这里指的 Student 对象,用 slot 表示数组的每一个元素,slot 由数组下标 index 来唯一标识(slot 的意思是槽,数组的元素就像是一个槽一样,等着被 Student 对象填满)。下图展示了 Student 对象在数组中的存储状态。

但是实际情况很可能是这 20 个 Student 对象的 studentID 在某个范围内随机分布,比如 0~2000。如果我们这个时候还把 studentID 作为数组下标的话,就需要创建一个长度为 2001 的数组,但我们只使用其中的 20 个用来存放 Student 对象,这样就造成了大量的空间浪费。

2. 哈希函数和哈希碰撞

那如何既能利用数组的常数查找特性,又能避免空间浪费呢?我们可以很自然地想到,建立一个将 studentID 映射到 0~19 的函数,比如 h(studentID) = studentID % 20。这个函数就叫做哈希函数(或者散列函数),以此为例,我们可以将 studentID 分别为 21,140,1163 的 Student 对象存储到数组上,如下图。


<code>Student stu21 = new Student(21, "stu21");Student stu140 = new Student(140, "stu140");Student stu1163 = new Student(1163, "stu1163");students[stu21.studentID % 20] = stu21;students[stu140.studentID % 20] = stu140;students[stu1163.studentID % 20] = stu1163; 复制代码/<code>

哈希函数的实现方式有很多,一个好的哈希函数应该尽可能地让映射后的结果均匀地分布在数组上。

接下来我们再考虑另一个问题,如果我们有两个 Student 对象,他们的 studentID 分别为 21 和 41,那么这两个对象都要存储到数组上 index 为 1 的 slot,这种情况就叫做哈希碰撞。

解决哈希碰撞的方式有两种,拉链法和开放寻址法。前者就是 HashMap 中用到的方法,在每个 slot 后面悬挂一条链表,每进来一个结点就把它放到链表的尾部。后者是采用不同的方式重新计算一次 key 对应的 index,直到找到一个不包含 Student 对象的 slot。

以上就是哈希表的基本原理,studentID 就类似于下文所说的哈希值的概念,我们通过 key 的哈希值来判断这个结点要放到哪个 slot 中。

下面我们通过 JDK 12.0.2 版本的源码来看看 HashMap 是如何工作的。

3. HashMap 中的常量

HashMap 中的一些比较重要的常量如下。

<code>static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; static final float DEFAULT_LOAD_FACTOR = 0.75f;static final int TREEIFY_THRESHOLD = 8; 复制代码/<code>

根据英文名称我们可以大致了解该常量的作用,DEFAULT_INITIAL_CAPACITY 是默认初始容量,1 << 4 表示 1 左移四位,也就是 16。大家已经明白,HashMap 是以 数组+链表 的方式实现的,这里容量指的就是实例化 HashMap 对象内部数组的长度。如果我们调用哈希表的构造函数时,未指定初始容量,数组的长度就由这个默认初始容量确定。

DEFAULT_LOAD_FACTOR 默认装载因子,装载因子

的含义是平均每个 slot 上悬挂了多少个结点,可以由下式计算得到

装载因子 = 结点数量 / 数组长度

同样的,如果调用哈希表的构造函数时,未指定装载因子,就使用这个装载因子。

TREEIFY_THRESHOLD 是树形化阈值,当某个 slot 悬挂的结点数量大于树形化阈值的时候,就把链表转化为一棵红黑树。为什么树形化阈值取 8 呢?按照官方的说法,如果 key 的哈希值是随机的,在装载因子为 0.75 时,每个 slot 中节点出现的频率服从参数为 0.5 的泊松分布,所以链表的长度(size)为 k 的概率为

代入 k = 8 可以得到 0.00000006,也就是说链表长度为 8 的概率小于千万分之一,所以选取 8 作为树形化阈值。

4. 静态内部类 Node

node 就是结点的意思,本文中所说的 “结点”,指的就是一个 Node 对象。Node 实现了 Entry 接口。

<code>static class Node implements Map.Entry 复制代码/<code>

Node 中有 4 个成员变量。可以看出 Node 的主要功能是把 key 和与之对应的 value 封装到一个结点中,该结点的 next 字段指向下一个结点,从而实现单向链表。hash 由 key 的哈希值得来,下文会介绍。

<code>final int hash;final K key;V value;Node next; 复制代码/<code>

5. HashMap 构造函数和扩容

以下是 HashMap 的成员变量,table 是 Node 数组,HashMap 就用它来存放结点。size 表示目前 HashMap 中存放的结点总数。threshold 是阈值,表示当前的数组容量所能容纳的结点数,它是装载因子和数组容量的乘积,当 size 大于 threshold 的时候,就需要进行扩容操作。loadFactor 即装载因子。

<code>transient Node[] table;transient int size;int threshold;final float loadFactor; 复制代码/<code>

构造函数的主要任务就是初始化其中的一些成员变量,因为我们调用的是无参构造函数,所以只有装载因子被赋值了。注意这个时候并没有初始化 table 数组。

<code>public HashMap(int initialCapacity, float loadFactor) {// 省略了一些判断极端输入的代码this.loadFactor = loadFactor;this.threshold = tableSizeFor(initialCapacity);}public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted} 复制代码/<code>

tableSizeFor() 这个方法返回大于等于 initialCapaticy 的最小的 2 的幂。比如输入 16 就返回 16,输入 17 就返回 32。以下是该方法的实现。

<code>static final int tableSizeFor(int cap) { int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1); return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;} 复制代码/<code>

因为位运算的执行效率很高,所以在 HashMap 中有很多地方都有应用,最大化地提高了执行速度。-1 的十六进制表示是 0x1111,numberOfLeadingZeros 返回 cap - 1 前面 0 的个数,>>> 是无符号右移运算。以 cap= 16 为例,cap -1 = 0x000F,于是 n = 0x000F,返回 n + 1, 也就是 16,。

扩容操作由 resize() 方法完成,因为代码要综合考虑各种情况,所以有很多 if-else 语句,但是这些并不是我们要理解的重点。我们需要知道的是,一般情况下, resize() 主要完成的任务是构造一个新的数组,数组的长度为原数组长度的 2 倍,然后将原数组的节点复制到新数组,最后返回新数组。

<code>final Node[] resize() { Node[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node[] newTab = (Node[])new Node[newCap]; table = newTab; if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { Node e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) ((TreeNode)e).split(this, newTab, j, oldCap); else { // preserve order Node loHead = null, loTail = null; Node hiHead = null, hiTail = null; Node next; do { next = e.next; if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; } 复制代码/<code>

复制过程由 for 循环来完成,其中 e instanceof TreeNode 是用来判断结点 e 是不是已经被树形化为红黑树结点。

因为数组容量始终是 2 的幂,所以原数组中某个 index 对应的 slot 悬挂的链表上的结点,只可能出现在新数组的两个 slot 中:index 和 index + oldCap。oldCap 表示原数组的长度。相应的,loHead 表示 index 对应的 slot 悬挂的链表头部,hiHead 表示 index + oldCap 对应的 slot 悬挂的链表尾部。

在判断 e 应该放到哪条链表的尾部时,也采用了比较讨巧的办法,e.hash & oldCap 如果为 0 就放到 loTail,如果为 1 就放到 hiTail。

6. put() 和 get() 方法

以下面的代码为例,分析 put() 方法的执行过程。

<code>Student stu21 = new Student(21, "stu21");HashMap<integer> map = new HashMap<>();map.put(stu21.studentID, stu21); 复制代码/<integer>/<code>

stu21.studentID 是 int 类型,在执行 put() 方法之前,需要进行装箱,把它转换为 Integer 类型,这一过程由编译器自动完成。

<code>public V put(K key, V value) { return putVal(hash(key), key, value, false, true);} 复制代码/<code>

put() 方法中调用了 putVal() 方法。如下所示,其中的 hash() 方法是一个静态方法。返回的是 key 的哈希值无符号右移 16 位,然后跟自身异或的结果。其目的是为了利用哈希值前 16 位的信息。

<code>static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);} 复制代码/<code>

下面是 putVal() 方法的代码,HashMap 不允许内部有重复的 key 存在,所以当 put() 方法的参数 key 与已有节点的 key 重复时,默认会将原来的 value 覆盖。onlyIfAbsent 为 true 表示只有在原来的 value 为 null 的时候才进行覆盖,此处传入的是 false,所以新的 value 一定会把原有的 value 覆盖。

<code>final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node[] tab; Node p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; } 复制代码/<code>

虽然代码有点长,但是 put() 执行的操作也比较简单,根据 i = hash & (n - 1) 计算出应该存放的 slot 的下标,如果 table[i] 为 null,直接生成新的结点放进去。否则从结点开始往下走,如果哪个节点的 key 和传入的 key 一样,就覆盖掉该结点的 value,直到到达链表尾部,生成一个新结点放到最后,这时如果这条链表的节点数大于 8,就开始执行树形化操作。

这里补充一点,当我们用某个类作为 key 的时候,我们如果重写了 equals() 方法,应该把 hashCode() 方法也一起重写了。因为我们需要保证两个 key 相等的时候,它们的哈希值一定要相等。否则我们就有可能在 HashMap 里面存储两个相等的 key。

get() 方法相对来讲就比较简单了,不做过多解释。

<code>public V get(Object key) { Node e; return (e = getNode(hash(key), key)) == null ? null : e.value;} 复制代码/<code>

<code>final Node getNode(int hash, Object key) { Node[] tab; Node first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; if ((e = first.next) != null) { if (first instanceof TreeNode) return ((TreeNode)first).getTreeNode(hash, key); do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null;}/<code>

总结:HashMap 以 “数组 + 链表” 的方式实现,其内部以 Node 对象的方式存储 key-value 键值对。数组的长度始终保持为 2 的幂,方便使用位运算提高执行速度。key 的哈希值随机的条件下,其增删改查操作的时间复杂度正比于它的负载因子(loadFactor)。当某条链表的结点数大于 8 的时候,该链表被转化为一棵红黑树。当结点总数大于 threshold 的时候,进行扩容操作,新数组的长度是原数组的两倍。