转 Java中集合的扩容策略及实现的对比分析(ArrayList,Vector,HashMap,ArrayMap,SparseArray源码)

个人理解:ArrayList和Vector用数组存储,调用私有的grow方法扩容,最终落地到Arrays.copyOf()方法中

                 HashMap使用链地址法解决hash冲突,数组+链表储存,当链表容量大到门限值改用红黑树存储进行树形转换,门限值=负载因子0.75×桶容量




版权声明:原创不易,转载前请留言获得作者许可,转载后标明作者 Troy.Tang 与 原文链接。

转载自: https://blog.csdn.net/Troy_kfrozen/article/details/78889947  

侵删。




本文将从源码角度来分析和对比一下集合扩容相关的知识,涉及到的集合框架有:ArrayList,Vector,HashMap,ArrayMap,SparseArray。下面先从ArrayList开始。

  • ArrayList

    ArrayList是以数组实现的一个集合类,在ArrayList的源码中可以看到,所有元素都是被储存在elementData这个全局的数组变量中,而所谓的扩容也是针对这个数组对象进行操作。具体来说,当一个添加元素的动作,即add或addAll被执行时,都会先调用ensureCapacityInternal(…)方法进行容量预检,如果当前elementData数组的容量不足以完成本次添加操作便会进行自动扩容。该方法代码如下:

        //这是一个私有方法,ArrayList提供了另一个public的扩容方法ensureCapacity以满足外界手动扩容的需求
        //其实质也是调用了本方法,在此不做累述
        //minCapacity是指本次添加操作后所需要的数组容量,即elementData.length + newSize
        private void ensureCapacityInternal(int minCapacity) {
                //这个if判断如果成立,则代表当前ArrayList为空,而本次添加操作是第一次添加。
                //此时需要将ArrayList的容量扩充至DEFAULT_CAPACITY=10和本次添加操作所要求的minCapacity二者中的较大者。
                if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
                    minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
                }
    
                ensureExplicitCapacity(minCapacity);
        }
    
        private void ensureExplicitCapacity(int minCapacity) {
            //操作计数器+1
            modCount++;
    
            // overflow-conscious code
            // 确保需要进行扩容,即当前数组的容量小于本次添加操作所要求的新的总容量
            if (minCapacity - elementData.length > 0)
                grow(minCapacity);
        }

    在ensureCapacityInternal方法中,一开始会对本次添加操作是否为第一次添加进行判断。从源码:private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; 中可以得知,DEFAULTCAPACITY_EMPTY_ELEMENTDATA变量指代的是一个空的对象数组,这也是通过无参构造函数new出来的ArrayList对象的初始状态,也即 elementData = {};这种情况下的第一次扩容,如果所要求的新容量小于10,则会直接扩容至10。

    下面继续看到grow方法,这个方法是ArrayList扩容操作的实现

     private void grow(int minCapacity) {
            // overflow-conscious code
            //记录当前elementData数组的容量
            int oldCapacity = elementData.length;
            //新的容量暂定为当前容量的1.5倍
            int newCapacity = oldCapacity + (oldCapacity >> 1);
            //如果1.5倍容量仍不满足minCapacity所要求的,则直接将容量定为minCapacity
            if (newCapacity - minCapacity < 0)
                newCapacity = minCapacity;
            //如果新的容量超过了Integer.MAX_VALUE - 8,则做最大化处理
            if (newCapacity - MAX_ARRAY_SIZE > 0)
                newCapacity = hugeCapacity(minCapacity);
            // minCapacity is usually close to size, so this is a win:
            //将当前数组copy到新的数组中
            elementData = Arrays.copyOf(elementData, newCapacity);
        }
    
        private static int hugeCapacity(int minCapacity) {
            if (minCapacity < 0) // overflow
                throw new OutOfMemoryError();
            return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;
        }

    最后的扩容操作,Arrays.copyOf方法会新创建一个大小为newCapacity的数组,然后通过System.arraycopy方法将之前elementData数组中的元素复制到新数组中(起点的index为0),并将新数组返回给变量elementData完成扩容。

  • Vector

    Vector和ArrayList其实身出同门,都是AbstractList的子类并且都实现了List接口,所提供的功能也基本相同,最大的不同在于Vector所有的公有API都是加锁的,也即Vector是线程安全的。但这也正是它不受欢迎的原因之一,太重了。。。言归正传,我们来关心一下Vector的扩容操作。其实基本跟ArrayList相同,方法如下:

        private void ensureCapacityHelper(int minCapacity) {
            // overflow-conscious code
            //当前容量不够,扩之
            if (minCapacity - elementData.length > 0)
                grow(minCapacity);
        }

    可以看到,基本只是方法名不一样而已,再来看看Vector的grow函数,这里还真有些不同:

    private void grow(int minCapacity) {
            // overflow-conscious code
            int oldCapacity = elementData.length;
    
            //不同之处在这里,MARK
            int newCapacity = oldCapacity + ((capacityIncrement > 0) ? capacityIncrement : oldCapacity);
    
            if (newCapacity - minCapacity < 0)
                newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
                newCapacity = hugeCapacity(minCapacity);
            elementData = Arrays.copyOf(elementData, newCapacity);
        }

    上面代码块中MARK出的那一句便是不同之处,可以看出Vector的扩容是先判断有没有大于0的capacityIncrement,该变量是通过:

        public Vector(int initialCapacity, int capacityIncrement) {
            super();
            if (initialCapacity < 0)
                throw new IllegalArgumentException("Illegal Capacity: "+initialCapacity);
            this.elementData = new Object[initialCapacity];
            this.capacityIncrement = capacityIncrement;
        }

    这个带参构造函数传入的,如果不调用这个构造函数,则capacityIncrement为0。在capacityIncrement不为0的情况下,则每次扩容都暂定扩大capacityIncrement个,反之扩容oldCapacity个,即当前容量的一倍。后续的扩容操作和ArrayList一致,也是通过Arrays.copyOf方法来完成,这里不再重复分析。

  • HashMap

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

    //哈希表中的数组,JDK 1.8之前存放各个链表的表头。1.8中由于引入了红黑树,则也有可能存的是树的根
        transient Node<K,V>[] 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 << 4; 
    
        //默认加载因子为: 0.75,这是在时间、空间两方面均衡考虑下的结果。
        //这个值过大会导致发生冲突的几率增加,容易形成长链表,降低查找效率;太小则会导致频繁的扩容,降低整体性能。
        static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
        //阈值,下次需要扩容时的值,等于 容量*加载因子
        int threshold;
    
        //最大容量: 2^30次方
        static final int MAXIMUM_CAPACITY = 1 << 30;
    
        //树化阈值。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<K,V>[] resize() {
        Node<K,V>[] 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 << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //先将oldCap翻倍,如果得到的值小于最大容量,并且oldCap不小于默认初始值,则将扩容阈值也翻倍,结束
                newThr = oldThr << 1; // double threshold
        }
        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 < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
    
        /******-----分割线,至此新的容量和扩容因子已确定------*************/
    
        @SuppressWarnings({"rawtypes","unchecked"})
        //创建一个大小为newCap的Node<K,V>数组,并赋值给table变量
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            //遍历扩容前的数组
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> 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<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        //剩下这种情况就是该位置存放的是一个链表,需要说明的是在JDK1.7和1.8中这里有着不同的实现,下面分别讨论
    
                        /******--- JDK 1.7版本 starts ---*******/
                        //遍历链表
                        while(null != e) {
                            //将原链表中e的下一个元素暂存到next变量中
                            HashMapEntry<K,V> 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<K,V> loHead = null, loTail = null;
                        //hiHead和hiTail则用于生成将被放入新数组高段的子链表
                        Node<K,V> hiHead = null, hiTail = null;
                        //跟1.7中一样,next用于暂存原链表中e的下一个元素
                        Node<K,V> 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<K,V> map, Node<K,V>[] tab, int index, int bit) {
            TreeNode节点继承自LinkedHashMapEntry,在链表节点的基础上扩充了树节点的功能,譬如left,right,parent
            TreeNode<K,V> b = this;
            // Relink into lo and hi lists, preserving order
            // 将树分为两部分这里的做法和链表结构时是相似的
            TreeNode<K,V> loHead = null, loTail = null;
            TreeNode<K,V> hiHead = null, hiTail = null;
            int lc = 0, hc = 0;
            //遍历整棵树,这里说明一下,由于这棵树是由链表treeify生成的,其next指针依旧存在并指向之前链表中的后继节点,
            //因此遍历时依然可以按照遍历链表的方式来进行
            for (TreeNode<K,V> e = b, next; e != null; e = next) {
                //暂存next
                next = (TreeNode<K,V>)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了,否则也会经历一次扩容。

    • ArrayMap

      ArrayMap是一个(key,value)映射的数据结构,它设计上更多的是考虑内存的优化,内部是使用两个数组进行数据存储,一个数组记录key的hash值,另外一个数组记录Value值。它会对key使用二分法进行从小到大排序,在添加、删除、查找数据的时候都是先使用二分查找法得到相应的index,然后通过index来进行添加、查找、删除等操作。相比于HashMap,它更适合在数据量不是很大的情况下(千级)使用,有点用时间换空间的意思,如果在数据量比较大的情况下,那么它的性能将退化至少50%。

      下面还是先来看看跟ArrayMap扩容相关的成员变量:

       //最小扩容容量,即一次扩容最少扩大4个
              private static final int BASE_SIZE = 4;
      
              //第一个数组,存放所有key的hash值,这个数组的容量就是ArrayMap当前的容量
              int[] mHashes;
      
              //第二个数组,容量是mHashes的两倍,元素的key和value被相邻存放,即一个元素占两格
              Object[] mArray;
      
              //ArrayMap中当前元素的个数,也即mHashes数组中元素的个数
              int mSize;
      
              //小号缓存数组,数组长度为8,其中index=0位置存放的是指向下一个缓存数组index=0位置的指针,
              //即该位置存储的其实是一个链表,链表元素为一个一个的mBaseCache数组,其通过index=0位置相连
              //index=1位置存放的是一个长度为4的数组,供mHashes使用
              static Object[] mBaseCache;
      
              //小号缓存数组中index=0的位置存放的链表的长度
              static int mBaseCacheSize;
      
              //大号缓存数组,数组长度为16,其中index=0位置存放的是指向下一个缓存数组index=0位置的指针,
              //即该位置存储的其实是一个链表,链表元素为一个一个的mTwiceBaseCache数组,其通过index=0位置相连
              //index=1位置存放的是一个长度为8的数组,供mHashes使用
              static Object[] mTwiceBaseCache;
      
              //大号缓存数组中index=0的位置存放的链表的长度
              static int mTwiceBaseCacheSize;
      
              //缓存数组中的链表的最大长度,不能超过10
              private static final int CACHE_SIZE = 10;

      相关成员变量就是这些,先提一句,ArrayMap不同于HashMap,如果你在构造函数中指定了capacity,则构造函数会直接为mHashes和mArray两个数组申请内存,并不会延迟到第一次put。如果不指定capacity,则两个数组会被分别赋值为EmptyArray.INT和EmptyArray.OBJECT,即对于类型的空数组。下面我们来看扩容的实现:

      /**
              * Ensure the array map can hold at least <var>minimumCapacity</var>
              * items.
              */
              //这个方法在putAll中会被调用,传入的参数是mSize+array.size(),即当前个数与新put进来的集合个数的和。
              //这个方法并没有在put中被调用,但核心方法都是allocArrays(capacity),唯一的差别在于这个capacity的计算方法,
              //所以此处我们先用它来分析,至于计算capacity的区别后面会提到。
              //minimumCapacity这个输入参数表示新的操作需要这个容量来支撑,也即新的最小容量
              public void ensureCapacity(int minimumCapacity) {
                  //暂存扩容前的元素个数至osize
                  final int osize = mSize;
                  //若当前ArrayMap的容量小于所需的最小容量,则进行扩容
                  if (mHashes.length < minimumCapacity) {
                      //暂存扩容前的mHashes数组至ohashes
                      final int[] ohashes = mHashes;
                      //暂存扩容前的mArray数组至oarray
                      final Object[] oarray = mArray;
                      //申请内存,核心操作,后面单独分析
                      allocArrays(minimumCapacity);
                      //若ArrayMap中有元素
                      if (mSize > 0) {
                          //这里的mHashes是经过扩容后的空数组,这一句是将扩容前暂存的ohashes中的内容copy到新的mHashes中
                          System.arraycopy(ohashes, 0, mHashes, 0, osize);
                          //同理,这里的mArray是经过扩容后的空数组,这里将扩容前暂存的oarray中的内容copy到新的mArray中
                          System.arraycopy(oarray, 0, mArray, 0, osize<<1);
                      }
                      //善后工作,该缓存的缓存,该置空的置空
                      freeArrays(ohashes, oarray, osize);
                  }
                  if (CONCURRENT_MODIFICATION_EXCEPTIONS && mSize != osize) {
                      throw new ConcurrentModificationException();
                  }
              }

      下面继续看真正干活儿的allocArrays方法,上面说到的putAll方法会通过ensureCapacity来调用到这个方法,传入的size为mSize+array.size()。除此之外,构造函数中根据指定的capacity初始化数组的操作也是通过这个函数完成的,size自然就是指定的capacity。但是最常用到的还是put方法,即添加单个元素,put中调用allocArrays(size)时传入的size是这样算出来的:

      final int n = osize >= (BASE_SIZE*2) ? (osize+(osize>>1))
                      : (osize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE);

      翻译一下就是先判断mSize值是否大于等于8,如果是则n=mSize*1.5,如果否,则判断是否大于等于4,是则n=8个,否则n=4个。

       private void allocArrays(final int size) {
              if (mHashes == EMPTY_IMMUTABLE_INTS) {
                  throw new UnsupportedOperationException("ArrayMap is immutable");
              }
              if (size == (BASE_SIZE*2)) {
                  //线程安全,并且上的是类锁
                  synchronized (ArrayMap.class) {
                      //若要求的size是8,且大号缓存数组不为空,则从大号缓存数组中取缓存
                      if (mTwiceBaseCache != null) {
                          //将当前大号缓存数组赋给array,其容量为16,符合mArray的要求
                          final Object[] array = mTwiceBaseCache;
                          mArray = array;
                          //index=0的位置存放的是指向下一个大号缓存数组的指针,取出赋给mTwiceBaseCache变量,即第一层缓存被mArray取走了
                          mTwiceBaseCache = (Object[])array[0];
                          //第一层缓存的index=1的位置存放的是长度为8的数组,赋给mHashes
                          mHashes = (int[])array[1];
                          //将已经取出的第一层缓存数组的前两位都清空,这样一来便得到了长度分别为16和8的空的mArray和mHashes数组,好精巧的设计!
                          array[0] = array[1] = null;
                          //大号缓存被取走了一层,链表长度减一
                          mTwiceBaseCacheSize--;
                          if (DEBUG) Log.d(TAG, "Retrieving 2x cache " + mHashes
                                  + " now have " + mTwiceBaseCacheSize + " entries");
                          return;
                      }
                  }
              } else if (size == BASE_SIZE) {
                  //这边size=4,与上同理,只是操作的缓存对象变成了小号缓存数组
                  synchronized (ArrayMap.class) {
                      if (mBaseCache != null) {
                          final Object[] array = mBaseCache;
                          mArray = array;
                          mBaseCache = (Object[])array[0];
                          mHashes = (int[])array[1];
                          array[0] = array[1] = null;
                          mBaseCacheSize--;
                          if (DEBUG) Log.d(TAG, "Retrieving 1x cache " + mHashes
                                  + " now have " + mBaseCacheSize + " entries");
                          return;
                      }
                  }
              }
      
              //如果要求的size不是4或8,则不满足使用缓存的条件,直接按照要求的size新创建两个数组
              mHashes = new int[size];
              mArray = new Object[size<<1];
          }

      上面缓存的逻辑有点绕,还有一个缓存相关的地方需要分析一下,就是freeArrays这个方法,不要被它的名字迷惑了,它不光是做了回收内存,上面两个缓存数组的添加就是在这里完成的,下面来看一下:

      //调用时是这个样子的,三个参数分别为扩容前暂存的mHashes,mArrays和mSize
          freeArrays(ohashes, oarray, osize);
      
          private static void freeArrays(final int[] hashes, final Object[] array, final int size) {
              if (hashes.length == (BASE_SIZE*2)) {
                  synchronized (ArrayMap.class) {
                      //若需要回收的容量为8,并且大号缓存数组的链表长度还没到10
                      if (mTwiceBaseCacheSize < CACHE_SIZE) {
                          //直接将传入的array数组(显然,此处长度为16)作为新一层的缓存,index=0的位置指向现有的缓存数组
                          array[0] = mTwiceBaseCache;
                          //index=1的位置指向本次传入的hashes数组,也即将这个hashes缓存在此
                          array[1] = hashes;
                          //将后面14个位置都置空,因为已经无用,避免内存泄漏
                          for (int i=(size<<1)-1; i>=2; i--) {
                              array[i] = null; //for gc
                          }
                          //将这层新做好的缓存数组赋值给mTwiceBaseCache变量,下次allocArray中就能通过mTwiceBaseCache变量取到这一层缓存。这里缓存层也是后进先出,类似于栈。
                          mTwiceBaseCache = array;
                          //大号缓存数组的链表长度加1
                          mTwiceBaseCacheSize++;
                          if (DEBUG) Log.d(TAG, "Storing 2x cache " + array
                              + " now have " + mTwiceBaseCacheSize + " entries");
                      }
                  }
              } else if (hashes.length == BASE_SIZE) {
                  synchronized (ArrayMap.class) {
                      //这边size=4,与上同理,只是操作的缓存对象变成了小号缓存数组
                      if (mBaseCacheSize < CACHE_SIZE) {
                          array[0] = mBaseCache;
                          array[1] = hashes;
                          for (int i=(size<<1)-1; i>=2; i--) {
                              array[i] = null;
                          }
                          mBaseCache = array;
                          mBaseCacheSize++;
                          if (DEBUG) Log.d(TAG, "Storing 1x cache " + array
                              + " now have " + mBaseCacheSize + " entries");
                      }
                  }
              }
          }

      到这里ArrayMap的扩容机制就分析完毕了,可以看到ArrayMap内部对于内存的使用进行很大的优化,不仅将每次扩容的容量通过4和8分为三个档位,更是对于4和8的数组采取了缓存的机制,以避免重复创建对象。尤其是缓存结构的设计不可谓不精妙,看完之后只能感叹路还很长,自己何时能设计出如此精妙的结构!

      言归正传,其实ArrayMap是在Android SDK中存在,专门供Android使用以在数据量不大的场景下替换HashMap的。通过上面对两者扩容机制的分析不难看出其在这方面的区别:HashMap每次扩容是直接申请双倍于当前容量的内存,而ArrayMap则是根据所需size大小,如果size长度大于8时申请size*1.5个长度,大于4小于8时申请8个,小于4时申请4个。这样一来同等情况下,粒度更细的ArrayMap自然会申请更少的内存空间,同时导致的问题就是扩容频率会大于HashMap。另外由于ArrayMap在查找元素使用的是二分法,当数据量过大时性能会远不如用hash值定位的HashMap。但众所周知内存对于移动设备来说有多么珍贵,因此ArrayMap这种用时间换空间的做法,在数据量较小的时候是非常适合于Android应用的。

    • SparseArray

      最后再来看一下SparseArray,与ArrayMap类似,这也是Android对于HashMap的一种优化和替代方案,不同的是它只能将int作为key的类型(注意是int不是Integer),也就是说它不会对key进行自动装箱,这个是当key为int时SparseArray优于ArrayMap的地方,所以对于数据量不大,且确定key为int类型的场景,可以使用SparseArray代替HashMap或ArrayMap,因为它避免了自动装箱的过程。

      SparseArray内部也是通过两个数组来存储数据,一个存key,一个存value。由于key只能是int,所以比较时只需要看是否相等即可,所以结构非常简单。它的扩容是通过GrowingArrayUtils.growSize(int currentsize)方法来完成的,这个工具类位于android.support.v7.content.res包中,其中输入参数是指扩容前当前的数组容量,这个方法非常简单:

        public static int growSize(int currentSize) {
              //当前容量不大于4,就扩容到8,否则就扩容一倍
              return currentSize <= 4 ? 8 : currentSize * 2;
          }
    • 最后

      至此几个常用的集合类的扩容机制就都分析完毕了,下面给出一个简单的表格用于总结:

      容器 单次扩容容量 缓存策略
      ArrayList 默认扩容当前容量的一半,若不满足需求,则扩容至需求容量(此处需考虑边界情况,若所需求容量超过了Integer.MAX_VALUE - 8,则做最大化处理)
      Vector 可在构造函数中指定,默认扩容一倍
      HashMap 扩容一倍,并进行rehash
      ArrayMap 所需size长度大于8时申请size*1.5个长度,大于4小于8时申请8个,小于4时申请4个 对长度为4和8的数组进行缓存
      SparseArray 当前容量不大于4,就扩容到8,否则就扩容一倍

      后续会继续对这些集合类的查找,添加,删除操作,线程安全性以及HashMap中的红黑树等方面做源码分析,感谢阅读!

    • 感谢

      http://blog.csdn.net/u011240877/article/details/53351188

      http://blog.csdn.net/u011240877/article/details/53358305

      http://blog.csdn.net/vansbelove/article/details/52422087

    版权声明:原创不易,转载前请留言获得作者许可,转载后标明作者 Troy.Tang 与 原文链接。

猜你喜欢

转载自www.cnblogs.com/tabCtrlShift/p/9440564.html