一、概述
HashMap是Java工程师使用频率最高的类型之一,同时在基础面试中也是出现频率最高的面试题之一。因为它涵盖了很多知识点,其中包含了散列、链表、树等多种数据结构,同时它在Java7和Java8两个版本的结构有差异,这一点也是面试官喜欢问它的原因之一。今天这篇文章就来带大家好好欣赏一下HashMap的底层实现。
二、源码剖析
在Java7中HashMap的结构就和我们之前在数据结构之散列里用分离链接法实现的散列表类似,都是由数组和链表组成,(这里不再做过多的介绍)而在Java8版本中它的链表结构会在一定条件下转换为红黑树,下面我们来看看源码。
首先来看看HashMap中定义的常量
// 默认的初始化容量为16,HashMap的容量一定是2的幂 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 // HashMap的最大容量 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;
了解完基础常量以后,我们再看下
HashMap中存储元素所用到的
Node对象,下面来看下
Node的结构
static class Node<K,V> implements Map.Entry<K,V> { 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; } 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节点的定义我们可以看到
,Node有四个属性
hash、key、value、next。其中hash用来计算元素所在散列表的位置,
key和
value就是我们使用
HashMap中所熟知的
(key,value),
next是单链表中指向下一元素的“指针”,读过
java基础数据结构之链表的朋友相信很容易理解。看完
HashMap中的基础属性和基础对象之后,我们来看一下
HashMap是如何插入数据的,下面请看源码
// HashMap中put方法 public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } /** * @param hash key的Hash值 * @param key * @param value * @param onlyIfAbsent 如果是true,则不改变原有值 * @param evict 如果是false,则表处于创建模式. * @return 返回上一个值,如果为空就返回null */ 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) // 表为空就先进行reSize方法,给HashMap初始化一个默认容量 n = (tab = resize()).length; // 利用 hash算出该插入元素在表中的index if ((p = tab[i = (n - 1) & hash]) == null) // 若果在表的该索引下面没有元素,则直接插入该元素 tab[i] = newNode(hash, key, value, null); else { // 如果该索引下面有元素 Node e; K k; // 判断该元素和要插入的元素的 hash和 key是否相等 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 相等的话就让e=p e = p; // 不相等再判断 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); // 判断单链表的长度是否大于TREEIFY_THRESHOLD - 1(8-1) if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st // 调用treeifyBin()方法 treeifyBin(tab, hash); break; } // 判断插入元素的hash和 key是否和单链表中的元素相同 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // 要插入元素的 key已经在 HashMap中存在 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; }
上面就是
HashMap的插入逻辑,其中还留下了一个
treeifyBin()的方法没有剖析,接下来我们便看下它的源码
final void treeifyBin(Node[] tab, int hash) { int n, index; Node e; // 如果表为空或者表的长度小于MIN_TREEIFY_CAPACITY(64) if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)、 // 扩容 resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { // 将链表转化为红黑树进行操作 TreeNode hd = null, tl = null; do { TreeNode p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); if ((tab[index] = hd) != null) hd.treeify(tab); } }
从中我们可以看出,在
链表的长度大于8的时候,调用了
treeifyBin()方法,在
treeifyBin()方法里面再次判断表是否大于64,
如果表的容量大于64,则将链表转化为红黑树
进行插入操作,如果不大于64则进行扩容操作。经过上面的分析我们已经大致知道 HashMap是怎么进行新增操作的,不过在以上分析中,我们已经看到很多次
reSize()的身影,那么
reSize()是如何运作的呢?我们再接着往下分析
final Node[] resize() { Node[] oldTab = table; // 未扩容前的表为空,则oldCap=0,否则就是未扩容前的长度 int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { // 如果未扩容时的容量大于等于HashMap的最大容量,则不扩容 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 如果未扩容前的容量大于默认容量,且乘2以后小于最大容量,则扩容两倍 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); } // 如果新的容量等于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[] newTab = (Node[])new Node[newCap]; // 将新的扩容量赋值给table 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 // 链表的长度大于1时 Node loHead = null, loTail = null; Node hiHead = null, hiTail = null; Node next; do { next = e.next; // 当通过该位运算算出的值等于0,则该元素则不变动位置 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } // 若是不等于0,则重新生成链表hihead 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) { // 将hiHead放入到 j+oldCap(原索引+原表长度)的索引位置 hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
Java8里面的扩容方法做了优化(相较于
Java7),可以从以上源码中看出,有部分数据在扩容后的位置并没发生变化,通过位运算计算出的
e.hash & oldCap如果等于0,那么该元素在扩容后就不需要改变位置,如果该值不等于0,那么新的位置就是 原索引+原表大小。最后我们再分析一下
HashMap的查找方法,请看下面源码
public V get(Object key) { Node e; return (e = getNode(hash(key), key)) == null ? null : e.value; } /** * Implements Map.get and related methods. * * @param hash key的hash值 * @param key * @return 返回查找的节点,没有则返回空 */ 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) { // 如果通过位运算找出的第一个节点的hash和key和要找的相等,则直接返回该节点 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; // 如果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; }
源码看到这里,我们已经将
HashMap中的几个重要方法都分析完毕,从以上的分析中我们可以很清楚的看到,
HashMap中涉及到的数据结构有
散列、单链表、红黑树,所幸的是,笔者在这之前
已经讲过散列和单链表,如果看完这两篇文章的朋友,再看这篇源码分析一定游刃有余。至于红黑树,以后有机会的话笔者会再次进行分享。
三、面试题
HashMap经常在面试中被问到,而问到的问题无非是以下几种:
- HashMap是怎样实现的?
- Java8对HashMap做了哪些优化?
- HashMap中的加载因子为什么默认是0.75?可以设置成别的值吗?
笔者解答
第一个问题:
关于HashMap的底层实现,本篇源码剖析已经讲的相当细致了,相信看完这篇文章的你心中应该已经有了答案。
第二个问题:
关于Java8的优化,其实以上源码已经给出答案了,我们再总结下。第一点:Java8中对HashMap添加了红黑树的结构,在链表长度大于8且Hash表容量大于64时,链表会转化为红黑树结构,红黑树具有快速增、删、改、查的特点,这样就可以有效的解决链表过长时操作比较慢的问题。第二点:在给HashMap扩容时,Java7给每个元素都重新计算索引值,将其重新分配到扩容后的hash表里面,而Java8通过位运算,让那些 hash & oldCap =0的元素不改变位置,减少元素位置变动的开销。
第三个问题:
首先加载因子可以自定义,但是关于默认的加载因子为0.75,有很多的考量,这里笔者先不给出答案,感兴趣的读者朋友可以自行找一些资料,因为没有留言功能,读者找到答案以后可以到公众号下面交流彼此的答案。