一文看懂HashMap原理

先看put方法保存数据,内部调用putVal()方法保存到map,onlyIfAbsent参数的意思是传true时表示如果key对应的value存在就不会保存,evict暂不考虑,这是LinkedHashMap中实现的。找到节点的过程是:

  1. 数组不存在时创建数组
  2. 根据节点的哈希值计算出来的索引处没有节点时,新建一个newNode
  3. 判断链表的第一个是否为要更新的节点
  4. 否则遍历链表,如果遍历到链表尾部则新增newNode;如果发现链表深度大于阈值TREEIFY_THRESHOLD(默认8)时,链表转数,加速查询;如果在链表中发现要更新的节点则返回。
    找到节点后更新数据即可。
java 复制代码
    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;
//在第一次调用put方法时为数组分配内存
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
//数据保存在数组的i = (n - 1) & hash位置,i表示数组的索引
//如果此处先前没有数据,直接创建节点把数据保存在这里
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K, V> e;
            K k;
//如果数组i处的链表中第一个节点的key与保存的key相同,则在后面直接替换value
            if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
//如果是树的话则使用红黑树组织数据
            else if (p instanceof TreeNode)
                e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
            else {
//遍历链表
                for (int binCount = 0; ; ++binCount) {
//(e = p.next) == null表示遍历到链表中最后一个节点,如果没有发现哪个节点的key与要保存的key相同,链表尾添加的新的Node ,作为链表中最后一个节点                 
                 if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
//当链表的深度大于TREEIFY_THRESHOLD(8)时,可以链表转树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
//如果找到一个节点的key与要保存的key相同,则在后面替换value
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break;
                //移动到下个节点
                p = e;
            }
        }
//如果在保存数据到map过程发现已有节点的key与要保存的key相同,则替换已有节点的value,返回之前的value
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
        ++modCount;
        if (++size > threshold)
            resize();

        afterNodeInsertion(evict);
        return null;
    }

再看下hashmap怎么扩容的,首先计算容量,默认为16(0x1111),这样与运算计算出来的索引就是由节点的hash值决定,引起哈希碰撞的概率就很小。扩容时右移一位即扩大两倍,仍然为2的整数次幂。然后将原来数据拷贝到新数组,为了让数据更加均匀的分布,可以让原来的key的hash值与数组容量与运算,创建两条0和1的链条,分别保存在原理数组对应的索引处和新数组中"原索引+oldCap"处。首先计算扩容后的数组容量为原始数组翻倍newCap = oldCap << 1,然后将原数组中数据移动到新数组,过程如下:

  1. 不存在链表,直接移动到新数组的e.hash & (newCap - 1)位置处
  2. 存在链表,如果 if ((e.hash & oldCap) == 0),则移动到跟原数组位置相同的地方,否则移动到"原索引+oldCap"处。
    现在考虑下为什么e.hash & oldCap就能判断节点移动后在新数组中的位置,首先原始索引为:oldIndex = hash & (oldCap - 1),新索引本来应该是newIndex = hash & (newCap - 1),但是不要忘了newCap = oldCap << 1,即计算新的索引时相对原索引只要考虑最高位的值,而最高位的值刚好为oldCap,进而能够减少位运算的位数提升效率。比如,oldCap=16时,oldIndex = hash & (oldCap - 1)为oldIndex = hash & 0x1111,而newCap=32,newIndex = hash & & 0x11111,跟原来的与运算相比结果由最高位0x10000决定的,如果hash的最高位是1就发生变化,结果为原始索引的二进制的oldCap中1的位置处置1,如果oldCap为0x10000,则将计算结果的第五位置1,这样只用考虑这一位的二进制结果即可。
