ArrayMap 原理

一 概述

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

然而,你是否了解这么广泛使用的基础数据结构存在缺陷?要回答这个问题,需要先从源码角度来理解 ArrayMap 的原理。

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

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

HashMap 的查找和插入时间复杂度为 O(1) 的代价是牺牲大量的内存来实现的,而 SparseArray 和 ArrayMap 性能略逊于 HashMap,但更节省内存。

接下来,从源码看看 ArrayMap,为了全面解读,文章有点长,请耐心阅读。

二 源读 ArrayMap

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;
    static int mTwiceBaseCacheSize;

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

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

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

在这里插入图片描述
其中 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 对象。为了节省内存使用了更加保守的内存扩张以及内存收缩策略。 接下来分别说说缓存机制和扩容机制。

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++;
            }
        }
    }
}

最初 mTwiceBaseCache 和 mBaseCache 缓存池中都没有数据,在 freeArrays 释放内存时,如果同时满足释放的 array 大小等于 4 或者 8,且相对应的缓冲池个数未达上限,则会把该 arrya 加入到缓存池中。

加入的方式是将数组 array 的第 0 个元素指向原有的缓存池,第 1 个元素指向 hashes 数组的地址,第 2 个元素以后的数据全部置为 null。再把缓存池的头部指向最新的 array 的位置,并将该缓存池大小执行加 1 操作。具体如下所示。
在这里插入图片描述
ArrayMap 的 freeArrays() 的触发条件:

  • 当执行 removeAt() 移除最后一个元素的情况
  • 当执行 clear() 清理的情况
  • 当执行 ensureCapacity() 在当前容量小于预期容量的情况下,先执行 allocArrays,再执行 freeArrays
  • 当执行 put() 在容量满的情况下,先执行 allocArrays,再执行 freeArrays

2.2.2 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,且相对应的缓冲池不为空,则会从相应缓存池中取出缓存的 mArray 和 mHashes。

从缓存池取出缓存的方式是将当前缓存池赋值给 mArray,将缓存池指向上一条缓存地址,将缓存池的第 1 个元素赋值为 mHashes,再把 mArray 的第 0 和第 1 个位置的数据置为 null,并将该缓存池大小执行减 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.3 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 的差异在于,该方法不会去做扩容的操作,是一个轻量级的插入方法。 那么什么场景适合使用 append() 方法呢?答应就是对于明确知道肯定会插入队尾的情况下使用 append() 性能更好,因为 put() 上来先做 binarySearchHashes() 二分查找,时间复杂度为 O(logN),而 append() 的时间复杂度为 O(1)。

2.4.4 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() 则只会清空数组内的数据,并不会回收内存。

三 ArrayMap 缺陷分析

3.1 异常现象

接下来看看 ArrayMap 的缺陷。事实上对 ArrayMap 的不恰当使用,概率会导致系统重启,对于不少应用在使用 ArrayMap 过程中,会抛出如下异常,以下是利用缺陷模拟场景后,然后在单线程里面首次执行如下语句则抛出异常。

ArrayMap map = new ArrayMap(4)

这只是一条基本的对象实例化操作,居然也能报出如下异常,是不是很神奇?这是低概率问题,本地难以复现,之所以能模拟出来,是因为先把这个缺陷研究明白了,再做的模拟验证过程。

FATAL EXCEPTION: Thread-20
Process: com.tony.arraymapdemo, PID: 29003
java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Object[]
    at com.tony.arraymapdemo.application.ArrayMap.allocArrays(ArrayMap.java:178)
    at com.tony.arraymapdemo.application.ArrayMap.<init>(ArrayMap.java:255)
    at com.tony.arraymapdemo.application.ArrayMap.<init>(ArrayMap.java:238)
    at com.tony.arraymapdemo.application.MainActivity$4.run(MainActivity.java:240)

先来看看异常调用栈所对应的代码如下:

private void allocArrays(final int size) {
    if (size == (BASE_SIZE*2)) {  
        ......
    } 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;
            }
        }
    }
    ......
}

3.2 深入分析

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

从异常调用栈来看,说明从缓存池中取出这条缓存的第 0 号元素被破坏,由于 ArrayMap 是非线程安全的,除了静态变量 mBaseCache 和 mTwiceBaseCache 加类锁保护,其他成员变量并没有保护。可能修改 array[0] 的地方 put、append、removeAt、erase 等方法,此处省去分析过程,最终有两种情况:

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

场景一:

