HashMap,哈希表,一种存储键值对的数据结构。
JDK 7 的 HashMap的结构 JDK 7 的HashMap底层结构十分简单:数组 + 链表 。示意图:
1 2 3 4 5 6 7 table[] | v [0 ] -> Node -> Node -> Node [1 ] -> null [2 ] -> Node -> Node [3 ] -> null
源码结构
1 2 3 4 5 6 7 8 static class Entry <K,V> implements Map .Entry<K,V> { final K key; V value; Entry<K,V> next; int hash; }
为什么会有链表在数据后面?
答:
不同的Key经过哈希函数的计算,得出的哈希值是不同的。而这个哈希值,在经过一系列算法后,会给数据在数组中找到一个位置插入。我们可以通过一个简单的模型来理解这个问题:index = hash % table.length,可以看出,如果数据量够大,就会出现数组长度不够用,多个数据会挤在一个位置。我们称这种情况为哈希冲突(或哈希碰撞)。 但我们又不想丢弃数据,怎么办呢?于是我们便将数组的每个位置想象成一个头节点,当出现哈希冲突时,便插入在已存在的数据之后,便形成了一个链表的结构。 值得注意的是,JDK 7对链表插入采用的是头插法,即新元素成为头节点,旧元素接在新元素后面,这为后面的安全隐患埋下了伏笔。 JDK 7的HashMap存在什么问题吗?
答:
存在,最大的问题是多线程扩容时出现的死循环问题。 我们来分析一下它的扩容源码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 void transfer (Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while (null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } } }
两个线程A,B同时进行扩容,旧链表结构为A -> B -> null. 假设线程A执行完Entry<K,V> next = e.next;时,线程B就已经完成了扩容,此时是先插A,再插B,由于是头插法,形成了B -> A -> null。 但是线程A里面的e.next是旧值,相当于线程A此时依旧是A(e) -> B(e.next),但现在新链表是B -> A -> null。当A完成扩容时便形成了B -> A -> B -> A这样的环形链表。 链表成环会带来什么后果?
答: 后续调用 get() 方法查询一个不存在的Key时,程序会进入这个环形链表不断循环,最终导致 CPU 利用率飙升到 100%。
JDK 8对HashMap的升级 JDK 8对HashMap做了三个重大升级
JDK 8 的HashMap结构:数组 + 链表 + 红黑树 。
结构:
1 2 3 4 5 6 7 8 transient Node<K,V>[] table;
HashMap的一些参数 1 2 3 4 5 6 7 8 9 10 11 12 //默认的大小2^4 = 16 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //最大大小2^30 static final int MAXIMUM_CAPACITY = 1 << 30; //负载因子 -> 扩容阈值 = 负载因子 * 数组大小 = 0.75 * 16 = 12所以第一次扩容阈值是12 static final float DEFAULT_LOAD_FACTOR = 0.75f; //树化阈值,即转换成红黑树需要 节点数>= 8 static final int TREEIFY_THRESHOLD = 8; //退化阈值,从红黑树转换成链表需要 节点数<= 8 static final int UNTREEIFY_THRESHOLD = 6; //最小树化容量,需要满足数组大小>=64 static final int MIN_TREEIFY_CAPACITY = 64;
Node:
1 2 3 4 5 6 7 8 9 10 11 12 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; }
红黑树结构:
1 2 3 4 5 6 7 8 9 static final class TreeNode <K,V> extends LinkedHashMap .Entry<K,V> { TreeNode<K,V> parent; TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; boolean red; TreeNode(int hash, K key, V val, Node<K,V> next) { super (hash, key, val, next); }
红黑树的条件?
答: 在链表长度 >8 && 数组长度>64 时,会转换成红黑树。
使用红黑树有什么好处?
答: 链表增删改查的复杂度为O(n) ,红黑树的增删改查时间复杂度是 O(logn) 。
为什么树化阈值是8,退化阈值是6,以及中间的空出7是为什么?
答:
这背后的设计逻辑主要基于 泊松分布(Poisson Distribution) 的概率统计: 在理想状态下,哈希桶中元素长度达到 8 的概率极低(约为 $0.00000006$)。 只有当碰撞概率达到一定程度,链表查询性能显著下降时,才会采用牺牲空间换取时间进行树化。 空出的7是为了缓冲,避免在链表和红黑树之间频繁转换导致性能浪费。 节点数6 -> 7时会继续是链表,节点数8 -> 7时会继续是红黑树。 完整的HashMap计算流程是什么样的?
答:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 key ↓ key.hashCode() ↓ 扰动函数 h ^ (h >>> 16) ↓ 得到hash ↓ index = hash & (length - 1) ↓ 找到桶 ↓ 链表 / 红黑树
扩容机制 源码精简版:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null ) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0 ; if (oldCap > 0 ) { newCap = oldCap << 1 ; newThr = oldThr << 1 ; } threshold = newThr; Node<K,V>[] newTab = (Node<K,V>[])new Node [newCap]; table = newTab; if (oldTab != null ) { for (int j = 0 ; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null ) { oldTab[j] = null ; } } } return newTab; }
为什么数组长度总是2的n次幂?
答: 这是出于三个方面的考量: 位运算替代取模 ,减少hash冲突 ,扩容优化
1.位运算替代取模:
要找到数据在数组中的位置,最原始的算法其实是index = hash % length 但是取模运算比位运算慢的多,所以出于性能的考量,我们将这个算法改良成index = hash & (length - 1)。 HashMap计算index的源码在方法putVal中,下面是截选: 1 2 if ((p = tab[i = (n - 1 ) & hash]) == null )
2.减少Hash冲突:
在index = hash & (length - 1)这个运算中,只用到了hash的低位,在哈希值相似时会出现严重的哈希冲突。 举例 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 length = 16 length - 1 = 15 二进制 = 1111 计算 index: hash & 1111 只会取最后4位。 假设存在key: 00010000 00100000 01000000 10000000 此时最后4位全是0000 计算 index: hash & 1111 全都等于0,都进入一个桶,出现哈希冲突,查询复杂度上升
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 static final int hash (Object key) { int h; return (key == null ) ? 0 : (h = key.hashCode()) ^ (h >>> 16 ); } 这里做了三件事: 1 取hashCode2 高16 位右移3 和原hash做异或举例: h = 1010101010101010 0000111100001111 右移16 位 h >>> 16 0000000000000000 1010101010101010 异或 1010101010101010 0000111100001111 ^ 0000000000000000 1010101010101010 -------------------------------- 1010101010101010 1010010110100101 低位被高位信息影响了,随机性更大
3.扩容优化:
1 2 3 static int indexFor (int h, int length) { return h & (length-1 ); }
每遍历一个元素,都要重新计算所有元素在新数组的位置。性能较差。
在JDK 8 中,使用了新的扩容算法,下面是扩容流程: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 HashMap size > threshold │ ▼ resize() │ ▼ newCap = oldCap * 2 │ ▼ 遍历旧数组每个桶 │ ▼ 遍历链表节点 │ ▼ 判断 (hash & oldCap) │ ┌──┴───┐ ▼ ▼ 0(不变) 1(移动) │ │ ▼ ▼ index index+oldCap
举例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 原数组: length = 16, length - 1 = 15 二进制下: 16 = 10000, 15 = 01111 下标计算 index = hash & 01111 --------------------- 扩容规则: newCap = oldCap * 2 --------------------- 新数组: oldCap = 16 newCap = 32 二进制下: 32 = 100000 31 = 011111 下标计算 index = hash & 11111 --------------------- 注意变化: 旧:01111 新:11111 多了一位:10000 刚好是oldCap: 16 = 10000 --------------------- 扩容后,元素只有两种选择:index Or index + oldCap 我们通过对首位进行&运算便能判断是否该将元素移动,而不用重新hash
2的幂次方在二进制下首位都是1,其余位置是0。所以使用2的幂次方来做容量铺垫在此刻利用了起来。 节点迁移 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 Node<K,V> loHead = null , loTail = null ; Node<K,V> hiHead = null , hiTail = null ; Node<K,V> 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; }
利用尾插法保持链表顺序不变,解决了死循环问题。 将链表拆分成两个链表: 1 2 3 4 5 6 原链表 │ ▼ / \ 低位 高位 链表 链表
性能优化体现在哪?
答:
JDK 7: 扩容 = O(n) + 重新hashJDK 8: 扩容 = O(n),因为hash不重新算,index不重新算,只判断首位。Put和Get Put流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 put(key,value) │ ▼ 计算 hash(key) │ ▼ index = hash & (n-1) │ ▼ 桶是否为空? │ ├─ 是 → 直接插入 │ └─ 否 │ ▼ key是否相同? │ ├─ 是 → 覆盖value │ └─ 否 │ ▼ 是红黑树? │ ├─ 是 → 树插入 │ └─ 否 → 链表插入 │ ▼ 链表≥8? │ ├─ 是 → 树化 │ └─ 否 │ ▼ size > threshold ? │ ├─ 是 → resize └─ 否
伪代码说明:
1 2 3 4 5 6 7 8 if table[index] == null newNode else if (key相同) 覆盖 else if (TreeNode) 红黑树插入 else 链表遍历
源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 final V putVal (int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> 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<K,V> 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<K,V>)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 ) treeifyBin(tab, hash); break ; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break ; p = e; } } if (e != null ) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null ) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null ; }
Get流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 get(key) │ ▼ 计算 hash │ ▼ index = hash & (n-1) │ ▼ 找到 bucket │ ▼ 第一个节点key匹配? │ ├─ 是 → 返回value │ └─ 否 │ ▼ 是红黑树? │ ├─ 是 → 红黑树查找 │ └─ 否 → 链表遍历
源码:
1 2 3 4 public V get (Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; }
GetNode 方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 final Node<K,V> getNode(Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n, hash; K k; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & (hash = hash(key))]) != 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<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; }
线程安全:快速失败机制
在JDK8 中HashMap依旧在并发环境下存在安全问题,但它提供了一个防御策略:快速失败 。 如果对HahMap进行并发修改,会快速抛出异常ConcurrentModificationException。 因此,面对并发修改时,迭代器能够迅速且干净利落地失败,而不是在未来某个未知时间点冒着任意、非确定性行为的风险(原文:Thus, in the face of concurrent modification, the iterator fails quickly and cleanly, rather than risking arbitrary, non-deterministic behavior at an undetermined time in the future.) 在多线程下建议使用Collections.synchronizedMap(new HashMap(...))或者ConcurrentHashMap。 总结 JDK8 对 HashMap 做了三大优化:
当链表长度超过 8 时转为红黑树,将查询复杂度从 O(n) 降到 O(log n)。 插入方式由头插法改为尾插法,解决了 JDK7 扩容时可能出现的环形链表死循环问题。 扩容时利用 (e.hash & oldCap) 判断节点是否需要移动到 index + oldCap,避免重新计算 hash,提高扩容效率。