Android-内存优化-数据结构—ArrayMap原理解析

一、概论

    在移动设备端,内存资源很珍贵,HashMap 为实现快速查询带来了很大内存的浪费。为此,2013年5月20日 Google 工程师 Dianne Hackborn 在 Android 系统源码中新增 ArrayMap 类,从 Android 源码中发现有不少提交,专门把之前使用 HashMap 的地方改用 ArrayMap,不仅如此,大量的应用开发者中也广为使用。

ArrayMap 是 Android 专门针对内存优化而设计的,用于取代 Java API 中的 HashMap 数据结构。

为了更进一步优化 key 是 int 类型的 Map,Android 再次提供效率更高的数据结构 SparseArray,可避免自动装箱过程。对于 key 为其他类型,则可以使用 ArrayMap。

HashMap 的查找和插入时间复杂度为 O(1) 的代价是牺牲大量的内存来实现的,而 SparseArray 和 ArrayMap 性能略逊于 HashMap,但更节省内存。
接下来带着这几个问题来学习 ArrayMap的底层原理:

  • 1)ArrayMap 与 HashMap 有什么差异
  • 2)ArrayMap 的优点与缺点
  • 3)ArrayMap 与 HashMap 的取舍

二、源码解析

2.1 基本成员变量

public final class ArrayMap<K, V> implements Map<K, V> {

    private static final boolean CONCURRENT_MODIFICATION_EXCEPTIONS = true;
    
    private static final int BASE_SIZE = 4;  // 容量增量的最小值
    private static final int CACHE_SIZE = 10; // 缓存数组的上限

    static Object[] mBaseCache; // 用于缓存大小为 4 的 ArrayMap    
    static Object[] mTwiceBaseCache; // 用于缓存大小为 8 的 ArrayMap
    
    static int mBaseCacheSize; // 缓存大小为 4 的 ArrayMap  的长度
    static int mTwiceBaseCacheSize; // 缓存大小为 8 的 ArrayMap  的长度

    final boolean mIdentityHashCode;
    int[] mHashes;         // 由 key 的 hashcode 所组成的数组
    Object[] mArray;       // 由 key-value 对所组成的数组,是 mHashes 大小的 2 倍
    int mSize;             // 成员变量的个数
}

ArrayMap 对象的数据储存格式如下图所示:

  • mHashes 是一个记录所有 key 的 hashcode 值组成的数组,是从小到大的排序方式
  • mArray 是一个记录着 key-value 键值对所组成的数组,是 mHashes 大小的2倍
    ArrayMap 对象的数据储存格式

其中 mSize 记录着该 ArrayMap 对象中有多少对数据,执行 put() 或者 append() 操作,则 mSize 会加 1,执行 remove(),则 mSize 会减 1。

mSize 往往小于 mHashes.length,如果 mSize 大于或等于 mHashes.length,则说明 mHashes 和 mArray 需要扩容

ArrayMap 类有两个非常重要的静态成员变量 mBaseCache 和 mTwiceBaseCache,用于 ArrayMap 所在进程的全局缓存功能:

  • mBaseCache:用于缓存大小为 4 的 ArrayMap,mBaseCacheSize 记录着当前已缓存的数量,超过 10 个则不再缓存
  • mTwiceBaseCache:用于缓存大小为 8 的 ArrayMap,mTwiceBaseCacheSize 记录着当前已缓存的数量,超过 10 个则不再缓存。

为了减少频繁地创建和回收 Map 对象,ArrayMap 采用了两个大小为 10 的缓存队列来分别保存大小为 4 和 8 的 Map 对象。为了节省内存使用了更加保守的内存扩张以及内存收缩策略。 接下来分别说说缓存机制和扩容机制。

这也是 ArrayMap 所特有的,与HashMap不同,ArrayMap所具有的缓存机制。

2.2 缓存机制

    ArrayMap 是专为 Android 优化而设计的 Map 对象,使用场景比较高频,很多场景可能起初都是数据很少,为了减少频繁地创建和回收,特意设计了两个缓存池,分别缓存大小为 4 和 8 的 ArrayMap 对象。要理解缓存机制,那就需要看看内存分配 (allocArrays)内存释放 (freeArrays)。

与其说这是两个缓存池,不如说是两个缓存链

