性能优化:为什么要使用SparseArray和ArrayMap替代HashMap?

7,637 阅读7分钟

背景

在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) { ... }
  1. 为什么要引入红黑树?

    JDK 1.8以前是数组+链表,还未引入红黑树,这就导致了链表过长时查找的时间复杂度是O(n), 完全失去了设计HashMap时的设计初衷,针对这种情况JDK 1.8引入了红黑树(查找的时间复杂度为O(logN),什么是红黑树呢,红黑树是一种自平衡的二叉查找树,不是一种绝对平衡的二叉树,它放弃了追求绝对平衡,追求大致平衡,在与平衡二叉树的时间复杂度相差不大的情况下,保证每次插入最多只需要三次旋转就能达到平衡,实现起来也更为简单。从而获得更高的查找性能。

  2. 为什么初始值大小为2的N次方,以后每次扩容后的结果也是2的N次方?

    hashmap的初始值是16,即2的4次方,之后的每次扩容都是两倍扩容,为什么要这么设计呢?我觉得有一下几点:

    • 如果往hashmap中存放数据,我们首先得保证它能够尽量均匀分布,为了保证能够均匀分布,我们可能会想到用取模的方式去实现,如果用传统的‘%’方式来实现效率不高,当大小(length)总为2的n次方时,h&(length-1)运算等价于对length取模,也就是h%lenth,但是&比%具有更高的效率,同时也减少了hash碰撞。
    • 和h&(length-1)相关,当容量大小(n)为2的n次方时,n-1 的二进制的后几位全是1,在h为随机数的情况下,与(n-1)进行与操作时,会分布的更均匀,想一想,如果n-1的二进制数是1110,当尾数为0时,与出来的值尾数永远为0,那么0001,1001,1101等尾数为1的位置就永远不可能被entry占用,就造成了空间浪费。

    在hashmap源码中,put方法会调用indexFor方法,这个方法主要是根据key的hash值找到这个entry在table中的位置,最后 return 的是 h&(length-1)

    这里还要提到一点,程序中的所有数在计算机内存中都是以二进制的形式储存的。位运算就是直接对整数在内存中的二进制位进行操作。在Java中,位运算符有很多,例如与(&)、非(~)、或(|)、异或(^)、移位(<<和>>)等,Java的运算最终都会转化成位运算

  3. HashMap和其他数据结构的关联

    • 与Hashtable的关系,Hashtable是线程安全的,比如put,get等很多使用了synchronized修饰,和ConcurrentHashMap相比,在Hashtable的大小增加到一定的时候,效率会急剧下降,因为迭代时需要被锁定很长的时间(而ConcurrentHashMap引入了分割,仅仅需要锁定map的某个部分)所以其实效率并不高。再看看Hashtable的put方法

      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;
      }
      

    可以看到,里面的index是用%的方式进行取下标,看起来效率也不行啊,最后从命名上看HashMap和Hashtable,Hashtable明显没遵循驼峰式命名规则,这可能也是它被弃用原因之一(哈哈哈)。

    • 与HashSet的关系,我们先来看HashSet的add方法

      public boolean add(E e) {
         return map.put(e, PRESENT)==null;
      }
      

    这里面的map是一个HashMap,e是放入的元素,value此时有一个统一的值:

    private static final Object PRESENT = new Object();

    可以看出,其实HashSet就是一个限制功能了的HashMap,如果你了解了HashMap,HashSet你自然就懂了。虽然说Set对于重复的元素不放入,倒不如直接说是底层的Map直接把原值替代了。HashSet没有提供get方法,原因同HashMap一样,Set内部是无序的,只能通过迭代的方式获取。

    • 与LinkedHashMap的关系,LinkedHashMap是一个数组+双向链表与HashMap不同,可以保证元素的迭代顺序,该迭代顺序可以是插入顺序或者是访问顺序(LRU原理)。
    • 与LinkedHashSet的关系,LinkedHashSet是继承自HashSet,底层实现是LinkedHashMap。
    • TreeSet中存放的元素是有序的(不是插入时的顺序,是有按关键字大小排序的),且元素不能重复。
  4. HashMap在Java7和Java8的变化

    比如扩容时是否重新Hash和引入红黑树

  5. 链表长度大于8一定会转化成红黑树吗?

    下面做个小测验,链表长度大于8是否一定会转化成红黑树,首先我们自定义一个key类

    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;
      }
    }
    

    可以看到我重写了hashcode方法,所有的key的hashcode的值都是1,下面是我的测试方法

    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");
    }
    

    我们可以先大致预想一下结果,一开始前8个数都是在同一个链表上,然后超过8个转化为红黑树。这里有个关键参数

    static final int MIN_TREEIFY_CAPACITY = 64

    然后我们再看看链表转化为红黑树的源码,可以得出

就算链表长度超过8,进入了这个转化为红黑树的方法,仔细看第756行,还是要判断当前容量的大小是否小于64,如果小于这个值还是要进行扩容而不是转化为红黑树,然后你应该可以看到上面那个test()方法的结果了,在put第9个值后,扩容为32,put第10个值后,扩容为64,这时链表的长度已经超过8了啊(因为已经写死了hashcode方法),直到put第11个值后,才开始转化为红黑树。下面是我用调试这个方法时的截图,可供参考

  1. HashMap并发的问题

    HashMap不是线程安全的,在Java7 中,HashMap被多个线程操作可能造成形成环形链表,造成死循环。可以用ConcurrentHashMap来解决,Java7 中ConcurrentHashMap 是用分段锁的方式实现的,也就是二级哈希表。在Java8 中的实现方式是通过Unsafe类的CAS自旋赋值+synchronized同步+LockSupport阻塞等手段实现的高效并发,代码可读性稍差。最大的区别在于JDK8的锁粒度更细,理想情况下talbe数组元素的大小就是其支持并发的最大个数。

  2. HashMap的缺点,扩容因子为0.75(数据论文表示0.6~0.75性能最好),同时默认每次扩容都是2倍进行扩容,有点浪费空间(如果只是超过了一个),其实是以空间换时间。

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%

ArrayMap相关内容引用自该博客

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的源码,希望对你有帮助,喜欢的点个赞和关注吧!

下面是一些我的文章:

Activity的初级,中级,高级问法,你都掌握了吗?

Handler的初级、中级、高级问法,你都掌握了吗?

参考文章:

juejin.cn/post/684490…

gityuan.com/2019/01/13/…