Android Gems — Java源码分析之HashMap和SparseArray

SparseArray是Google为了提高性能替换HashMap<Integer, Object>而推荐使用的容器类,本文从源码的角度来了解一下两种容器的实现原理,从而分析一下其性能对比。

HashMap是hash函数+拉链法实现,SparseArray则使用的是有序数组+二分查找实现。HashMap的性能影响主要体现:占用内存过大,插入过程内存动态增长的扩容操作代价,hash冲突时的链表遍历。SparseArray的性能影响主要体现:查找时的二分查找,插入时为了维护有序数组的数组的移位操作。

性能对比:

1,内存占用

插入100万条数据,SparseArray占内存40M,HashMap占内存80M,一倍的差距

2,升序插入

插入100万条数据,插入的数值是升序的,SparseArray耗时1.1秒,HashMap耗时2.0秒,看起来还不错,后面分析源码的时候会解释,如果是升序的,那么SparseArray基本没有维护有序数组的开销,所以升序的时候,SparseArray性能是最好的。

3,降序插入

插入10万条数据,插入的数值是降序的,SparseArray耗时25.0秒,HashMap耗时0.2秒,性能差100倍,降序是SparseArray的最差效果,相当于每次插入,都必须对整个数组做拷贝。

4,随机插入
插入10万条数据,插入的数值是随机序的,SparseArray耗时10.5秒,HashMap耗时0.27秒,性能差50倍,差距正好介于升序和降序之间。所以如果你的key值插入如果是接近于升序的话,那么用SparseArray的性能是极好的。HashMap是hash算法,和key的值大小并无相关,所以不管key是什么样的顺序,耗时都差不多。

5,查询操作

100万次查询,SparseArray耗时300毫秒左右,HashMap耗时1000毫秒左右,HashMap是SparseArray的3倍。这是因为SparseArray的查询就是二分,而HashMap由于有内存限制,所以会导致出现hash冲突,拉链法占了些时间,所以虽然理论上HashMap是O(1)的,实际上比二分要差,如果内存足够的话会逼近O(1)


数据分析完毕,我们来分析HashMap和SparseArray的源码,解释一下以上出现的现象。先从HashMap入手:

HashMap的核心数据结构是HashMapEntry<K, V>[] table,构造的时候可以指定容量大小,不指定就是empty table。指定了capacity时,调用makeTable来创建新表。

    public HashMap() {
        table = (HashMapEntry<K, V>[]) EMPTY_TABLE;
        threshold = -1; // Forces first put invocation to replace EMPTY_TABLE
    }

    public HashMap(int capacity) {
        if (capacity < 0) {
            throw new IllegalArgumentException("Capacity: " + capacity);
        }

        if (capacity == 0) {
            HashMapEntry<K, V>[] tab = (HashMapEntry<K, V>[]) EMPTY_TABLE;
            table = tab;
            threshold = -1; // Forces first put() to replace EMPTY_TABLE
            return;
        }

        if (capacity < MINIMUM_CAPACITY) {
            capacity = MINIMUM_CAPACITY;
        } else if (capacity > MAXIMUM_CAPACITY) {
            capacity = MAXIMUM_CAPACITY;
        } else {
            capacity = Collections.roundUpToPowerOfTwo(capacity);
        }
        makeTable(capacity);
    }
makeTable除了新建capacity大小的table数组之外,还会将threshold设置为3/4的capicity,threshold是一个阈值,当HashMap的size达到这个阈值以后,就会调用doubleCapacity函数对HashMap进行扩容,顾名思义capacity增长一倍,扩容会将当前的table的数据copy到新的table里。扩容是比较耗时的,需要避免扩容的次数。

