美文网首页Java拾遗
Java拾遗:002 - HashMap源码解读

Java拾遗:002 - HashMap源码解读

作者: ed72fd6aaa3c | 来源:发表于2018-08-03 12:20 被阅读12次

    哈希表

    散列表(Hash Table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。这种数据结构在不考虑哈希碰撞的条件下,有前着O(1)的时间复杂度,所以效率非常高。Java中HashMap底层就使用了哈希表,所以通常我们认为HashMap的时间复杂度也是O(1)。

    HashMap实现原理浅析

    在JDK中,HashMap底层是由数组实现,该数组即为哈希表(由HashCode决定索引位置)。在不存在哈希碰撞的条件下,哈希表的性能最优,但在实际代码实现中不能不考虑这个问题。所以在HashMap中,每个Bucket(哈希表中的节点)都是一个链表(JDK1.8中当链表元素超过8个时,会将链表转换为红黑树),当发生哈希碰撞时,该元素将被添加到链表的末端。由于链表中的时间复杂度是O(n),所以当Bucket所在链表过长时,会影响HashMap性能。

    JDK1.7 HashMap源码解读

    JDK自1.6以后(以前的代码没读过)的版本HashMap的实现本质上没有太大差别(核心结构都是哈希表),这里以JDK1.7版本为例讲解,下面是HashMap的核心方法源码解读。

    构造方法

    下面是HashMap的主要构造方法,其它三个构造方法都是该方法的变种(通过默认参数实现)。

        public HashMap(int initialCapacity, float loadFactor) {
            if (initialCapacity < 0)
                throw new IllegalArgumentException("Illegal initial capacity: " +
                                                   initialCapacity);
            if (initialCapacity > MAXIMUM_CAPACITY)
                initialCapacity = MAXIMUM_CAPACITY;
            if (loadFactor <= 0 || Float.isNaN(loadFactor))
                throw new IllegalArgumentException("Illegal load factor: " +
                                                   loadFactor);
    
            this.loadFactor = loadFactor;
            threshold = initialCapacity;
            init();
        }
    

    方法只是初始化了一些属性,this.loadFactor是扩容因子,即当实际使用容量比总容量为该因子时,将发生扩容。threshold表示扩容阈值,由总容量乘以扩容因子计算得出,但在构造方法中,直接使用初始容量表示。在无参的构造方法中,初始容量为16,扩容因子为0.75。

    isEmpty方法

    判断集合是否为空的方法,实际只是内部维护了一个计数器,如果计数器为0即为空,否则非空。

        public boolean isEmpty() {
            return size == 0;
        }
    
    size方法

    同理集合实际大小也是由该计数器表示,该计数器将在添加、移除元素时被维护。

        public int size() {
            return size;
        }
    
    put方法

    put方法是HashMap最核心方法之一,其代码实现复杂之处也在于此。

        public V put(K key, V value) {
            if (table == EMPTY_TABLE) {
                inflateTable(threshold);
            }
            if (key == null)
                return putForNullKey(value);
            int hash = hash(key);
            int i = indexFor(hash, table.length);
            for (Entry<K,V> e = table[i]; e != null; e = e.next) {
                Object k;
                if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                    V oldValue = e.value;
                    e.value = value;
                    e.recordAccess(this);
                    return oldValue;
                }
            }
    
            modCount++;
            addEntry(hash, key, value, i);
            return null;
        }
    

    代码中先判断哈希表是否为空,空则执行扩容(初始化哈希表的过程实际使用的扩容的逻辑,所以构造方法中用扩容阈值来表示初始容量,减少一个全局变量)。
    初始化哈希表后,会判断键是否为空,空键不会使用哈希函数来计算哈希和索引,而是直接遍历哈希表找到空键所在Bucket,将元素加入该Bucket(该Bucket也是一个链表,但最多只能有一个元素,这就是HashMap中最多只能一个空键的原因)。
    如果键不为空,则计算它的哈希码,并根据哈希码找到其对应哈希表的索引。找到的Bucket是一个链表,遍历该链表,如果键已存在,则更新找到的节点值,将旧值返回,方法结束,如果没有找到该键,则作为一个新的节点加入链表末端。
    上面解读中遗留了几个细节没讲,下面一一解读:

    • 扩容inflateTable(threshold);逻辑
        private void inflateTable(int toSize) {
            // Find a power of 2 >= toSize
            int capacity = roundUpToPowerOf2(toSize);
    
            threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
            table = new Entry[capacity];
            initHashSeedAsNeeded(capacity);
        }
    

    实际逻辑很简单,通过roundUpToPowerOf2方法保证扩容容量值一定是刚好大于等于传入容量值的2的整数次幂(关于这点的原因,后面会解释),然后根据扩容后的容量计算扩容后的扩容阈值threshold,最后重新构造哈希表table(最后一行根据容量生成哈希种子的逻辑不影响主逻辑,这里略过)。实际上这个方法只在哈希表空时执行,只是一个初始化的方法,后续在添加新元素的过程中触发扩容是由其它方法实现。

    • 空键的插入逻辑
        private V putForNullKey(V value) {
            for (Entry<K,V> e = table[0]; e != null; e = e.next) {
                if (e.key == null) {
                    V oldValue = e.value;
                    e.value = value;
                    e.recordAccess(this);
                    return oldValue;
                }
            }
            modCount++;
            addEntry(0, null, value, 0);
            return null;
        }
    

    由源码可以看出只是简单的遍历哈希表,找到空键对应的Bucket(没有就新增一个),更新找到节点值,返回旧值。

    • 计算哈希码和根据哈希码查找索引
        final int hash(Object k) {
            int h = hashSeed;
            if (0 != h && k instanceof String) {
                return sun.misc.Hashing.stringHash32((String) k);
            }
    
            h ^= k.hashCode();
    
            // This function ensures that hashCodes that differ only by
            // constant multiples at each bit position have a bounded
            // number of collisions (approximately 8 at default load factor).
            h ^= (h >>> 20) ^ (h >>> 12);
            return h ^ (h >>> 7) ^ (h >>> 4);
        }
    
        static int indexFor(int h, int length) {
            // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
            return h & (length-1);
        }
    

    生成哈希码的过程略过(我会告诉你是因为实现得太过复杂,我也不是很懂?),重点讲一下根据哈希码计算索引的逻辑。代码只有一行h & (length-1),这行代码实际上保证了返回值在[0 ~ 哈希表长度 - 1]这个区间内,如果不用位运算,它的等效(注意:这里是说等效,非等价,两者的返回值未必是相同的,但效果是一致的)实现为:h % length,但位运算的性能更好,所以使用了这种写法,另外使用位运算还有一个原因,后面解释为什么HashMap的容量一定是2的整数次幂里会讲到,这里先略过。
    计算出索引后,直接从哈希表中得到Bucket(因为是通过下标查找,所以时间复杂度是O(1)),Bucket是一个链表,遍历这个链表找到对应的节点(键相同),更新节点值,返回旧值,如果未找到说明是一个新的节点,通过addEntry(hash, key, value, i);添加一个节点到链表末端。

    • 添加一个新节点
        void addEntry(int hash, K key, V value, int bucketIndex) {
            if ((size >= threshold) && (null != table[bucketIndex])) {
                resize(2 * table.length);
                hash = (null != key) ? hash(key) : 0;
                bucketIndex = indexFor(hash, table.length);
            }
    
            createEntry(hash, key, value, bucketIndex);
        }
    
        void createEntry(int hash, K key, V value, int bucketIndex) {
            Entry<K,V> e = table[bucketIndex];
            table[bucketIndex] = new Entry<>(hash, key, value, e);
            size++;
        }
    

    实际上添加一个新节点很容易,只需要构造一个Entry节点,添加到链表末端即可,但这里需要考虑的时,如果实际容量到达扩容阈值时,需要触发扩容逻辑。
    首先通过resize(2 * table.length);将哈希表扩容为原来的两倍,然后重新计算哈希码和索引,最后通过createEntry(hash, key, value, bucketIndex);创建一个新节点,将其加入链表末端。
    这里重点需要介绍一下resize方法(区别于inflateTable方法,这个方法会被多次调用)

        void resize(int newCapacity) {
            Entry[] oldTable = table;
            int oldCapacity = oldTable.length;
            if (oldCapacity == MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return;
            }
    
            Entry[] newTable = new Entry[newCapacity];
            transfer(newTable, initHashSeedAsNeeded(newCapacity));
            table = newTable;
            threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
        }
    
        void transfer(Entry[] newTable, boolean rehash) {
            int newCapacity = newTable.length;
            for (Entry<K,V> e : table) {
                while(null != e) {
                    Entry<K,V> next = e.next;
                    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;
                }
            }
        }
    

    使用扩容后的容量构造一个新的哈希表,将原哈希表中的数据复制到新表中,再将新表赋值给类的table属性,从而完成扩容。

    get方法

    查找一个元素,在put方法中已经体现了查找过程,先计算哈希码,再计算索引,找到Bucket后遍历链表,根据键找到目标元素即可

        public V get(Object key) {
            if (key == null)
                return getForNullKey();
            Entry<K,V> entry = getEntry(key);
    
            return null == entry ? null : entry.getValue();
        }
    
        final Entry<K,V> getEntry(Object key) {
            if (size == 0) {
                return null;
            }
    
            int hash = (key == null) ? 0 : hash(key);
            for (Entry<K,V> e = table[indexFor(hash, table.length)];
                 e != null;
                 e = e.next) {
                Object k;
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            }
            return null;
        }
    
    remove方法

    删除元素,先找到元素,将其从对应链表中删除即可,此时size计数器会递减

        public V remove(Object key) {
            Entry<K,V> e = removeEntryForKey(key);
            return (e == null ? null : e.value);
        }
    
        final Entry<K,V> removeEntryForKey(Object key) {
            if (size == 0) {
                return null;
            }
            int hash = (key == null) ? 0 : hash(key);
            int i = indexFor(hash, table.length);
            Entry<K,V> prev = table[i];
            Entry<K,V> e = prev;
    
            while (e != null) {
                Entry<K,V> next = e.next;
                Object k;
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k)))) {
                    modCount++;
                    size--;
                    if (prev == e)
                        table[i] = next;
                    else
                        prev.next = next;
                    e.recordRemoval(this);
                    return e;
                }
                prev = e;
                e = next;
            }
    
            return e;
        }
    
    clear方法

    清空集合,只需要将哈希表(数组)所有元素置空,并将计数器归零即可

        public void clear() {
            modCount++;
            Arrays.fill(table, null);
            size = 0;
        }
    

    类重写hashCode方法时返回一个常数会引起什么问题?

    前面讲过,哈希表的时间复杂度为O(1),但前提是没有发生哈希碰撞的情况下。如果一个类在重写hashCode方法时直接返回了一个常数(下次不能偷懒了~),就会造成存入集合的所有元素的哈希码相同,也就是哈希碰撞,那么HashMap的存储结构就会退化为一个链表结构(只有一个Bucket),而链表的时间复杂度为O(n),因此一个好的hashCode实现,可以提升其在HashMap或者HashSet等数据结构中的性能的,而只返回一个常数是万不可取的。

    为什么HashMap实际容量一定是2的整数次幂

    之前提到过个问题,这时就要解释一下indexFor这个方法了。h & (length-1)使用了按位与运算,而该运算的特点是,参与计算的两个相同位都为1时输出1,否则输出0。参考一下下面的示例(假设当前容量为8):

    # 8 - 1 == 7 转换二进制为 00000111
    7 & 1 == 00000111 & 00000001 == 00000001 == 1
    7 & 2 == 00000111 & 00000010 == 00000010 == 2
    7 & 11 == 00000111 & 00001011 == 00000011 == 3
    

    首先第一点,解释一下该方法是怎样保证返回索引一定在[0, length - 1]这个区间内。根据按位与运算的特点,(length - 1)中为1的项,在与任何数做按位与计算时才有可能为1,而(length - 1)中为0的项与任何数做按位与计算一定返回0,所以整个运算返回的最大值只能是(length - 1),而最小值是0,所以保证了[0, length - 1]这个区间范围。
    HashMap实际容量一定是2的整数次幂也和这里的按位与运算有关。如果length是2的整数次幂(那么length的二进制一定是...1000...0的形式),那么(length - 1)的二进制一定是...0001111...1形式(这点读者自行去验证一下)。而在做按位与计算时,1才是会引起值变化的项,假设length为8,那么length-1的二进制就是00000111,任意数与其做按位与计算可以得到[0000, 0111]即[0, 7]所有项,而如果length不是2的整数次冥,那么length-1必须中间会有0(空项)存在,这意味着这些位置无法表示,从而造成哈希表中存在空洞(空间浪费),实际可用空间减少,那么哈希碰撞的几率就更大,即浪费空间,也影响效率。

    # 下面是一组length不为2的整数次幂时,length - 1的二进制值
    length = 10, length - 1 == 9 ==   00001001
    length = 13, length - 1 == 12 == 00001100
    length = 15, length - 1 == 14 == 00001110
    ... ...
    

    所以只有length值为2的整数次幂时,length - 1才会是...1111...1的结构,做按位与计算才能表示所有值。

    JDK1.8 HashMap源码有什么变化?

    JDK1.8中对HashMap做了大量优化,代码细节调整非常多,但代码结构基本一致,也仍然使用哈希表,所以这里不再展开, 有兴趣的自行阅读。JDK1.8中对HashMap做的最大调整是哈希表中单项(Bucket)不再完全使用链表结构了,当链表长度超过8时,将被转换为红黑树,而红黑树相比与链表有着更好的查询性能。

    为什么HashMap不适用于多线程场景

    具体分析过程略,这里只说结论:
    因为在HashMap中添加元素时,可能发生扩容,扩容过程中会将遍历原来的链表数据,复制到新哈希表过,在多线程的情况下可以多个线程同时触发扩容,那么在这个过程中很有可能造成链表变成循环链表或者空表,导致数据get的时候死循环或者数据丢失的情况。
    所以多线程(并发)场景下推荐使用ConcurrentHashMap,后面的文章中会介绍该集合类。

    参考资料:

    结语

    HashMap在实际开发中用得非常多,其代码实现所涉及到的知识点也比较多,而且应用广泛,所以它的源码还是很有必要读一读的。本文主要介绍的是JDK1.7的源码,JDK1.8做了相当多的调整,后续有时间会深入阅读一下JDK1.8的源码,尤其是关于红黑树这一块。

    编写本文除了阅读源码以外,了参考了其它博主的文章(其实都比我写得好,不会画图是硬伤):

    相关文章

      网友评论

        本文标题:Java拾遗:002 - HashMap源码解读

        本文链接:https://www.haomeiwen.com/subject/ucxyvftx.html