• Java基础之HashMap原理分析(put、get、resize)


    准备知识:hash知识

    在分析HashMap之前,先看下图,理解一下HashMap的结构

    图片

    我手画了一个图,简单描述一下HashMap的结构,数组+链表构成一个HashMap,当我们调用put方法的时候增加一个新的 key-value 的时候,HashMap会通过key的hash值和当前node数据的长度计算出来一个index值,然后在把 hash,key,value 创建一个Node对象,根据index存入Node[]数组中,当计算出来的index上已经存在了Node对象的话。就把新值存在 Node[index].next 上,就像图中的 a->aa->a1 一样,这样的情况我们称之为hash冲突

    HashMap基本用法

    Map<String, Object> map = new HashMap<>();
    map.put("student", "333");//正常入数组,i=5
    map.put("goods", "222");//正常入数据,i=9
    map.put("product", "222");//正常入数据,i=2
    map.put("hello", "222");//正常入数据,i=11
    map.put("what", "222");//正常入数据,i=3
    map.put("fuck", "222");//正常入数据,i=7
    map.put("a", "222");//正常入数据,i=1
    map.put("b", "222");//哈希冲突,i=2,product.next
    map.put("c", "222");//哈希冲突,i=3,what.next
    map.put("d", "222");//正常入数据,i=4
    map.put("e", "222");//哈希冲突,i=5,student.next
    map.put("f", "222");//正常入数据,i=6
    map.put("g", "222");//哈希冲突,i=7,fuck.next
    

    首先我们都是创建一个Map对象,然后用HashMap来实现,通过调用 put get 方法就可以实现数据存储,我们就先从构造方法开始分析

    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    

    初始化负载因子为0.75,负载因子的作用是计算一个扩容阀值,当容器内数量达到阀值时,HashMap会进行一次resize,把容器大小扩大一倍,同时也会重新计算扩容阀值。扩容阀值=容器数量 * 负载因子,具体为啥是0.75别问我,自己查资料吧(其实我是不知道,我觉得这个不重要吧~)

    继续看 put 方法

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    

    额,也没啥可看的,继续往下看putVal方法吧

    transient Node<K,V>[] table;
    
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //先判断当前容器内的哈希表是否是空的,如果table都是空的就会触发resize()扩容
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //通过 (n - 1) & hash 计算索引,稍后单独展开计算过程
        if ((p = tab[i = (n - 1) & hash]) == null)
            //如果算出来的索引上是空的数据,直接创建Node对象存储在tab下
            tab[i] = newNode(hash, key, value, null);
        else {
            //如果tab[i]不为空,说明之前已经存有值了
            Node<K,V> e; K k;
            //如果key相同,则需要先把旧的 Node 对象取出来存储在e上,下边会对e做替换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 {
                //在这里解决hash冲突,判断当前 node[index].next 是否是空的,如果为空,就直接
                //创建新Node在next上,比如我贴的图上,a -> aa -> a1
                //大概逻辑就是a占了0索引,然后aa通过 (n - 1) & hash 得到的还是0索引
                //就会判断a的next节点,如果a的next节点不为空,就继续循环next节点。直到为空为止
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //如果当前这个链表上数量超过8个,会直接转化为红黑树,因为红黑树查找效率
                        //要比普通的单向链表速度快,性能好
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //只有替换value的时候,e才不会空
            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;
    }
    

    虽然写我加了注释,但是我还是简单说一下这个的逻辑吧
    1.首先判断哈希表,是否存在,不存在的时候,通过resize进行创建
    2.然后在通过索引算法计算哈希表上是否存在该数据,不存在就新增node节点存储,然后方法结束
    3.如果目标索引上存在数据,则需要用equals方法判断key的内容,要是判断命中,就是替换value,方法结束
    4.要是key也不一样,索引一样,那么就是哈希冲突,HashMap解决哈希冲突的策略就是遍历链表,找到最后一个空节点,存储值,就像我的图一样。灵魂画手有木有,很生动的表式了HashMap的数据结构
    5.最后一步就是判断是否到扩容阀值,容量达到阀值后,进行一次扩容,按照2倍的规则进行扩容,因为要遵循哈希表的长度必须是2次幂的概念

    好,put 告一断落,我们继续 get

    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
    

    get方法,恩,好,很简单。hash一下key,然后通过getNode来获取节点,然后返回value,恩。get就讲完了,哈哈。开个玩笑。我们继续看getNode

        final Node<K,V> getNode(int hash, Object key) {
            Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
            //哈希表存在的情况下,根据hash获取链表的头,也就是first对象
            if ((tab = table) != null && (n = tab.length) > 0 &&
                (first = tab[(n - 1) & hash]) != null) {
                //检测第一个first是的hash和key的内容是否匹配,匹配就直接返回
                if (first.hash == hash && // always check first node
                    ((k = first.key) == key || (key != null && key.equals(k))))
                    return first;
                //链表的头部如果不是那就开始遍历整个链表,如果是红黑树节点,就用红黑树的方式遍历
                //整个链表的遍历就是通过比对hash和equals来实现
                if ((e = first.next) != null) {
                    if (first instanceof TreeNode)
                        return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                            return e;
                    } while ((e = e.next) != null);
                }
            }
            return null;
        }
    

    我们在整理一下,get方法比put要简单很多,核心逻辑就是取出来索引上的节点,然后挨个匹配hash和equals,直到找出节点。
    那么get方法就搞定了

    再来看一下resize吧。就是HashMap的扩容机制

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        //检测旧容器,如果旧容器是空的,就代表不需要处理旧数据
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //保存扩容阀值
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 对阀值进行扩容更新,左移1位代表一次2次幂
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //如果哈希表是空的,这里会进行初始化扩容阀值,
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        //处理旧数据,把旧数据挪到newTab内,newTab就是扩容后的新数组
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    //如果当前元素无链表,直接安置元素
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    //红黑树处理
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        //对链表的索引重新计算,如果还是0,那说明索引没变化
                        //如果hash的第5位等于1的情况下,那说明 hash & n - 1 得出来的索引已经发生变化了,变化规则就是 j + oldCap,就是索引内向后偏移16个位置
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
    

    resize方法的作用就是初始化容器,以及对容器做扩容操作,扩容规则就是double
    扩容完了之后还有一个重要的操作就是会对链表上的元素重新排列

    (e.hash & oldCap) == 0

    在讲这个公式之前,我先做个铺垫

    16的二进制是 0001 0000
    32的二进制是 0010 0000
    64的二进制是 0100 0000

    我们知道HashMap每次扩容都是左移1位,其实就是2的m+1次幂,也就是说哈希表每次扩容都是 16、32、64........n
    然后我们知道HashMap内的索引是 hash & n - 1,n代表哈希表的长度,当n=16的时候,就是hash & 0000 1111,其实就是hash的后四位,当扩容n变成32的时候,就是 hash & 0001 1111,就是后五位

    我为啥要说这个,因为跟上边的 (e.hash & oldCap) == 0 有关,这里其实我们也可以用

    假设我们的HashMap从16扩容都了32。
    其实可以用 e.hash & newCap -1 的方式来重新计算索引,然后在重排链表,但是源码作者采用的是另外一种方式(其实我觉得性能上应该一样)作者采用的是直接比对 e.hash 的第五位(16长度是后四位,32长度是后五位)进行 0 1校验,如果为0那么就可以说明 (hash & n - 1)算出来的索引没有变化,还是当前位置。要是第五位校验为1,那么这里(hash & n - 1)的公式得出来的索引就是向数据后偏移了16(oldCap)位。

    所以作者在这里定义了两个链表,
    loHead低位表头,loTail低位表尾(靠近索引0)
    hiHead高位表头,hiTail高位表尾(远离索引0)

    然后对链表进行拆分,如果计算出来索引没有变化,那么还让他停留在这个链表上(拼接在loTail.next上)
    如果计算索引发生了变化。那么数据就要放置在高位链表上(拼接在hiTail.next)上

    最后来个灵魂配图,链表重排
    图片
    重拍完成后的HashMap
    图片

    好了。HashMap就讲完了,可能还需要自己消化消化,反正我是消化完了。

    一个被程序耽误的画手
  • 相关阅读:
    android中样式和自定义button样式
    android——实现多语言支持
    sizeof,数组,指针
    C++预处理相关
    内联函数
    牛客C++刷题
    leetcode刷题列表
    ends在linux和Windows下输出结果不同
    计算机负数为什么使用补码及浮点型计算
    个人技术博客:VUE:0基础入门
  • 原文地址:https://www.cnblogs.com/kezhuang/p/13673695.html
Copyright © 2020-2023  润新知