美文网首页
深入理解HashMap

深入理解HashMap

作者: CodingRunning | 来源:发表于2021-02-12 17:35 被阅读0次

    一, 什么是HashMap

    Java中Map是用来存储键值对<key,value>的集合类,也就是哈希表,而HashMap是Map的实现类.

    具有存储效率高,查询快的特点,但不是线程同步的,按照哈希表的特点,Map中的key是不能重复的.

    image-20210212112919731

    二, 实现原理

    HashMap采用数组+链表+红黑树实现
    每个数组空间采用Node<key,value>节点来保存键值对,在一定的条件下会采用TreeNode<key,value>保存数据.
    
    image-20210212112919731

    三, 基本属性介绍

    //The default initial capacity - MUST be a power of two
    //默认初始容量-必须是2的幂.
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4
    
    //The maximum capacity, used if a higher value is implicitly specified by either of the constructors with arguments.MUST be a power of two <= 1<<30.
    //哈希数组最大容量,如果构造函数制定了更大的值,则只使用最大值,并且大小只能为2的幂
    static final int MAXIMUM_CAPACITY = 1 << 30
    
    //The load factor used when none specified in constructor
    //构造函数中未指定时使用的负载系数(默认使用)
    static final float DEFAULT_LOAD_FACTOR = 0.75f
    
    //The bin count threshold for using a tree rather than list for a bin.
    //Bins are converted to trees when adding an element to a bin with at least this many nodes.
    //The value must be greater than 2 and should be at least 8 to mesh with assumptions in tree removal about //conversion back to plain bins upon shrinkage.
    //树化阈值: 当一个数组框中的节点数量大于TREEIFY_THRESHOLD时应当采用树来存储,而不是链表
    static final int TREEIFY_THRESHOLD = 8
    
    //The bin count threshold for untreeifying a (split) bin during a resize operation.
    //Should be less than TREEIFY_THRESHOLD, and at most 6 to mesh with shrinkage detection under removal.
    //树节点数量小于6个时,则将树转换为链表
    static final int UNTREEIFY_THRESHOLD = 6
    
    //The smallest table capacity for which bins may be treeified.
    //树化的最小阈值: 只有哈希表中的节点数量大于64时,才能将链表转换为树
    static final int MIN_TREEIFY_CAPACITY = 64
    
    //存储节点的哈希数组
    transient Node<K, V>[] table
    
    //哈希表中的节点数量
    transient int size
    
    //修改HashMap的次数,用于fail-fast
    transient int modCount
    
    //The next size value at which to resize (capacity * load factor)
    //下一次需要扩容的界限(扩容阈值)
    int threshold
    
    //The load factor for the hash table
    //负载系数,主要用来调节哈希表的填充度
    final float loadFactor
    

    四, 构造函数

    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;
        //设置下一次扩容的阈值,这里不是真正的阈值,后面会重新设置
        this.threshold = tableSizeFor(initialCapacity);
    }
    

    五, 常用方法介绍

    /**
     * 计算传入值的hash值,用来计算存储的位置
     * 将hashCode的高16位与低16位进行异或操作,这样做的原因主要是为了更好的分散hash分布,减少哈希碰撞
     * 如果采用&则计算出来的结果会向0靠近,采用|计算的结果会向1靠近,都不能做到均匀分布
     * 做异或操作的原因: 加大对哈希码低位的随机性
     * String s = "Hello World!";
     * Integer.toBinaryString(s.hashCode())
     * h.hashCode() = 1100 0110 0011 1100 1011 0110 0001 1101
     * h.hashCode() >>> 16 = 0000 0000 0000 0000 1100 0110 0011 1100
     * 1100 0110 0011 1100 1011 0110 0001 1101  ^
     * 0000 0000 0000 0000 1100 0110 0011 1100
     * 1100 0110 0011 1100 1000 0110 0001 1100
     */
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
    /**
     * 刚方法主要用来计算哈希表的大小,从上面的属性可知,哈希表的大小始终为2的幂
     * 这样做的原因是:
     * 1. length的二进制表示始终为100...000,length - 1的二进制为1111...111
     * 2. 长度等于2的幂时,hash%length = hash&(length - 1),但后者比前者效率更高
     * 3. 可以保证计算的位置更加的分散,如果长度为奇数,则length - 1为偶数,无论怎么取余或&操作,最终结果都为偶数,会造成一半空间浪费
     */
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
    

    获取节点

    image-20210212125544816
    final Node<K, V> getNode(int hash, Object key) {
        Node<K, V>[] tab;
        Node<K, V> first, e;
        int n;
        K k;
        if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
            // 始终检查首节点是否为要找的节点
            if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            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;
    }
    
    //查找节点
    final TreeNode<K, V> getTreeNode(int h, Object k) {
        return ((parent != null) ? root() : this).find(h, k, null);
    }
    
    //获取树的根节点
    final TreeNode<K, V> root() {
        for (TreeNode<K, V> r = this, p; ; ) {
            //只有根节点的父节点为空
            if ((p = r.parent) == null)
                return r;
            //向父节点移动
            r = p;
        }
    }
    
    //在树中查找
    final TreeNode<K, V> find(int h, Object k, Class<?> kc) {
        TreeNode<K, V> p = this;        //复制当前节点
        do {
            int ph, dir;    //当前节点的hash值,方向
            K pk;           //当前节点的key
            TreeNode<K, V> pl = p.left, pr = p.right, q;    //当前节点的左孩子,右孩子,以及q来返回查找到的结果
            //当前节点的hash值大于需要找的节点的hash值,需要向左子树移动
            if ((ph = p.hash) > h)
                p = pl;
            //当前节点的hash值小于需要找的节点的hash值,需要向右子树移动
            else if (ph < h)
                p = pr;
            //当前节点的hash值相同,并且key也相同,直接返回
            else if ((pk = p.key) == k || (k != null && k.equals(pk)))
                return p;
            //左子树为空,向右子树移动查找
            else if (pl == null)
                p = pr;
            //右子树为空,向左子树移动判断
            else if (pr == null)
                p = pl;
            //这一步主要通过Comparable对比当前遍历的节点p的key和要查找的key的大小
            else if ((kc != null || (kc = comparableClassFor(k)) != null) && (dir = compareComparables(kc, k, pk)) != 0)
                p = (dir < 0) ? pl : pr;        //小于0向左遍历,大于0向右遍历
            //这一步表示无法通过Comparable比较大小,则从右孩子继续递归遍历查找
            else if ((q = pr.find(h, k, kc)) != null)
                return q;
            else
                //从右孩子递归遍历后还没有找到,从左孩子继续查找
                p = pl;
        } while (p != null);
        return null;
    }
    
    static int compareComparables(Class<?> kc, Object k, Object x) {
        return (x == null || x.getClass() != kc ? 0 : ((Comparable) k).compareTo(x));
    }
    

    插入节点

    put
    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, 如果哈希表为空,则通过resize()创建.所以,初始化链表的时机为第一次调用put函数时
             */
            if ((tab = table) == null || (n = tab.length) == 0)
                n = (tab = resize()).length;
            /*
             * 判断是否存在hash冲突:
             * 若不存在,则直接在该数组位置新建节点,直接插入
             * 否则,代表hash冲突,即当前存储位置已存在节点,则依次向下判断
             * a. 当前位置的key与要存入位置的key相同
             * b. 判断需插入的数据结构是否为红黑树或链表
             */
            if ((p = tab[i = (n - 1) & hash]) == null)
                tab[i] = newNode(hash, key, value, null);
            else {
                Node<K, V> e;       //待修改的节点
                K k;
                /*
                 * a. 判断table[i]的元素的key是否与需插入的key一样,如果一样直接用新的value覆盖旧的value
                 */
                if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
                    e = p;
                    /*
                     * b. 判断需要插入的是否为红黑树,如果为红黑树则直接在树中插入或更新键值对
                     */
                else if (p instanceof TreeNode)
                    e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
                    /*
                     * c. 如果是链表,则直接在链表中更新键值对
                     * i.  遍历table[i],判断key是否存在,采用equals对比当前遍历节点的key与待插入数据的key,如果相同则直接用新的value覆盖旧value
                     * ii. 遍历完毕没有上述情况,则直接在链表尾部插入新的节点
                     * tips: 新增节点之后,需要判断链表的长度是否大于8,如果是,需要树化
                     */
                else {
                    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;
                        }
                        //如果找到了key相同的节点,则直接退出
                        if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                            break;
                        //向后移动节点进行遍历
                        p = e;
                    }
                }
                /*
                 * 找到需要修改的node则直接用新值代替旧值
                 */
                if (e != null) { // existing mapping for key
                    V oldValue = e.value;
                    if (!onlyIfAbsent || oldValue == null)
                        e.value = value;
                    //默认实现为空,为了给LinkedHashMap来实现有序map
                    afterNodeAccess(e);
                    return oldValue;
                }
            }
            //修改次数+1,用于fail-fast
            ++modCount;
            //节点数量大于阈值,需要扩容
            if (++size > threshold)
                resize();
            afterNodeInsertion(evict);
            return null;
    }
    
    resize
    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;
            } else if
                //旧容量小于最大容量的一半,并且哈希表已经初始化了则新阈值为旧阈值的一倍
                ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        } else if (oldThr > 0)
            // 如果旧的阈值大于0,则表示哈希表已经被初始化了,则新的容量等于旧的阈值
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            //初始化
            newCap = DEFAULT_INITIAL_CAPACITY;
            //新的阈值等于初始容量*加载因子
            newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //如果新的阈值等于0
        if (newThr == 0) {
            //计算新的阈值
            float ft = (float) newCap * loadFactor;
            //新的容量大于最大值并且新的阈值大于最大值,则新的阈值为最大值
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ? (int) ft : Integer.MAX_VALUE);
        }
        //赋值新的阈值
        threshold = newThr;
        //创建新的哈希表,赋值新的哈希表
        @SuppressWarnings({"all"})
        Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            //将每个bucket中都移动到新的bucket中
            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 {
                        // 处理链表节点
                        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);
                        //原索引放在bucket中
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        //原索引+oldCap放到bucket中
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
    

    五, 与之前版本对比

    1. hash值计算方式
    //1.7 
    static final int hash(int h) {
        h ^= k.hashCode(); 
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
    //1.8 
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    

    从上可以看出1.7 一共做了5次异或操作和4次无符号右移操作,而1.8一共做了一次右移和一次异或,效率比1.8高

    2. 存储数据结构不同

    1.7 采用数组 + 链表解决哈希冲突,链表采用头插法

    1.8 采用数组 + 链表 + 红黑树 解决哈希冲突,链表采用尾插法

    并且1.7的数据结构还会造成循环链表

    问题产生的条件:

    多线程同时进行put时,如果同时调用了resize操作,可能会导致循环链表产生,进而会导致后面get的时候产生死循环

    问题产生的原因:

    多线程下进行put,原本应该在当前节点的next被其他线程移到新数组的空位置上,而当前节点的next指针指向刚放入的节点,从而造成循环链表.

    3. 扩容机制不同

    1.7 对原先的每一个节点重新计算插入位置

    1.8 只有第一个位置上的节点不需要重新计算插入位置,其他的都为当前位置+旧数组容量

    相关文章

      网友评论

          本文标题:深入理解HashMap

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