一、简介
哈希表也叫散列表,是一种非常重要的数据结构,底层实现是一个 key - value 键值对。应用场景及其丰富,许多缓存技术(比如 Redis)的核心其实就是在内存中维护一张大的哈希表。Java 中的 HashMap 就是这样一种结构,不仅经常用于开发当中,而且 HashMap 的实现原理也常常出现在各类的面试题中。
在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为 O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶 O(1) 的。
我们知道,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组。比如我们要新增或查找某个元素,我们通过把当前元素的关键字通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。
存储位置 = f(关键字)
其中,这个函数 f 一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。
举个例子,比如我们要在哈希表中执行插入操作:
查找操作同理,先通过哈希函数计算出实际存储地址,然后从数组中对应地址取出即可。
哈希冲突
然而没有完美的设计,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证计算简单和散列地址分布均匀。但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),散列函数法,链地址法,而 HashMap 即是采用了链地址法。
二、继承结构
和大多数集合类一样,HashMap 也实现了 Cloneable 接口和 Serializable 接口,分别用来支持克隆以及支持序列化。Map 接口也不用多说,定义了一套 Map 集合类型的方法规范。
三、属性
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始化容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 桶的树化阈值
static final int TREEIFY_THRESHOLD = 8;
// 桶的链表还原阈值
static final int UNTREEIFY_THRESHOLD = 6;
// 最小树形化容量阈值
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组
transient Node<K,V>[] table;
// 用于存放 HashMap 的集合
transient Set<Map.Entry<K,V>> entrySet;
// 实际存储的 key-value 键值对的个数
transient int size;
transient int modCount;
int threshold;
// 负载因子,代表了 table 的填充度有多少,默认是 0.75
final float loadFactor;
}
-
HashMap 的默认初始化容量为 16,默认负载因子为 0.75。
-
因为在 JDK 1.8 以后采用了数组 + 链表 + 红黑树的结构,所以新增了 3 个与红黑树相关的参数。第一个是桶的树化阈值(TREEIFY_THRESHOLD),即链表转成红黑树的阈值,在存储数据时,当链表长度 > 该值时,则将链表转换成红黑树。第二个是桶的链表还原阈值(TREEIFY_THRESHOLD),即红黑树转为链表的阈值,当在扩容时,此时 HashMap 的数据存储位置会重新计算,在重新计算存储位置后,当原有的红黑树内数量 < 6 时,则将红黑树转换成链表。第三个是最小树形化容量阈值(MIN_TREEIFY_CAPACITY),即当哈希表中的容量 > 该值时,才允许树形化链表,即将链表转换成红黑树,否则,若桶内元素太多时,则直接扩容,而不是树形化,为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD。
-
threshold 为阈值,当 table == {} 时,该值为初始容量(初始容量默认为 16),当 table 被填充了,也就是为 table 分配内存空间后,threshold 一般为 capacity * loadFactory。HashMap 在进行扩容时需要参考 threshold,后面会详细谈到。
-
modCount 用于快速失败,由于 HashMap 非线程安全,在对 HashMap 进行迭代时,如果期间其他线程的参与导致 HashMap 的结构发生变化了(比如 put,remove 等操作),需要抛出异常 ConcurrentModificationException。
四、数据结构
因为 JDK 1.8 之后,HashMap 采用了数组 + 链表 + 红黑树的结构,所以我们来看看在 HashMap 中链表和红黑树以及哈希函数是怎么实现的。
0x01、链表
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
Node 是 HashMap 的一个内部类,用来存放链表的节点信息,这个链表是单向的。
0x02、红黑树
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
/**
* Returns root of tree containing this node.
*/
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
...
}
0x03、哈希函数
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里有个点比较有意思,这个哈希算法要将取到的哈希值与该哈希值右移 16 位后的数做异或运算。
为什么要这么做呢?直接通过 key.hashCode() 获取 hash 不就得了吗?为什么在右移 16 位后进行异或运算?
答案:与 HashMap 的 table 数组下计算标有关
我们在下面讲解的 put/get 方法代码块中都出现了这样一段代码
// put 方法代码块中
tab[i = (n - 1) & hash])
// get 方法代码块中
tab[(n - 1) & hash])
我们知道这段代码是根据索引得到 tab 中节点数据,它是如何与 hash 进行与运算后得到索引位置呢?假设 tab.length() = 1 << 4
这样做的根本原因是当发生较大碰撞时也用树形存储降低了冲突,减少了系统的开销。
五、构造方法
HashMap 共有四种形式的构造方法,我们先看无参构造方法:
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
无参构造方法采用的就是默认的负载因子,将默认的负载因子 0.75 赋值给 loadFactor。
再来看一下两个参数的构造方法:
public HashMap(int initialCapacity, float loadFactor) {
// 当指定的初始容量小于 0 时抛出 IllegalArgumentException 异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 当指定的初始容量大于 MAXIMUM_CAPACITY 时,就让初始容量等于 MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 设定 threshold,当 HashMap 的 size 达到 threshold 时,就要进行 resize,也就是扩容
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
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;
}
这个构造方法的参数分别为初始化容量和负载因子。tablesizefor() 的主要功能是返回一个比给定整数大且最接近的 2 的幂次方整数,如给定 10,返回 2 的 4 次方 16。
注意:HashMap 要求容量必须是 2 的幂。
首先,int n = cap - 1 是为了防止 cap 已经是 2 的幂时,执行完后面的几条无符号右移操作之后,返回的 capacity 是这个 cap 的 2 倍,因为 cap 已经是 2 的幂了,就已经满足条件了。
-
如果 n 这时为 0 了(经过了 cap - 1 之后),则经过后面的几次无符号右移依然是 0,最后返回的 capacity 是1(最后有个 n + 1 的操作)。这里只讨论 n 不等于 0 的情况。以 16 位为例,假设开始时 n 为 0000 1xxx xxxx xxxx(x 代表不关心 0 还是 1)
-
第一次右移 n l= n >>> 1,由于 n 不等于 0,则 n 的二进制表示中总会有一位为 1,这时考虑最高位的 1。通过无符号右移 1 位,则将最高位的 1 右移了 1 位,再做或操作,使得 n 的二进制表示中与最高位的 1 紧邻的右边一位也为 1,如 0000 11xx xxxx xxxx。
-
第二次右移 n l= n >>> 2,注意,这个 n 已经经过了 n l= n >>> 1 操作。此时 n 为 0000 11xx xxxx xxxx,则 n 无符号右移两位,会将最高位两个连续的 1 右移两位,然后再与原来的 n 做或操作,这样 n 的二进制表示的高位中会有 4 个连续的 1,如 0000 1111 xxxx xxxx。
-
第三次右移 n l= n >>> 4,这次把已经有的高位中的连续的 4 个 1,右移 4 位,再做或操作,这样 n 的二进制表示的高位中会有 8 个连续的 1,如 0000 1111 1111 xxxx。
-
第。。。你还忍心让我继续推么?相信聪明的你已经想出来了,容量最大也就是 32 位的正数,所以最后一次n l= n >>> 16 可以保证最高位后面的全部置为 1,当然如果是 32 个 1 的话,此时超出了 MAXIMUM_CAPACITY,所以取值到 MAXIMUN_CAPACITY。
再来看一下一个参数的构造方法:
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
这个构造方法的参数是初始化容量,采用默认的负载因子。
六、常用方法
0x01、put(K key, V value)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// 判定 table 不为空并且 table 长度不可为 0,否则将从 resize 函数中获取
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 这样写法有点绕,其实这里就是通过索引获取 table 数组中的一个元素看是否为 null
if ((p = tab[i = (n - 1) & hash]) == null)
// 若判断成立,则 new 一个 Node 出来赋给 table 中指定索引下的这个元素
tab[i] = newNode(hash, key, value, null);
else { // 若判断不成立
Node<K,V> e; K k;
// 对这个元素进行 Hash 和 key 值匹配
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode) // 如果数组中的这个元素 P 是 TreeNode 类型
// 判定成功则在红黑树中查找符合条件的节点并返回此节点
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else { // 若以上条件均判断失败,则执行以下代码
// 向 Node 单向链表中添加数据
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 若节点数大于等于 8
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 记录下一个节点
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;
}
0x02、get(Object key)
public V get(Object key) {
Node<K,V> e;
// 经过 hash 函数运算,获取 key 的 hash 值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 判定三个条件 table 不为 null && table 的长度大于 0 && table 指定的索引值不为 null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 判定是否匹配 hash 值 && 匹配 key 值,成功则返回该值
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 若 first 节点的下一个节点不为 null
if ((e = first.next) != null) {
// 若 first 的类型为 TreeNode 红黑树
if (first instanceof TreeNode)
// 通过红黑树查找匹配值并返回
return ((TreeNode<K,V>)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;
}
0x03、resize()
// 重新设置 table 大小/扩容,并返回扩容的 Node 数组即 HashMap 的最新数据
final Node<K,V>[] resize() {
// table 赋给 oldTab 作为扩充前的 table 数据
Node<K,V>[] 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;
}
// 若新表大小(oldCap * 2)小于数组极限大小,并且旧表大于等于数组初始化大小
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 旧数组大小 oldThr 经二进制运算向左位移 1 个位置,即 oldThr * 2 当作新数组的大小
newThr = oldThr << 1; // double threshold
}
// 若旧表中下次扩容大小 oldThr 大于 0
else if (oldThr > 0) // initial capacity was placed in threshold
// 将 oldThr 赋予控制新表大小的 newCap
newCap = oldThr;
// 若其他情况则将获取初始默认大小
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 若新表的下表下一次扩容大小为 0
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<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 将当前表赋予 table
table = newTab;
// 若 oldTab 中有值需要通过循环将 oldTab 中的值保存到新表中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 获取旧表中第 j 个元素,赋予 e
if ((e = oldTab[j]) != null) {
// 并将旧表中的元素数据置 null
oldTab[j] = null;
// 若此判定成立,则代表 e 的下面没有节点了
if (e.next == null)
// 将 e 直接存于新表的指定位置
newTab[e.hash & (newCap - 1)] = e;
// 若 e 是 TreeNode 类型
else if (e instanceof TreeNode)
// 分割树,将新表和旧表分割成两个树,并判断索引处节点的长度是否需要转换成红黑树放入新表存储
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 存储与旧索引的相同的节点
Node<K,V> loHead = null, loTail = null;
// 存储与新索引相同的节点
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 通过 do~while 循环,获取新旧索引的节点
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;
}
0x04、treeifyBin(Node<K,V>[] tab, int hash)
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 做判定,若 tab 为 null 或 tab 的长度小于红黑树最小容量
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
// 则通过扩容,扩容 table 数组大小
resize();
// 做判定,若 tab 索引位置下数据不为空
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 定义两个红黑树,分别表示头部节点、尾部节点
TreeNode<K,V> hd = null, tl = null;
// 通过循环将单向链表转换为红黑树存储
do {
// 将单向链表转换为红黑树
TreeNode<K,V> p = replacementTreeNode(e, null);
// 若头部节点为 null,则说明该树没有根节点
if (tl == null)
hd = p;
else {
// 指向父节点
p.prev = tl;
// 指向下一个节点
tl.next = p;
}
// 将当前节点设尾节点
tl = p;
} while ((e = e.next) != null); // 若下一个不为 null,则继续遍历
// 红黑树转换后,替代原位置上的单向链表
if ((tab[index] = hd) != null)
// 构建红黑树,以头部节点为根节点
hd.treeify(tab);
}
}
七、HashMap 为什么是线程不安全的?
HashMap 在并发时可能出现的问题主要是两方面:
- put 的时候导致的多线程数据不一致
比如有两个线程 A 和 B,首先 A 希望插入一个 key-value 对到 HashMap 中,首先计算记录所要落到的 Hash 桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程 A 的时间片用完了,而此时线程 B 被调度得以执行,和线程 A 一样执行,只不过线程 B 成功将记录插到了桶里面,假设线程 A 插入的记录计算出来的 Hash 桶索引和线程 B 要插入的记录计算出来的 Hash 桶索引是一样的,那么当线程 B 成功插入之后,线程 A 再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程 B 插入的记录,这样线程 B 插入的记录就凭空消失了,造成了数据不一致的行为。
- resize 而引起死循环
这种情况发生在 HashMap 自动扩容时,当 2 个线程同时检测到元素个数超过数组大小 * 负载因子。此时 2 个线程会在 put() 方法中调用了 resize(),两个线程同时修改一个链表结构会产生一个循环链表(JDK 1.7 中,会出现 resize 前后元素顺序倒置的情况)。接下来再想通过 get() 获取某一个元素,就会出现死循环。
八、1.7 和 1.8 的 HashMap 的不同点
0x01、JDK 1.7 用的是头插法,而 JDK 1.8 及之后使用的都是尾插法,那么为什么要这样做呢?
因为 JDK 1.7 是用单链表进行的纵向延伸,当采用头插法就是能够提高插入的效率,但是也会容易出现逆序且环形链表死循环问题。但是在 JDK 1.8 之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。
0x02、扩容后数据存储位置的计算方式也不一样
-
在 JDK 1.7 的时候是直接用 Hash 值和需要扩容的二进制数进行 &(这里就是为什么扩容的时候为啥一定必须是 2 的多少次幂的原因所在,因为如果只有 2 的 n 次幂的情况时最后一位二进制数才一定是 1,这样能最大程度减少 Hash 碰撞)(Hash 值 & length - 1)。
-
而在 JDK 1.8 的时候直接用了 JDK 1.7 的时候计算的规律,也就是扩容前的原始位置 + 扩容的大小值 = JDK 1.8 的计算方式,而不再是 JDK 1.7 的那种异或的方法。但是这种方式就相当于只需要判断 Hash 值的新增参与运算的位是 0 还是 1 就直接迅速计算出了扩容后的储存方式。
0x03、JDK 1.7 的时候使用的是数组 + 单链表的数据结构。但是在 JDK 1.8 及之后,使用的是数组
+链表+红黑树的数据结构(当链表的深度达到 8 的时候,也就是默认阈值,再添加元素就会自动扩容,把链表转成红黑树的数据结构来把时间复杂度从 O(N) 变成O(logN),提高了效率)。
参考
https://www.jianshu.com/p/ee0de4c99f87
https://www.jianshu.com/p/003256ce41ce