2.2.1 ArrayMap.freeArrays
private static void freeArrays(final int[] hashes, final Object[] array, final int size) {
    if (hashes.length == (BASE_SIZE*2)) {  // 当释放的是大小为 8 的对象
        synchronized (ArrayMap.class) {
            // 当大小为 8 的缓存池的数量小于 10 个,则将其放入缓存池
            if (mTwiceBaseCacheSize < CACHE_SIZE) { 
                array[0] = mTwiceBaseCache;  // array[0] 指向原来的缓存池
                array[1] = hashes;
                for (int i=(size<<1)-1; i>=2; i--) {
                    array[i] = null;  // 清空其他数据
                }
                mTwiceBaseCache = array; // 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++;
            }
        }
    }
}
  1. 最初 mTwiceBaseCache 和 mBaseCache 缓存池中都没有数据,
  2. 在 freeArrays 释放内存时,如果同时满足释放的 array 大小等于 4 或者 8
  3. 且相对应的缓冲池个数未达上限,则会把该 arrya 加入到缓存池中。
  • 加入的方式是将数组 array 的第 0 个元素指向原有的缓存池,
  • 第 1 个元素指向 hashes 数组的地址,
  • 第 2 个元素以后的数据全部置为 null。
  • 再把缓存池的头部指向最新的 array 的位置,并将该缓存池大小执行加 1 操作。具体如下所示。
    dn.net/liuwg1226/article/details/118885316
freeArray操作步骤

从这个步骤模型可以看出这是一个缓存链,每个数组的第0个元素都保存上一次的缓存。
ArrayMap 的 freeArrays() 的触发条件:

  • 当执行 removeAt() 移除最后一个元素的情况
  • 当执行 clear() 清理的情况
  • 当执行 ensureCapacity() 在当前容量小于预期- 容量的情况下,先执行 allocArrays,再执行 freeArrays
  • 当执行 put() 在容量满的情况下,先执行 allocArrays,再执行 freeArrays
2.2.1 ArrayMap.allocArrays
private void allocArrays(final int size) {
    if (size == (BASE_SIZE*2)) {  // 当分配大小为 8 的对象,先查看缓存池
        synchronized (ArrayMap.class) {
            if (mTwiceBaseCache != null) { // 当缓存池不为空时
                final Object[] array = mTwiceBaseCache; 
                mArray = array;         // 从缓存池中取出 mArray
                mTwiceBaseCache = (Object[])array[0]; // 将缓存池指向上一条缓存地址
                mHashes = (int[])array[1];  // 从缓存中 mHashes
                array[0] = array[1] = null;
                mTwiceBaseCacheSize--;  // 缓存池大小减 1
                return;
            }
        }
    } else if (size == BASE_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--;
                return;
            }
        }
    }
    
    // 分配大小除了 4 和 8 之外的情况,则直接创建新的数组
    mHashes = new int[size];
    mArray = new Object[size<<1];
}

当 allocArrays 分配内存时,如果所需要分配的大小等于 4 或者 8,且相对应的缓冲池不为空,则会从相应缓存池中取出缓存的 mArraymHashes

从缓存池取出缓存的方式是:

  1. 将当前缓存池赋值给 mArray,
  2. 将缓存池指向上一条缓存地址,
  3. 将缓存池的第 1 个元素赋值为 mHashes,
  4. 再把 mArray 的第 0 和第 1 个位置的数据置为 null
  5. 并将该缓存池大小执行减 1 操作,具体如下所示。


    缓存链条获取

ArrayMap 的 allocArrays 的触发时机:

  • 当执行 ArrayMap 的构造函数的情况
  • 当执行 removeAt() 在满足容量收紧机制的情况
  • 当执行 ensureCapacity() 在当前容量小于预期容量的情况下,先执行 allocArrays,再执行 freeArrays
  • 当执行 put() 在容量满的情况下,先执行 allocArrays,再执行 freeArrays
    这里需要注意的是只有大小为 4 或者 8 的内存分配,才有可能从缓存池取数据,因为 freeArrays 过程放入缓存池的大小只有 4 或 8,对于其他大小的内存分配则需要创建新的数组。

优化小技巧,对于分配数据不超过 8 的对象的情况下,一定要创建 4 或者 8 大小,否则浪费了缓存机制。比如 ArrayMap[7] 就是不友好的写法,建议写成 ArrayMap[8]。

2.3 容量机制

