java集合扩容理解_Java中集合的扩容策略及实现

HashMap

下面来看另一个熟人--HashMap。不同于上面两个List的实现类,HashMap是一个采用哈希表实现的键值对集合,继承自AbstractMap,实现了Map接口并使用拉链法解决Hash冲突,其内部储存的元素并不是在连续内存地址的,并且是无序的。此处我们只关心其扩容操作的逻辑和实现,先说一下,由于要重新创建数组,rehash,重新分配元素位置等,HashMap扩容的开销要比List大很多。下面介绍几个和扩容相关的成员变量://哈希表中的数组,JDK 1.8之前存放各个链表的表头。1.8中由于引入了红黑树,则也有可能存的是树的根

transient Node[] table;    //默认初始容量:16,必须是2的整数次方。这样规定是因为在通过key来确定元素在table中的index时

//所用的算法为:index = (n - 1) & hash,其中n即为table容量。保证n是2的整数次方就能保证n-1的低位均为1,

//这样便能保留hash(key)得到的hash值的所有低位,从而保证得到的index在n范围内分布均匀,因为hash算法的结果就是均匀的

static final int DEFAULT_INITIAL_CAPACITY = 1 <

//默认加载因子为: 0.75,这是在时间、空间两方面均衡考虑下的结果。

//这个值过大会导致发生冲突的几率增加,容易形成长链表,降低查找效率;太小则会导致频繁的扩容,降低整体性能。

static final float DEFAULT_LOAD_FACTOR = 0.75f;

//阈值,下次需要扩容时的值,等于 容量*加载因子

int threshold;

//最大容量: 2^30次方

static final int MAXIMUM_CAPACITY = 1 <

//树化阈值。JDK 1.8后HashMap对冲突处理做了优化,引入了红黑树。

//当桶中元素个数大于TREEIFY_THRESHOLD时,就需要用红黑树代替链表,以提高操作效率。此值必须大于2,并建议大于8

static final int TREEIFY_THRESHOLD = 8;

//非树化阈值。在进行扩容操作时,桶中的元素可能会减少,这很好理解,因为在JDK1.7中,

//每一个元素的位置需要通过key.hash和新的数组长度取模来重新计算,而1.8中则会直接将其分为两部分。

//并且在1.8中,对于已经是树形的桶,会做一个split操作(具体实现下面会说),在此过程中,

//若剩下的树元素个数少于UNTREEIFY_THRESHOLD,则需要将其非树化,重新变回链表结构。

//此值应小于TREEIFY_THRESHOLD,且规定最大值为6

static final int UNTREEIFY_THRESHOLD = 6;

