HashMap源码解析(JDK1.8)

前言

java的HashMap实质上是一个哈希表,采用了链地址法处理哈希冲突,被称为拉链法。本文将从HashMap的存储结构、HashMap重要属性、构造方法以及其它相关方法方面解析,在每一部分中分别掺入对HashMap静态变量的介绍。

 

HashMap的存储结构

 

 HashMap的底层数据结构是由数组、链表、红黑树组成的。

数组:

// HashMap的底层数组
transient Node<K,V>[] table;

 HashMap中的数组又称为桶数组,因为数组中的每个位置都存放着一个链表的头结点或是红黑树的根结点,当节点数量多时,数组的每个元素就像是一个桶一样。

链表:

链表中的每一个都是一个Node<K,V>类型的结点,每个结点存放着一个键值对。

    /**
     * Basic hash bin node, used for most entries.  (See below for
     * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
     */
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

红黑树:

红黑树要介绍的内容比较的多,所以我将在另一篇博客中单独对其做介绍。

 

HashMap的构造方法

在说构造方法前我们得认识几个HashMap构造方法中用到的静态变量:

    // HashMap的默认容量16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    // HashMap的最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;

    // HashMap的默认负载因子为0.75f
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    // HashMap的阈值
    int threshold;
    
    // HashMap的实际负载因子
    final float loadFactor;

    // 桶数组,构造方法中并未初始化
    transient Node<K,V> table;

第一次接触HashMap源码的人可能会疑惑:

1、什么是负载因子(loadFactor)和阈值(threshold),为什么要定义它们呢?

 这和后面要介绍的扩容机制有关,我们目前只需要知道当容器当前容量超出阈值时,就会进行扩容,而阈值就是负载因子和容器实际容量的乘积。举个例子,当前的总容量是16,负载因子是0.75,那么阈值就是 16*0.75 = 12,当前容量超出12后会触发扩容。

2、为什么初始容量要定义为16,加载因子要定义为0.75呢?

这个问题留待最后解答,作为悬念A

 


 

 HashMap定义了四个构造方法,我们先来看默认构造方法:

1、默认构造方法

    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

这是无参的构造方法。代码中注释的意思是构造了一个空的HashMap,初始容量为16而加载因子为0.75,所有的字段都是用的默认值。

不过我们要注意的是,并不是构造方法运行后初始容量和加载因子就被定义了的,事实上所有的构造方法最多只是定义了一些变量的值而没有初始化table这个实际存储数组。具体什么时候初始化我们看扩容机制,里面会有细谈,这算是悬念B吧。

 

2、指定初始容量和加载因子的构造方法

    // 该构造方法会指定初始容量和加载因子    
    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);
    }

方法中的参数检验没什么好说的,但给阈值赋值时用到了tableSizeFor方法,这个tableSizeFor方法颇值一提,让我们来看看它的源码吧:

    /**
     * Returns a power of two size for the given target capacity.(即返回一个大于且最接近给定容量的2次方数)
     */
    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;
    }

这个方法的返回值是大于且最接近cap的2的k次方数(k>=0),如果输入非正数则会返回1.

这里的n = cap-1我们放到最后讲,先来搞清楚其他的东西。

我们设cap为00...01xxxxxx(第一个1指的是二进制数中出现的第一个1,1后的x表示0或1,但它的数量不代表具体位数)

序号运算前n 运算后n
100...01xxxxxxn>>>1 = 00...001xxxxx00...011xxxxx
200..011xxxxxn>>>2 = 00...00011xxxxx00....01111xxxxx
300...01111xxxxxn>>>4 = 00...000001111xxxx00...011111111xxxx
4.........

观察上表能发现,1、我们先是找到最高位的1,我们将这一位的位置记为t,然后将n无符号右移一位使得(t-1)位变成1,再通过或运算使得t和t-1都变为1;2、随后,我们再用t和t-1位使得t-2和t-3变为1;3、这样一步步下去,n最多可以使得以最高位1为起点开始,总共有(1+2+4+8+16)=31位都变为1,而java中int类型是4个字节,32位。所以该方法可以保证最后得到的n从最高位开始剩下的位数都被转化为1。4、如果n一开始就是负数,即最高位为1,那么最后得到的还是负数n,所以返回值肯定为1;5、n为正数时我们最后将它+1,最后得到的就是只有最高位一个为1的二进制数,即2的次方数。

现在解释一下为什么一开始n = cap-1,我们设想一下cap为8,也就是1000,如果不对它-1,直接拿去运算,我们最后得到的是1111然后对他+1,得到的就是16了,也就是我们本身传入的就是2^k时,如果不对它先-1,那么最后返回的就是2^k+1了,而本身我们想要的是2^k。

回归构造方法,这时我们知道了,阈值的值就是大于且最接近initialCapacity的值,比如intialCapacity是10,那么threshold就是16了,而此时的loadFactor是自己指定的或是系统默认值,所以这时一些小伙伴会奇怪:threshold != capacity * loadFactor啊,其实,这也是在table被初始化后才形成的关系,而这也是在扩容机制里面会提到的。

 

3、指定加载因子的构造方法

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

实际上调用了HashMap(int initialCapacity,int loadFactor)方法

4、指定Map映射的构造方法

    /**
     * Constructs a new <tt>HashMap</tt> with the same mappings as the
     * specified <tt>Map</tt>.  The <tt>HashMap</tt> is created with
     * default load factor (0.75) and an initial capacity sufficient to
     * hold the mappings in the specified <tt>Map</tt>.
     *
     * @param   m the map whose mappings are to be placed in this map
     * @throws  NullPointerException if the specified map is null
     */
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

这个方法是将另一个Map的映射拷贝到自己的存储结构中来,不常用,大家有兴趣可以自己查一下。

 


 

HashMap的hash算法与数组下标

HashMap的存取离不开它的数组下标,我们存取一个键值对时要将它包装成Node结点,计算出它的数组下标(即找到它应该在哪个桶里),然后才能进行下一步的存取。而计算数组下标前我们要先了解它的hash算法。

 

HashMap的hash算法

    
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

这里的key可以是任何类型,如果key是null那返回值就是0。重点的表达式是 (h = key.hashcode()) ^ (h>>>16)。

1、key.hashcode()方法是key类型自带的hashcode方法,我们建议HashMap中的key如果类本身不是像Integer、String那样已经重写过hashcode方法,那最好自己重写hashcode方法,因为对于任何两个对象Object自带的hashcode()返回的值都不同,而在HashMap中我们的要求是两个不同对象而内容相同的key返回的hashcode相同。举个例子:

 我们定义一个类未重写它的hashcode方法,可以看到即使内容相同,输出的hashcode也不同。

现在我们重写它的hashcode方法,我们指定Test1的Hashcode就是内部字符串name的hashcode:

可以看到,现在两个Test1的实例对象内容相同,hashcode也相同了。

 

2、h^h>>>16:h异或它无符号右移16位以后的二进制数。我们都知道int类型的hashcode是32位,而右移16位以后就是这样的:

而这个例子中h异或后的返回结果是:01110111 11110000  00011100 11110111。

那么为什么要 将h^h>>>16的值作为最终HashMap的hash值呢?其实,这个函数被称为扰动函数,是为了在h的后16位中保留h的前16位的信息。而保留高位信息是在之后的取模运算中混合原始码的高位和低位信息,以此来加大低位的随机性。大家可能目前还不明白,待会讲了hash的取模以后再回过头来看就能理解了。


         HashMap的数组下标

在jdk.17中,求数组下标还是独立为一个函数的:

    static int indexFor(int h, int length) {
        return h & (length-1);
    }

而在jdk1.8时,它卑微的被合并到个方法中了,这是putVal方法中使用的数组下标:

不论是哪个版本,我们都能发现,数组下标 i = hash & length -1,即hash值&数组长度-1,这就相当于对数组长度取模。因为HashMap的数组长度一直保持在2的整次幂,所以对数组长度-1是为了让它最高位一下的位数全部变1,比如1000-1 = 0111,这样hash&length-1的结果就是最高位包括最高位以上的位数全部归零,只保留最低位数,这也解释了之前为什么使用扰动函数的疑问。

比如:


hash:10001010 01010101 11000011 00001010
length-1:00000000 00000000 00000000 00000111

i = hash&length-1 = 010 = 2,所以数组下标是2

到此为止,关于HashMap hash和数组下标的问题就讲完了。

 

HashMap的存取机制

  HashMap存入元素

我们平常调用HashMap对象存元素时用的是put()方法,它的源码如下:

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

可以看到,它的内部调用的是putVal方法,所以下面给出putVal方法的源码,并拆分该方法给大家讲述 putVal方法存元素的几个步骤。

先给出完整源码吧:

    /**
     * Implements Map.put and related methods
     *
     * @param hash hash for key  ; hash参数就是hash(key)的值
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value		;如果当前指定的key已存在关联的value是否对其进行替换,false是替换,true是不替换
     * @param evict if false, the table is in creation mode. 	;表是否在创建模式,如果为false,那么表就是在创建模式
     * @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;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            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) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        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;
                }
            }
            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、判断有无初始化table数组:前面也提到过,构造方法运行后table数组并未被初始化,这也是前面留下的悬念B,现在可以解答一半了,我们看下面的代码:

        Node<K,V>[] tab;//存放链表头指针或是红黑树根节点的数组
        Node<K,V> p;//新建节点,用于保存当前指定的key和value
        int n, i;//n为HashMap长度,i为(n-1) & hash,就是数组下标,具体怎么赋值前文已提过
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

我们重点看if((tab = table) == null || (n = tab.length) = 0)这段代码,执行putVal()时,首先先判断当前的table有无被初始化过或者当前容量是否为0,如果没有被初始化过,我们就调用tab.resize()进行初始化,所以table数组实际初始化是在resize()方法中发生的。记住这件事对我们下一篇看扩容机制的文章非常非常有帮助。

 

2、计算键值对所在的桶,判断桶是否为空:

    // 如果桶是空的,那就直接插到桶中
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

如果桶为空就直接把新元素放入桶中

3、桶不为空时

①首先去同一hash桶中检查是否存在key相同的元素,如果存在就让e指向这个元素

②如果这个桶中不存在key相同的元素,我们再去检查这个桶的类型是红黑树还是链表,如果是红黑树就调用红黑树的插入操作

③在这个桶中遍历,直到找到没有后继节点的元素时插入e结点

4、在这之后如果onlyIfAbsent为false(说明允许覆盖原值)或者原值为null,则替换原值

 

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值