doubleCapacity的源码我们稍后再分析,先看HashMap的基本操作get/put/remove。先看put操作:

       @Override 
        public V put(K key, V value) {
        if (key == null) {
            return putValueForNullKey(value);
        }
        int hash = Collections.secondaryHash(key);
        HashMapEntry<K, V>[] tab = table;
        int index = hash & (tab.length - 1);
        for (HashMapEntry<K, V> e = tab[index]; e != null; e = e.next) {
            if (e.hash == hash && key.equals(e.key)) {
                preModify(e);
                V oldValue = e.value;
                e.value = value;
                return oldValue;
            }
        }
        // No entry for (non-null) key is present; create one
        modCount++;
        if (size++ > threshold) {
            tab = doubleCapacity();
            index = hash & (tab.length - 1);
        }
        addNewEntry(key, value, hash, index);
        return null;
    }
首先调用Collections.secondaryHash函数计算key的hash值,secondaryHash函数就不分析了,就是用key的hashcode计算hash值。接着通过hash值来计算桶的下标index = hash & (tab.length - 1),从这也可以看出capacity越大,hash冲突的可能性就越小,复杂度也会越逼近O(1)。capacity的值一定是2的幂次,所以hash和 tab.length - 1的与,其实就等于求模,所以index的大小是 < tab.length的,去index号的桶,里面放了hash冲突的entry的链表,再遍历该key的entry是否存在,存在则用新的value替换oldValue,不存在则新增进链表。每次push的时候,如果新增一个entry,size会自增,需要判断是否超过threshold,超过则需要doubleCapacity。

    private HashMapEntry<K, V>[] doubleCapacity() {
        HashMapEntry<K, V>[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            return oldTable;
        }
        int newCapacity = oldCapacity * 2;
        HashMapEntry<K, V>[] newTable = makeTable(newCapacity);
        if (size == 0) {
            return newTable;
        }
        for (int j = 0; j < oldCapacity; j++) {
            HashMapEntry<K, V> e = oldTable[j];
            if (e == null) {
                continue;
            }
            int highBit = e.hash & oldCapacity;
            HashMapEntry<K, V> broken = null;
            newTable[j | highBit] = e;
            for (HashMapEntry<K, V> n = e.next; n != null; e = n, n = n.next) {
                int nextHighBit = n.hash & oldCapacity;
                if (nextHighBit != highBit) {
                    if (broken == null)
                        newTable[j | nextHighBit] = n;
                    else
                        broken.next = n;
                    broken = e;
                    highBit = nextHighBit;
                }
            }
            if (broken != null)
                broken.next = null;
        }
        return newTable;
    }
MAXIMUM_CAPACITY是最大的capacity,超过就不增了。否则newCapacity就增长一倍,增长之后,还需要把oldTable的entry都copy到newTable,所以需要遍历oldTable, for (int j = 0; j < oldCapacity; j++),对每个桶的所有entry,做重新hash。因为capacity变了,而之前entry的index由hash & (capacity - 1)也会变,新的newTable的index算法如下:由于newCapacity是增长一倍,newTable的index相当于比oldTable的index增加了一个最高位,而e.hash & oldCapacity其实就是拿到最高位,因此j|nextHighBit就是newTable的index,同理对这个桶的链表都做rehash。由此可见doubleCapacity相当于对所有entry都遍历了一遍。同样HashMap有ensureCapacity函数,可以提前申请内存,这样能减少doubleCapacity的次数。


接着看HashMap.get函数的实现:

    public V get(Object key) {
        if (key == null) {
            HashMapEntry<K, V> e = entryForNullKey;
            return e == null ? null : e.value;
        }

        int hash = Collections.secondaryHash(key);
        HashMapEntry<K, V>[] tab = table;
        for (HashMapEntry<K, V> e = tab[hash & (tab.length - 1)];
                e != null; e = e.next) {
            K eKey = e.key;
            if (eKey == key || (e.hash == hash && key.equals(eKey))) {
                return e.value;
            }
        }
        return null;
    }
看了put,再看get,就相当简单,直接计算hash,并根据hash & (tab.length - 1)来查找链表项。get的性能完全取决于hash的冲突率,冲突得越多,就表示链表项越多,耗时也就越多。而冲突率主要是看hash函数的均匀性,entry的个数为size,而size是小于< threshold的,threshold是capacity的3/4,所以entry的个数是小于当前的table数组大小,一来浪费了内存,另一方面增加了额外的链表遍历。从另一个角度看的话,HashMap理论上完成可以做到无冲突,这完全取决于hash函数,如果能设计个牛逼的hash函数,既可以降低内存使用,也同时提升了查询的性能。由之前的数据可知,HashMap的内存占用比SparseArray多一倍,查询的性能也是SparseArray的三倍。


