上一个笔记中分析了HashMap的大概结构以及基本用法。这一次笔记就再深入了解一下底层的实现细节。我们首先从hash函数以及扩容过程讲起,然后再了解一下链表数据结构以及红黑树的实现。
hash函数
hash音译为哈希,学名称为散列,功能是将任意长度的输入通过散列函数变换为固定长度的输出。HashMap在Java7中被设计为“线性表+链表”的数据结构,在Java8中被设计为“线性表+链表/红黑树”的数据结构。在HashMap中以散列码作为节点的位置标识,不同散列码被映射为线性表的索引。
散列码的空间是有限的,而输入空间可能是无限的,因此计算索引的过程实际上是压缩的过程,将大空间的输入映射到小空间,这样一来就不可避免的发生散列冲突,也就是可能出现两个不同的输入映射为相同的散列码,或者不同的散列码映射为相同的索引。
在HashMap中往往数组的大小有限,初始HashMap的table长度仅为16,因此很容易会发生散列冲突。这时就需要解决散列冲突,一般来说解决散列冲突的方法有以下几种:
- 开放定址法:发生散列冲突之后,寻找下一个散列地址,也就是加一操作,只要散列空间足够大,就能寻找到空的位置。
- 链地址法:将table的每个节点作为链表的头节点,发生散列碰撞之后,可以将新的元素插入到链表的尾部
- 再哈希法:出现散列碰撞之后,用定义好的第二个哈希函数再次计算散列,直到不发生冲突。
- 建立公共溢出区:将哈希表分为基本表和溢出表,发生散列冲突的元素全部存入溢出表中。
HashMap采用的是
链地址法,在Java7以前采用的是链表结构,在Java8中为了提高索引的效率,引入了红黑树的数据结构,当链表增长到一定长度时转换链表为红黑树。当然,一方面我们引入处理散列冲突的方法,提高散列冲突处理下数据结构的索引效率,另一方面我们还要想办法减少散列冲突的发生。在HashMap中,一般是将散列值除以数组长度,取余数为下标,这个方法也可以写成按位&操作 。
// 下面两种运算都是利用散列值对数组长度取余,按位与操作基于内存,效率更高 length % n (length-1) & n
在Java8中,为了更方便应用按位操作,数组长度往往都是2的幂次,因此可能会出现如下的情况,只要低位保持一致,则无论高位如何变化,最终的索引都是一样的。这样就形成了周期规律,与散列的原则不符。
0000 0000 0000 0000 0000 0000 0001 0011 n1=19 0000 0000 0000 0000 0011 1111 1111 1111 length-1 0000 0000 0000 0000 0000 0000 0001 0011 19 = 19 % length 0100 1000 0000 0000 0000 0000 0001 0011 n2 0000 0000 0000 0000 0000 0000 0001 1111 31 0000 0000 0000 0000 0000 0000 0001 0011 19 = n2 % length
于是HashMap中引入了hash方法,对hashCode进行扰动,打破周期规律,操作方法是将原hashCode右移16位,然后进行或运算,这个操作是将高16位与低16位进行或运算,然后将高16位置零,这样一来,高位数据的影响就可以引入到哈希码的计算中,也能打破周期规律。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
0000 0000 0000 0000 0000 0000 0001 0011 n1=19 0000 0000 0000 0000 0000 0000 0000 0000 n1>>>16 0000 0000 0000 0000 0000 0000 0001 0011 n1 ^ (n1>>>16) 0000 0000 0000 0000 0011 1111 1111 1111 length-1 0000 0000 0000 0000 0000 0000 0001 0011 19 = n1 % length 0100 1000 0000 0000 0000 0000 0001 0011 n2 0000 0000 0000 0000 0100 1000 0000 0000 n2>>>16 0100 1000 0000 0000 0100 1000 0001 0011 n2 ^ (n2>>>16) 0000 0000 0000 0000 0111 1111 1111 1111 length-1 0000 0000 0000 0000 0100 1000 0001 0011 不再是19
扩容过程
当HashMap存储元素超过一定容量时,就会调用resize方法进行扩容。首先我们看下触发扩容的条件:
// putMapEntries /* s为待插入的Map集合的size,当待添加的元素个数超过阈值,则开始扩容 */ else if (s > threshold) resize(); // putVal /* 当table为null或者table的长度为零,这时需要通过resize进行初始化 */ if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; /* 添加元素之后,若size超过阈值,则开始扩容 */ if (++size > threshold) resize(); // treeifyBin /* 在树化操作里,若table为null或者table的长度小于最小树化容量,则开始扩容 */ if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); // computeIfAbsent /* 若size超过阈值或者table为null或者table长度为0,则进行扩容或者初始化 */ if (size > threshold || (tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // compute /* 若size超过阈值或者table为null或者table长度为0,则进行扩容或者初始化 */ if (size > threshold || (tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // merge /* 若size超过阈值或者table为null或者table长度为0,则进行扩容或者初始化 */ if (size > threshold || (tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
由上述调用resize的源码可以看出,启动扩容的情况有如下几种:
- table未初始化,即table为null,或者长度为0
- size大于阈值,size是指存储的元素个数,而非table的长度
- 树化操作前,table的长度小于最小树化容量
那么HashMap的扩容过程是如何进行的呢?且看resize方法的源码
final Node<K,V>[] resize() { // 定义局部变量存储旧table,旧容量,旧阈值 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) // 新容量 = 2 * 旧容量, 且保证新容量小于最大值,并且旧容量大于16 // 新阈值 = 2 * 旧阈值 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); } 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"}) // 初始化新table Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 将新table赋值给table table = newTab; /* 进行数据迁移 */ if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { // 遍历旧table Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; // 旧表置为null if (e.next == null) // 若table中该位置只有一个节点,无链表或者树 // 则将该节点按新索引迁移至新表 newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) // 若table中该位置节点为树节点 // 则对树进行操作,调用split方法将树的数据进行迁移 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // preserve order // 若table中该位置节点为链表头节点 // 则遍历链表,将链表进行拆分并迁移到新的table中 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; } } } } } return newTab; }
这里关于链表拆分的代码很有意思,单独拎出来看一下
// 定义两个新的链表,名为lo链表、hi链表。用于存放拆分后的链表 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) { // 如果e.hash & oldCap == 0, 将节点添加进lo链表 if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { // 将节点添加进hi链表 if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 如果lo链表非空 // 将lo链表添加到new table中 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 如果hi链表非空 // 将hi链表添加到new table中 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }
上述代码分析很简单,具体的步骤就是将一个链表拆分成两个链表,然后分别放置在新table中。拆分出来的两个链表中,lo链表头部节点在table中的位置不变,hi链表头部节点在table中的位置后移oldCap位。这一点设计的十分巧妙。
另外,需要注意的是,HashMap中所指的容量均为线性表的长度,而size指的才是元素的个数。
newCap = oldCap << 1 oldCap 010000 2^4 newCap 100000 2^5 索引运算公式 index = hash & (Cap-1) oldIndex hash & 001111 newIndex hash & 011111 两者的区别在于第4位 也就是说如果hash值第4位为0,则newIndex = oldIndex 如果第4位为1, 则newIndex = oldIndex + 2^4 = oldIndex + oldCap 所以我们通过 hash & oldCap(010000) 来判断第4位是否为零
划分示意图如下:
图片来源:https://segmentfault.com/a/1190000015812438
扫描二维码关注公众号,回复: 6560344 查看本文章