2.3.1 容量扩张
public V put(K key, V value) {
    final int osize = mSize;
    if (osize >= mHashes.length) { // 当 mSize 大于或等于 mHashes 数组长度时需要扩容
        final int n = osize >= (BASE_SIZE*2) ? (osize+(osize>>1))
                : (osize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE);
        allocArrays(n);  // 分配更大的内存【小节2.2.2】
    }
}

当 mSize 大于或等于 mHashes 数组长度时,则扩容,完成扩容后需要将老的数组拷贝到新分配的数组,并释放老的数组内存。

  • 当 map 个数满足条件 osize<4 时,则扩容后的大小为 4
  • 当 map 个数满足条件 4<= osize < 8 时,则扩容后的大小为 8
  • 当 map 个数满足条件 osize>=8 时,则扩容后的大小为原来的 1.5 倍
    可见 ArrayMap 大小在不断增加的过程,size 的取值一般情况依次会是 4,8,12,18,27,40,60,…
2.3.2 容量收紧
public V removeAt(int index) {
    final int osize = mSize;
    final int nsize;
    if (osize > 1) {  // 当 mSize 大于 1 的情况,需要根据情况来决定是否要收紧
        nsize = osize - 1;
        if (mHashes.length > (BASE_SIZE*2) && mSize < mHashes.length/3) {
            final int n = osize > (BASE_SIZE*2) ? (osize + (osize>>1)) : (BASE_SIZE*2);
            allocArrays(n); // 分配更小的内存【小节2.2.2】
        } 
    }
}

当 mHashes 的大小大于 8,且已存储数据的个数 mSize 小于数组空间大小的 1/3 的情况下,需要收紧数据的内容容量,分配新的数组,老的内存靠虚拟机自动回收。

  • 如果 mSize<=8,则设置新大小为 8
  • 如果 mSize> 8,则设置新大小为 mSize 的1.5倍
    也就是说在 mHashes.length 较大的情况下,当 使用量(mSize)不足 1/3 的情况下,内存数组会收紧。

2.4 基本成员方法

2.4.1 构造方法
public ArrayMap() {
    this(0, false);
}

// 指定初始容量大小
public ArrayMap(int capacity) {
    this(capacity, false);
}

/** {@hide} */ 这是一个隐藏方法
public ArrayMap(int capacity, boolean identityHashCode) {
    mIdentityHashCode = identityHashCode;

    if (capacity < 0) {
        mHashes = EMPTY_IMMUTABLE_INTS;
        mArray = EmptyArray.OBJECT;
    } else if (capacity == 0) {
        mHashes = EmptyArray.INT;
        mArray = EmptyArray.OBJECT;
    } else {
        allocArrays(capacity); // 分配内存【小节2.2.2】
    }
    mSize = 0;  //初始值为0
}

public ArrayMap(ArrayMap<K, V> map) {
    this();
    if (map != null) {
        putAll(map);  
    }
}

public void putAll(Map<? extends K, ? extends V> map) {
    // 确保 map 的大小至少为 mSize + map.size(),如果默认已满足条件则不用扩容
    ensureCapacity(mSize + map.size()); 
    for (Map.Entry<? extends K, ? extends V> entry : map.entrySet()) {
        put(entry.getKey(), entry.getValue());
    }
}

针对构造方法,如果指定大小则会去分配相应大小的内存,如果没有指定默认为 0,当需要添加数据的时候再扩容。

