HashMap的原理学习

97 阅读6分钟

每一个复杂知识,都是由一些分散的知识点组合而成的,想要读懂源码,首先需要了解其中所必须的原理知识。
阅读本文,需要预先了解的内容有(在文章尾部有相关知识的参考链接,可以跳转查阅):

  1. 哈希桶
  2. B树
  3. 红黑树

分析思路

笔者在准备阅读HashMap源码的时候,能够想到的符合思维直觉的方法,就是从HashMap的使用入手,剖析HashMap是如何利用开发者的调用,将数据存储。

先看看大部分程序员常写的HashMap代码:

// 创建对象
Map<Integer, String> map = new HashMap();
// 添加数据
map.put(0, "hello");

可以看到使用到了HashMap的两个方法:构造方法和put方法;而“哈希桶”作为阅读HashMap源码指导思想的理论,大家可以在文章尾部的参考文章中了解。

怎么存储数据的?

数组,是最常见的数据结构;而复杂数据结构,其实都是利用简单数据结构实现的,HashMap也不例外,既然通过构造方法和put方法就可以实现数据的存储,那么就可以从中找到HashMap是如何利用哈希桶这个数据结构。

下面是笔者绘制的流程图,与代码里面注释的流程编号对应,读者可以详细对照阅读。

HashMap-putVal流程图.jpeg

接下来就是结合流程图,理解代码的逻辑:

/**
 * 无参构造方法
 * 发现并没有什么与实现“哈希桶”相关的代码
 */
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

/**
 * 实际上调用的是putVal方法
 */
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

/**
 * 一个存储方法为什么逻辑会这么复杂呢?
 * 其实里面就包含了,存储结构的创建、扩容、寻址、存储
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 1.判断table数组是否为null或者是否为空
    if ((tab = table) == null || (n = tab.length) == 0)
        // 2.调用resize()创建一个table
        // 3.tab的引用指向resize()返回的table
        n = (tab = resize()).length;
    // 4.通过(n - 1) & hash 寻址新插入的k,v的下标
    // 5.判断寻址到的数组位置是否为null
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 6.向数组对应的位置存一个Node对象
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 7.如果找到的数组中的节点的hash、key、value均相等
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            // 8.记录节点
            e = p;
        // 9.找到的节点是否为TreeNode的示例
        else if (p instanceof TreeNode)
            // 10.按照树结构插入key,value
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 11.从当前节点向后遍历,直到node.next为null,将key,value以Node的形式存储
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 12.当判断到链表的长度大于等于7,将链表转换为树,跳出循环
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 13.当e的hash、key与插入的值相等,跳出循环
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 14.根据onlyIfAbsent,修改节点的值,并返回之前的值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    // 15.modCount++,并且判断size大于域值后,调用resize()方法扩容
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

跟着笔者读完了put()流程后,是不是还是没有发现HashMaptable在什么时候创建的呢?没关系,我来告诉大家,是在resize()中创建了数组以及对数组进行扩容的,下一小节,我们就看看resize()的内容。

resize()方法详解

老办法,先看流程图,对流程有个大致的印象

HashMap-resize()方法详解.jpeg 下面是在代码中对流程的标记:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 1.如果旧的容量大于0,那么就表示原来有数据,此次调用则是要进行扩容
    if (oldCap > 0) {
        // 2.如果旧的容量大于最大容量
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 3.将域值设置为最大,并返回oldTab
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 4. 如果将newCap赋值为oldCap扩容一倍,仍小于最大容量;并且oldCap大于等于初始化容量
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 5.就把newThr赋值为oldThr扩大一倍
            newThr = oldThr << 1; // double threshold
    }
    // 6.如果oldCap <=0,并且oldThr大于0
    else if (oldThr > 0) // initial capacity was placed in threshold
        // 7.将newCap赋值为oldThr
        newCap = oldThr;
    // 8. 如果oldCap<=0&&oldThr<=0,表示创建新的HashMap对象
    else {               // zero initial threshold signifies using defaults
        // 9.将newCap赋值为初始容量,newThr赋值为初始加载因子x初始容量
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 10.如果newThr为0
    if (newThr == 0) {
        // 11.对newThr赋值
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 12.将HashMap的threshold属性赋值为newThr
    threshold = newThr;
    // 13.创建一个以newCap为大小的数组,并将table指向newTab
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 14.如果oldTab不为null,表示调用当前方法是为了扩容
    if (oldTab != null) {
        // 15.遍历旧数组,将oldTab内存储的内容移到newTab上
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            // 16.遍历数组中不为null的对象,即存储了内容的位置,将引用赋值给e
            if ((e = oldTab[j]) != null) {
                // 17.清除oldTab中原来的位置内容
                oldTab[j] = null;
                // 18.如果链表有一个节点,就直接在newTab中存储e
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 19.如果节点是TreeNode
                else if (e instanceof TreeNode)
                    // 20.调用split方法,将旧数据存储到newTab
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    // 21. 准备两个链表,lo表和hi表 
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 22.遍历链表,直到e.next为null
                    do {
                        // 23.将next赋值为e.next
                        next = e.next;
                        // 24.存储在原来链表上hash与oldCap为0的元素,存到lo表中
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 25.与oldCap为1的元素,存到hi表中
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 26.将原来存储在一个链表上元素,在新表上根据与oldCap的情况,存在两个位置
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

这部分代码中,比较重要的就是将oldTab的数据移到newTab,尤其是链表上的数据移动,下面给出示意图,供读者参考(下图来自作者ChiuCheng的“深入理解HashMap系列文章”):

HashMap-oldTab数据移到newtab.png

总结

通过上面的分析,从编程时HashMap的使用习惯,分析了HashMap是如何存储数据的,包含:

  1. 建表
  2. 扩容
  3. 数据迁移
  4. 数据插入(a. 表中节点为null;b. 表中节点为TreeNode;c. 表中节点为链表)

其实万变不离其宗,了解了数据的存储,在不同的情况下,数据的插入、删除动作就比较好理解和实现了,大家可以自行阅读源码了解其中的逻辑,我就不继续展开了,谢谢大家的阅读!!!文章中有什么疑问,可以提出来交流^^

参考文章:

数据结构之哈希表(包含哈希桶)_无聊星期三的博客-CSDN博客_哈希表有14个桶

平衡多路搜索树 - Algorithm-Pattern

30张图带你彻底理解红黑树 - 简书

Java HashMap为什么通过(n - 1) & hash 获取哈希桶数组下标?_hmi1024的博客-CSDN博客_hashmap的hash桶

HashMap evict 放逐之旅_一钱夏柘半代赭的博客-CSDN博客_hashmap的evict

HashMap实现LRU(最近最少使用)缓存更新算法_zhaohong_bo的博客-CSDN博客_hashmap lru

深入理解HashMap(四): 关键源码逐行分析之resize扩容