java 复制代码
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) {
        // 1.1 判断老表的容量是否超过最大容量值:如果超过则将阈值设置为Integer.MAX_VALUE,并直接返回老表,
        // 此时oldCap * 2比Integer.MAX_VALUE大,因此无法进行重新分布,只是单纯的将阈值扩容到最大
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 1.2 将newCap赋值为oldCap的2倍,如果newCap<最大容量并且oldCap>=16, 则将新阈值设置为原来的两倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 2.如果老表的容量为0, 老表的阈值大于0, 是因为初始容量被放入阈值,则将新表的容量设置为老表的阈值
    else if (oldThr > 0)
        newCap = oldThr;
    else {
        // 3.老表的容量为0, 老表的阈值为0,这种情况是没有传初始容量的new方法创建的空表,将阈值和容量设置为默认值
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 4.如果新表的阈值为空, 则通过新的容量*负载因子获得阈值
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 5.将当前阈值设置为刚计算出来的新的阈值,定义新表,容量为刚计算出来的新容量,将table设置为新定义的表。
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 6.如果老表不为空,则需遍历所有节点,将节点赋值给新表
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {  // 将索引值为j的老表头节点赋值给e
                oldTab[j] = null; // 将老表的节点设置为空, 以便垃圾收集器回收空间
                // 7.如果e.next为空, 则代表老表的该位置只有1个节点,计算新表的索引位置, 直接将该节点放在该位置
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 8.如果是红黑树节点,则进行红黑树的重hash分布(跟链表的hash分布基本相同)
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    // 9.如果是普通的链表节点,则进行普通的重hash分布
                    Node<K,V> loHead = null, loTail = null; // 存储索引位置为:"原索引位置"的节点
                    Node<K,V> hiHead = null, hiTail = null; // 存储索引位置为:"原索引位置+oldCap"的节点
                    Node<K,V> next;
                    do {
                        next = e.next;
                        //创建两条链表,分别是0和1
                        // 9.1 如果e的hash值与老表的容量进行与运算为0,则扩容后的索引位置跟老表的索引位置一样
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null) // 如果loTail为空, 代表该节点为第一个节点
                                loHead = e; // 则将loHead赋值为第一个节点
                            else
                                loTail.next = e;    // 否则将节点添加在loTail后面
                            loTail = e; // 并将loTail赋值为新增的节点
                        }
                        // 9.2 如果e的hash值与老表的容量进行与运算为非0,则扩容后的索引位置为:老表的索引位置+oldCap
                        else {
                            if (hiTail == null) // 如果hiTail为空, 代表该节点为第一个节点
                                hiHead = e; // 则将hiHead赋值为第一个节点
                            else
                                hiTail.next = e;    // 否则将节点添加在hiTail后面
                            hiTail = e; // 并将hiTail赋值为新增的节点
                        }
                    } while ((e = next) != null);
                    // 10.如果loTail不为空(说明老表的数据有分布到新表上"原索引位置"的节点),则将最后一个节点
                    // 的next设为空,并将新表上索引位置为"原索引位置"的节点设置为对应的头节点
                    //0的链表在数组原来位置
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 11.如果hiTail不为空(说明老表的数据有分布到新表上"原索引+oldCap位置"的节点),则将最后
                    // 一个节点的next设为空,并将新表上索引位置为"原索引+oldCap"的节点设置为对应的头节点
                    //1的链条在数组扩容后的位置
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    // 12.返回新表
    return newTab;
}

总结下,HashMap保存数据会先计算索引,再生成链表,扩容时会将链表移动新数组中。有不对的地方请大神指出,欢迎大家一起讨论交流,共同进步,更多请关注微信公众号 葡萄开源

相关推荐
程序员爱钓鱼12 分钟前
Go语言实战案例:执行基本的增删改查
后端·google·go
long31615 分钟前
java 工厂方法设计模式 FactoryMethod
java·开发语言·后端·设计模式
程序员爱钓鱼15 分钟前
Go语言实战案例:连接MySQL数据库
后端·google·go
BillKu4 小时前
Spring Boot 3中JWT密钥安全存储方案
spring boot·后端·安全
独泪了无痕4 小时前
一文搞懂MyBatis中的TypeHandler
数据库·后端·mybatis
程序员岳焱6 小时前
Java 调用 Python 脚本:实现 HelloWorld
java·后端·python
快乐就是哈哈哈7 小时前
Java 短信验证码实战:发送、验证、防刷一步到位
后端
快乐就是哈哈哈8 小时前
Java 开发必备:注册登录功能 + MD5 盐值加密 + 异常处理实现
后端
夕颜1118 小时前
Cursor 分析 bug 记录
后端