2.4.2 put()
public V put(K key, V value) {
    final int osize = mSize; // osize 记录当前 map 大小
    final int hash;
    int index;
    if (key == null) {
        hash = 0;
        index = indexOfNull();
    } else {
        // 默认 mIdentityHashCode=false
        hash = mIdentityHashCode ? System.identityHashCode(key) : key.hashCode();
        // 采用二分查找法,从 mHashes 数组中查找值等于 hash 的 key
        index = indexOf(key, hash); 
    }
    // 当 index 大于零,则代表的是从数据 mHashes 中找到相同的 key,执行的操作等价于修改相应位置的 value
    if (index >= 0) {
        index = (index<<1) + 1;  // index 的 2 倍 +1 所对应的元素存在相应 value 的位置
        final V old = (V)mArray[index];
        mArray[index] = value;
        return old;
    }

    // 当 index<0,则代表是插入新元素
    index = ~index;
    if (osize >= mHashes.length) { // 当 mSize 大于或等于 mHashes 数组长度时,需要扩容【小节2.3.1】
        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);  // 分配更大的内存【小节2.2.2】

        // 由于 ArrayMap 并非线程安全的类,不允许并行,如果扩容过程其他线程调整 mSize 则抛出异常
        if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
            throw new ConcurrentModificationException();
        }

        if (mHashes.length > 0) {
            // 将原来老的数组拷贝到新分配的数组
            System.arraycopy(ohashes, 0, mHashes, 0, ohashes.length);
            System.arraycopy(oarray, 0, mArray, 0, oarray.length);
        }
        freeArrays(ohashes, oarray, osize); // 释放原来老的内存【小节2.2.2】
    }

    // 当需要插入的位置不在数组末尾时,需要将 index 位置后的数据通过拷贝往后移动一位
    if (index < osize) {
        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 添加相应数组的位置,数据个数 mSize 加 1
    mHashes[index] = hash;
    mArray[index<<1] = key;
    mArray[(index<<1)+1] = value;
    mSize++; 
    return null;
}

put() 设计巧妙地将 修改已有数据对(key-value)插入新的数据对 合二为一个方法,主要是依赖 indexOf() 过程中采用的 二分查找法
当找到相应 key 时则返回正值,当找不到 key 则返回负值,
按位取反所对应的值代表的是需要插入的位置 index。

put() 在插入时,如果当前数组内容已填充满时,则会先进行扩容,再通过 System.arraycopy 来进行数据拷贝,最后在相应位置写入数据。

2.4.4 append()
public void append(K key, V value) {
    int index = mSize;
    final int hash = key == null ? 0
            : (mIdentityHashCode ? System.identityHashCode(key) : key.hashCode());
    //使用append前必须保证mHashes的容量足够大,否则抛出异常
    if (index >= mHashes.length) {
        throw new IllegalStateException("Array is full");
    }
    //当数据需要插入到数组的中间,则调用put来完成
    if (index > 0 && mHashes[index-1] > hash) {
        put(key, value); // 【小节2.4.1】
        return;
    }
    //否则,数据直接添加到队尾
    mSize = index+1;
    mHashes[index] = hash;
    index <<= 1;
    mArray[index] = key;
    mArray[index+1] = value;
}

append() 与 put() 类似,append 的差异在于,该方法不会去做扩容的操作,是一个轻量级的插入方法。

Q:那么什么场景适合使用 append() 方法呢?
A:应就是对于明确知道肯定会插入队尾的情况下使用 append() 性能更好,因为 put() 上来先做 binarySearchHashes() 二分查找,时间复杂度为 O(logN),而 append() 的时间复杂度为 O(1)。

2.4.5 remove()
public V remove(Object key) {
    final int index = indexOfKey(key); // 通过二分查找 key 的 index
    if (index >= 0) {
        return removeAt(index); // 移除相应位置的数据
    }
    return null;
}

