文章已经收录在 Github.com/niumoo/JavaNotes ,更有 Java 程序员所须要掌握的核心知识,欢迎Star和指教。
欢迎关注个人 公众号,文章每周更新。
HashMap 做为最经常使用的集合类之一,有必要深刻浅出的了解一下。这篇文章会深刻到 HashMap 源码,刨析它的存储结构以及工做机制。java
HashMap 的数据存储结构是一个 Node<K,V> 数组,在(Java 7 中是 Entry<K,V> 数组,但结构相同)node
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { // 数组 transient Node<K,V>[] table; static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; // 链表 Node<K,V> next; .... } ..... }
存储结构主要是数组加链表,像下面的图。git
在 Java 8 中 HashMap 的 put 方法以下,我已经详细注释了重要代码。程序员
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } // 计算哈希值 与(&)、非(~)、或(|)、异或(^) static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 若是数组为空,进行 resize() 初始化 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 若是计算的位置上Node不存在,直接建立节点插入 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { // 若是计算的位置上Node 存在,链表处理 Node<K,V> e; K k; // 若是 hash 值,k 值彻底相同,直接覆盖 if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 若是 index 位置元素已经存在,且是红黑树 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) { // 找到节点链表中next为空的节点,建立新的节点插入 p.next = newNode(hash, key, value, null); // 若是节点链表中数量超过TREEIFY_THRESHOLD(8)个,转化为红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } // 若是节点链表中有发现已有相同key if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // 若是节点 e 有值,放入数组 table[] 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; }
举个例子,若是 put 的 key 为字母 a,当前 HashMap 容量是初始容量 16,计算出位置是 1。github
# int hash = key.hashCode() # hash = hash ^ (hash >>> 16) # 公式 index = (n - 1) & hash // n 是容量 hash HEX(97) = 0110 0001 n-1 HEX(15) = 0000 1111 -------------------------- 结果 = 0000 0001 # 计算获得位置是 1
总结 HashMap put 过程。面试
计算方式是 (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
shell
计算方式:(容量 - 1) & hash
. 数组
由于容量老是2的次方,因此-1的值的二进制老是全1。方便与 hash 值进行与运算。安全
若是计算出的坐标元素为空,建立节点加入,put 结束。网络
若是计算出的坐标元素有值。
若是坐标上的元素和要加入的元素不一样(尾插法增长)。
若是发现有 next 节点的 key 和要加入的 key 同样,对应的值替换为新值。
在 Java 8 中 get 方法源码以下,我已经作了注释说明。
public V get(Object key) { Node<K,V> e; 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; // 只有在存储数组已经存在的状况下进入这个 if if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { // first 是获取的坐标上元素 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) // key 相同,说明first是想要的元素,返回 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; }
get 方法流程总结。
若是获取到的元素的 key 值不相等,查找 next 节点的元素。
index = (n - 1) & hash
,其中 n
是容量。这里可能会有同窗对 hash ^ (hash >>> 16)
有疑惑,很好奇为何这里要拿 hash 值异或上 hash 值无符号右移 16 位呢?下面经过一个例子演示其中道理所在。
假设 hash 值是 0001 0100 1100 0010 0110 0001 0010 0000
,当前容量是 16。
hash = 0001 0100 1100 0010 0110 0001 0010 0000 --- | 与或计算 hash >>> 16 = 0000 0000 0000 0000 0001 0100 1100 0010 --- ------------------------------------------------------ hash 结果 = 0001 0100 1100 0010 0111 0101 1110 0100 --- | & 与运算 容量 -1 = 0000 0000 0000 0000 0000 0000 0000 1111 --- ------------------------------------------------------ # 获得位置 = 0000 0000 0000 0000 0000 0000 0000 0100 获得位置是 4
若是又新增一个数据,获得 hash 值是 0100 0000 1110 0010 1010 0010 0001 0000
,容量仍是16,计算他的位置应该是什么呢?
hash = 0100 0000 1110 0010 1010 0010 0001 0000 --- | 与或计算 hash >>> 16 = 0000 0000 0000 0000 0001 0100 1100 0010 --- ------------------------------------------------------ hash 结果 = 0100 0000 1110 0010 1011 0110 1101 0010 --- | & 与运算 容量 -1 = 0000 0000 0000 0000 0000 0000 0000 1111 --- ------------------------------------------------------ # 获得位置 = 0000 0000 0000 0000 0000 0000 0000 0010 获得位置是 2
上面两个例子,获得位置一个是 4,一个是 2,上面只是我随便输入的两个二进制数,那么这两个数若是不通过 hash ^ (hash >>> 16)
运算,位置会有什么变化呢?
hash = 0001 0100 1100 0010 0110 0001 0010 0000 容量 -1 = 0000 0000 0000 0000 0000 0000 0000 1111 ------------------------------------------------------ 结果 = 0000 0000 0000 0000 0000 0000 0000 0000 # 获得位置是 0 hash = 0100 0000 1110 0010 1010 0010 0001 0000 容量 -1 = 0000 0000 0000 0000 0000 0000 0000 1111 ------------------------------------------------------ 结果 = 0000 0000 0000 0000 0000 0000 0000 0000 # 获得位置是 0
能够发现位置都是 0 ,冲突几率提升了。可见 hash ^ (hash >>> 16)
让数据的 hash 值的高 16 位与低 16 位进行与或混合,能够减小低位相同时数据插入冲突的几率。
这多是由于每次扩容都是 2 倍。而选择 2 的次方值 16 做为初始容量,有利于扩容时从新 Hash 计算位置。为何是 16 我想是一个经验值,理论上说只要是 2 的次方都没有问题。
负载因子是多少?负载因子是 0.75。
扩容方式是什么?看源码说明。
/** * Initializes or doubles table size. If null, allocates in * accord with initial capacity target held in field threshold. * Otherwise, because we are using power-of-two expansion, the * elements from each bin must either stay at same index, or move * with a power of two offset in the new table. * * @return the table */ 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) { // 若是当前长度已经大于最大容量。结束扩容 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 } // 当前容量 = 0 ,可是当前记录容量 > 0 ,获取当前记录容量。 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<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; // 若是 oldTab != null,说明是扩容,不然是初始化,直接返回 if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; // 若是当前元素 next节点没有元素,当前元素从新计算位置直接放入 if (e.next == null) newTab[e.hash & (newCap - 1)] = e; 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 { next = e.next; // == 0 ,位置不变 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } // e.hash & oldCap != 0 ,位置变为:位置+扩容前容量 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; }
扩容时候怎么从新肯定元素在数组中的位置,咱们看到是由 if ((e.hash & oldCap) == 0)
肯定的。
hash HEX(97) = 0110 0001 n HEX(16) = 0001 0000 -------------------------- 结果 = 0000 0000 # e.hash & oldCap = 0 计算获得位置仍是扩容前位置 hash HEX(17) = 0001 0001 n HEX(16) = 0001 0000 -------------------------- 结果 = 0001 0000 # e.hash & oldCap != 0 计算获得位置是扩容前位置+扩容前容量
经过上面的分析也能够看出,只有在每次容量都是2的次方的状况下才能使用 if ((e.hash & oldCap) == 0)
判断扩容后的位置。
HashMap 在 Java 8 中的实现增长了红黑树,当链表节点达到 8 个的时候,会把链表转换成红黑树,低于 6 个的时候,会退回链表。究其缘由是由于当节点过多时,使用红黑树能够更高效的查找到节点。毕竟红黑树是一种二叉查找树。
链表节点个数大于等于 8 时,链表会转换成树结构。
节点个数小于等于 6 时,树会转变成链表。
若是没有差值,都是 8 ,那么若是频繁的插入删除元素,链表个数又恰好在 8 徘徊,那么就会频繁的发生链表转树,树转链表。
容量是2的幂时,key 的 hash 值而后 & (容量-1)
肯定位置时碰撞几率会比较低,由于容量为 2 的幂时,减 1 以后的二进制数为全1,这样与运算的结果就等于 hash值后面与 1 进行与运算的几位。
下面是个例子。
hash HEX(97) = 0110 0001 n-1 HEX(15) = 0000 1111 -------------------------- 结果 = 0000 0001 # 计算获得位置是 1 hash HEX(99) = 0110 0011 n-1 HEX(15) = 0000 1111 -------------------------- 结果 = 0000 0011 # 计算获得位置是 3 hash HEX(101) = 0110 0101 n-1 HEX(15) = 0000 1111 -------------------------- 结果 = 0000 0101 # 计算获得位置是 5
若是是其余的容量值,假设是9,进行与运算结果碰撞的几率就比较大。
hash HEX(97) = 0110 0001 n-1 HEX(09) = 0000 1001 -------------------------- 结果 = 0000 0001 # 计算获得位置是 1 hash HEX(99) = 0110 0011 n-1 HEX(09) = 0000 1001 -------------------------- 结果 = 0000 0001 # 计算获得位置是 1 hash HEX(101) = 0110 0101 n-1 HEX(09) = 0000 1001 -------------------------- 结果 = 0000 0001 # 计算获得位置是 1
另外,每次都是 2 的幂也可让 HashMap 扩容时能够方便的从新计算位置。
hash HEX(97) = 0110 0001 n-1 HEX(15) = 0000 1111 -------------------------- 结果 = 0000 0001 # 计算获得位置是 1 hash HEX(97) = 0110 0001 n-1 HEX(31) = 0001 1111 -------------------------- 结果 = 0000 0001 # 计算获得位置是 1
HashMap 遍历使用的是一种快速失败机制,它是 Java 非安全集合中的一种广泛机制,这种机制可让集合在遍历时,若是有线程对集合进行了修改、删除、增长操做,会触发并发修改异常。
它的实现机制是在遍历前保存一份 modCount ,在每次获取下一个要遍历的元素时会对比当前的 modCount 和保存的 modCount 是否相等。
快速失败也能够看做是一种安全机制,这样在多线程操做不安全的集合时,因为快速失败的机制,会抛出异常。
实现原理:有一个变量 final Object mutex;
,操做方法都加了这个 synchronized (mutex)
排它锁。
最后的话
文章已经收录在 Github.com/niumoo/JavaNotes ,欢迎Star和指教。更有一线大厂面试点,Java程序员须要掌握的核心知识等文章,也整理了不少个人文字,欢迎 Star 和完善,但愿咱们一块儿变得优秀。
文章有帮助能够点个「赞」或「分享」,都是支持,我都喜欢!
文章每周持续更新,要实时关注我更新的文章以及分享的干货,能够关注「 未读代码 」公众号或者个人博客。