从android HashMap的源码实现谈谈HashMap的性能问题

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/y1962475006/article/details/78844263

从android HashMap的源码实现谈谈HashMap的性能问题

前言

实际开发中,HashMap可能是我们用的最多的数据结构了。基础稍微扎实一点的开发,问起HashMap的特性,都会说,HashMap是key可以为null的,无序的,非线程安全的。没错,这几点确实是HashMap的特性,但是HashMap究竟为什么是非线程安全的,多线程下会有什么问题,作为“有理想”的程序员,都不应该放过。

预备知识

HashMap,顾名思义,是使用Hash表实现的Map。和Hash表相关的几个概念有:
Hash(哈希)函数,Hash冲突。Hash函数的表达式:Addr = H(key),就是以key为输入,Addr为输出的函数,Addr就是key对应的在哈希表中的地址。当不同的key产生相同的Addr时,就会发生hash冲突。

android中HashMap的实现

android中hashMap的实现和jdk里的大致思路一样,以android的为例。

先看构造函数:

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) {
                @SuppressWarnings("unchecked")
                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);
        }

    public HashMap(int capacity, float loadFactor) {
            this(capacity);

            if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
                throw new IllegalArgumentException("Load factor: " + loadFactor);
            }

            /*
             * Note that this implementation ignores loadFactor; it always uses
             * a load factor of 3/4. This simplifies the code and generally
             * improves performance.
             */
            }
    public HashMap(Map<? extends K, ? extends V> map) {
            this(capacityForInitSize(map.size()));
            constructorPutAll(map);
        }

HashMap提供了四个构造方法,按照上面的代码排列依次是:无参构造、初始容量构造、初始容量+装载因子构造、map构造。这几个构造方法涉及到了几个名词:

threshold : 阀值,容量为0时默认-1
capacity : 容量
loadFactor: 装载因子,默认0.75(3/4)
makeTable()的实现:

 private HashMapEntry<K, V>[] makeTable(int newCapacity) {
            @SuppressWarnings("unchecked") HashMapEntry<K, V>[] newTable
                    = (HashMapEntry<K, V>[]) new HashMapEntry[newCapacity];
            table = newTable;
            threshold = (newCapacity >> 1) + (newCapacity >> 2); // 3/4 capacity
            return newTable;
        }

这段代码决定了,threshold阀值 = table大小的3/4(0.75)。
HashMap()构造函数中,出现了

table = (HashMapEntry<K, V>[]) EMPTY_TABLE

这样一行代码,看下table的申明:

transient HashMapEntry<K, V>[] table;

table是一个HashMapEntry的数组,而HashMapEntry的实现:

static class HashMapEntry<K, V> implements Entry<K, V> {
            final K key;
            V value;
            final int hash;
            HashMapEntry<K, V> next;

            HashMapEntry(K key, V value, int hash, HashMapEntry<K, V> next) {
                this.key = key;
                this.value = value;
                this.hash = hash;
                this.next = next;
            }

            public final K getKey() {
                return key;
            }

            public final V getValue() {
                return value;
            }

            public final V setValue(V value) {
                V oldValue = this.value;
                this.value = value;
                return oldValue;
            }

            @Override public final boolean equals(Object o) {
                if (!(o instanceof Entry)) {
                    return false;
                }
                Entry<?, ?> e = (Entry<?, ?>) o;
                return Objects.equal(e.getKey(), key)
                        && Objects.equal(e.getValue(), value);
            }

            @Override public final int hashCode() {
                return (key == null ? 0 : key.hashCode()) ^
                        (value == null ? 0 : value.hashCode());
            }

            @Override public final String toString() {
                return key + "=" + value;
            }
        }

可以看到,HashMapEntry是一个实现了Entry接口,并且封装了get、set相关方法的类,看到HashMapEntry next;这一行了么,这说明,每个HashMapEntry实体都是一个单链表。回过头来,table又是一个HashMapEntry的数组,这么一看,HashMap的数据结构就清晰了:table的每个位置都是一个HashMapEntry单链表。如示意图:
HashMapEntry

HashMap(int capacity)构造函数中,对capacity做了判断:capacity==0,用默认的空table,capacity

private static final int MINIMUM_CAPACITY = 4;

MAXIMUM_CAPACITY的声明:

private static final int MAXIMUM_CAPACITY = 1 << 30;

