Android数据容器之ArrayMap

3,251 阅读12分钟

ArrayMap是谷歌推出的一个数据容器,也是为内存更珍贵的移动端设计的。和SparseArray不同之处在于,ArrayMap选择了使用<K,V>结构,当key的类型不为Integer的时候,可以选择使用ArrayMap,因此它的使用场景更广。

源码解析

以android.util.ArrayMap为例,SDK=28

整体结构

ArrayMap实现了Java中的Map接口,拥有相同的方法特征,但是ArrayMap没有实现额外的Entry,而是通过数组存储了所有的hash code和key-value pair。

成员变量

ArrayMap中比较重要的成员变量:

private static final int BASE_SIZE = 4;     //扩容的最小值
private static final int CACHE_SIZE = 10;   //缓存数组的上限
static final int[] EMPTY_IMMUTABLE_INTS = new int[0];   //没有其他用处,只是初始化容量传负数时标记抛异常用

static Object[] mBaseCache;     //缓存大小为4的ArrayMap,该进程内唯一
static int mBaseCacheSize;      //当前已缓存的大小为4的ArrayMap数量,超过10不再缓存
static Object[] mTwiceBaseCache;    //缓存大小为8的ArrayMap
static int mTwiceBaseCacheSize;

int[] mHashes;      //存储key的hashcode的数组
Object[] mArray;    //存储key-value对的数组,大小是mHashes的两倍
int mSize;      //实际存储个数

数据结构

如图,mHashes存放的是所有key的hashCode,按升序排列。mArray存放的是所有key-value对,大小是mHashes的两倍,下标和mHashes的index对应关系是key放在index<< 1的位置上,value放在index<< 1+1的位置上。这里hashCode如果存在冲突,和HashMap处理方式不同,后面会说。

初始化

public ArrayMap() {
    this(0, false);
}

默认创建的ArrayMap初始容量为0,mHashes和mArray都是空数组。元素被添加进来的时候会触发扩容。如果创建时指定一个大于0的Capacity,则调用allocArrays分配初始化容量

put

核心的方法当然还是元素的存储过程,和HashMap一样,如果要插入的位置没有值则返回null,否则返回旧值

public V put(K key, V value) {
    final int osize = mSize;
    final int hash;
    int index;
    //计算key和hashCode
    if (key == null) {
        hash = 0;   //key为null就取0
        index = indexOfNull();
    } else {
        //否则取的Object.hashCode()
        hash = mIdentityHashCode ? System.identityHashCode(key) : key.hashCode();    
        index = indexOf(key, hash);
    }
    //这里查找index和SparseArray类似,都是通过二分查找,如果找到了直接覆盖旧值
    if (index >= 0) {
        index = (index<<1) + 1;
        final V old = (V)mArray[index];
        mArray[index] = value;
        return old;
    }
    //没找到,通过取反获取要插入的位置
    index = ~index;
    //如果mSize>= mHashes数组的长度,要进行扩容
    if (osize >= mHashes.length) {
        final int n = osize >= (BASE_SIZE*2) ? (osize+(osize>>1))
                : (osize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE);

        final int[] ohashes = mHashes;
        final Object[] oarray = mArray;
        //分配新的空间
        allocArrays(n);

        if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
            throw new ConcurrentModificationException();
        }
        //拷贝数据到新数组中,包括mHashes和mArray
        if (mHashes.length > 0) {
            System.arraycopy(ohashes, 0, mHashes, 0, ohashes.length);
            System.arraycopy(oarray, 0, mArray, 0, oarray.length);
        }
        //将原数组进行回收缓存,因为原先的hash数组大小有可能为4或8
        freeArrays(ohashes, oarray, osize);
    }

    if (index < osize) {
        //index在mSize之内,需要将原数组index处的元素都向后移一位,空出index的位置存储新元素
        System.arraycopy(mHashes, index, mHashes, index + 1, osize - index);
        System.arraycopy(mArray, index << 1, mArray, (index + 1) << 1, (mSize - index) << 1);
    }

    if (CONCURRENT_MODIFICATION_EXCEPTIONS) {
        if (osize != mSize || index >= mHashes.length) {
            throw new ConcurrentModificationException();
        }
    }
    //插入hash、key、value,当前元素个数+1
    mHashes[index] = hash;
    mArray[index<<1] = key;
    mArray[(index<<1)+1] = value;
    mSize++;
    return null;
}

