1.1 背景知识
1.1.1 红黑树
二叉查找树可能因为多次插入新节点导致失去平衡,使得查找效率低,查找的复杂度甚至可能会出现线性的,为了解决因为新节点的插入而导致查找树不平衡,此时就出现了红黑树。
红黑树它一种特殊的二叉查找树。红黑树的每个节点上都有存储位表示节点的颜色,可以是红(Red)或黑(Black)。它具有以下特点:
(1)每个节点或者是黑色,或者是红色。
(2)根节点是黑色。
(3)每个叶子节点(叶子节点,是指为空(NIL或NULL)的叶子节点)是黑色。
(4)如果一个节点是红色的,则它的子节点一定是黑色(即从根节点到叶子节点的路径上不能有两个重复的红色节点)。
(5)从一个节点到其上每一个叶节点的所有路径都具有相同的黑色节点个数。
红黑树的基本操作--添加
① 将红黑树当作一颗二叉查找树,将节点插入。
② 将插入的节点着色为"红色"。(因为条件5,从一个节点到其中每一个节点的的所有路径都具有相同的黑色节点)。
③通过一系列的旋转(左旋或右旋操作)或着色等操作,使之重新成为一颗红黑树。
1.2 源码
在java 1.7之前是用数组和链表一起组合构成HashMap,在java1.8之后就使用当链表长度超过8之后,就会将链表转化为红黑树,缩小查找的时间(红黑树维护也会花费大量时间,包含左旋、右旋和变色过程)。
1.2.1 HashMap的初始化
hashmap构造函数会初始化三个值:
- 初始容量initialCapacity:默认值是16,当储存的数据越来越多的时候,就必须进行扩容操作。
- 阈值threshold:hashmap的数组结构中所能存放的最大数量,超过该数量,则会对数组进行扩容。阈值的计算方式为:容量(initialCapacity)*负载因子(loadFactor)。
- 负载因子loadFactor:当负载因子很大时,阈值会很大,table数组扩容的可能性比较小,会使得一个数组中的链表(红黑树)存放过多的数据,虽然节省了一定的空间,但会导致查询时间很长。相反负载因子很小时,扩容的可能性会很高,使得数组中的数据变得相对少,查询时间会缩短,但会花费较长的时间。
在初始化一个hashmap对象的时候,指定键值对的同时,也可以指定初始map的容量大小,假设此处我们指定大小为11,则会在构造函数中调用tableSizeFor将容量改为2的n幂次,即比当前容量大,而且必须是2的指数次幂的最小数,就会变成16。这是因为2的指数次幂便于计算进行位运算操作,提升运行效率问题(位运算>加法>乘法>除法>取模)。
hashmap的的默认值是16,负载因子默认是0.75,代码如下:
//HashMap<String,String> hashMap = new HashMap<String, String>(11); /** * Returns a power of two size for the given target capacity. **/ static final int tableSizeFor(int cap) { int n = cap - 1; //10 防止在cap已经是2的n次幂的情况下 // >>> 表示不关心符号位,对数据的二进制形式进行右移 |表示或运算 n |= n >>> 1; //15 n |= n >>> 2; //15 n |= n >>> 4; //15 n |= n >>> 8; //15 n |= n >>> 16; //15 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; //16 }
1.2.2 HashMap的put操作
这里可以介绍一下&位运算,当我们将一对KV存储到hashmap当中时,会通过(n - 1) & hash运算来定位将要插入的键值对放入到哈希表的某个桶中。其中n表示哈希表的长度,通常n为2的倍数,通过n-1即可n所表示的二进制数,除最高位外,全部转化为1,借助与运算即可快速完成取模操作。
//hashMap.put("2020", "good luck"); /** * Implements Map.put and related methods. * * @param hash hash for key * @param key the key * @param value the value to put * @param onlyIfAbsent if true, don't change existing value * @param evict if false, the table is in creation mode. * @return previous value, or null if none */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //如果hashtable没有初始化,则初始化该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<K,V> e; K k; //如果插入的元素key是已经存在的,则将新的value替换掉原来的旧值 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //如果此时table数组对应的位置是红黑树结构,则将该节点插入红黑树中 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { //如果此时table数组对应的位置是链表结构 for (int binCount = 0; ; ++binCount) { //遍历到数组尾端,没有与插入键值对相同的key,则将新的键值对插入链表尾部 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 = 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; }
1.2.3 HashMap的get操作
/** * Implements Map.get and related methods. * * @param hash hash for key * @param key the key * @return the node, or null if none */ final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; //table数组不为空,且对应的下标位置也不为空。 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { //如果第一个位置是对应的key,则返回 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<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; }
1.2.4 HashMap的扩容操作
/** * 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; //table不为空,且容量大于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,直接使用旧的阈值 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; //将之前旧数组桶的数据重新移到新数组桶中 if (oldTab != null) { //依次遍历旧table中每个数组桶的元素 for (int j = 0; j < oldCap; ++j) { Node<K,V> 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<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; /* * e.hash & oldCap 对hash取模运算, * 虽然数组大小扩大了一倍, * 但是同一个key在新旧table中对应的index却存在一定联系: * 要么一致,要么相差一个 oldCap。 */ 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; }
此处在处理链表的时候,如何将链表中的节点重新分配到新的哈希表需要做一些解释。在扩容的时候,将原来的哈希表扩大了一倍,原来属于同一个桶中的数据会被重新分配,此时取模运算时(a mod b),会注意到,b会扩大两倍(a mod 2b),此时如果该桶中的某一个数据的哈希值是c1(0<c<b),则它必定还是会落入原来的位置,而如果桶中的某一个数据的哈希值是c2(b<c2<2b),则它会被重新分配到一个新的位置(这个位置是原先的哈希桶位置+旧桶的大小)。
HashMap在多线程的情况下出现的死循环现象
在某些java版本中扩容机制如果使用链表,且再插入时使用尾插法会出现死循环,具体原因可以参考老生常谈,HashMap的死循环,在本文中所参考的java版本使用了头插法的方式将元素添加到链表当中,可以避免死循环的出现,但是会出现一部分节点丢失的问题。如图:
假设原始的哈希map的某个桶的数据如下,此时线程开始扩容,将桶中的数据分配到lo和hi桶的链表中。
初始时刻线程1和线程2开始运行,线程1在执行完以下代码后,线程1的时间片运行结束。线程1运行的结果如图所示
线程2与线程1同时运行,线程2的时间片未用完,还在继续执行,根据代码的分配策略,线程2直到时间片运行结束,出现如图所示的结果:
此时CPU的时间片又被分配到了线程1,线程1继续运行,因为此时A所在的链表结构已经发生了变化,只能处理A,B,D三个元素。此时线程1创建的hashmap如图: