zoukankan      html  css  js  c++  java
  • Android版数据结构与算法(四):基于哈希表实现HashMap核心源码彻底分析

    版权声明:本文出自汪磊的博客,未经作者允许禁止转载。

    存储键值对我们首先想到HashMap,它的底层基于哈希表,采用数组存储数据,使用链表来解决哈希碰撞,它是线程不安全的,并且存储的key只能有一个为null,在安卓中如果数据量比较小(小于一千),建议使用SparseArray和ArrayMap,内存,查找性能方面会有提升,如果数据量比较大,几万,甚至几十万以上还是使用HashMap吧。本篇只详细分析HashMap的源码,SparseArray和ArrayMap不在本篇讨论范围内,后续会单独分析。

    HashMap的理解,最最核心就是扩容那二十几行代码,可以说是HashMap的核心所在了,然而网上绝大部分博客只是一带而过,大体说了一下结论,让人十分失望,本篇将会彻底分析扩容机制,源码分析基于android-23。

    好了,直入主题吧。

    一、HashMap中成员变量

     
    1
    private static final int MINIMUM_CAPACITY = 4;//约定hashmap中最小容量,也可以是0,如果不为0,那么最小容量限制为4 2 private static final int MAXIMUM_CAPACITY = 1 << 30;约定hashmap中最大容量,为2的30次方
     3 static final float DEFAULT_LOAD_FACTOR = .75F;//扩容因子:主要用于扩容时机,后续会细讲  
    4

    5
    transient HashMapEntry<K, V>[] table;//盛放数据的table,数据每一项key不为null,每一项都是一个HashMapEntry对象
    6

    7
    transient HashMapEntry<K, V> entryForNullKey;盛放key为null的数据项
    8

    9
    transient int size;//hashmap中已经盛放数据的大小
    10
    11 private transient int threshold;//用来判断是否需要扩容,其值为DEFAULT_LOAD_FACTOR * hashmap的容量,当盛放数据达到hashmap的四分之三时,急需要考虑扩容了

    主要成员变量已经有所标注,后续分析的时候会再次提及,此处不做过多解释。

    二、HashMap中数据项HashMapEntry

    HashMap中每个数据项都是HashMapEntry对象,HashMapEntry是HashMap的内部类,我们先来看看其结构:

     1 static class HashMapEntry<K, V> implements Entry<K, V> {
     2         final K key;
     3         V value;
     4         final int hash;
     5         HashMapEntry<K, V> next;
     6 
     7         HashMapEntry(K key, V value, int hash, HashMapEntry<K, V> next) {
     8             this.key = key;
     9             this.value = value;
    10             this.hash = hash;
    11             this.next = next;
    12         }
    13 
    14         public final K getKey() {
    15             return key;
    16         }
    17 
    18         public final V getValue() {
    19             return value;
    20         }
    21 
    22         public final V setValue(V value) {
    23             V oldValue = this.value;
    24             this.value = value;
    25             return oldValue;
    26         }
    27 
    28         @Override public final boolean equals(Object o) {
    29             if (!(o instanceof Entry)) {
    30                 return false;
    31             }
    32             Entry<?, ?> e = (Entry<?, ?>) o;
    33             return Objects.equal(e.getKey(), key)
    34                     && Objects.equal(e.getValue(), value);
    35         }
    36 
    37         @Override public final int hashCode() {
    38             return (key == null ? 0 : key.hashCode()) ^
    39                     (value == null ? 0 : value.hashCode());
    40         }
    41 
    42         @Override public final String toString() {
    43             return key + "=" + value;
    44         }
    45     }

    主要信息就是每一个数据项都包含了我们存储的key,value以及根据key算出来的hash值,next用于发生哈希碰撞的时候指向其下一个数据项。

    三、HashMap构造方法

    HashMap构造方法有如下:

    1 public HashMap()
    2 public HashMap(int capacity) 
    3 public HashMap(int capacity, float loadFactor)
    4 public HashMap(Map<? extends K, ? extends V> map)

    构造方法有如上4种方式,我们平时最常用的就是第一种方式,直接初始化然后不停往里面仍数据就可以了,第二种初始化的时候可以指定容量大小,第三,四中估计大部分人没用过,第三种除了指定容量大小我们还可以指定扩容因子,不过我们还是不要动扩容因子为好,指定为0.75是时间和空间的权衡,平时我们使用就用默认的0.75就可以了。

    我们看一下HashMap()这种构造方式:

    1     public HashMap() {
    2         table = (HashMapEntry<K, V>[]) EMPTY_TABLE;
    3         threshold = -1; // Forces first put invocation to replace EMPTY_TABLE
    4     }

    太简单了,就是初始化table为空的数组EMPTY_TABLE,这个EMPTY_TABLE的初始化容量可不为0,源码如下:

    private static final Entry[] EMPTY_TABLE
                = new HashMapEntry[MINIMUM_CAPACITY >>> 1];

    看到了吧,初始化容量为MINIMUM_CAPACITY的一半,也就是2。

    此外threshold初始化的时候置为-1。

    接下来我们在看下HashMap(int capacity) 这种构造方式:

     1     public HashMap(int capacity) {
     2         if (capacity < 0) {
     3             throw new IllegalArgumentException("Capacity: " + capacity);
     4         }
     5 
     6         if (capacity == 0) {
     7             @SuppressWarnings("unchecked")
     8             HashMapEntry<K, V>[] tab = (HashMapEntry<K, V>[]) EMPTY_TABLE;
     9             table = tab;
    10             threshold = -1; // Forces first put() to replace EMPTY_TABLE
    11             return;
    12         }
    13 
    14         if (capacity < MINIMUM_CAPACITY) {
    15             capacity = MINIMUM_CAPACITY;
    16         } else if (capacity > MAXIMUM_CAPACITY) {
    17             capacity = MAXIMUM_CAPACITY;
    18         } else {
    19             capacity = Collections.roundUpToPowerOfTwo(capacity);
    20         }
    21         makeTable(capacity);
    22     }
    23 
    24 
    25     private HashMapEntry<K, V>[] makeTable(int newCapacity) {
    26         @SuppressWarnings("unchecked") HashMapEntry<K, V>[] newTable
    27                 = (HashMapEntry<K, V>[]) new HashMapEntry[newCapacity];
    28         table = newTable;
    29         threshold = (newCapacity >> 1) + (newCapacity >> 2); // 3/4 capacity
    30         return newTable;
    31     }

     6-12行逻辑大体就是我们调用hashmap()空参数的构造函数初始化一样。

    14-17行,就是对我们设置的容量capacity进行检查了,如果小于MINIMUM_CAPACITY那么就重置为MINIMUM_CAPACITY,如果大于MAXIMUM_CAPACITY则重置为MAXIMUM_CAPACITY。

    19行,Collections.roundUpToPowerOfTwo这个方法就是找出一个2^n的数,使其不小于给出的数字,并且最近接给出的数字。

    比如:

    Collections.roundUpToPowerOfTwo(3)返回4,22.

    Collections.roundUpToPowerOfTwo(4)返回4,22.

    Collections.roundUpToPowerOfTwo(100)返回128,27.

    明白了吧?也就是说返回的数肯定是2的几次方,也就是说hashmap的容量肯定是2的几次方形式,这里很重要,一定要记住,后续分析的时候还会用到。

    接下来就是调用makeTable了。

    26,27就是根据给定的容量创建newTable数组。

    28行,成员变量table指向新创建的newTable数组。

    29行,计算threshold的值,也就是我们指定的容量的四分之三了。

    好了,以上就是构造方法逻辑,其余两种方法可自行查看一下,比较核心的就是19行代码,对capacity数据的转换,约束hashmap容量大小肯定为2的n次方。

    四、HashMap中put方法分析

    接下来就是HashMap核心所在了,我们一点点分析,先看下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     }

     3-5行,如果我们放入的数据key为null,那么执行4行代码逻辑并且直接返回,putValueForNullKey源码如下:

     1     private V putValueForNullKey(V value) {
     2         HashMapEntry<K, V> entry = entryForNullKey;
     3         if (entry == null) {
     4             addNewEntryForNullKey(value);
     5             size++;
     6             modCount++;
     7             return null;
     8         } else {
     9             preModify(entry);
    10             V oldValue = entry.value;
    11             entry.value = value;
    12             return oldValue;
    13         }
    14     }

    大体逻辑很简单,就是对成员变量entryForNullKey操作,其就是HashMapEntry对象实例,3-8行如果entry为null,则代表之前没有放入过key为null的数据,则只需要创建即可。8-12行表示之前放入锅key为null的数据,那么只需要将value替换为新的value即可,这里说明HashMap只会有一个数据的key为null,重复放入只会将value替换为最新value.好了,这里就只是简单分析一下。

    回到put方法,如果我们放入的key不为null,则继续向下执行:

    6行,根据key计算二次哈希值,源码如下:

     1     public static int secondaryHash(Object key) {
     2         return secondaryHash(key.hashCode());
     3     }
     4 
     5     private static int secondaryHash(int h) {
     6         // Spread bits to regularize both segment and index locations,
     7         // using variant of single-word Wang/Jenkins hash.
     8         h += (h <<  15) ^ 0xffffcd7d;
     9         h ^= (h >>> 10);
    10         h += (h <<   3);
    11         h ^= (h >>>  6);
    12         h += (h <<   2) + (h << 14);
    13         return h ^ (h >>> 16);
    14     }

    就是将key的hashCode方法返回的值传入secondaryHash(int h) 再次计算一次返回一个值,这里最重要的一点就是我们传入的key必须有hashCode()方法并且每次返回的值一样,如果HashMap Key的哈希值在存储键值对后发生改变,Map可能再也查找不到这个Entry了,所以HashMap中key我们需要使用不可变对象,比如经常使用的String,Integer对象,其HashCode()方法分别如下:

     1     @Override 
     2     public int hashCode() {//String中HashCode()方法
     3         int hash = hashCode;
     4         if (hash == 0) {
     5             if (count == 0) {
     6                 return 0;
     7             }
     8             for (int i = 0; i < count; ++i) {
     9                 hash = 31 * hash + charAt(i);
    10             }
    11             hashCode = hash;
    12         }
    13         return hash;
    14     }
    15 
    16     @Override
    17     public int hashCode() {//Integer中HashCode()方法
    18         return value;
    19     }

    回到put方法,则继续向下执行:

    7行定义局部变量tab指向全局变量table数组。

    8行,计算放入的数据在tab中的位置,计算方式为key的hash值按位与tab的长度减1,这样确保了计算出的index不会超出数组角标,比如:

    key的hash值为11111111111111111111111111111111,tab容量为8,则tab.length-1为7,其数组角标范围为0~7。

    hash & (tab.length - 1)按位与计算如下:

    也就是经过上述计算最大值为7,不会超过数组角标。

    为什么要进行二次哈希值得计算呢?

    比如我们放入三个数据,key的HashCode值分别为:31,63,95。tab容量为8

    如果不进行二次哈希值计算索引index,也就是key.hashcode() & (tab.length - 1),计算如下:

    31=00011111 & 00000111 = 0111 = 7

    63=00111111 & 00000111 = 0111 = 7

    95=01011111 & 00000111 = 0111 = 7

    进行二次哈希值后再计算索引index,也就是源码中secondaryHash(key.hashCode())& (tab.length - 1),计算如下:

    31=00011111 =>secondaryHash=> 00011110 & 00000111= 0110 = 6

    63=00111111 ==secondaryHash=> 00111100 & 00000111= 0100 = 4

    95=01011111 ==secondaryHash=> 01011010 & 00000111= 0010 = 2

    如上不经过二次哈希值计算最终计算出的index值均为7,也就是我们放入数组中都处于同一位置。而经过二次哈希值计算之后再计算index值分别为6,4,2也就是在数组中处于了三个不同的位置,这样就达到了更加散列的效果。但是即使经过二次哈希值计算也不能保证计算出的index值都不相同,这里只是尽可能的散列化,不能保证避免哈希碰撞。

    回到put方法,继续向下分析:

    我们知道HashMap存储数据结构如下:

    简单说就是我们放入一个数据的时候会先根据数据项的key计算出其在table数组中的索引,如果索引位置已经有元素了,那么则与已经放入的数据形成链表的关系,相信稍有经验的都明白,这里只是稍微提一下。

    9-16行的for循环逻辑就是挨个遍历所在数组行链表中每一个数据项,然后将每个数据项的hash值和key与将要放入的key及其hash值比较,如果二者均相等则表明HashMap中已经存在此数据。

    12-14行就是将对应数据项的value值替换为新的value值,并将之前value返回。

    如果整个for循环都没有找到则表明HashMap中没有将要存储的数据项,继续向下执行。

    19行,判断是否需要扩容,threshold上面说过值为table容量的四分之三,size记录我们HashMap中存入数据的大小,我们放入数据时如果超过容量的四分之三那么就需要扩容了。

    20行,如果需要扩容那么调用doubleCapacity()方法进行扩容(后续会仔细分析扩容机制),扩容完此方法会返回扩容后的数组。

    21行,由于数组已经扩容,容量发生了变化,所以这里需要重新计算一下将要放入数据的index索引。

    23行调用addNewEntry方法将数据放入数组中。addNewEntry源码如下:

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

    这里就是根据我们传入的key,value,hash值新建HashMapEntry数据节点,此数据节点的next指向原table[index],最后将新数据节点赋值给table[index],这里说的有点蒙圈,用图来解释一下,又要展示我强大的画图能力了:

    这里通过阅读源码可以发现新添加的数据项是放在链表头部的,而不是直接放在尾部。

    好了,以上就是put方法主要逻辑了,不再做其余分析,下面我们着重看一下HashMap的扩容机制。

    五、HashMap中扩容机制分析

    好了,如果你看到这里那么清理一下大脑吧,下面的有点烧脑了。

    废话少说,直接看扩容方法源码;

     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             /*
    14              * Rehash the bucket using the minimum number of field writes.
    15              * This is the most subtle and delicate code in the class.
    16              */
    17             HashMapEntry<K, V> e = oldTable[j];
    18             if (e == null) {
    19                 continue;
    20             }
    21             int highBit = e.hash & oldCapacity;
    22             HashMapEntry<K, V> broken = null;
    23             newTable[j | highBit] = e;
    24             for (HashMapEntry<K, V> n = e.next; n != null; e = n, n = n.next) {
    25                 int nextHighBit = n.hash & oldCapacity;
    26                 if (nextHighBit != highBit) {
    27                     if (broken == null)
    28                         newTable[j | nextHighBit] = n;
    29                     else
    30                         broken.next = n;
    31                     broken = e;
    32                     highBit = nextHighBit;
    33                 }
    34             }
    35             if (broken != null)
    36                 broken.next = null;
    37         }
    38         return newTable;
    39     }

     2-6行oldTable,oldCapacity记录原来数组,数组长度以及检查原数组长度是否已经达到MAXIMUM_CAPACITY,如果已经达到最大长度,那么不好意思了,直接返回原数组了,老子无法给你扩容了,都那么长了,还扩什么容,自己继续在原数组玩吧,管你哈希碰撞导致链表多长我都不管了。

    7行,定义newCapacity也就是新数组长度为原数组长度的2倍。

    8行,就是执行makeTable()逻辑,创建新的数组newTable了,至于makeTable方法上面说过,就不再分析了。

    9-11行,检查size是否为0,如果为0那么表明HashMap中没有存储过数据,不用执行下面的数据拷贝逻辑了,直接返回newTable就可以了。

    12-37行,这可就是HashMap整个类的精华所在了,这几行代码看不懂这个类你就没有真正理解,看懂了其余扫一下就明白了。

    假设原HashMap如图:

    12行很简单就是遍历原数组中每个位置的数据,也可以说每个链表的头数据。

    13-16行,风骚的注释:直白翻译就是下面这几行代码是这个类中最风骚的几行代码。

    17-20行代码,就是检查取出的数组中每个数据项是否为null,为null则表明此行没有数据,继续循环就可以了。

    在继续向下讲请大家思考一个问题:HashMap中同一个链表中每一个数据项的哈希值有什么相同点?比如原数组大小是8,那么同一链表中每一个数据项的哈希值有什么相同点?

    思考。。。。。

    这里直接说了:能在同一链表说明计算出来的index值相同,在看计算公式为int index = hash & (tab.length - 1),这里在扩容之前tab.length-1的值是相同的,比如数组长度为8,那么tab.length - 1的二进制表示为00000111,不同hash值计算出的index又相同,那么这里同一链表中每一个数据项的hash值得最后三位一定相同,只有这样计算出的index值才相同,如下:

    如上图 两个hash值不同的数据项,经过运算后得出index均为2,原因就是虽然整体的hash值不同,但是最后三位均为010,所以计算出index值是相同的(此处假设数组长度为8)。

    进而得出结论:如果HashMap中数组长度为2的n次方,那么同一链表中不同数据项的hash值的最后n位一定相同。

    好了,到这里第一个难点通过,我们继续分析doubleCapacity()方法。

    21行,int highBit = e.hash & oldCapacity计算出highBit位,翻译过来就是高位,这他妈又是什么玩意?仔细看计算方式与的oldCapacity,而不是oldCapacity-1,所以这里取得是数据项hash值得第n+1位(hashmap数组长度为2的n次方)是0还是1,这里一定要知道HashMap数组长度一定为2的n次方,二进制形式就是第n+1位为1其余为均为0。这里先记住这个highBit是哪一位,后面会用到。

    22行,定义一个broken,知道有这么个玩意,后面也会用到。

    23行,newTable[j | highBit] = e,将我们从原数组取出的数据项放入新数组中,也就是数据的拷贝了,注意这里e是每一个链表的头部,也就是处于数组中的数据。链表其余数据是通过24行for循环挨个遍历再放入新数组中的。但是这里有个疑问原数组放入数据是按照hash & (tab.length - 1)计算其在数组中位置的,这里怎么成了j | highBit这样计算了呢?这里真是卡住我了,一开始我是怎么想怎么想不通,但是我觉得二者之间一定有什么关系,不可能用两个完全不相关的算法来计算同一数据项在数组中的位置,绝不可能,一定有内在联系,我查啊查,算啊算,在经过如下计算我终于想明白了:hash & (tab.length - 1)与j | highBit这二者逻辑是完全相同的,TMD,逻辑竟然是相同的。

    接下来咱们推导分析一下:

    j | highBit   

    = j | (e.hash & oldCapacity)   第一步

    = (e.hash & (oldCapacity-1)) | (e.hash & oldCapacity)   第二步

    = e.hash & ( (oldCapacity-1) | oldCapacity)  第三步

    = e.hash & (newCapacity- 1) 第四步

    从开始到第一步很简单了,highBit的计算方式就是e.hash & oldCapacity这里只是替换回来。

    第一步到第二步,j怎么就成了e.hash & (oldCapacity-1)呢?还记得index的计算方式吗?就是e.hash & (oldCapacity-1),那就是说j就是index了,在看看j是什么?j就是从0开始到oldCapacity的值,这里我们想一下啊,e就是通过oldTable[j]获取的,我们想想put方法怎么放入的呢,不就是oldTable[e.hash & (oldCapacity-1)] = e吗,想到了什么?想到了什么?对,通过j获取的元素e,这个j就是e.hash & (oldCapacity-1),所以这里可以替换的。

    第二步到第三步就是数学方面的,记住就可以了。

    第三步到第四步怎么来的呢?也就是(oldCapacity-1) | oldCapacity与newCapacity- 1相等,还记得上面说的HashMap数组容量一定是2的n次方吗?并且newCapacity = oldCapacity * 2 。

    oldCapacity为2的n次方,也就是n+1位为1,其余都为0,oldCapacity-1也就是0到n位为1其余都为0,二者或运算后0到n+1为1其余位0。

    newCapacity= oldCapacity * 2 也就是n+2位为1其余位0,newCapacity- 1也就是0到n+1位为1其余为0.

    所以(oldCapacity-1) | oldCapacity与newCapacity- 1相等

    到此,我们就证明了j | highBit = e.hash & (newCapacity- 1) 其计算数据项在新数组中位置与原数组的计算逻辑是一样的,只不过十分巧妙的运用了位运算,好了,想明白这里恭喜你通过了第二个难点,我们继续向下分析。

    24-34行就是遍历链表中数据项了,把他们挨个放入新数组中,这里思考一个问题?在原数组中同一链表的数据项在新数组中还处于同一链表吗?如果不是那么是什么决定它们不在同一链表了?

    在上面分析的时候我们得出一个结论:如果HashMap中数组长度为2的n次方,那么同一链表中不同数据项的hash值的最后n位一定相同。

    扩容后数组容量为原来的2倍了,根据index的计算方式e.hash & (newCapacity- 1)每个数据项的hash值是不变的,但是长度变了,所以同一链表中不同数据项在新数组中不一定还处于同一链表,那么具体是什么决定在新数组中二者在不在同一链表呢?

    原数组长度为2的n次方,新数组长度扩容后为原数组2倍也就是2的n+1次方,原数组中同一链表中不同数据项的hash值的最后n位一定相同,所以新数组同一链表中不同数据项的hash值的最后n+1位一定相同。如果上面讲的你真的理解了,这里就不难理解,不过多解释。

    在原数组中同一链表的数据项已经确保了hash值最后n位相同,按照计算方式新数组中处于同一链表的数据项需要确保hash值最后n+1位相同即可,既然原链表中的数据项最后n位已经相同了,在新数组中是否处于同一链表那么只需要比较同链表数据项hash值的n+1位即可,如果相同则表明在新数组中依然处于同一链表,如果不同那么就处于不同链表了,上面的高位highBit就是取的是每一数据项的第n+1位,后面比较也只是比较每个数据项的highBit是否相同。

    好了,这里我认为解释的已经很清楚了,这里你要是明白了,恭喜你,HashMap中最难理解的部分你已经完全掌握了。

    至于24-34行具体逻辑我就不一一分析了,静下心来,自己试着分析,难度不大。

    六、总结

    好了,到这里本篇就要结束了,咦?这不就分析了一个put方法外加扩容机制吗?这就完了?是的,我想说的就这些,这部分是最难理解的,至于其余自己看看都能理解的差不多了。

    最关键是一定要理解扩容机制,那几行最难理解的代码设计的真是巧妙,大神的思想真是无法企及啊!!!

    本篇到此结束,希望对你有用。

    声明:文章将会陆续搬迁到个人公众号,以后文章也会第一时间发布到个人公众号,及时获取文章内容请关注公众号

  • 相关阅读:
    box-sizing: border-box的作用
    什么时候用created,什么时候用mounted
    圣杯布局
    关闭浏览器 清除session
    js this
    js 深拷贝
    经常犯的一些小错误
    语法:c++对关于空指针0/NULL/nullptr三者的演变
    常用的函数
    C++一些小技巧
  • 原文地址:https://www.cnblogs.com/leipDao/p/9482764.html
Copyright © 2011-2022 走看看