赞
踩
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技术经理、视频云技术总监
欢迎关注微信公众号 磨剑石,定期推送技术心得以及源码分析等文章,谢谢
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。