如果面试也能这样说HashMap,那么就不会有那么多遗憾!(中):https://developer.aliyun.com/article/1413689
4.3.2将链表转换为红黑树的treeifyBin方法
节点添加完成之后判断此时节点个数是否大于TREEIFY_THRESHOLD临界值8,如果大于则将链表转换为红黑树,转换红黑树的方法 treeifyBin,整体代码如下:
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st //转换为红黑树 tab表示数组名 hash表示哈希值 treeifyBin(tab, hash);
treeifyBin方法如下所示:
/** * Replaces all linked nodes in bin at index for given hash unless * table is too small, in which case resizes instead. 替换指定哈希表的索引处桶中的所有链接节点,除非表太小,否则将修改大小。 Node<K,V>[] tab = tab 数组名 int hash = hash表示哈希值 */ final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; /* 如果当前数组为空或者数组的长度小于进行树形化的阈值(MIN_TREEIFY_CAPACITY = 64), 就去扩容。而不是将节点变为红黑树。 目的:如果数组很小,那么转换红黑树,然后遍历效率要低一些。这时进行扩容,那么重新计算哈希值 ,链表长度有可能就变短了,数据会放到数组中,这样相对来说效率高一些。 */ if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) //扩容方法 resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { /* 1)执行到这里说明哈希表中的数组长度大于阈值64,开始进行树形化 2)e = tab[index = (n - 1) & hash]表示将数组中的元素取出赋值给e,e是哈希表中指定位 置桶里的链表节点,从第一个开始 */ //hd:红黑树的头结点 tl :红黑树的尾结点 TreeNode<K,V> hd = null, tl = null; do { //新创建一个树的节点,内容和当前链表节点e一致 TreeNode<K,V> p = replacementTreeNode(e, null); if (tl == null) //将新创键的p节点赋值给红黑树的头结点 hd = p; else { /* p.prev = tl:将上一个节点p赋值给现在的p的前一个节点 tl.next = p;将现在节点p作为树的尾结点的下一个节点 */ p.prev = tl; tl.next = p; } tl = p; /* e = e.next 将当前节点的下一个节点赋值给e,如果下一个节点不等于null 则回到上面继续取出链表中节点转换为红黑树 */ } while ((e = e.next) != null); /* 让桶中的第一个元素即数组中的元素指向新建的红黑树的节点,以后这个桶里的元素就是红黑树 而不是链表数据结构了 */ if ((tab[index] = hd) != null) hd.treeify(tab); } }
小结:上述操作一共做了如下几件事:
1.根据哈希表中元素个数确定是扩容还是树形化
2.如果是树形化遍历桶中的元素,创建相同个数的树形节点,复制内容,建立起联系
3.然后让桶中的第一个元素指向新创建的树根节点,替换桶的链表内容为树形化内容
4.3.3扩容方法_resize
4.3.3.1扩容机制
想要了解HashMap的扩容机制你要有这两个问题
- 1.什么时候才需要扩容
- 2.HashMap的扩容是什么
1.什么时候才需要扩容
当HashMap中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,就会进行数组扩容,loadFactor的默认值(DEFAULT_LOAD_FACTOR)是0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中的元素个数超过16×0.75=12(这个值就是阈值或者边界值threshold值)的时候,就把数组的大小扩展为2×16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预知元素的个数能够有效的提高HashMap的性能。
补充:
当HashMap中的其中一个链表的对象个数如果达到了8个,此时如果数组长度没有达到64,那么HashMap会先扩容解决,如果已经达到了64,那么这个链表会变成红黑树,节点类型由Node变成TreeNode类型。当然,如果映射关系被移除后,下次执行resize方法时判断树的节点个数低于6,也会再把树转换为链表。
2.HashMap的扩容是什么
进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。在编写程序中,要尽量避免resize。
HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n-1)&hash的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。
怎么理解呢?例如我们从16扩展为32时,具体的变化如下所示:
因此元素在重新计算hash之后,因为n变为2倍,那么n-1的标记范围在高位多1bit(红色),因此新的index就会发生这样的变化:
说明:5是假设计算出来的原来的索引。这样就验证了上述所描述的:扩容之后所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。
因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就可以了,是0的话索引没变,是1的话索引变成“原索引+oldCap(原位置+旧容量)”。可以看看下图为16扩充为32的resize示意图:
正是因为这样巧妙的rehash方式,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,在resize的过程中保证了rehash之后每个桶上的节点数一定小于等于原来桶上的节点数,保证了rehash之后不会出现更严重的hash冲突,均匀的把之前的冲突的节点分散到新的桶中了。
4.3.3.2源码resize方法的解读
下面是代码的具体实现:
final Node<K,V>[] resize() { //得到当前数组 Node<K,V>[] oldTab = table; //如果当前数组等于null长度返回0,否则返回当前数组的长度 int oldCap = (oldTab == null) ? 0 : oldTab.length; //当前阀值点 默认是12(16*0.75) int oldThr = threshold; int newCap, newThr = 0; //如果老的数组长度大于0 //开始计算扩容后的大小 if (oldCap > 0) { // 超过最大值就不再扩充了,就只好随你碰撞去吧 if (oldCap >= MAXIMUM_CAPACITY) { //修改阈值为int的最大值 threshold = Integer.MAX_VALUE; return oldTab; } /* 没超过最大值,就扩充为原来的2倍 1)(newCap = oldCap << 1) < MAXIMUM_CAPACITY 扩大到2倍之后容量要小于最大容量 2)oldCap >= DEFAULT_INITIAL_CAPACITY 原数组长度大于等于数组初始化长度16 */ else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //阈值扩大一倍 newThr = oldThr << 1; // double threshold } //老阈值点大于0 直接赋值 else if (oldThr > 0) // 老阈值赋值给新的数组长度 newCap = oldThr; else {// 直接使用默认值 newCap = DEFAULT_INITIAL_CAPACITY;//16 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 计算新的resize最大上限 if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } //新的阀值 默认原来是12 乘以2之后变为24 threshold = newThr; //创建新的哈希表 @SuppressWarnings({"rawtypes","unchecked"}) //newCap是新的数组长度--》32 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; //判断旧数组是否等于空 if (oldTab != null) { // 把每个bucket都移动到新的buckets中 //遍历旧的哈希表的每个桶,重新计算桶里元素的新位置 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { //原来的数据赋值为null 便于GC回收 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 { // 采用链表处理冲突 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; //通过上述讲解的原理来计算节点的新位置 do { // 原索引 next = e.next; //这里来判断如果等于true e这个节点在resize之后不需要移动位置 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); // 原索引放到bucket里 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 原索引+oldCap放到bucket里 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
4.3.4 删除方法(remove)
理解了put方法之后,remove方法已经没什么难度了,所以重复的内容就不再做详细介绍了。
删除的话就是首先先找到元素的位置,如果是链表就遍历链表找到元素之后删除。如果是用红黑树就遍历树然后找到之后做删除,树小于6的时候要转链表。
删除remove方法:
//remove方法的具体实现在removeNode方法中,所以我们重点看下removeNode方法 public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; }
removeNode方法:
final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { Node<K,V>[] tab; Node<K,V> p; int n, index; //根据hash找到位置 //如果当前key映射到的桶不为空 if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { Node<K,V> node = null, e; K k; V v; //如果桶上的节点就是要找的key,则将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 { //判断是否以链表方式处理hash冲突,是的话则通过遍历链表来寻找要删除的节点 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; break; } p = e; } while ((e = e.next) != null); } } //比较找到的key的value和要删除的是否匹配 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; //记录修改次数 ++modCount; //变动的数量 --size; afterNodeRemoval(node); return node; } } return null; }
4.3.5查找元素方法(get)
查找方法,通过元素的Key找到Value。
代码如下:
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; }
get方法主要调用的是getNode方法,代码如下:
final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; //如果哈希表不为空并且key对应的桶上不为空 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) { // 判断是否是红黑树,是的话调用红黑树中的getTreeNode方法获取节点 if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { // 不是红黑树的话,那就是链表结构了,通过循环的方法判断链表中是否存在该key if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
小结:
1.get方法实现的步骤:
1)通过hash值获取该key映射到的桶
2)桶上的key就是要查找的key,则直接找到并返回
3)桶上的key不是要找的key,则查看后续的节点:
a:如果后续节点是红黑树节点,通过调用红黑树的方法根据key获取value
b:如果后续节点是链表节点,则通过循环遍历链表根据key获取value
2.上述红黑树节点调用的是getTreeNode方法通过树形节点的find方法进行查找:
final TreeNode<K,V> getTreeNode(int h, Object k) { return ((parent != null) ? root() : this).find(h, k, null); } final TreeNode<K,V> find(int h, Object k, Class<?> kc) { TreeNode<K,V> p = this; do { int ph, dir; K pk; TreeNode<K,V> pl = p.left, pr = p.right, q; if ((ph = p.hash) > h) p = pl; else if (ph < h) p = pr; else if ((pk = p.key) == k || (k != null && k.equals(pk))) return p;//找到之后直接返回 else if (pl == null) p = pr; else if (pr == null) p = pl; else if ((kc != null || (kc = comparableClassFor(k)) != null) && (dir = compareComparables(kc, k, pk)) != 0) p = (dir < 0) ? pl : pr; //递归查找 else if ((q = pr.find(h, k, kc)) != null) return q; else p = pl; } while (p != null); return null; }
3.查找红黑树,由于之前添加时已经保证这个树是有序的了,因此查找时基本就是折半查找,效率更高。
4.这里和插入时一样,如果对比节点的哈希值和要查找的哈希值相等,就会判断key是否相等,相等就直接返回。不相等就从子树中递归查找。
5.若为树,则在树中通过key.equals(k)查找,O(logn)
若为链表,则在链表中通过key.equals(k)查找,O(n)。
4.3.6遍历HashMap集合几种方式
1、分别遍历Key和Values
2、使用Iterator迭代器迭代
3、通过get方式(不建议使用)
说明:根据阿里开发手册,不建议使用这种方式,因为迭代两次。keySet获取Iterator一次,还有通过get又迭代一次。降低性能。
4.jdk8以后使用Map接口中的默认方法:
default void forEach(BiConsumer<? super K,? super V> action) BiConsumer接口中的方法: void accept(T t, U u) 对给定的参数执行此操作。 参数 t - 第一个输入参数 u - 第二个输入参数
遍历代码:
public class Demo02 { public static void main(String[] args) { HashMap<String,String> m1 = new HashMap(); m1.put("001", "zhangsan"); m1.put("002", "lisi"); m1.forEach((key,value)->{ System.out.println(key+"---"+value); }); } }
5.如何设计多个非重复的键值对要存储HashMap的初始化?
5.1HashMap的初始化问题描述
如果我们确切的知道我们有多少键值对需要存储,那么我们在初始化HashMap的时候就应该指定它的容量,以防止HashMap自动扩容,影响使用效率。
默认情况下HashMap的容量是16,但是,如果用户通过构造函数指定了一个数字作为容量,那么Hash会选择大于该数字的第一个2的幂作为容量。(3->4、7->8、9->16) .这点我们在上述已经进行过讲解。
《阿里巴巴Java开发手册》中建议我们设置HashMap的初始化容量。
那么,为什么要这么建议?你有想过没有。
当然,以上建议也是有理论支撑的。我们上面介绍过,HashMap的扩容机制,就是当达到扩容条件时会进行扩容。HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。在HashMap中,threshold = loadFactor * capacity。
所以,如果我们没有设置初始容量大小,随着元素的不断增加,HashMap会有可能发生多次扩容,而HashMap中的扩容机制决定了每次扩容都需要重建hash表,是非常影响性能的。
但是设置初始化容量,设置的数值不同也会影响性能,那么当我们已知HashMap中即将存放的KV个数的时候,容量设置成多少为好呢?
5.2HashMap中容量的初始化
当我们使用HashMap(int initialCapacity)来初始化容量的时候,jdk会默认帮我们计算一个相对合理的值当做初始容量。那么,是不是我们只需要把已知的HashMap中即将存放的元素个数直接传给initialCapacity就可以了呢?
关于这个值的设置,在《阿里巴巴Java开发手册》有以下建议:
也就是说,如果我们设置的默认值是7,经过Jdk处理之后,会被设置成8,但是,这个HashMap在元素个数达到 8*0.75 = 6的时候就会进行一次扩容,这明显是我们不希望见到的。我们应该尽量减少扩容。原因也已经分析过。
如果我们通过initialCapacity/ 0.75F + 1.0F计算,7/0.75 + 1 = 10 ,10经过Jdk处理之后,会被设置成16,这就大大的减少了扩容的几率。
当HashMap内部维护的哈希表的容量达到75%时(默认情况下),会触发rehash,而rehash的过程是比较耗费时间的。所以初始化容量要设置成initialCapacity/0.75 + 1的话,可以有效的减少冲突也可以减小误差。
所以,我可以认为,当我们明确知道HashMap中元素的个数的时候,把默认容量设置成initialCapacity/ 0.75F + 1.0F是一个在性能上相对好的选择,但是,同时也会牺牲些内存。
我们想要在代码中创建一个HashMap的时候,如果我们已知这个Map中即将存放的元素个数,给HashMap设置初始容量可以在一定程度上提升效率。
但是,JDK并不会直接拿用户传进来的数字当做默认容量,而是会进行一番运算,最终得到一个2的幂。原因也已经分析过。
但是,为了最大程度的避免扩容带来的性能消耗,我们建议可以把默认容量的数字设置成initialCapacity/ 0.75F + 1.0F。