1.什么是HashMap
HashMap是Map族中最为常用的一种,也是 Java Collection Framework 的重要成员。在开发工程师的面试中,HashMap的原理也是最为经常被问到的题目之一。因此,对于其实现原理是非常有必要去深入了解的。
2.HashMap的定义
我们从HashMap的源码中去看HashMap的定义,其定义如下:
public class HashMap extends AbstractMap implements Map, Cloneable, Serializable
可以看到HashMap继承于AbstractMap,实现了Map, Cloneable, Serializable三个接口。
3.HashMap构造函数
HashMap 提供了四个构造函数:
//构造方法1public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR);}//构造方法2public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; } //构造方法3public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }//构造方法4public HashMap(Map extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false);}
上面的构造函数中用的最多的可能就是构造方法1和构造方法2。构造方法1完全使用默认值,构造方法2初始容量自定义,进而调用构造方法3,构造方法3则是初始容量和加载因子自定义。构造方法4则是将另一个Map的额映射拷贝一份到自己的存储结构中,不是很常用。所以我们重点分析一下构造方法3。
构造方法3前面主要是关于一些异常情况的处理,最后调用了tableSizeFor方法。tableSizeFor的功能(不考虑大于最大容量的情况)是返回大于输入参数且最近的2的整数次幂的数。比如10,则返回16。代码如下所示:
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; }
我们通过一张图来理解一下:
先来假设n的二进制为01xxxx...xxxx。如下图中第一行
对n右移1位:001xxxx...xxxx,再位或:011xxxx...xxxx
对n右移2为:00011...xxx,再位或:01111...xxx
此时前面已经有四个1了,再右移4位且位或可得8个1
同理,有8个1,右移8位肯定会让后八位也为1。
综上可得,该算法让最高位的1后面的位全变为1。
最后再让结果n+1,即得到了2的整数次幂的值了。
非常巧妙的算法。
到此构造函数就结束了,但是其实这个时候并没有去分配实际的空间,实际上HashMap一直到插入才会真正去做初始化,这是一种lazy load的方式。关于HashMap的真正的初始化会在put和resize 中介绍。
4.HashMap操作之get
因为HashMap 的get 操作相对put而言会简单很多,因此我们先看get。
源码如下:
public V get(Object key) { Node e; return (e = getNode(hash(key), key)) == null ? null : e.value;}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;}
查找的真正做事情的函数是getNode函数
这里出现了HashMap的Node结构
static class Node implements Map.Entry { final int hash; final K key; V value; Node next; Node(int hash, K key, V value, Node next) { this.hash = hash; this.key = key; this.value = value; this.next = next; }
Node中存储了hash值,key的值,value的值,next引用等等。
接着我们继续看getNode函数,首先计算哈希桶的下标。
first = tab[(n - 1) & hash]
这种方法的目的和 hash % ( n - 1)计算下标的思想是类似的。
但是由于n是2的幂,因此(n - 1)& hash 等价于对哈希桶的长度取模。
计算完哈希桶的下标之后,查看对应的哈希桶的哈希值是否相同,除此还会比较key是否相同,这里使用== 或者 equals 去比较。
if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first;
如果对应的哈希桶的不是我们要找的值,就会继续往下查找,首先判断该节点是否是红黑树的节点,如果是就会采用红黑树的查找方法。如果不了解红黑树,可以参考我的另一篇文章: 红黑树的原理详解。
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);
到这里为止get就结束了,接下来我们要看put方法。
5.HashMap操作之put
通过对get的分析,大家对 HashMap 底层的数据结构应该有了个初步的认识。接下来,先来看一下插入操作的源码:
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) { Node[] tab; Node p; int n, i; // 初始化桶数组 table,table 被延迟到插入新数据时再进行初始化 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; // 如果键的值以及节点 hash 等于链表中的第一个键值对节点时,则将 e 指向该键值对 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 如果桶中的引用类型为 TreeNode,则调用红黑树的插入方法 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; } // 条件为 true,表示当前链表包含要插入的键值对,终止遍历 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // 判断要插入的键值对是否存在 HashMap 中 if (e != null) { // existing mapping for key V oldValue = e.value; // onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值 if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; // 键值对数量超过阈值时,则进行扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null;}
插入操作的入口方法是 put(K,V),但核心逻辑在V putVal(int, K, V, boolean, boolean) 方法中。putVal 方法主要做了这么几件事情:
- 当桶数组 table 为空时,通过扩容的方式初始化 table
- 查找要插入的键值对是否已经存在,存在的话根据条件判断是否用新值替换旧值
- 如果不存在,则将键值对链入链表中,并根据链表长度决定是否将链表转为红黑树
- 判断键值对数量是否大于阈值,大于的话则进行扩容操作
首先将table赋值给tab,如果tab=null或者tab的长度为0,说明HashMap还没有初始化,这个时候就会调用resize方法,之前我们提到过这是一种lazy load的方式,就是一直到插入才会真正的去初始化。
Node[] tab; Node p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
接着通过hash值计算hash桶的下标,如果对应位置是null,说明这个位置还没有元素,就新建键值对节点插入桶中。
if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);
接下来如果对应下标的hash桶已经有元素,那么就会看这个节点的key的哈希值和要插入的hash值是否相同,相同的话还会用==和equals继续比较。当经过比较,两个key相同时,我们就需要把这个插入转化成一个key对应的value的更新问题了。
Node e; K k; // 如果键的值以及节点 hash 等于链表中的第一个键值对节点时,则将 e 指向该键值对 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p;
接下来如果对应下表的哈希桶是红黑树的节点,那么就需要调用红黑树的插入方法。
// 如果桶中的引用类型为 TreeNode,则调用红黑树的插入方法 else if (p instanceof TreeNode) e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
接下来就是对链表这种情况的处理了,对链表进行便利查找,看是否有相同的key。
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;}
接下来就是处理存在相同key的情况,因为之前我们在发现有相同的key的时候只是把这个几点赋值给了e,并没有处理。 onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值
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方法中出现了afterNodeAccess,afterNodeInsertion等函数,实际上在HashMap中只是一个空函数,我们可以忽略它。通过查看源码,可以知道这三个函数是为LinkedHashMap而写的。
// Callbacks to allow LinkedHashMap post-actions void afterNodeAccess(Node p) { } void afterNodeInsertion(boolean evict) { } void afterNodeRemoval(Node p) { }
6.HashMap操作之resize
前面我们说到HashMap在初始化的时候是lazy load的,一直到put的时候调用resize才真正开始初始化。
在 HashMap 中,桶数组的长度均是2的幂,阈值大小为桶数组长度与负载因子的乘积。当 HashMap 中的键值对数量超过阈值时,进行扩容。
HashMap 的扩容机制与其他变长集合的套路不太一样,HashMap 按当前桶数组长度的2倍进行扩容,阈值也变为原来的2倍(如果计算过程中,阈值溢出归零,则按阈值公式重新计算)。扩容之后,要重新计算键值对的位置,并把它们移动到合适的位置上去。以上就是 HashMap 的扩容大致过程,接下来我们来看看具体的实现:
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; }
源码相对比较长,但是主要做了如下几件事情
- 计算新桶数组的容量 newCap 和新阈值 newThr
- 根据计算出的 newCap 创建新的桶数组,桶数组 table 也是在这里进行初始化的
- 将键值对节点重新映射到新的桶数组里。如果节点是 TreeNode 类型,则需要拆分红黑树。如果是普通节点,则节点按原顺序进行分组。
首先看第一段
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; } } } } }
e.hash & oldCap是啥意思?我刚开始看也非常困惑。
可以看下图,令n=4,看零个hash值35和7,首先当n=4时候,他们计算出的下标都是3.
然后当n=8时,他们一个下标仍然是3,一个变成了7。因为一开始n=4时,n-1=3,只有后2位参加了求与运算,然而n=8,即n-1=7时,后三位参与了运算,如果hash值第三位是1,那么重新计算的hash值就是oldCap+原位置。
根据这样的判断,我们只要用n & hash值,就知道链表中的元素重新哈希之后是否需要换位置。
如下图所示,一开始35、7、19、15都在3这个哈希桶上。当n=8的时候,35和19仍然在3上。
而7和15则在7上。
总结
讲到这里,基本上将HashMap的几个重要的模块讲解完了,包含了构造方法、get方法、put方法和resize方法。
本篇文章是自己在阅读HashMap源码时的一些心得和一些记录,也方便自己今后遗忘后的快速上手。如果内容当中有任何问题,希望大家在评论区中指出,我也会及时的修改。
感谢大家的阅读!
参考文章
https://segmentfault.com/a/1190000012926722