美文网首页
我的HashMap果然有问题

我的HashMap果然有问题

作者: 我永远喜欢御坂美琴 | 来源:发表于2020-05-17 17:24 被阅读0次

    1.HashMap数据结构

    HashMap底层数据结构为hash表,也称散列表。hash表是根据键值key直接访问内存中数据存储位置的数据结构,也就是说通过key值将数据映射到相对应的内存储存位置中,映射函数也被称为散列函数。hash表是典型的空间换时间的数据结构,因为能根据key直接访问到具体的数据存储位置,所以hash表查找的时间复杂度可以到达极致的O(1)。然而,在一段一定长的储存位置中进行key值的映射,不可避免地就会产生不同key值映射的位置相同,即出现冲突,当出现冲突时的解决方案一般有:开放定址法,链表法,再散列等。

    2.Java中HashMap源码(jdk 1.8)

    先上一段简单的代码

    public static void main(String[] args) {
            HashMap hashMap=new HashMap();
            //存值
            hashMap.put("key1",1);
            //取值
            hashMap.get("key1");
        }
    

    put方法即是将key1-1的键值对存入hashmap中,再通过get方法,用key1将相对应的值1取出来。那...put方法调用时具体发生了什么?HashMap是如何解决冲突的?get时又发生了什么?...解决这些问题的最快捷最直接的方式就是查看源码。于是,我就用我的宇宙第一IDE——IntelliJ IDEA 点开了HashMap源码(手动狗头)

    public V put(K key, V value) {
            //调用了hash方法和putVal方法
            return putVal(hash(key), key, value, false, true);
        }
    
    //先康康hash方法
    //hash方法实际上是一个扰动函数
    static final int hash(Object key) {
            int h;
            //1.若key==null 则返回0
            //2.若key!=null 则返回key的hash值与其自身右移16位后的数进行异或运算
            return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
        }
    

    看到这里不禁会疑惑为什么要做这一步骚操作,直接返回hash值不行吗?为解决这个问题我们接下来康康putVal方法,康康这个方法到底用hash(key)干了些什么。

    /**
         * Implements Map.put and related methods
         *
         * @param hash hash for key
         * @param key the key
         * @param value the value to put
         * @param onlyIfAbsent if true, don't change existing value 是否不允许替换旧值
         * @param evict if false, the table is in creation mode. 该参数服务于afterNodeInsertion(evict); 该方法于HashMap中是个空方法,服务于HashMap的子类LinkedHashMap
         * @return previous value, or null if none
         */
        final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
            Node<K,V>[] tab; Node<K,V> p; int n, i;
            
            //transient Node<K,V>[] table; table为其成员变量 是一个Node键值对数组
            //若table为null或者table的数组长度为0
            if ((tab = table) == null || (n = tab.length) == 0)
                //进行扩容操作 并将扩容之后的数组长度赋给n
                n = (tab = resize()).length;
            //若 数组下标为[数组长度-1&hash]的元素为空 
            if ((p = tab[i = (n - 1) & hash]) == null)
                //在该数组下标元素处插入一个节点
                tab[i] = newNode(hash, key, value, null);
            //若 数组下标为[数组长度-1&hash]的元素不为空
            else {
                Node<K,V> e; K k;
                //p = tab[i = (n - 1) & hash]
                //若p节点的hash值与key的hash值相同
                //  若相同 判断p节点key和传入的key是否为同个对象 或者 p节点key和传入的key是否一致
                if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                    e = p;
                //若p为红黑树的树结点
                else if (p instanceof TreeNode)
                    //调用p节点的putTreeVal
                    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
                //若不在头节点也不为红黑树
                //故为链表
                else {
                    //遍历链表
                    //binCount记录节点个数
                    for (int binCount = 0; ; ++binCount) {
                        //若链表中不含key
                        if ((e = p.next) == null) {
                            //创建新节点
                            p.next = newNode(hash, key, value, null);
                            //static final int TREEIFY_THRESHOLD = 8;
                            //若此时链表(包含链表头)大于等于8个节点
                            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                                //将该链表转化为红黑树
                                treeifyBin(tab, hash);
                            break;
                        }
                        //若链表包含key 此时key值节点为e节点
                        if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                            break;
                        p = e;
                    }
                }
                //若链表包含key
                if (e != null) { // existing mapping for key
                    V oldValue = e.value;
                    //若允许替换值或旧值为空
                    if (!onlyIfAbsent || oldValue == null)
                        //节点赋新值
                        e.value = value;
                    //空方法 服务于HashMap的子类LinkedHashMap
                    afterNodeAccess(e);
                    //返回旧值
                    return oldValue;
                }
            }
            //修改次数+1 用于迭代器的快速失败
            ++modCount;
            //若容量大于阈值
            if (++size > threshold)
                //进行扩容操作
                resize();
            //空方法 服务于HashMap的子类LinkedHashMap
            afterNodeInsertion(evict);
            return null;
        }
    

    源码中,p = tab[i = (n - 1) & hash]操作是通过将key的hash值数组长度-1相与去获取key值在Node数组上的映射位置,其中数组长度被hashmap强制设置为2的幂。为什么要强制设置为2的幂呢?2的幂有什么好处?

    当数组长度为2的幂时,转化为二进制即为只有一位为1的情况,以16举例:1 0000,此时减一就会变成:0 1111

    这时候假设有一hash值为1010 1110 1011的key & 15得

    1010 1110 1011
    &
    0000 0000 1111
    ---------------
    0000 0000 1011
    

    我们再举一个例子

    1010 1110 1011
    &
    0000 0000 1101
    ---------------
    0000 0000 1001
    ===============
    1010 1110 1001
    &
    0000 0000 1101
    ---------------
    0000 0000 1001
    

    我们可以看到hash值&1111后,低位全部得到保留

    但如果我们用hash值&1101后,我们发现倒数第二位永远为0,会导致分布不均匀以及冲突的增多

    所以采用2的幂作为数组长度能够保证映射的均匀

    再回到我们之前的问题:hash方法的作用是什么?

    前面我们已经知道,使用2的幂作为数组长度的好处,它能完全保留hash的低位,然而,光靠hash值的低位就能保证映射的均匀吗?答案是否定的,我们如果直接用key的hash值进行&操作就意味着直接抛弃hash值的高位,那能不能将高位的信息保留到低位呢?hash方法就是做的这项工作

    //hash方法代码
    //1.若key==null 则返回0
    //2.若key!=null 则返回key的hash值与其自身右移16位后的数进行异或运算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    
    image-20200517160759674.png
    hash值为int类型,占4个字节(byte),32位(bit);右移16位再和自身进行异或运算混合了hash值的高位和低位,由此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来

    在putVal这个方法中,调用了resize方法,该方法用于数组的扩容,而且在putVal方法最初会检查Node数组是否为空,若为空就进行一次扩容操作,这次扩容就是Node数组的初始化。接下来就让我们康康resize方法

    /**
         * Initializes or doubles table size.  If null, allocates in
         * accord with initial capacity target held in field threshold.
         * Otherwise, because we are using power-of-two expansion, the
         * elements from each bin must either stay at same index, or move
         * with a power of two offset in the new table.
         *
         * @return the table
         */
        /**
         * The default initial capacity - MUST be a power of two.
         */
        //static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
        
        final Node<K,V>[] resize() {
            Node<K,V>[] oldTab = table;
            //获取旧数组长度
            int oldCap = (oldTab == null) ? 0 : oldTab.length;
            //获取旧阈值
            int oldThr = threshold;
            //初始化新数组长度和新阈值
            int newCap, newThr = 0;
            //若旧数组长度大于0
            if (oldCap > 0) {
                //若数组长度大于最大容量
                if (oldCap >= MAXIMUM_CAPACITY) {
                    //将阈值等于Integer的最大值
                    threshold = Integer.MAX_VALUE;
                    return oldTab;
                }
                //若新数组长度=旧数组长度*2 且 新阈值小于最大容量
                //  且旧数组长度大于默认初始值
                else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                         oldCap >= DEFAULT_INITIAL_CAPACITY)
                    //新阈值等于旧阈值*2(采用位运算是因为位运算更加高效)
                    newThr = oldThr << 1; // double threshold
            }
            //此时数组长度不大于0(即为初始化状态) 若旧阈值大于0 代表的是初始设置了容量和阈值的情况
            else if (oldThr > 0) // initial capacity was placed in threshold
                //这种情况在自定义初始化hashmap时会出现
                //将新数组长度等于旧阈值
                newCap = oldThr;
            //若旧阈值也不大于0 代表的是初始情况
            else {               // zero initial threshold signifies using defaults
                //新数组长度等于DEFAULT_INITIAL_CAPACITY 也就是为16
                newCap = DEFAULT_INITIAL_CAPACITY;
                //新阈值=16*0.75
                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
            threshold = newThr;
            @SuppressWarnings({"rawtypes","unchecked"})
                Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
            table = newTab;
            //进行rehash操作 新建一个Node数组,将旧数组中的元素转移到新数组中
            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)
                            //进行红黑树节点rehash操作
                            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                        else { // preserve order
                            //采用尾插法
                            //遍历链表
                            //低位链表 头尾指针
                            Node<K,V> loHead = null, loTail = null;
                            //高位链表 头尾指针
                            Node<K,V> hiHead = null, hiTail = null;
                            Node<K,V> next;
                            do {
                                next = e.next;
                                //表示该节点rehash之后位于低位
                                if ((e.hash & oldCap) == 0) {
                                    if (loTail == null)
                                        loHead = e;
                                    else
                                        loTail.next = e;
                                    loTail = e;
                                }
                                //表示该节点rehash之后位于高位
                                else {
                                    if (hiTail == null)
                                        hiHead = e;
                                    else
                                        hiTail.next = e;
                                    hiTail = e;
                                }
                            } while ((e = next) != null);
                            //若低位有节点 置于新数组 j 处
                            if (loTail != null) {
                                loTail.next = null;
                                newTab[j] = loHead;
                            }
                            //若高位有节点 置于新数组 j+旧数组.len 处
                            if (hiTail != null) {
                                hiTail.next = null;
                                newTab[j + oldCap] = hiHead;
                            }
                        }
                    }
                }
            }
            return newTab;
        }
    

    在resize方法的源码中,如果我们没有指定HashMap的初始长度和阈值时,hashmap会为我们默认初始化一个长度为16的数组,16也是2的幂。这时候,有小伙伴就要问了,hashmap有为用户提供初始化hashmap大小的构造方法,那可不可以搞破坏,把hashmap的初始长度设置为不为2的幂,从而降低这个hashmap的性能呢?嘿嘿嘿,那就来康康hashmap的构造方法吧。

    public HashMap(int initialCapacity) {
            this(initialCapacity, DEFAULT_LOAD_FACTOR);
        }
    
    //initialCapacity:初始容量
    //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);
        }
    
    /**
     * Returns a power of two size for the given target capacity.
     */
    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;
        }
    

    其中的tableSizeFor方法是为了让传入的参数转化成2的幂,具体过程可以看看下面这张图

    image-20200517164124229.png
    所以,就算我们把初始容量设置成乱七八糟的数字,hashmap也会把你所设置的初始容量改成2的幂 image-20200517164556401.png
    说完了put方法,get相比之下就简单很多了,下面是get方法源码,相信都是看的懂了的(手动狗头)
    public V get(Object key) {
            Node<K,V> e;
            return (e = getNode(hash(key), key)) == null ? null : e.value;
        }
    
    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 && // always check first node
                    ((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;
        }
    

    参考文献:

    HashMap源码注解 之 静态工具方法hash()、tableSizeFor()(四)

    JDK 源码中 HashMap 的 hash 方法原理是什么?

    java容器三:HashMap源码解析

    相关文章

      网友评论

          本文标题:我的HashMap果然有问题

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