Android Gems — Java源码分析之HashMap和SparseArray
Posted threepigs
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了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技术经理、视频云技术总监
欢迎关注微信公众号 磨剑石,定期推送技术心得以及源码分析等文章,谢谢
以上是关于Android Gems — Java源码分析之HashMap和SparseArray的主要内容,如果未能解决你的问题,请参考以下文章
Android Gems — Java源码分析之HashMap和SparseArray
Android Gems — Java源码分析之HashMap和SparseArray
Android Gems — Fragment本质之View管理
Android Gems — Fragment本质之View管理