也就是说,table的容量应该是在4到1 << 30(1*2^30)之间,小于4按4算,大于1 << 30按1 << 30算。之间的数按照Collections.roundUpToPowerOfTwo(int i)方法的返回值算。从字面上看,这个函数是找出向上离i最近的2的指数幂。这么说可能还是有点晕乎,直接上一段代码测试下:

public void test(){
            System.out.println(roundUpToPowerOfTwo(5));
        }

输出结果为8。对着这个例子解释就是,离5最近的2的指数幂有两个,就是2^3=8,2^2=4,但是向上(up)的含义就是,从大于i的方向取,也就是8。所以不论是4还是1 << 30还是中间,最后capacity都是2的指数幂。

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

这段代码,内容并不多,总体分为几个部分:
1、如果key是空,则添加以null为key的值;
2、如果key不空,key已经存在,更新value并且返回旧值;如果存在冲突(同一个index),遍历到这个index下面的单链表末尾。
3、如果大小达到阀值,把table扩容两倍
4、添加实体

第1部分,看下putValueForNullKey的实现:

private V putValueForNullKey(V value) {
            HashMapEntry<K, V> entry = entryForNullKey;
            if (entry == null) {
                addNewEntryForNullKey(value);
                size++;
                modCount++;
                return null;
            } else {
                preModify(entry);
                V oldValue = entry.value;
                entry.value = value;
                return oldValue;
            }
        }

如果之前没有以null为key的实体entryForNullKey,就添加;否则就修改,并返回旧的value。这个很好理解,以null为key的实体只能有一个。
第2部分,先是用转二进制散列值函数得到一个关于key的二进制的散列值:
public static int secondaryHash(Object key) {
return secondaryHash(key.hashCode());
}用key的hashCode去转成二进制的。然后用这个散列值和table长度-1做与运算,得到一个要填充的位置index,找到这个index的entry,然后以entry的hash值和key的quals结果判断是不是同一个entry。
第3部分,数组table的大小达到阀值,就去扩容:

      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++) {
                /*
                 * Rehash the bucket using the minimum number of field writes.
                 * This is the most subtle and delicate code in the class.
                 */
                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;
        }

总体来说,就是table容量扩大为两倍,然后重新计算每个entry的位置,并计算出新的实体应在的位置。这个过程很精巧,新表的位置是按照高位去计算新的位置的,把旧的冲突链上(此时整个冲突链已经在新的位置),高位不一样的放到新计算的位置,一样的保留。
第4步,按照之前计算的位置添加实体,但是此时index上可能会有存在的实体:

void addNewEntry(K key, V value, int hash, int index) {
            table[index] = new HashMapEntry<K, V>(key, value, hash, table[index]);
        }

HashMapEntry这个构造,第四个参数是下一个实体(next),也就是当前实体table[index]是当前冲突链的链表头。上述的代码可以写成:Entry head = table[index];n.next = head;head = n;所以添加的位置,是冲突链表的头部。

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

get方法逻辑也很简单,key为空就把之前放进来的对应实体返回,实体也为null就返回null。然后找到这个实体的位置,按照传参进来的key和实体的key指向同一个地址(eKey == key)或者实体的hash值和传参key的hash值相等(e.hash == hash)并且传参key和这个位置上的实体key的equals方法返回truekey.equals(eKey),这样的规则确定是否是同一个。

性能问题分析

