版本迭代
在JDK1.7时,HashMap的结构为数组+链表。而在JDK1.8时更新为了数组+链表(红黑树)。数组和链表的特点非常简单。数组:查询快增删慢,链表:查询慢增删快。为什么这样设计呢,下面会做一定的解释。
详解
为什么选择数组+链表(红黑树的结构)
数组用于快速定位,数组的查询的时间复杂度为O(1),链表查询的时间复杂度为O(n),当我们的HashMap足够大,hash冲突足够严重时,链表的查询效率会急剧降低,JDK1.8对此做出了调整,当链表长度超过8时,就会将链表转为红黑树,红黑树是一种自平衡二叉查找树,其查询的时间复杂度为O(log n),查询效率会显著提高。
相关属性
初始容量(DEFAULT_INITIAL_CAPACITY)
HashMap的初始容量为16(DEFAULT_INITIAL_CAPACITY = 1 << 4),为什么初始容量被设定为16呢?在源码注释中,特别强调了初始容量必须是2的n次方,这又是为什么呢?在创建HashMap时我们可以指定初始容量的大小,当我们在创建HashMap时可以传入HashMap的初始容量大小。但是我们所设定的初始容量大小在初始化时会被转换为大于设定值但最接近设定值的的2的整数次幂。例如我们传入初始容量Map<String, Object> map = new HashMap<>(5);
为5,那么会将初始容量设置为8,传入13会被设置为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; }
为什么要强行转换为2的整数次幂呢?这主要还是为了运行效率和随机分布。Key的HashCode经过某种运算需要均匀分布在HashMap的数组中。我们最先能想到的是取模运算hashcode % length
,此处的length为HashMap中数组的长度。这样就可以均匀分布在数组中。但是取模运算的效率是很低的。于是HashMap中采用的是与(&)运算。它将Key的hashcode值与数组长度-1进行与(&)运算。这样就可以获取到随机分布于HashMap的数组中。原因如下:
例如: 数组长度为16,某个Key的hashcode值为0101 0101,运算过程为:
- hashcode: 0101 0101
- 数组长度减一,也就是16-1=15的二进制进行&运算: 0000 1111
- &运算后:0000 0101
可以看出,数组长度减一的高4位全部为0,低4位全部为1,那么最终的运算结果也就由Key的hashcode低4位决定,第四位的取值范围在0-15之间,也不会出现数组越界。
加载因子(DEFAULT_LOAD_FACTOR)
HashMap的加载因子为0.75,这个主要是一个折中的结果。加载因子是表示Hsah表中元素的填满的程度。加载因子越大,填满的元素越多,空间利用率越高,但冲突的机会加大了。反之,加载因子越小,填满的元素越少,冲突的机会减小,但空间浪费多了。冲突的机会越大,则查找的成本越高。反之,查找的成本越小。因此,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折中。
扩容阀值(threshold)
扩容阀值为数组容量 * 加载因子,当超过阀值时就进行扩容。
常用方法
put方法
put方法就是向HashMap中添加一个元素,再添加元素之前先了解一下怎么获取的key的hashcode值:
static final int hash(Object key) { int h; // 将key的hashcode值与其hashcode值的高16位进行异或运算,的到最后的hash值。复杂的原因主要是为了减少hash冲突 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
获取了key的hash值后,开始调用putVal方法:
public V put(K key, V value) { // 调用putVal方法,传入的参数分别为:key的hashcode值,key,value,第四个参数表示如果该key存在值,如果为null的话,则插入新的value,最后一个参数,在hashMap中没有用,可以不用管,使用默认的即可 return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { // tab为Hash表中的数组。p为该Hash同的首个节点,n为hashMap的长度,i为计算出的数组下标 Node<K,V>[] tab; Node<K,V> p; int n, i; // 获取长度并进行扩容,使用的是懒加载,table一开始是没有加载的,等put后才开始加载 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 计算出该key的位于哪个桶后,检查该桶是否为空,为空则将插入的值插入到桶中。 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { // 进入到此则表示该桶不为空,需要遍历该桶中的所有节点。 // e为临时节点,k存放当前节点的key值 Node<K,V> e; K k; // 第一种情况:插入的key-value的hash值,key都与当前节点的相等,e = p,则表示为首节点 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 第二种情况:hash值和首节点不相同,判断插入的节点是否为红黑树的节点。 else if (p instanceof TreeNode) // 如果为红黑树的节点,则到红黑树中添加节点,如果该节点已经存在,则返回该节点(不为null),如果添加成功,则返回null 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) // -1 for 1st treeifyBin(tab, hash); break; } // 当链表中有和插入节点完全相同的节点时,e则为当前重复的节点,直接跳出循环 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // 有重复的key,则用待插入值进行覆盖,返回旧值。 if (e != null) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } // 记录修改的次数 ++modCount; // 实际长度+1,判断是否大于临界值,大于则扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); // 添加节点成功 return null; }
在put方法中,反复提到了扩容,我们看看扩容方法resize的源码:
final Node<K,V>[] resize() { // 把没扩容之前的HashMap中的数组存储为旧的table Node<K,V>[] oldTab = table; // 记录旧数组的容量 int oldCap = (oldTab == null) ? 0 : oldTab.length; // 记录旧数组的扩容阀值 int oldThr = threshold; // 创建新数组的初始容量和扩容阀值 int newCap, newThr = 0; // 判断旧数组的容量是否大于0,也就是说不是首次初始化,因为hashMap用的是懒加载 if (oldCap > 0) { // 判断旧数组的容量是否已经大于了最大值,如果大于,无法扩容,直接返回旧的数组。 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // (标记1)如果旧数组的容量的2倍不大于最大值,且旧数组的容量大于等于默认的数组容量16,则将HashMap的容量和扩容阀值都扩充为原来的2倍。 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } // 判断旧数组的扩容阀值是否大于0,如果oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在,如果是首次初始化,它的临界值则为0 else if (oldThr > 0) newCap = oldThr; else { // 进入到这一步表示该HashMap是首次初始化,所以两个值都给予默认值 newCap = DEFAULT_INITIAL_CAPACITY; // 扩容阀值 = 加载因子 * 容量 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 这是对(标记1)的补充,也就是初始化时容量小于默认值16的,此时newThr没有赋值 if (newThr == 0) { // new的临界值 float ft = (float)newCap * loadFactor; // 判断是否new容量是否大于最大值,临界值是否大于最大值 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } // 把上面各种情况分析出的临界值,在此处真正进行改变,也就是容量和临界值都改变了。 threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) // 初始化一个新的table数组 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 将新的table赋予给原先的table table = newTab; // 遍历oldTable中的元素,添加到新的newTab中 if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { // 临时存储的节点e Node<K,V> e; // 如果该桶不为空 if ((e = oldTab[j]) != null) { // 把已经赋值之后的变量置位null,当然是为了好回收,释放内存 oldTab[j] = null; // 如果该节点是最后一个节点 if (e.next == null) // 则将该节点存储到新的table中,也是通过hash值和数组长度-1来定位到某个桶 newTab[e.hash & (newCap - 1)] = e; // 如果获取到的节点是属于红黑树的节点 else if (e instanceof TreeNode) // 把此树进行转移到newTab中 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // 此处表示为链表结构,同样把链表转移到newTab中,就是把链表遍历后,把值转过去,在置为null便于内存回收 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; } } } } } // 返回扩容后的HashMap return newTab; }
最复杂的put方法到这里就结束了,下面我们再来看看remove方法;
remove方法
public V remove(Object key) { Node<K,V> e; // 调用removeNode方法,传入了key的hash值,key和value,此处的value值为null,表示把key的节点直接都删除了,不需要用到值,如果设为值,则还需要去进行查找操作,第四个为是为true的话,则表示删除它key对应的value,不删除key,第四个如果为false,则表示删除后,不移动节点 return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; } final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { // tab为hash数组,p为数组下标的节点,n为数组的长度,index为当前数组下标 Node<K,V>[] tab; Node<K,V> p; int n, index; // 哈希数组不为null,且长度大于0,然后获得到要删除key的节点所在是数组下标位置 if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { // node 存储要删除的节点,e 临时变量,k 当前节点的key,v 当前节点的value Node<K,V> node = null, e; K k; V v; // 如果数组下标的节点正好是要删除的节点,把值赋给临时变量node if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; // 也就是要删除的节点,在链表或者红黑树上,先判断是否为红黑树的节点 else if ((e = p.next) != null) { // 如果节点属于红黑树的节点,遍历红黑树,找到该节点并返回 if (p instanceof TreeNode) node = ((TreeNode<K,V>)p).getTreeNode(hash, key); else { // 表示节点是链表中的节点,则遍历链表找到节点 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; break; } // 注意,如果进入了链表中的遍历,那么此处的p不再是数组下标的节点,而是要删除结点的上一个结点 p = e; } while ((e = e.next) != null); } } // 找到要删除的节点后,判断!matchValue,我们正常的remove删除,!matchValue都为true if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { // 如果删除的节点是红黑树结构,则去红黑树中删除 if (node instanceof TreeNode) ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); // 如果是链表结构,且删除的节点为数组下标节点,也就是头结点,直接让下一个节点作为头节点 else if (node == p) tab[index] = node.next; else // 为链表结构,删除的节点在链表中,把要删除的下一个结点设为上一个结点的下一个节点 p.next = node.next; // 修改计数器+1 ++modCount; // 大小-1 --size; afterNodeRemoval(node); //返回删除的节点 return node; } } // 返回null则表示没有该节点,删除失败 return null; }
get方法
public V get(Object key) { Node<K,V> e; // 调用getNode方法,传入key的hash值和key return (e = getNode(hash(key), key)) == null ? null : e.value; } final Node<K,V> getNode(int hash, Object key) { // tab:hashMap数组,first:头节点,e:临时存储变量,k:key Node<K,V>[] tab; Node<K,V> first, e; int n; K k; // 如果table被初始化了且key所在的桶里面有节点 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { // 判断首节点是否为我们满足我们的key,如果满足则返回首节点 if (first.hash == hash && ((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); } } // 返回null表示没有该key所对应的节点 return null; }
修改
修改其实就是调用的put方法,只不过key是相同的。
总结
到此,JDK1.8的HashMap常用的方法源码分析到这里就结束了,里面涉及到了红黑树相关的代码,本人还没有搞清楚,日后将会完善HashMap中红黑树源码的分析。