最后分析remove函数:

    @Override 
    public V remove(Object key) {
        if (key == null) {
            return removeNullKey();
        }
        int hash = Collections.secondaryHash(key);
        HashMapEntry<K, V>[] tab = table;
        int index = hash & (tab.length - 1);
        for (HashMapEntry<K, V> e = tab[index], prev = null;
                e != null; prev = e, e = e.next) {
            if (e.hash == hash && key.equals(e.key)) {
                if (prev == null) {
                    tab[index] = e.next;
                } else {
                    prev.next = e.next;
                }
                modCount++;
                size--;
                postRemove(e);
                return e.value;
            }
        }
        return null;
    }
HashMap的remove操作和get差不多,只不过查询到之后会把entry从链表里移除,并对size自减1,而HashMap的table内存并不调整,因此HashMap一旦涨了内存,就降不下来了。


分析完了HashMap,我们再看SparseArray,SparseArray的key和value是分两个数组存放的,int[] mKeys,Object[] mValues。mKeys是个有序的数组,mKeys的下标和mValues的下标是对应的,比如entry(key = 3, value = 1),3在mKey的下标是5,那么mValues[5]的值就是value = 1。构造函数就不说了,可以设置初始容量。我们先看put操作:

    public void put(int key, E value) {
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

        if (i >= 0) {
            mValues[i] = value;
        } else {
            i = ~i;

            if (i < mSize && mValues[i] == DELETED) {
                mKeys[i] = key;
                mValues[i] = value;
                return;
            }

            if (mGarbage && mSize >= mKeys.length) {
                gc();

                // Search again because indices may have changed.
                i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
            }

            mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
            mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
            mSize++;
        }
    }
首先二分查找key值在mKeys的下标,如果命中那么直接对mValues赋值。如果没找到,ContainerHelpers.binarySearch没找到的情况下,返回的值取反,刚好是将要插入的位置,如果这个将要插入的位置是空的(DELETED),那么可以直接复用,否则就需要调用 GrowingArrayUtils.insert和GrowingArrayUtils.insert插入新的key和value,插入的时候,如果数组的capacity不够了,同样是按照double来扩容,同时使用System.arraycopy来移数组,从而保证mKeys还是升序数组。从这就可以解释之前的性能测试结果,如果插入顺序也是升序的话,那么插入的位置就在最后,就可以省了System.arraycopy,如果是降序的话,那么每次插入都在第一个,那么每次都对整个数组做移动。SparseArray还有一个append函数,他对key比mKeys的所有元素都大的插入case做了优化,省了无用的binarySearch。综上,SparseArray对于插入非常频繁的场景,其实并不太适合。


SparseArray.get:

    public E get(int key, E valueIfKeyNotFound) {
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

        if (i < 0 || mValues[i] == DELETED) {
            return valueIfKeyNotFound;
        } else {
            return (E) mValues[i];
        }
    }
get简单,就是一次二分查找。


SparseArray.delete:

    public void delete(int key) {
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

        if (i >= 0) {
            if (mValues[i] != DELETED) {
                mValues[i] = DELETED;
                mGarbage = true;
            }
        }
    }
delete同样就一次binarySearch,delete操作做了lazy delete,先置DELETED标识位,等到entry的size大于mKeys数组的大小的时候,就可以把标识为DELETED的entry删除了。

总结:

SparseArray在内存占用、查询、删除方面,有比较好的性能,但在于插入方面表现较差。

HashMap主要问题在于内存使用上,在查询、删除、插入上的性能比较均衡。如果要再提升性能只能设计更牛逼的hash函数。



作者简介:

田力,网易彩票Android端创始人,小米视频创始人,现任roobo技术经理、视频云技术总监


欢迎关注微信公众号 磨剑石,定期推送技术心得以及源码分析等文章,谢谢


  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值