分析完上面几个关键的方法,基本可以得出几个结论:

  • HashMap是可以以null为key的(代码显而易见);
  • HashMap是非线程安全的(也没看到用到sychronized、volatile和CAS);
  • HashMap的实际大小总是2的指数幂;
  • HashMap数组+单链表的数据结构,其实就是用直接链法处理hash冲突(相关概念可以自行百度);
  • HashMap的大小不是一成不变的,而是在达到阀值(数组大小0.75倍),就开始扩容;
  • 扩容后的大小是原来的两倍;
  • put方法中,e.hash == hash && key.equals(e.key)是判定是否为同一个实体的条件;
  • get方法中,eKey == key || (e.hash == hash && key.equals(eKey)是判定是否为同一个实体的条件。

性能问题一,HashMap的大小:

为什么说HashMap的实际大小总是2的指数幂?因为就算初始化的时候不是2的指数幂,roundUpToPowerOfTwo函数也会帮你转。为什么一定要是2的指数幂?这个是由于HashMap使用的散列算法,就是用key的hashCode转成对应的二进制,然后和HashMap的size-1座“&”操作。为什么这样做?举个例子,如果HashMap的设定大小为10,那么roundUpToPowerOfTwo转完大小是2^4 = 16,那么16-1=15,用二进制表示就是1111,此时如果一个实例的二进制哈希码为850873883(仅用来举例),二进制表示是110010101101110100111000011011,两者进行与运算,结果就是截取低四位1011,十进制就是11,也就是进来的key<->value的实体放在11这个位置上。试想如果HashMap的大小不是16而是10,10-1 = 9,二进制表示是1001,那么中间两个0的位置永远不会取到1,也就是2,3,4,6,5,7,这6个位置永远都都不会被算到实际填充的位置,空间利用率不足一半。这样的话就明白为什么用2的指数幂了:散列均匀。

那么问题来了,既然HashMap的实现已经帮我们做了这么多工作,我们是不是直接用就好了,不用管其他的了?明显不是。首先当HashMap的大小过小的时候,会增加Hash冲突的几率;另外如上面分析put方法说道的第三点,当当前的大小达到阀值(默认0.75*size),就会扩容,容量扩大为原来的两倍,扩容的过程会遍历原来的table,把它的元素重新计算在对应的新table中的位置,最坏时间复杂度为O(n^2);而在hash不冲突的场景下,不需要扩容的话,实际的时间复杂度为O(1)(只需要按照得到的index放进去)。所以我们最好给HashMap一个初始值,这个值是2的指数幂,并且它呈上装载因子(默认0.75)后的大小大于我们实际需要的大小。例如,我们实际需要200,那么200/0.75 约等于267,那么实际大于方向靠近267的2的指数幂为2^9 = 512。

性能问题2:重写equals和hashCode方法:

首先明确这两者的关系:
A和B对象equals方法返回true,hasCode方法返回值必然一样;
A和B对象hashCode不一样,那么equals方法必须返回false。
A和B对象hashCode一样,不能判定A equals B。
所以equals方法返回true和hasCode方法返回值一样是充分非必要的关系。

从Collections.secondaryHash的方法看,最终散列的位置index是和key的hashCode有关的,如果key是引用类型对象,且没有重写hashCode,就会很容易出现hash冲突,在put的过程中,发生冲突就会沿着单链表遍历到最后并插入。这个时间复杂度也是O(n)。

同时,put和get的判定都有e.hash == hash && key.equals(e.key),如果不重写equals方法,默认用“==”判定,比较内存地址。如果key是引用对象,则必须是同一个引用才能判定是相同的对象。例如:

public class UserData {

        public String mUserName;
        UserData(String userName){
            mUserName = userName;
        }
        public static void main(String[] arg){
            HashMap<UserData,String> map = new HashMap<>(8);
            map.put(new UserData("yue"),"yue");
            String result = map.get(new UserData("yue"));
            System.out.println("result="+result);
        }
    }

输出的结果为null。所以不管是为了满足equals和hashCode充分非必要的关系,还是保障程序的健壮性,都应重写equals。

性能问题3:多线程问题
HashMap是非线程安全的,这点已经毋庸置疑。那多线程条件下,怎么个不安全呢?关键在于扩容的过程。现在已知,不管哪种实现(android、jdk各版本),扩容的过程都是遍历旧的table,然后把旧table中的元素填充到扩容后的table中。伪代码如下:

{
        newTable = new Entry[oldCapacity*2];
        for(int i = 0;i<oldCapacity;i++){
            Entry e = oldTable[i];//取出老的数组上的entry
            for(遍历冲突链e){
                newTable[newIndex];//创建新数组
                findNewIndex();//找到新的位置
                updateNodes(newTable);//更新冲突链
            }
        }
    }

不同的jdk有不同方式的实现。其中最经典的jdk1.7的写法:


    void transfer(Entry[] newTable, boolean rehash) {
            //把旧的数据转存到新的table中
            int newCapacity = newTable.length;
            for (Entry<K,V> e : table) {
                while(null != e) {
                    //在这有循环,有链表的next节点赋值,
                    Entry<K,V> next = e.next;  //flag       
                    if (rehash) {
                        e.hash = null == e.key ? 0 : hash(e.key);
                    }
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                }
            }
        }

以经典组合3、7、5,findNewIndex()为retrun key.hashCode() % table长度为例(这个例子很特殊,一会说为什么很特殊)。
假设:

HashMap<Integer,Object> map = new HashMap<>(2);
map.put(3,obj1);//node3
map.put(7,obj2);//node7
map.put(5,obj3);//node5

按照(size++> threshold),那么在put7的时候,会扩容(注意++前置和后置的区别)此时的map的变化(冲突节点头插):
可以发现,原来index = 1的位置上,是Entry(7,obj1)->Entry(3,obj2);
扩容后:index = 3 的位置上,Entry(3,obj2)->Entry(7,obj1);顺序发生了倒置!如图:
节点倒置
假设:线程A和线程B,同时进到了上面标注的flag处。线程B挂起,线程A继续执行,那么A执行完的结果就是上图,在A的本地内存和JMM主内存newTable的情况是:
newTable[0]=null;
newTable[1]=node5;
newTable[2]=null;
newTable[3]=node3->node7(node3.next = node7);
此时线程B唤起开始执行,但是线程B的本地内存中的情况是执行之前的:newTable各个位置上没有值,
oldTable[0] = null;
oldTable[1] = node7->node3(node7.next = node3);
然后继续按mod4计算新的位置,此时node7的新位置应该在newTable的3上,即上述代码中i= 3;此时的e = node7;
然后执行e.next = newTable[3],注意此时的操作,对newTable是一个读操作,在JMM中,读操作都是直接从主内存中读取,所以现在的newTable[3] = node3->node7(node3.next = node7);于是有e.next = node3->node7,刚刚说到,e = node7,所以node7插在了单链表node3->node7之前,此时的newTable[3]= node7->node3->node7;死循环出现,线程B无法停止,耗尽CPU资源,只能重启机器。过程图如下:
死循环过程
从上面的分析看,死循环的根源是:
1、原来的冲突链上的节点rehash(扩容)的时候又冲突在了一起(node3和node7);
2、findNewIndex()这个环节存在节点倒置。
3、多线程下的顺序一致性问题
这三个条件都满足,就可能会出现死循环。

为什么说这个例子很特殊呢?因为初始大小为2,扩容后为4,mod2时,这三个数会直接冲突到一个冲突链上,在扩容后重新计算index,3和7又产生了冲突。仅仅三个操作,就能模拟到不断冲突的情况。当然也许有人说这太特殊了,我们平时不可能这么写,但是谁都没法保证,程序实际使用过程中是否会发生这样的情况,Hash和rehash的算法优化固然可以减少死循环发生的几率,但是一旦发生,就是灾难性的。

回过头来看这个特殊例子在android的doubleCapacity的情况:
初始大小为2,3的hashCode为3,即011,7的hashCode为7,即111,5的hashCode为5,即101,那么在放3和7的时候,index分别为011&001 = 001 ->1,111&001 = 001 ->1,所以3和7是冲突在1的位置上的,由于是头插法,所以oldtable:
oldtable[0]= null;
oldtable[1]= node7->node3;
然后根据

    int highBit = e.hash & oldCapacity;
   HashMapEntry<K, V> broken = null;
   newTable[j | highBit] = e;

扩容时,node7的highBit为111&010=010->2;node3的highBit为011&010=010->2;
根据

if (nextHighBit != highBit) {
    if (broken == null)
        newTable[j | nextHighBit] = n;
    else
        broken.next = n;
    broken = e;
    highBit = nextHighBit;
}

当前后两个相邻的节点的highBit相同时,对这条链内部不作处理,所以node7->node3直接指向了newTable[j | highBit]也就是newTable[2],新来的node5,直接根据index= hash & (tab.length - 1) = 101&011 = 001->1,放在了1的位置上。
这个分析可以看到,android的实现并不会造成节点倒置,不满足死循环的条件2,所以不会导致死循环。但是由于多线程下,每个线程都在更新冲突链,可能会出现put的值和预期不匹配的情况,所以我们仍要关注HashMap的并发问题。

解决这个问题有几个方法:
1、使用HashTable;
2、Collections.synchronizedMap处理HashMap;
3、使用ConcurrentHashMap
前两种性能太差,推荐使用第三种,ConcurrentHashMap使用CAS轻量级锁,性能更好。

猜你喜欢

转载自blog.csdn.net/y1962475006/article/details/78844263