Java拾遗:002 - HashMap源码解读

哈希表

散列表(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的源码,尤其是关于红黑树这一块。

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

转载于:https://my.oschina.net/zhanglikun/blog/1922043

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值