好了,相关变量介绍完了,接下来开始分析HashMap的扩容函数resize,一个长得很讨厌的方法:final Node[] resize() {

Node[] oldTab = table;    //记录当前数组长度

int oldCap = (oldTab == null) ? 0 : oldTab.length;    //记录当前扩容阈值

int oldThr = threshold;    int newCap, newThr = 0;    //下面一长串if-else是为了确定newCap和newThr,即新的容量和扩容阈值

if (oldCap > 0) {        //oldCap不为0,已被初始化过

if (oldCap >= MAXIMUM_CAPACITY) {            //当前已经是最大容量,不允许再扩容,返回当前哈希表

threshold = Integer.MAX_VALUE;            return oldTab;

}        else if ((newCap = oldCap <

oldCap >= DEFAULT_INITIAL_CAPACITY)            //先将oldCap翻倍,如果得到的值小于最大容量,并且oldCap不小于默认初始值,则将扩容阈值也翻倍,结束

newThr = oldThr <

}    else if (oldThr > 0)

// initial capacity was placed in threshold

// 若构造函数中有传入initialCapacity,则会暂存在oldThr=threshold变量中。

// 然后在第一次put操作导致的resize方法中被赋给newCap,这样做的目的应该是避免污染oldCap从而影响上面那个if的判断

// 从这里也可以看出HashMap对于所需内存的申请是被延迟到第一次put操作时进行的,而非在构造函数中。

newCap = oldThr;    else {

// zero initial threshold signifies using defaults

// Map没有被初始化,用默认值初始化

newCap = DEFAULT_INITIAL_CAPACITY;

newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

}    if (newThr == 0) {        //若经过计算后,新阈值为0,则赋值为新容量和扩容因子的乘积(需考虑边界条件)

float ft = (float)newCap * loadFactor;

newThr = (newCap 

(int)ft : Integer.MAX_VALUE);

}

threshold = newThr;

/******-----分割线,至此新的容量和扩容因子已确定------*************/

@SuppressWarnings({"rawtypes","unchecked"})    //创建一个大小为newCap的Node数组,并赋值给table变量

Node[] newTab = (Node[])new Node[newCap];

table = newTab;    if (oldTab != null) {        //遍历扩容前的数组

for (int j = 0; j 

Node e;            if ((e = oldTab[j]) != null) {                //将原数组中第j个元素赋给e,并将原数组第j位置置空

oldTab[j] = null;                if (e.next == null)                    //该元素没有后续节点,即该位置未发生过hash冲突。则直接将该元素的hash值与新数组的长度取模得到新位置并放入

newTab[e.hash & (newCap - 1)] = e;                else if (e instanceof TreeNode)                    //JDK1.8中,如果该元素是一个树节点,说明该位置存放的是一颗红黑树,则需要对该树进行分解操作

//具体实现后面会讨论,这里split的结果就是分为两棵树(这里必要时要进行非树化操作)并分别放在新数组的高段和低段

((TreeNode)e).split(this, newTab, j, oldCap);                else { // preserve order

//剩下这种情况就是该位置存放的是一个链表,需要说明的是在JDK1.7和1.8中这里有着不同的实现,下面分别讨论

/******--- JDK 1.7版本 starts ---*******/

//遍历链表

while(null != e) {                        //将原链表中e的下一个元素暂存到next变量中

HashMapEntry next = e.next;                        //算出在新数组的index=i,indexFor其实就是e.hash & (newCapacity - 1)

int i = indexFor(e.hash, newCapacity);                        //改变e.next的指向,将新数组该位置上原先的内容(一个链表,元素或是null)挂在e的身后,使e成为这个链表的表头

e.next = newTable[i];                        //将这个e位表头的新链表放回到index为i的位置中

newTable[i] = e;                        //将之前暂存的原链表中的下一个元素赋给e,继续遍历原链表

e = next;

}                    /******--- JDK 1.7版本 ends ---*******/

/******--- JDK 1.8版本 starts ---*******/

//在1.8的实现中,新数组被分成了高低两个段,而原链表也会被分成两个子链表,分别放入新数组的高段和低段中

//loHead和loTail用于生成将被放入新数组低段的子链表

Node loHead = null, loTail = null;                    //hiHead和hiTail则用于生成将被放入新数组高段的子链表

Node hiHead = null, hiTail = null;                    //跟1.7中一样,next用于暂存原链表中e的下一个元素

Node next;                    //开始遍历原链表

do {

next = e.next;                        //用if中的方法确定e是该去新数组的高段还是低段

if ((e.hash & oldCap) == 0) {                            //将e加到将被放入低段的子链表的尾部

if (loTail == null)

loHead = e;                            else

loTail.next = e;

loTail = e;

}                        else {                            //将e加到将被放入高段的子链表的尾部

if (hiTail == null)

hiHead = e;                            else

hiTail.next = e;

hiTail = e;

}

} while ((e = next) != null);

if (loTail != null) {                        //将loHead指向的子链表放入新数组中index=j的位置

loTail.next = null;

newTab[j] = loHead;

}                    if (hiTail != null) {                        将hiHead指向的子链表放入新数组中index=j+oldCap的位置

hiTail.next = null;

newTab[j + oldCap] = hiHead;

}                    /******--- JDK 1.8版本 ends ---*******/

}

}

}

}    return newTab;

}

可以看到JDK1.8对resize方法进行了彻底的改造,引入红黑树结合之前的链表势必会提高在发生hash冲突时的操作效率(红黑树能保证在最坏情况下插入,删除,查找的时间复杂度都为O(logN))。

此外最大的改动便是在扩容的时候对链表或树的处理,在1.7时代,链表中的每一个元素都会被重新计算在新数组中的index,具体方法仍旧是e.hash对新数组长度做取模操作;而在1.8时代,这个链表或树会被分为两部分,我们暂且称其为A和B,若元素的hash值按位与扩容前数组的长度得到的结果为0(其实就是判断hash的某一位是1还是0,由于hash值均匀分布的特性,这个分裂基本可以认为是均匀的),则将其接入A,反之接入B。最后保持A的位置不变,即在新数组中仍位于原先的index=j处,而B则去到j+oldCap处。

其实对于这个改动带来的好处我理解的不是特别透彻,因为整个过程并没有减少计算的次数。目前看到的好处是可以避免扩容重定向过程中发生哈希冲突(因为是扩容一倍,所以一个萝卜一个坑,不会有冲突),并且不会将链表中的元素倒置(考虑极端情况,就一条链表,1.7的方法每次都会将元素插到表头)。这里还是得求教大家,欢迎讨论~

回到resize方法,上面还留了一个尾巴,就是当桶中是树形结构时的split方法,下面就来看源码:/**

* Splits nodes in a tree bin into lower and upper tree bins,

* or untreeifies if now too small. Called only from resize;

* see above discussion about split bits and indices.

*

* @param map the map

* @param tab the table for recording bin heads

* @param index the index of the table being split

* @param bit the bit of hash to split on

*/

final void split(HashMap map, Node[] tab, int index, int bit) {

TreeNode节点继承自LinkedHashMapEntry,在链表节点的基础上扩充了树节点的功能,譬如left,right,parent

TreeNode b = this;        // Relink into lo and hi lists, preserving order

// 将树分为两部分这里的做法和链表结构时是相似的

TreeNode loHead = null, loTail = null;

TreeNode hiHead = null, hiTail = null;        int lc = 0, hc = 0;        //遍历整棵树,这里说明一下,由于这棵树是由链表treeify生成的,其next指针依旧存在并指向之前链表中的后继节点,

//因此遍历时依然可以按照遍历链表的方式来进行

for (TreeNode e = b, next; e != null; e = next) {            //暂存next

next = (TreeNode)e.next;            //将e从链表中切断

e.next = null;            //与对链表的处理相同,若e.hash按位与bit=oldCap结果为0,则接到低段组的尾部

if ((e.hash & bit) == 0) {                if ((e.prev = loTail) == null)

loHead = e;                else

loTail.next = e;

loTail = e;

++lc;

}            //否则接到高段组的尾部

else {                if ((e.prev = hiTail) == null)

hiHead = e;                else

hiTail.next = e;

hiTail = e;

++hc;

}

}        //若低段不为空

if (loHead != null) {            //如果低段子树的元素个数小于非树化阈值,则将该树进行非树化,还原为链表

if (lc <= UNTREEIFY_THRESHOLD)

tab[index] = loHead.untreeify(map);            else {                //否则的话将低段子树按照原本在旧数组中的index放入新数组中

tab[index] = loHead;                //对低段子树进行树化调整。这里有一个优化,如果发现高段子树为空,则说明之前树中的所有元素都被放到了低段子树中,

//也即这已经是一棵完整的,调整好了的红黑树,不需要再进行树化调整

if (hiHead != null) // (else is already treeified)

loHead.treeify(tab);

}

}        if (hiHead != null) {            //与低段子树同样的逻辑,放入新数组的位置为旧数组的index+oldCap

if (hc <= UNTREEIFY_THRESHOLD)

tab[index + bit] = hiHead.untreeify(map);            else {

tab[index + bit] = hiHead;                //这里同样的,如果低段子树为空,说明高段这棵树已经是一棵完整的红黑树,无需调整

if (loHead != null)

hiHead.treeify(tab);

}

}

}

注:由于篇幅所限,红黑树的treeify树化操作和untreeify非树化操作将在另一篇关于红黑树的文章中单独进行说明,在此大家只需理解treeify做的事情是将一个链表中的TreeNode节点,按照二叉查找树的结构连接其left,right和parent指针,并根据红黑树的规则进行调整,同时也可以对一个非红黑树的树结构进行调整;而untreeify反之,是将一个红黑树的TreeNode节点还原为HashMap.Node节点,并将其首尾相连还原为一个链表结构。

至此,HashMap的扩容逻辑和实现就分析完成了,可以看到1.7中的逻辑和做法还比较简单粗暴,而到了1.8中由于红黑树的引入,整体变得精巧了许多,整体HashMap的操作性能也有了大的提升。但即便如此,HashMap的扩容依旧是一个很贵的操作,这就要求我们在初始化HashMap的时候根据自己的业务场景设置尽可能合适的初始容量,以降低扩容发生的几率。例如我需要一个容纳96个元素的map,那么只要我把capacity初始值设置为128,那么就不会经历16到32到64再到128的三次扩容,这样来说是节省内存和运算成本的。当然如果需要容纳97个元素的话,因为超过了capacity值的3/4,所以就需要设置为256了,否则也会经历一次扩容。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值