当前位置:   article > 正文

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

gems源码

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来创建新表。

  1. public HashMap() {
  2. table = (HashMapEntry<K, V>[]) EMPTY_TABLE;
  3. threshold = -1; // Forces first put invocation to replace EMPTY_TABLE
  4. }
  5. public HashMap(int capacity) {
  6. if (capacity < 0) {
  7. throw new IllegalArgumentException("Capacity: " + capacity);
  8. }
  9. if (capacity == 0) {
  10. HashMapEntry<K, V>[] tab = (HashMapEntry<K, V>[]) EMPTY_TABLE;
  11. table = tab;
  12. threshold = -1; // Forces first put() to replace EMPTY_TABLE
  13. return;
  14. }
  15. if (capacity < MINIMUM_CAPACITY) {
  16. capacity = MINIMUM_CAPACITY;
  17. } else if (capacity > MAXIMUM_CAPACITY) {
  18. capacity = MAXIMUM_CAPACITY;
  19. } else {
  20. capacity = Collections.roundUpToPowerOfTwo(capacity);
  21. }
  22. makeTable(capacity);
  23. }
makeTable除了新建capacity大小的table数组之外,还会将threshold设置为3/4的capicity,threshold是一个阈值,当HashMap的size达到这个阈值以后,就会调用doubleCapacity函数对HashMap进行扩容,顾名思义capacity增长一倍,扩容会将当前的table的数据copy到新的table里。扩容是比较耗时的,需要避免扩容的次数。

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

  1. @Override
  2. public V put(K key, V value) {
  3. if (key == null) {
  4. return putValueForNullKey(value);
  5. }
  6. int hash = Collections.secondaryHash(key);
  7. HashMapEntry<K, V>[] tab = table;
  8. int index = hash & (tab.length - 1);
  9. for (HashMapEntry<K, V> e = tab[index]; e != null; e = e.next) {
  10. if (e.hash == hash && key.equals(e.key)) {
  11. preModify(e);
  12. V oldValue = e.value;
  13. e.value = value;
  14. return oldValue;
  15. }
  16. }
  17. // No entry for (non-null) key is present; create one
  18. modCount++;
  19. if (size++ > threshold) {
  20. tab = doubleCapacity();
  21. index = hash & (tab.length - 1);
  22. }
  23. addNewEntry(key, value, hash, index);
  24. return null;
  25. }
首先调用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。

  1. private HashMapEntry<K, V>[] doubleCapacity() {
  2. HashMapEntry<K, V>[] oldTable = table;
  3. int oldCapacity = oldTable.length;
  4. if (oldCapacity == MAXIMUM_CAPACITY) {
  5. return oldTable;
  6. }
  7. int newCapacity = oldCapacity * 2;
  8. HashMapEntry<K, V>[] newTable = makeTable(newCapacity);
  9. if (size == 0) {
  10. return newTable;
  11. }
  12. for (int j = 0; j < oldCapacity; j++) {
  13. HashMapEntry<K, V> e = oldTable[j];
  14. if (e == null) {
  15. continue;
  16. }
  17. int highBit = e.hash & oldCapacity;
  18. HashMapEntry<K, V> broken = null;
  19. newTable[j | highBit] = e;
  20. for (HashMapEntry<K, V> n = e.next; n != null; e = n, n = n.next) {
  21. int nextHighBit = n.hash & oldCapacity;
  22. if (nextHighBit != highBit) {
  23. if (broken == null)
  24. newTable[j | nextHighBit] = n;
  25. else
  26. broken.next = n;
  27. broken = e;
  28. highBit = nextHighBit;
  29. }
  30. }
  31. if (broken != null)
  32. broken.next = null;
  33. }
  34. return newTable;
  35. }
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函数的实现:

  1. public V get(Object key) {
  2. if (key == null) {
  3. HashMapEntry<K, V> e = entryForNullKey;
  4. return e == null ? null : e.value;
  5. }
  6. int hash = Collections.secondaryHash(key);
  7. HashMapEntry<K, V>[] tab = table;
  8. for (HashMapEntry<K, V> e = tab[hash & (tab.length - 1)];
  9. e != null; e = e.next) {
  10. K eKey = e.key;
  11. if (eKey == key || (e.hash == hash && key.equals(eKey))) {
  12. return e.value;
  13. }
  14. }
  15. return null;
  16. }
看了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函数:

  1. @Override
  2. public V remove(Object key) {
  3. if (key == null) {
  4. return removeNullKey();
  5. }
  6. int hash = Collections.secondaryHash(key);
  7. HashMapEntry<K, V>[] tab = table;
  8. int index = hash & (tab.length - 1);
  9. for (HashMapEntry<K, V> e = tab[index], prev = null;
  10. e != null; prev = e, e = e.next) {
  11. if (e.hash == hash && key.equals(e.key)) {
  12. if (prev == null) {
  13. tab[index] = e.next;
  14. } else {
  15. prev.next = e.next;
  16. }
  17. modCount++;
  18. size--;
  19. postRemove(e);
  20. return e.value;
  21. }
  22. }
  23. return null;
  24. }
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操作:

  1. public void put(int key, E value) {
  2. int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
  3. if (i >= 0) {
  4. mValues[i] = value;
  5. } else {
  6. i = ~i;
  7. if (i < mSize && mValues[i] == DELETED) {
  8. mKeys[i] = key;
  9. mValues[i] = value;
  10. return;
  11. }
  12. if (mGarbage && mSize >= mKeys.length) {
  13. gc();
  14. // Search again because indices may have changed.
  15. i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
  16. }
  17. mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
  18. mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
  19. mSize++;
  20. }
  21. }
首先二分查找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:

  1. public E get(int key, E valueIfKeyNotFound) {
  2. int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
  3. if (i < 0 || mValues[i] == DELETED) {
  4. return valueIfKeyNotFound;
  5. } else {
  6. return (E) mValues[i];
  7. }
  8. }
get简单,就是一次二分查找。


SparseArray.delete:

  1. public void delete(int key) {
  2. int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
  3. if (i >= 0) {
  4. if (mValues[i] != DELETED) {
  5. mValues[i] = DELETED;
  6. mGarbage = true;
  7. }
  8. }
  9. }
delete同样就一次binarySearch,delete操作做了lazy delete,先置DELETED标识位,等到entry的size大于mKeys数组的大小的时候,就可以把标识为DELETED的entry删除了。

总结:

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

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



作者简介:

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


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


声明:本文内容由网友自发贡献,转载请注明出处:【wpsshop博客】
推荐阅读
相关标签
  

闽ICP备14008679号