扩容的逻辑是:

  • 当前元素个数mSize<4,扩容后大小为4
  • 4<=mSize<8,扩容后大小为8
  • mSize>=8,扩容后大小为原先的1.5倍

indexOf

indexOf方法对null和非null的key做了区分,根据key的hashCode进行查找,主要看非null的情况:

int indexOf(Object key, int hash) {
        final int N = mSize;

        // 为了提高效率先做了判断,如果是空数组肯定是找不到的
        if (N == 0) {
            return ~0;
        }
        //通过二分查找找到该key对应的hash在mHashes中的位置
        int index = binarySearchHashes(mHashes, N, hash);

        // 为负说明没找到
        if (index < 0) {
            return index;
        }

        // mArray相应位置的元素和key相等,说明是同一个key,找到了
        if (key.equals(mArray[index<<1])) {
            return index;
        }

        // 如果key不相同,说明出现了hash冲突,和上面的数据结果示意的那样。那么就从index处先向后再向前遍历寻找
        int end;
        for (end = index + 1; end < N && mHashes[end] == hash; end++) {
            if (key.equals(mArray[end << 1])) return end;
        }

        for (int i = index - 1; i >= 0 && mHashes[i] == hash; i--) {
            if (key.equals(mArray[i << 1])) return i;
        }

        // 还是没找到,就取反返回。这种情况比如新插入的key和已有的发生了hash冲突,那么就将该key插入到发生冲突的所有旧key的最后面。所以发生了hash冲突的情况下后加的key永远在后面,并且多个相同的hashCode一定是连续的
        return ~end;
    }

get、remove

get方法其实就是根据key的值,调用indexOf算出该key在mHashes数组中的位置,得出对应的mArray数组中的value。remove也是先计算出index,再调用removeAt完成元素的删除

public V removeAt(int index) {
    final Object old = mArray[(index << 1) + 1];
    final int osize = mSize;
    final int nsize;
    if (osize <= 1) {
        // 一个小优化,如果数组中只有一个key-value对,remove后就成空数组了,所以直接置空和回收数组,不做其它判断
        final int[] ohashes = mHashes;
        final Object[] oarray = mArray;
        mHashes = EmptyArray.INT;
        mArray = EmptyArray.OBJECT;
        freeArrays(ohashes, oarray, osize);
        nsize = 0;
    } else {
        //记录hash数组的尾部下标,同时也是删除元素后新的容量
        nsize = osize - 1;
        //如果hash数组的长度大于8,并且元素个数小于hash数组长度的1/3,则进行压缩
        if (mHashes.length > (BASE_SIZE*2) && mSize < mHashes.length/3) {
            //n 为压缩后的mHashes长度,如果当前mSize大于8,则n=1.5*mSize,否则为8
            int n = osize > (BASE_SIZE*2) ? (osize + (osize>>1)) : (BASE_SIZE*2);

            final int[] ohashes = mHashes;
            final Object[] oarray = mArray;
            allocArrays(n);
            
            if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
                throw new ConcurrentModificationException();
            }
            //容量改变了,要将原有元素拷贝到新数组
            if (index > 0) {
                System.arraycopy(ohashes, 0, mHashes, 0, index);
                System.arraycopy(oarray, 0, mArray, 0, index << 1);
            }
            if (index < nsize) {
                System.arraycopy(ohashes, index + 1, mHashes, index, nsize - index);
                System.arraycopy(oarray, (index + 1) << 1, mArray, index << 1,(nsize - index) << 1);
            }
        } else {
            if (index < nsize) {
                //要删除的元素不在末位,需要将数组index后的元素都向前移一位
                System.arraycopy(mHashes, index + 1, mHashes, index, nsize - index);
                System.arraycopy(mArray, (index + 1) << 1, mArray, index << 1,(nsize - index) << 1);
            }
            //末位元素置为null
            mArray[nsize << 1] = null;
            mArray[(nsize << 1) + 1] = null;
        }
    }
    if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
        throw new ConcurrentModificationException();
    }
    mSize = nsize;
    return (V)old;
}

这里设计的压缩后的容量至少是8,注释是避免在8和4之间来回调整,也就是阈值过小的话会频发触发扩容和压缩,用差别不大的内存使用来换取执行效率。能注意到多个方法中都出现了allocArrays和freeArrays,这两个方法是用来管理ArrayMap内存空间的,并且包含了对缓存的相关操作。

freeArrays

