在Android开发中,性能优化是一个非常重要的模块,其中数据结构的性能优化是相当重要的,对于常用的HashMap来说,官方推荐我们使用SparseArray和ArrayMap替代它。
Java为数据Java为数据结构中的映射定义了一个接口java.util.Map,此接口主要有四个常用的实现类,分别是HashMap、Hashtable、LinkedHashMap和TreeMap,类的继承关系如图所示:
首先我们来介绍一下HashMap,了解它的优缺点,然后再对比一下其他的数据结构以及为什么要替代它。
HashMap
HashMap是由数组+单向链表的方式组成的,初始大小是16(2的4次方),首次put的时候,才会真正初始化。
链表长度大于8时转化成红黑树,小于6时又转化成链表。HashMap类中一个非常重要的字段Node[] table
,即哈希桶数组,我们来看一下Node这个类
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //用来定位数组索引位置
final K key;
V value;
Node<K,V> next; //链表的下一个node
Node(int hash, K key, V value, Node<K,V> next) { ... }
public final K getKey(){ ... }
public final V getValue() { ... }
public final String toString() { ... }
public final int hashCode() { ... }
public final V setValue(V newValue) { ... }
public final boolean equals(Object o) { ... }
复制代码
-
为什么要引入红黑树?
<p>JDK 1.8以前是数组+链表,还未引入红黑树,这就导致了链表过长时查找的时间复杂度是O(n), 完全失去了设计HashMap时的设计初衷,针对这种情况JDK 1.8引入了红黑树(查找的时间复杂度为O(logN),什么是红黑树呢,红黑树是一种自平衡的二叉查找树,不是一种绝对平衡的二叉树,它放弃了追求绝对平衡,追求大致平衡,在与平衡二叉树的时间复杂度相差不大的情况下,保证每次插入最多只需要三次旋转就能达到平衡,实现起来也更为简单。从而获得更高的查找性能。</p> </li> <li> <p>为什么初始值大小为2的N次方,以后每次扩容后的结果也是2的N次方?</p> <p>hashmap的初始值是16,即2的4次方,之后的每次扩容都是两倍扩容,为什么要这么设计呢?我觉得有一下几点:</p> <ul> <li>如果往hashmap中存放数据,我们首先得保证它能够尽量均匀分布,为了保证能够均匀分布,我们可能会想到用取模的方式去实现,如果用传统的‘%’方式来实现效率不高,当大小(length)总为2的n次方时,h&(length-1)运算等价于对length取模,也就是h%lenth,但是&比%具有更高的效率,同时也减少了hash碰撞。</li> <li>和h&(length-1)相关,当容量大小(n)为2的n次方时,n-1 的二进制的后几位全是1,在h为随机数的情况下,与(n-1)进行与操作时,会分布的更均匀,想一想,如果n-1的二进制数是1110,当尾数为0时,与出来的值尾数永远为0,那么0001,1001,1101等尾数为1的位置就永远不可能被entry占用,就造成了空间浪费。</li> </ul> <p><strong>在hashmap源码中,put方法会调用indexFor方法,这个方法主要是根据key的hash值找到这个entry在table中的位置,最后 return 的是 h&(length-1)</strong></p> <p>这里还要提到一点,程序中的所有数在计算机内存中都是以二进制的形式储存的。位运算就是直接对整数在内存中的二进制位进行操作。在Java中,位运算符有很多,例如与(&)、非(~)、或(|)、异或(^)、移位(<<和>>)等,<strong>Java的运算最终都会转化成位运算</strong>。</p> </li> <li> <p>HashMap和其他数据结构的关联</p> <ul> <li> <p>与Hashtable的关系,Hashtable是线程安全的,比如put,get等很多使用了synchronized修饰,和ConcurrentHashMap相比,在Hashtable的大小增加到一定的时候,效率会急剧下降,因为迭代时需要被锁定很长的时间(而ConcurrentHashMap引入了分割,仅仅需要锁定map的某个部分)所以其实效率并不高。再看看Hashtable的put方法</p> <pre>
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
HashtableEntry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
HashtableEntry<K,V> entry = (HashtableEntry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
复制代码
<p>可以看到,里面的index是用%的方式进行取下标,看起来效率也不行啊,最后从命名上看HashMap和Hashtable,Hashtable明显没遵循驼峰式命名规则,这可能也是它被弃用原因之一(哈哈哈)。</p>
<ul>
<li>
<p>与HashSet的关系,我们先来看HashSet的add方法</p>
<pre>
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
复制代码
<p>这里面的map是一个HashMap,e是放入的元素,value此时有一个统一的值:</p>
<p><strong>private static final Object PRESENT = new Object();</strong></p>
<p>可以看出,其实HashSet就是一个限制功能了的HashMap,如果你了解了HashMap,HashSet你自然就懂了。虽然说Set对于重复的元素不放入,倒不如直接说是底层的Map直接把原值替代了。HashSet没有提供get方法,原因同HashMap一样,Set内部是无序的,只能通过迭代的方式获取。</p>
<ul>
<li>与LinkedHashMap的关系,LinkedHashMap是一个数组+双向链表与HashMap不同<a href="https://www.breezeinn.cn" title="分离小三">,</a>可以保证元素的迭代顺序,该迭代顺序可以是插入顺序或者是访问顺序(LRU原理)。</li>
<li>与LinkedHashSet的关系,LinkedHashSet是继承自HashSet,底层实现是LinkedHashMap。</li>
<li>TreeSet中存放的元素是有序的(不是插入时的顺序,是有按关键字大小排序的),且元素不能重复。</li>
</ul>
</li>
<li>
<p>HashMap在Java7和Java8的变化</p>
<p>比如扩容时是否重新Hash和引入红黑树</p>
</li>
<li>
<p>链表长度大于8一定会转化成红黑树吗?</p>
<p>下面做个小测验,链表长度大于8是否一定会转化成红黑树,首先我们自定义一个key类</p>
<pre>
private class Key implements Comparable<Key> {
private final int value;
Key(int value) {
this.value = value;
}
@Override
public int compareTo(Key o) {
return Integer.compare(this.value, o.value);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass())
return false;
Key key = (Key) o;
return value == key.value;
}
@Override
public int hashCode() {
return 1;
}
}
复制代码
<p>可以看到我重写了hashcode方法,所有的key的hashcode的值都是1,下面是我的测试方法</p>
<pre>
public void test(){
Map<Key, String> map = new HashMap<>();
map.put(new Key(1), “13”);
map.put(new Key(2), “23”);
map.put(new Key(3), “33”);
map.put(new Key(4), “43”);
map.put(new Key(5), “53”);
map.put(new Key(6), “63”);
map.put(new Key(7), “73”);
map.put(new Key(8), “83”);
map.put(new Key(9), “93”);
map.put(new Key(10),“103”);
map.put(new Key(11),“104”);
map.put(new Key(12),“123”);
}
复制代码
<p>我们可以先大致预想一下结果,一开始前8个数都是在同一个链表上,然后超过8个转化为红黑树。这里有个关键参数</p>
<p><strong>static final int MIN_TREEIFY_CAPACITY = 64</strong></p>
<p>然后我们再看看链表转化为红黑树的源码,可以得出</p>
</li>
就算链表长度超过8,进入了这个转化为红黑树的方法,仔细看第756行,还是要判断当前容量的大小是否小于64,如果小于这个值还是要进行扩容而不是转化为红黑树,然后你应该可以看到上面那个test()方法的结果了,在put第9个值后,扩容为32,put第10个值后,扩容为64,这时链表的长度已经超过8了啊(因为已经写死了hashcode方法),直到put第11个值后,才开始转化为红黑树。下面是我用调试这个方法时的截图,可供参考
-
HashMap并发的问题
<p>HashMap不是线程安全的,在Java7 中,HashMap被多个线程操作可能造成形成环形链表,造成死循环。可以用ConcurrentHashMap来解决,Java7 中ConcurrentHashMap 是用分段锁的方式实现的,也就是二级哈希表。在Java8 中的实现方式是通过Unsafe类的CAS自旋赋值+synchronized同步+LockSupport阻塞等手段实现的高效并发,代码可读性稍差。最大的区别在于JDK8的锁粒度更细,理想情况下talbe数组元素的大小就是其支持并发的最大个数。</p> </li> <li> <p>HashMap的缺点,扩容因子为0.75(数据论文表示0.6~0.75性能最好),同时默认每次扩容都是2倍进行扩容,有点浪费空间(如果只是超过了一个),其实是以空间换时间。</p> </li>
SparseArray
SparseArray中Key为int类型(避免了装箱和拆箱),Value是Object类型,所有的Key和Value分别对应一个数组,Key数组iInt值是按顺序排列的,查找的时候采用的是二分查找,效率很高。而Value数组的位置和Key数组中的位置是一样的。
add的时候会进行位移,remove的时候不一定会进行位移,把某个值标记为delete,如果下次有符合的值直接放到该位置,就没有位移了。但是也有缺点,Key只能是int值。最后Android中还扩展了SparseLongArray。
ArrayMap
ArrayMap的Key和Value同HashMap一样都可以存放多种类型,ArrayMap对象的数据存储格式如下
- mHashes是一个记录所有key的hashcode值组成的数组,是从小到大的排序方式;采用二分查找法,从mHashes数组中查找值等于hash的key
- mArray是一个记录着key-value键值对所组成的数组,是mHashes大小的2倍;
ArrayMap中有两个非常重要的静态成员变量mBaseCache和mTwiceBaseCacheSize,用于ArrayMap所在进程的全局缓存功能:
- mBaseCache:用于缓存大小为4的ArrayMap,mBaseCacheSize记录着当前已缓存的数量,超过10个则不再缓存;
- mTwiceBaseCacheSize:用于缓存大小为8的ArrayMap,mTwiceBaseCacheSize记录着当前已缓存的数量,超过10个则不再缓存。
很多场景可能起初都是数据很少,为了减少频繁地创建和回收Map对象,ArrayMap采用了两个大小为10的缓存队列来分别保存大小为4和8的Map对象。为了节省内存有更加保守的内存扩张以及内存收缩策略。
freeArrays()触发时机:
- 当执行removeAt()移除最后一个元素的情况
- 当执行clear()清理的情况
- 当执行ensureCapacity()在当前容量小于预期容量的情况下, 先执行allocArrays,再执行freeArrays
- 当执行put()在容量满的情况下, 先执行allocArrays, 再执行freeArrays
allocArrays触发时机:
- 当执行ArrayMap的构造函数的情况
- 当执行removeAt()在满足容量收紧机制的情况
- 当执行ensureCapacity()在当前容量小于预期容量的情况下, 先执行allocArrays,再执行freeArrays
- 当执行put()在容量满的情况下, 先执行allocArrays, 再执行freeArrays
ArrayMap中解决Hash冲突的方式是追加的方式,比如两个key的hash(int)值一样,那就把数据全部后移一位,通过追加的方式解决Hash冲突。
ArrayMap的扩容
当mSize大于或等于mHashes数组长度时则扩容,完成扩容后需要将老的数组拷贝到新分配的数组,并释放老的内存。
- 当map个数满足条件 osize<4时,则扩容后的大小为4;
- 当map个数满足条件 4<= osize < 8时,则扩容后的大小为8;
- 当map个数满足条件 osize>=8时,则扩容后的大小为原来的1.5倍;
当数组内存的大小大于8,且已存储数据的个数mSize小于数组空间大小的1/3的情况下,需要收紧数据的内容容量,分配新的数组,老的内存靠虚拟机自动回收。
- 如果mSize<=8,则设置新大小为8;
- 如果mSize> 8,则设置新大小为mSize的1.5倍。
也就是说在数据较大的情况下,当内存使用量不足1/3的情况下,内存数组会收紧50%。
LinkedHashMap
LinkedHashMap是由HashMap+LinkedList组成的,其中LinkedList用于存储数据顺序。LinkedHashMap可以根据插入/访问的顺序进行排序,可以在调用构造器时决定。LinkedHashMap继承自HashMap,重写了put和get方法。
LinkedHashMap的数据存储和HashMap的结构一样采用(数组+单向链表)的形式,只是在每次节点Entry中增加了用于维护顺序的before和after变量维护了一个双向链表来保存LinkedHashMap的存储顺序,当调用迭代器的时候不在使用HashMap的迭代器,而是自己写迭代器来遍历这个双向列表。
Entry如下所示
/**
* HashMap.Node subclass for normal LinkedHashMap entries.
*/
static class LinkedHashMapEntry<K,V> extends HashMap.Node<K,V> {
LinkedHashMapEntry<K,V> before, after;
LinkedHashMapEntry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
复制代码
HashMap和LinkedHashMap内部逻辑对比如图
其实就是在put和get的时候维护好了这个双向链表,变量的时候就有序了。
总结
本文总结了Android开发中一些常用数据结构,SparseArray和ArrayMap这两个数据结构是要首先考虑使用的,在节省内存的情况下性能和HashMap差不了太多,同时还分析了HashMap和LinkedHashmap的源码,希望对你有帮助,喜欢的点个赞和关注吧!
作者:伤心的猪大肠