public V removeAt(int index) {
    final Object old = mArray[(index << 1) + 1];
    final int osize = mSize;
    final int nsize;
    if (osize <= 1) {  // 当被移除的是 ArrayMap 的最后一个元素,则释放该内存
        freeArrays(mHashes, mArray, osize);
        mHashes = EmptyArray.INT;
        mArray = EmptyArray.OBJECT;
        nsize = 0;
    } else {
        nsize = osize - 1;
        // 根据情况来收紧容量 【小节2.3.2】
        if (mHashes.length > (BASE_SIZE*2) && mSize < mHashes.length/3) {
            final 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) { // 当被移除的元素不是数组最末尾的元素时,则需要将后面的数组往前移动
                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; // 大小减1
    return (V)old;
}

remove() 过程:通过二分查找 key 的 index,再根据 index 来选择移除动作;

  • 当被移除的是 ArrayMap 的最后一个元素,则释放该内存,
  • 否则只做移除操作,这时会根据容量收紧原则来决定是否要收紧,当需要收紧时会创建一个更小内存的容量。
2.4.5 clear()
public void clear() {
    if (mSize > 0) { // 当容量中元素不为空的情况 才会执行内存回收操作
        final int[] ohashes = mHashes;
        final Object[] oarray = mArray;
        final int osize = mSize;
        mHashes = EmptyArray.INT;
        mArray = EmptyArray.OBJECT;
        mSize = 0;
        freeArrays(ohashes, oarray, osize); //【小节2.2.1】
    }
    if (CONCURRENT_MODIFICATION_EXCEPTIONS && mSize > 0) {
        throw new ConcurrentModificationException();
    }
}

clear() 清理操作会执行 freeArrays() 方法来回收内存,而类似的方法 erase() 则只会清空数组内的数据,并不会回收内存。

三、缺陷分析

3.1 缺陷原因

由于 ArrayMap 是非线程安全的,除了静态变量 mBaseCache 和 mTwiceBaseCache 加类锁保护,其他成员变量并没有保护。可能修改 array[0] 的地方 put、append、removeAt、erase 等方法,此处省去分析过程,最终有两种情况:

  • 场景一:这条缓存数据 array 在放入缓存池 (freeArrays) 后,被修改
  • 场景二:刚从缓存池取出来 (allocArrays) 的同时,数据立刻被其他地方修改

从 [小节2.2.1] freeArrays() 可知,

  1. 每一次放入缓存池 mBaseCache 时,一定会把 array[0] 指向 Object[] 类型的缓冲头。
  2. 并且 mBaseCache 的所有操作,都通过 synchronized 加锁 ArrayMap.class 保护,不可能会有修改其他线程并发修改 mBaseCache。
  3. 虽然 mBaseCache 会加锁保护,但 mArray 并没有加锁保护。如果有机会把 mBaseCache 的引用传递出去,在其他地方修改的话是有可能出现问题的。
3.2 修复方案

Google 工程师 Suprabh Shukla 在 2018.5.14 提交修复方案,合入 Android 9.0 的代码。方案的思路是利用局部变量保存 mArray,再斩断对外的引用。修复代码如下:

public V removeAt(int index) {
    final int osize = mSize;
    if (osize <= 1) {
        final int[] ohashes = mHashes; 
        final Object[] oarray = mArray;  // 利用局部变量 oarray 先保存 mArray
        mHashes = EmptyArray.INT;
        mArray = EmptyArray.OBJECT;      // 再将 mArray 引用置空
        freeArrays(ohashes, oarray, osize); 
        nsize = 0;
    } else {

**除了 removeAt(),其他调用 freeArrays() 的地方都会在调用之前先修改 mArray 内容引用,从而不会干扰缓存回收的操作。

四、类比分析

4.1 HashMap

4.1.1 HashMap 概论

详细的知识内容可以参考之前的文章进行学习hashMap原理解析,这里进行以下简要的概括:

public class HashMap<K,V>
    extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable
{
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认初始大小为 16
    static final float DEFAULT_LOAD_FACTOR = 0.75; // 默认负载因子
    static final int TREEIFY_THRESHOLD = 8;  // 当链表个数超过 8,则转红黑树
    
    // 用于存放数据的核心数组,老版本是 HashMapEntry,
    transient Node<K,V>[] table; 
    transient int size; // 实际存储的键值对的个数
    int threshold;  // 阈值,等于 capacity*loadFactory
    
    final float loadFactor = DEFAULT_LOAD_FACTOR; // 当前负载因子
    transient int modCount;  // 用于检测是否存在并发修改,transient 修饰则不会进入序列化
}

在不考虑哈希冲突的情况下,在哈希表中的增减、查找操作的时间复杂度为的 O(1)。
HashMap 是如何做到这么优秀的 O(1) 呢?
核心在于哈希函数能将 key 直接转换成哈希表中的存储位置,而哈希表本质是一个数组,在指定下标的情况下查找数组成员是一步到位的。

hashMap

那么哈希函数设计的好坏,会影响哈希冲突的概率,进而影响哈希表查找的性能。为了解决哈希冲突,也就是两个不同 key,经过 hash 转换后指向同一个 bucket,这时该 bucket 把相同 hash 值的 key 组成一个链表,每次插入链表的表头。(头插法)
可见 HashMap 是由数组+链表组成的,链表是为了处理哈希碰撞而存在的,所以链表出现得越少,其性能越好。

想想一种极端情况,所有 key 都发生碰撞,那么 HashMap 就退化成链表,其时间复杂度一下就退化到 O(n),这时比 ArrayMap 的性能还差,从 Android sdk26 开始,当链表长度超过 8 则转换为红黑树,让最坏情况的时间复杂度为 O(logn)。网上有大量介绍 HashMap 的资料,其中 table 是HashMapEntry<K,V>[],那说明是老版本,新版为支持 RBTree 的功能,已切换到 Node 类。
用图表示为:

4.1.1 HashMap 扩容机制
  • 扩容触发条件是当发生哈希冲突,并且当前实际键值对个数是否大于或等于阈值 threshold,默认为 0.75*capacity
  • 扩容操作是针对哈希表 table 来分配内存空间,每次扩容是至少是当前大小的 2 倍,扩容的大小一定是 2^n
  • 另外,扩容后还需要将原来的数据都 transfer 到新的 table,同时计算新的ihash值,映射新的index下标,这是耗时操作

4.2 SparseArray

public class SparseArray<E> implements Cloneable {
    private static final Object DELETED = new Object();
    private boolean mGarbage = false; // 标记是否存在待回收的键值对

    private int[] mKeys;
    private Object[] mValues;
    private int mSize;
}

SparseArray 对应的 key 只能是 int 类型,它不会对 key 进行装箱操作。它使用了两个数组一个保存 key,一个保存 value。 从内存使用上来说,SparseArray 不需要保存 key 所对应的哈希值,所以比 ArrayMap 还能再节省 1/3 的内存。

SparseArray

SparseArray 使用二分查找来找到 key 对应的插入位置,保证 mKeys 数组从小到大的排序。

4.2.1 SparseArray 的延迟回收机制

public void delete(int key) {
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
    if (i >= 0) {
        if (mValues[i] != DELETED) {
            mValues[i] = DELETED;  // 标记该数据为 DELETE
            mGarbage = true; // 设置存在 GC
        }
    }
}

当执行 delete() 或者 removeAt() 删除数据的操作,只是将相应位置的数据标记为 DELETE,并设置 mGarbage=true,而不会直接执行数据拷贝移动的操作。

当执行 clear() 会清空所有的数据,并设置 mGarbage=false;另外有很多时机(比如实际数据大于等于数组容量)都有可能会主动调用 gc() 方法来清理 DELETE 数据,代码如下:

private void gc() {
    int n = mSize;
    int o = 0;
    int[] keys = mKeys;
    Object[] values = mValues;

    for (int i = 0; i < n; i++) {
        Object val = values[i];
        if (val != DELETED) { // 将所有没有标记为 DELETE 的 value 移动到队列的头部
            if (i != o) {
                keys[o] = keys[i];
                values[o] = val;
                values[i] = null;
            }
            o++;
        }
    }
    mGarbage = false; // 垃圾整理完成
    mSize = o;
}

延迟回收机制的好处在于:首先删除方法效率更高,同时减少数组数据来回拷贝的次数,比如删除某个数据后被标记删除,接着又需要在相同位置插入数据,则不需要任何数组元素的来回移动操作。可见,对于 SparseArray 适合频繁删除和插入来回执行的场景,性能很好。

4.3 ArraySet

ArraySet 也是 Android 特有的数据结构,用于替代 HashSet 的,跟 ArrayMap 出自同一个作者,从源码来看 ArraySet 跟 ArrayMap 几乎完全一致,包含缓存机制,扩容机制。唯一的不同在于 ArrayMap 是一个 key-value 键值对的集合,而 ArraySet 是一个集合,mArray[] 保存所有的 value 值,而 mHashes[] 保存相应 value 所对应的hash 值。

当然 ArraySet 也有 ArrayMap 一样原理的缺陷,这一点 Google 修复如下:

private void allocArrays(final int size) {
    if (size == (BASE_SIZE * 2)) {
        ...
    } else if (size == BASE_SIZE) {
        synchronized (ArraySet.class) {
            if (sBaseCache != null) {
                final Object[] array = sBaseCache;
                try {
                    mArray = array;
                    sBaseCache = (Object[]) array[0];
                    mHashes = (int[]) array[1];
                    array[0] = array[1] = null;
                    sBaseCacheSize--;
                    return;
                } catch (ClassCastException e) {
                }
                // 从下面这段日志,可以看出谷歌工程师也发现了存在这个问题
                // Whoops!  Someone trampled the array (probably due to not protecting
                // their access with a lock).  Our cache is corrupt; report and give up.
                sBaseCache = null;
                sBaseCacheSize = 0;
            }
        }
    }

    mHashes = new int[size];
    mArray = new Object[size];
}
ArraySet

对于 ClassCastException 异常,这个有可能不是当前 ArraySet 使用不到导致的,也无法追溯,所以谷歌直接 catch 住这个异常,然后把缓冲池清空,再创建数组。这样可以解决问题,但这样有什么不足吗? 这样的不足在于当发生异常时会让缓存机制失效。

五、总结分析

从以下几个角度进行总结分析:

数据结构
  • ArrayMap 和 SparseArray 采用的都是两个数组,Android 专门针对内存优化而设计的
  • HashMap 采用的是数据+链表+红黑树
内存优化
  • ArrayMap 比 HashMap 更节省内存,综合性能方面在数据量不大的情况下,推荐使用 ArrayMap
  • Hash 需要创建一个额外对象来保存每一个放入 map 的 entry(数组),且容量的利用率比 ArrayMap 低,整体更消耗内存
  • SparseArray 比 ArrayMap 节省 1/3 的内存,但 SparseArray 只能用于 key 为 int 类型的 Map,所以 int 类型的 Map 数据推荐使用 SparseArray
性能方面
  • ArrayMap 查找时间复杂度 O(logN);ArrayMap 增加、删除操作需要移动成员,速度相比较慢,对于个数小于 1000 的情况下,性能基本没有明显差异
  • HashMap 查找、修改的时间复杂度为 O(1);
  • SparseArray 适合频繁删除和插入来回执行的场景,性能比较好
缓存机制
  • ArrayMap 针对容量为 4 和 8 的对象进行缓存,可避免频繁创建对象而分配内存与 GC 操作,这两个缓存池大小的上限为 10 个,防止缓存池无限增大
  • HashMap 没有缓存机制
  • SparseArray 有延迟回收机制,提供删除效率,同时减少数组成员来回拷贝的次数
扩容机制
  • ArrayMap 是在容量满的时机触发容量扩大至原来的 1.5 倍,在容量不足 1/3 时触发内存收缩至原来的 0.5 倍,更节省的内存扩容机制
  • HashMap 是在容量的 0.75 倍时触发容量扩大至原来的 2 倍,且没有内存收缩机制。HashMap 扩容过程有 - hash 重建,相对耗时。所以能大致知道数据量,可指定创建指定容量的对象,能减少性能浪费
并发问题
  • ArrayMap 是非线程安全的类,大量方法中通过对 mSize 判断是否发生并发,来决定抛出异常。但没有覆盖到所有并发场景,比如大小没有改变而成员内容改变的情况就没有覆盖
  • HashMap 是在每次增加、删除、清空操作的过程将 modCount 加 1,在关键方法内进入时记录当前 mCount,执行完核心逻辑后,再检测 mCount 是否被其他线程修改,来决定抛出异常。这一点的处理比 ArrayMap 更有全面

回到一开始的那几个问题,我们来简单回答一下:

  • ArrayMapa和HashMap的区别?
  1. ArrayMap的存在是为了解决HashMap占用内存大的问题,它内部使用了一个int数组用来存储元素的hashcode,使用了一个Object数组用来存储元素,两者根据索引对应形成key-value结构,这样就不用像HashMap那样需要额外的创建Entry对象来存储,减少了内存占用。
  2. 但是在数据量比较大时,ArrayMap的性能就会远低于HashMap,因为ArrayMap基于二分查找算法来查找元素的,并目数组的插入操作如果不是末尾的话需要挪动数组元素,效率较低。
  3. 而HashMap内部基于数组+单向链表+红黑树实现,也是key-value结构,正如刚才提到的,HashMap每put一个元素都需要创建一个Enty来存放元素,导致它的内存占用会比较大,但是在大数据量的时候,因为HashMap中当出现冲突时,冲突的数据量大于8,就会从单向链表转换成红黑树,而红黑树的插入、删除、查找的时间复杂度为O(logn),相对于ArrayMapl的数组而言在插入和删除操作上要快不少,所以数据量上百的情况下,使用HashMap:会有更高的效率。

在ArrayMap中,假设存在冲突的话,并不会像HashMap那样使用单向链表或红黑树来保留这些冲突的元素,而是全部key、value都存储到一个数组当中,然后查找的话通过二分查找进行,这也就是当数据量大时不宜用ArrayMap的原因了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容