该方法的作用是回收原数组,缓存下来以复用。因为缓存是进程内唯一的,ArrayMap使用的场景比较多的时候,符合大小的数组都可以利用到缓存,从而避免了对象的频繁创建和回收

private static void freeArrays(final int[] hashes, final Object[] array, final int size) {
    //释放的对象大小为8
    if (hashes.length == (BASE_SIZE*2)) {
        //值得注意的是,缓存是静态变量,被进程内所有ArrayMap共享,而ArrayMap本身是线程不安全的,所以这里用了类锁来同步,避免对缓存的并发修改。
        synchronized (ArrayMap.class) {
            //存储大小为8的对象的缓存池数量小于10,则加入缓存
            if (mTwiceBaseCacheSize < CACHE_SIZE) {
                //array[0]指向原先的缓存池
                array[0] = mTwiceBaseCache;
                //array[1]指向hash数组
                array[1] = hashes;
                for (int i=(size<<1)-1; i>=2; i--) {
                    //情况数组中的其它数据
                    array[i] = null;
                }
                //mTwiceBaseCache重新赋值,指向新加入缓存池的array,缓存池个数+1          
                mTwiceBaseCache = array;
                mTwiceBaseCacheSize++;
            }
        }
    } else if (hashes.length == BASE_SIZE) {
        //释放的对象大小为4,逻辑同上
        synchronized (ArrayMap.class) {
            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++;
            }
        }
    }
}

先理解一下这个缓存数组的数据结构,其实就是类似于LinkedList,新缓存的数组中的第0个位置指向原先的缓存数组,然后原先缓存数组重新指向当前的新缓存数组第0个位置供下次指向使用。第一个位置则一直指向mHashes。以mBaseCache为例,开始的时候为null,mBaseCacheSize=0,整体过程如下图:

在ArrayMap中的使用场景

  • 数组不为空,clear清空数组时
  • put时发现需要扩容,先allocArrays再freeArrays
  • ensureCapacity时,如果当前容量小于预期容量,则先allocArrays再freeArrays
  • remove时发现数组只有一个元素

allocArrays

//size为目标容量大小,该方法作用是将mHashes、mArray设置成指定容量
private void allocArrays(final int size) {
    //如果要设置的容量为8或4,要特殊处理
    if (size == (BASE_SIZE*2)) {
        synchronized (ArrayMap.class) {
            //如果缓存池不为空
            if (mTwiceBaseCache != null) {
                final Object[] array = mTwiceBaseCache;
                //从缓存池取出array
                mArray = array;
                //将缓存池指向上一条缓存
                mTwiceBaseCache = (Object[])array[0];
                //从缓存中获取mHashes
                mHashes = (int[])array[1];
                array[0] = array[1] = null;
                //缓存池大小减一
                mTwiceBaseCacheSize--;
                return;
            }
        }
    } else if (size == BASE_SIZE) {
        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--;
                return;
            }
        }
    }
    //如果容量不是4或者8,那么直接给hash和key-value数组分配空间即可
    mHashes = new int[size];    
    mArray = new Object[size<<1];
}

如上图,和前面的freeArrays示意图相对应,这里参考了Gityuan的图例:链接,用自己更容易理解的方式画了一遍。标红色的部分是方法执行结束后各变量的结果,开始的时候mBaseCacheSize=2,将mArray指向当前缓存池,mHashes指向缓存池的第1个位置。因为缓存池第0个位置指向上一个缓存,所以只要将mBaseCache指向第0个位置就完成了缓存池的更新。allocArrays方法涉及了扩容和压缩,使用场景如下

  • ArrayMap构造方法指定容量
  • put时发现需要扩容,先allocArrays再freeArrays
  • ensureCapacity时,如果当前容量小于预期容量,则先allocArrays再freeArrays
  • remove时数组元素数量大于1,并且满足容量收缩的条件

ArraySet

ArraySet实现了Collection和Set接口,相比HashSet更节省内存。在设计上和ArrayMap很相似,但实现上还有区别。ArraySet的去重,是将元素的hashCode按二分查找插入到mHashes中,value插入到mArray对应的下标index。如果该value对应的hashCode已经存在则跳出。

内存比较

private HashMap<Integer, String> mMap = new HashMap<>();
private ArrayMap<Integer, String> mArrayMap = new ArrayMap<>();
    