// 线程 A
public V removeAt(int index) {
    ...
    final int osize = mSize;
    if (osize <= 1) {
        freeArrays(mHashes, mArray, osize); // 进入方法体
        mHashes = EmptyArray.INT;
        mArray = EmptyArray.OBJECT;
    }
    ...
}

private static void freeArrays(final int[] hashes, final Object[] array, final int size) {
    if (hashes.length == (BASE_SIZE*2)) {
        ...
    } else if (hashes.length == BASE_SIZE) {
        synchronized (ArrayMap.class) {
            if (mBaseCacheSize < CACHE_SIZE) {
                array[0] = mBaseCache;           // CODE 1:此处 array 就是 mArray
                array[1] = hashes;
                for (int i=(size<<1)-1; i>=2; i--) {
                    array[i] = null;
                }
                mBaseCache = array;
                mBaseCacheSize++;
        }
    }
}
// 线程 B
public V put(K key, V value) {
    ...
    mHashes[index] = hash;
    mArray[index<<1] = key;    // CODE 2: 当 index=0 的情况,修改 array[0]
    mArray[(index<<1)+1] = value;
    mSize++;
    return null;
}
// 线程 C
ArrayMap map = new ArrayMap(4)// CODE 3: 躺枪

有三个线程,执行流程如下:

  • 首先线程 A 执行到刚执行完 freeArrays 的 CODE 1 处代码

  • 然后线程 B 开始执行 put() 的 CODE 2 处代码,再次修改 array[0] 为 String 字符串;那么此时缓存池中有了一条脏数据,线程 A 和 B 的工作已完成

  • 这时线程 C 开始执行 CODE 3,则会躺枪,直接抛出 ClassCastException 异常

如果你的队友在某处完成上述步骤 1 和 2,自己还安然执行完成,该你的代码上场执行,需要使用 ArrayMap 的时候,刚实例化操作就挂了,这时你连谁挖的坑估计都找不到。

一般来说,一个 APP 往往由很多人协作开发,难以保证每个人都水平一致,即便你能保证队友,那引入的第三方 JAR 出问题呢。

当我正在修复该问题时,查阅最新源码,发现 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 内容引用,从而不会干扰缓存回收的操作。

场景二:

// 线程 A
private void allocArrays(final int size) {
    if (size == (BASE_SIZE*2)) {
        ...
    } else if (size == BASE_SIZE) {
        synchronized (ArrayMap.class) {
            if (mBaseCache != null) {
                final Object[] array = mBaseCache;
                mArray = array;                   // CODE 1:将array引用暴露出去
                mBaseCache = (Object[])array[0];  //CODE 3
                mHashes = (int[])array[1];
                array[0] = array[1] = null;
                mBaseCacheSize--;
                return;
            }
        }
    }
    mHashes = new int[size];
    mArray = new Object[size<<1];
}
// 线程 B
public V put(K key, V value) {
    ...
    mHashes[index] = hash;
    mArray[index<<1] = key;    // CODE 2: 当 index=0 的情况,修改 array[0]
    mArray[(index<<1)+1] = value;
    mSize++;
    return null;
}

有两个线程,执行流程如下:

  • 首先线程 A 刚执行 allocArrays() 的 CODE1 处,将 array 引用暴露出去
  • 然后线程 B 执行完 CODE2 处,修改修改 array[0]
  • 这时线程 A 执行到 CODE3,则会抛出 ClassCastException

这种情况往往是自己造成的多线程问题,抛出异常的也会在自己的代码逻辑里面,不至于给别人挖坑。 这个修复比较简单,把上面的 CODE1 向下移动两行,先完成 CODE3,再执行 CODE1。

有了这两处修复,是不是完全解决问题呢,答案是否定的,依然还是有概率出现异常。

3.3 终极分析

经过大量尝试与研究,最终找到一种可以把缓存链变成缓存池的场景,这个场景比较复杂,省略 N 多字,就不说细节。直接上结论,简单来说就是 Double Free,同一个 ArrayMap 实例被连续两次 freeArrays(),这需要并发碰撞。两个线程都同时执行到 CODE 1,这样两个线程都能把 mArray 保存在各自的局部变量里,然后就是 double free。

public V removeAt(int index) {
    final int osize = mSize;
    if (osize <= 1) {
        final int[] ohashes = mHashes; 
        final Object[] oarray = mArray;  //CODE 1
        mHashes = EmptyArray.INT;
        mArray = EmptyArray.OBJECT;      //CODE 2
        freeArrays(ohashes, oarray, osize); 
        nsize = 0;
    } else {

即便出现 double free,也不一定会出现异常,因为调用 allocArrays() 方法后,会把 array[0]=null,这时 mBaseCache=null,也就是缓存池中的数据清空。 就这样这种情况,在分析过程被否定过。最终经过反复推敲,为了满足各方条件需要,终于制造了案发现场如下:
在这里插入图片描述
这个场景的条件有两个:

  • 必须要 double free 的 ArrayMap 实例(map2)的前面至少存在一个缓存(map1)
  • 必须在 double free 的 ArrayMap 实例(map2)的后面立即存放一个以上其他缓存(map3)

步骤1:由于 map2 所对应的 mArray 释放了两次导致缓存链变成了缓存环,如下图:
在这里插入图片描述
步骤2:通过创建新的 ArrayMap 从该缓存环中的 map2 和 map3 这两条缓存,实例如下代码

ArrayMap map4 = new ArrayMap(4);  // 取出 map2 缓存
map4.append("a11", "v11");    // 修改 map2 的 array[0
ArrayMap map5 = new ArrayMap(4); // 取出 map3 缓存

如果你足够熟悉前面的内存分配与回收过程,就会发现在这种缓存环的情况下,还会留下一条脏数据 map2 在缓存池 mBaseCache,这就形成了一个巨大的隐形坑位,并且难以复现与定位,如下图。
在这里插入图片描述
步骤3:缓存池中的坑位已准备就绪,这个坑可能是项目中引入的第三方 JAR 包,或者是 sdk,再或者是你的队友不小心给你挖的。此时你的代码可能仅仅执行 ArrayMap 的构造方法,那么就会抛出如下异常。

ATAL EXCEPTION: Thread-20
Process: com.gityuan.arraymapdemo, PID: 29003
java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Object[]
    at com.gityuan.arraymapdemo.application.ArrayMap.allocArrays(ArrayMap.java:178)
    at com.gityuan.arraymapdemo.application.ArrayMap.<init>(ArrayMap.java:255)
    at com.gityuan.arraymapdemo.application.ArrayMap.<init>(ArrayMap.java:238)
    at com.gityuan.arraymapdemo.application.MainActivity$4.run(MainActivity.java:240

当你去查询 API 文档资料,只告诉你 ArrayMap 是非线程安全的,不能多线程操作,于是你一遍遍地反复 Review 着自己写的代码,可以确信没有并发操作,却事实能抛出这样的异常,关键是这样的问题难以复现,只有这个异常栈作为唯一的信息,怎么也没想到这是其他地方使用不当所造出来的坑。 既然是由于 double free 导致的缓存池出现环,进而引发的问题,那应该如何修复呢,这里不讲,留给读者们自行思考。

四 知识延伸

4.1 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 直接转换成哈希表中的存储位置,而哈希表本质是一个数组,在指定下标的情况下查找数组成员是一步到位的。

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

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

HashMap 是非线程安全的类,并为了避免开发者错误地使用,在每次增加、删除、清空操作的过程会将 modCount 次数加 1。在一些关键方法内刚进入的时候记录当前的 mCount 次数,执行完核心逻辑后,再检测 mCount 是否被其他线程修改,一旦被修改则说明有并发操作,则抛出 ConcurrentModificationException 异常,这一点的处理比 ArrayMap 更全面。

HashMap 扩容机制:

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

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 使用二分查找来找到 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];
}

对于 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 更有全面

ConcurrentModificationException 这种异常机制只是为了提醒开发者不要多线程并发操作,这里强调一下千万不要并发操作 ArrayMap 和 HashMap。

本文重点介绍了 ArrayMap 的缺陷,这个缺陷是由于在开发者没有遵循非线程安全来不可并发操作的原则,从而引入了脏缓存导致其他人掉坑的问题。

从另外类 ArraySet 来看,Google 是知道有 ClassCastException 异常的问题,无法追溯根源,所以谷歌直接 catch 住这个异常,然后把缓冲池清空,再创建数组。 这样也不失为一种合理的解决方案,唯一遗憾的是触发这种情况时会让缓存失效,由于这个清楚是非常概率,绝大多数场景缓存还是有效的。

最后说一点,ArrayMap 这个缺陷是极低概率的,并且先有人没有做好 ArrayMap 的并发引入的坑才会出现这个问题。只要大家都能保证并发安全也就没有这个缺陷,只有前面讲的优势。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值