在前面的文章中,我们以及介绍了 List
大家族的相关知识:
在接下来的文章,则主要为大家介绍一下 Java
集合家庭中另一小分队 Map
,我们先来看看 Map
家庭的整体架构:
在这篇文章中,我们主要介绍一下HashMap:
HashMap 的依赖关系:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
从依赖关系上面来看, HashMap
并没有 List
集合 那么的复杂,主要是因为在迭代上面,HashMap 区别 key-value 进行迭代,而他们的迭代又依赖与keySet-valueSet 进行,因此,虽然依赖关系上面HashMap 看似简单,但是内部的依赖关系更为复杂。
默认 桶(数组) 容量 16 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 最大容量 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; 存储数据节点 static class Node<K,V> implements Map.Entry<K,V> 节点数组 transient Node<K,V>[] table; 数据容量 transient int size; 操作次数 transient int modCount; 扩容大小 int threshold;
对比于JDK8之前的HashMap ,成员变量主要的区别在于多了红黑树的相关变量,用于标示我们在什么时候进行 list
-> Tree
的转换。
附上Jdk8 中HashMap 的数据结构展示图:
HashMap 提供了四种构造函数:
m
接下来我们主要讲解一下,HashMap 在JDK8中的添加数据过程(引用):
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
上述方法是我们在开发过程中最常使用到的方法,但是却很少人知道,其实内部真正调用的方法是这个 putVal(hash(key), key, value, false, true)
方法。这里稍微介绍一下这几个参数:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。 这里引用一张图,易于大家了解相关机制
这里可能会比较疑惑,为什么需要对自身的hashCode 进行运算,这么做可以在数组table 比较小的时候,让高位bit 也能参与到hash 运算中,同时不会又太大的开销。
由于源码篇幅过长,这里我进行分开讲解,同学们可以对照源码进行阅读
Node<K,V>[] tab; Node<K,V> p; int n, i;
第一部分主要县声明几个需要使用到的成员变量:
table 为空说明当前操作为第一次操作,通过上面构造函数的阅读,我们可以了解到,我们并没有对table 进行初始化,因此在第一次put 操作的时候,我们需要先将table 进行初始化。
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
从上述代码可以看到,table 的初始化和扩容,都依赖于 resize()
方法,在后面我们会对该方法进行详细分析。
if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);
在上一步我们以及确认当前table不为空,然后我们需要计算我们对象需要存储的下标了。
如果该下标中并没有数据,我们只需创建一个新的节点,然后将其存入 tab[]
即可。
与上述过程相反,Hash碰撞结果后,发现该下标有保存元素,将其保存到变量 p = tab[i = (n - 1) & hash]
,现在 p
保存的是目标数组下标中的元素。如上图所示(引用):
在获取到 p
后,我们首先判断它的 key 是否与我们这次插入的key 相同,如果相同,我们将其引用传递给 e
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);
由于在JDK 8后,会对过长的链表进行处理,即 链表 -> 红黑树,因此对应的节点也会进行相关的处理。红黑树的节点则为TreeNode,因此在获取到 p
后,如果他跟首位元素不匹配,那么他就有可能为红黑树的内容。所以进行 putTreeVal(this, tab, hash, key, value)
操作。该操作的源码,将会在后续进行细述。
else { //for 循环遍历链表,binCount 用于记录长度,如果过长则进行树的转化 for (int binCount = 0; ; ++binCount) { // 如果发现p.next 为空,说明下一个节点为插入节点 if ((e = p.next) == null) { //创建一个新的节点 p.next = newNode(hash, key, value, null); //判断是否需要转树 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 p = e; } }
链表遍历处理,整个过程就是,遍历所有节点,当发现如果存在key 与插入的key 相同,那么退出遍历,否则在最后插入新的节点。判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; }
如果 e
不为空,说明在校验 key 的hash 值,发现存在相同的 key,那么将会在这里进行判断是否对其进行覆盖。
if (++size > threshold) resize();
如果 size
大于 threshold
则进行扩容处理。
在上面的构造函数,和 put
过程都有调用过 resize()
方法,那么,我们接下来将会分析一下 resize()
过程。由于 JDK 8
引入了红黑树,我们先从 JDK 7
开始阅读 resize()
过程。下面部分内容参考:传送门
在 JDK 7
中,扩容主要分为了两个步骤:
1 void resize(int newCapacity) { //传入新的容量 2 Entry[] oldTable = table; //引用扩容前的Entry数组 3 int oldCapacity = oldTable.length; 4 if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了 5 threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了 6 return; 7 } 8 9 Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组 10 transfer(newTable); //!!将数据转移到新的Entry数组里 11 table = newTable; //HashMap的table属性引用新的Entry数组 12 threshold = (int)(newCapacity * loadFactor);//修改阈值 13 }
1 void transfer(Entry[] newTable) { 2 Entry[] src = table; //src引用了旧的Entry数组 3 int newCapacity = newTable.length; 4 for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组 5 Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素 6 if (e != null) { 7 src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象) 8 do { 9 Entry<K,V> next = e.next; 10 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置 11 e.next = newTable[i]; //标记[1] 12 newTable[i] = e; //将元素放在数组上 13 e = next; //访问下一个Entry链上的元素 14 } while (e != null); 15 } 16 } 17 }
下面举个例子说明下扩容过程。假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。
由于扩容部分代码篇幅比较长,童鞋们可以对比着博客与源码进行阅读。 与上述流程相似, JDK 8
中扩容过程主要分成两个部分:
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 } 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); } // 第二步,创建新数组 threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab;
从上面的流程分析,我们可以看到在 JDK 8 HashMap
中,开始使用位运算进行扩容计算,主要优点将会在后续数据拷贝中具体表现。
在上述容器扩容结束后,如果发现 oldTab
不为空,那么接下来将会进行内容拷贝:
if (oldTab != null) { //对旧数组进行遍历 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; // if ((e = oldTab[j]) != null) { //将旧数组中的内容清空 oldTab[j] = null; //如果 e 没有后续内容,只处理当前值即可 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; //高位 与运算,确定索引为原索引 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } //高位与运算,确认索引为 愿索引+ oldCap 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; } } } } }
内容拷贝,在 JDK 8
中优化,主要是:
我们来看一下 JDK 8
是如何通过高位与运算确认存储位置的:
HashMap中,如果key经过hash算法得出的数组索引位置全部不相同,即Hash算法非常好,那样的话,getKey方法的时间复杂度就是O(1),如果Hash算法技术的结果碰撞非常多,假如Hash算极其差,所有的Hash算法结果得出的索引位置一样,那样所有的键值对都集中到一个桶中,或者在一个链表中,或者在一个红黑树中,时间复杂度分别为O(n)和O(lgn)。
(1) 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
(2) 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
(3) HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。
(4) JDK1.8引入红黑树大程度优化了HashMap的性能。
(5) 还没升级JDK1.8的,现在开始升级吧。HashMap的性能提升仅仅是JDK1.8的冰山一角。