public void test(){
    for (int i = 0; i < 100000; i++) {
        mMap.put(i, String.valueOf(i));
        //mArrayMap.put(i, String.valueOf(i));
    }
}

使用adb shell dumpsys meminfo 查看进程内存占用情况,下图是使用HashMap创建数据前内存使用情况(ArrayMap和这个差不多):

** MEMINFO in pid 19999 [com.example.dataset] **
                   Pss  Private  Private  SwapPss     Heap     Heap     Heap
                 Total    Dirty    Clean    Dirty     Size    Alloc     Free
                ------   ------   ------   ------   ------   ------   ------
  Native Heap    11553    11476        0        0    16384    14930     1453
  Dalvik Heap     1623     1468        0        0     3064     1528     1536
 Dalvik Other      574      560        0        0                           
        Stack       68       68        0        0                           
       Ashmem        5        0        0        0                           
    Other dev       12        0       12        0                           
     .so mmap     3562      136        4        0                           
    .apk mmap     3544     2092      252        0                           
    .ttf mmap       50        0        0        0                           
    .dex mmap     2640        0      576        0                           
    .oat mmap       77        0        0        0                           
    .art mmap     7074     6736        0        0                           
   Other mmap       16        4        0        0                           
      Unknown      422      416        0        0                           
        TOTAL    31220    22956      844        0    19448    16458     2989
 
 App Summary
                       Pss(KB)
                        ------
           Java Heap:     8204
         Native Heap:    11476
                Code:     3060
               Stack:       68
            Graphics:        0
       Private Other:      992
              System:     7420
 
               TOTAL:    31220       TOTAL SWAP PSS:        0

使用HashMap创建数据后内存使用情况:

** MEMINFO in pid 19999 [com.example.dataset] **
                   Pss  Private  Private  SwapPss     Heap     Heap     Heap
                 Total    Dirty    Clean    Dirty     Size    Alloc     Free
                ------   ------   ------   ------   ------   ------   ------
  Native Heap    11698    11636        0        0    16896    14986     1909
  Dalvik Heap     7807     7752        0        0    15197     9053     6144
 Dalvik Other      528      528        0        0                           
        Stack       72       72        0        0                           
       Ashmem        5        0        0        0                           
    Other dev       12        0       12        0                           
     .so mmap     3639      140        4        0                           
    .apk mmap     3556     2092      252        0                           
    .ttf mmap       50        0        0        0                           
    .dex mmap     2884        0      640        0                           
    .oat mmap       77        0        0        0                           
    .art mmap     7248     6816       56        0                           
   Other mmap       16        4        0        0                           
      Unknown      448      444        0        0                           
        TOTAL    38040    29484      964        0    32093    24039     8053
 
 App Summary
                       Pss(KB)
                        ------
           Java Heap:    14624
         Native Heap:    11636
                Code:     3128
               Stack:       72
            Graphics:        0
       Private Other:      988
              System:     7592
 
               TOTAL:    38040       TOTAL SWAP PSS:        0

使用ArrayMap创建数据后内存使用情况

** MEMINFO in pid 20275 [com.example.dataset] **
                   Pss  Private  Private  SwapPss     Heap     Heap     Heap
                 Total    Dirty    Clean    Dirty     Size    Alloc     Free
                ------   ------   ------   ------   ------   ------   ------
  Native Heap    11759    11696        0        0    16896    14880     2015
  Dalvik Heap     6639     6584        0        0    13370     7226     6144
 Dalvik Other      496      496        0        0                           
        Stack       72       72        0        0                           
       Ashmem        5        0        0        0                           
    Other dev       12        0       12        0                           
     .so mmap     3637      140        4        0                           
    .apk mmap     3556     2092      252        0                           
    .ttf mmap       50        0        0        0                           
    .dex mmap     2871        0      640        0                           
    .oat mmap       77        0        0        0                           
    .art mmap     7231     6796       56        0                           
   Other mmap       16        4        0        0                           
      Unknown      440      436        0        0                           
        TOTAL    36861    28316      964        0    30266    22106     8159
 
 App Summary
                       Pss(KB)
                        ------
           Java Heap:    13436
         Native Heap:    11696
                Code:     3128
               Stack:       72
            Graphics:        0
       Private Other:      948
              System:     7581
 
               TOTAL:    36861       TOTAL SWAP PSS:        0

以Dalvik Heap- Heap Alloc作为参考标准,多次比较前后使用内存,计算出来ArrayMap相比HashMap能减少20%-30%的内存。