java集合之HashMap源码分析(常用函数,扩容,哈希冲突,线程不安全问题,HashSet)

HashMap基础

HashMap的成员变量

静态变量

1.哈希桶数组的默认长度为16,同时其长度一定要为2^n,默认负载系数为0.75,其最大长度为2的30次方。

2.链表树化的条件是:哈希桶数组的长度大于等于64且链表中节点的个数大于等于8

3.红黑树链表化的条件是:树中节点数小于等于6。

  //哈希桶数组的默认长度(16)二进制:10000。
  static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
  //哈希桶数组的最大长度(2^30)
  static final int MAXIMUM_CAPACITY = 1 << 30;
  //默认的负载系数
  static final float DEFAULT_LOAD_FACTOR = 0.75f;
  //树化的最小链表节点数(链表节点数要大于等于8且哈希桶数组的长度大于等于64)
  static final int TREEIFY_THRESHOLD = 8;
  //由树转为链表的节点数,当树中节点数小于该值会转换为链表。
  static final int UNTREEIFY_THRESHOLD = 6;
  //树化的最小哈希桶数组值
  static final int MIN_TREEIFY_CAPACITY = 64;

实例变量

1.哈希桶数组存放的数据类型可以是链式节点(Node),也可以是树式节点(TreeNode继承自Node)。

2.threshold是桶扩充的阈值,这个阈值等于capacity * loadfactor,当键值对的数量超过这个阈值时会扩容。

  //哈希桶数组
  transient Node<K,V>[] table; 
  //存储键值对的Set,存储的类为Map中的内部类    
  transient Set<Map.Entry<K,V>> entrySet;                    
  //键值对的数量
  transient int size;
  //HashMap结构修改的次数
  transient int modCount;
  //扩容的阀值,当键值对的数量超过这个阀值会产生扩容
  int threshold;
  //负载因子
  final float loadFactor;

链式节点

这是链式节点的存储结构,其实是实现了内部接口Entry<K,V>。树式节点继承自链式节点,这里不写出来了。

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的构造函数

无参数构造函数:构造一个默认容量(16)与默认负载系数(0.75)的HashMap。

 	/**
     * 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
    }

传入容量的构造函数,与无参构造函数类似,会将传入的容量转化为大于该值的最小的2的幂次方,并赋值给扩充阈值。

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

传入容量和负载系数的构造函数,这里的tableSizeFor方法可以理解为将传入的容量转化为大于该值的最小的2的幂次方,比如传入6,就会返回8。可以看到HashMap没有在构造函数中初始化hash桶数组

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);
    }

HashMap减少哈希冲突与解决哈希冲突的方法

哈希冲突

比如现在有这几个key值:5,28,19,15,20,33,12,17,10,而我们的哈希桶数组大小是9,哈希函数为了简便起见为:

H(key)=key%9

会发现H(28)=1,而H(19)=1,这样就产生了哈希冲突。解决哈希冲突就是HashMap中要做的事情。

HashMap的hash函数与哈希桶数组下标的计算(重要)

HashMap是如何计算key的hash值呢,是通过hash函数。这里还可以知道HashMap存储的键值对允许key值为null。

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

之后计算对应哈希桶数组的下标是通过这行代码,其中hash是通过上面的hash函数求出来的哈希值。

&是按位与
i=(n - 1) & hash

为什么要无符号右移16位后做异或运算(减少哈希冲突)

第一次看到这里我也是很奇怪为什么hash函数不直接使用key的hashCode,而是使用下面的这行代码。

>>>是无符号右移
^是异或
(h = key.hashCode()) ^ (h >>> 16);

可以理解为设计者想要将高低二进制特征混合,防止哈希桶数组长度较小时,哈希桶数组下标的计算结果只与哈希值的低16位有关,而造成哈希冲突

以图中例子可知,h右移16位,相当于把高16位的数移动到了后11位,而在于h自身做异或操作后,原本高16位没有变化,低16位就变成了低16位与高16位的异或结果,这样可以将高低位二进制特征混合起来
在这里插入图片描述
而之所以要在低16位的结果改为高低位混合的原因在于,哈希桶数组的长度往往不会很大,也就是说除非长度大于2^16+1,才会用到高16位。这样会造成的结果是:如果两个hash值在低16位没有差别,而差别在高16位,如果低16位结果没有改变的话,他们计算出的哈希桶数组下标就相同了,很容易出现哈希冲突。而HashMap这种设计方法就是为了减少哈希冲突。

计算下标的方法:
i=(n - 1) & hash

这就是一个n值为16的例子,可以看到高16位的二进制特征都丢失了。
在这里插入图片描述

为什么桶数组的长度是2^n(减少哈希冲突)

是为了让结果更加均匀。比如哈希桶数组的长度为17,那样17-1的结果就是16(00010000),可以看到,因为是与运算,最后计算出的下标值就只与hash值的第五位有关系了,其他值不管为0或1与运算之后都是0,这样会造成更大的哈希冲突。

而如果桶数组的长度为2^n,做完减1操作后,其二进制就有多个1(16-1=15(00001111)),相当于结果与hash值多个位置有关(所有二进制为1的位置),可以有效地减少哈希冲突。
在这里插入图片描述

为什么使用&而非%(节省时间)

其实理论上来说两种方式结果相同,不过按位与的操作会更快。因为%是算术运算,最终还是会转换为位运算、

HashMap解决哈希冲突的方法(重要)

HashMap的常规存储方式是数组,数组中存放 Node<K,V>的节点,但是为了防止出现哈希冲突,HashMap使用数组+链表+红黑树的存储方式。

HashMap理想的情况是不出现哈希冲突,一个桶中装一个值。但不幸的是,即使HashMap已经通过上述很多种方式减少哈希冲突,可是哈希冲突还是会出现。HashMap中使用链表红黑树来解决哈希冲突。
在这里插入图片描述
HashMap会在链表的长度大于等于8且哈希桶数组的长度大于等于64时,会将链表树化。红黑树是一个自平衡的二叉查找树,因此查找效率就会从O(n)变成O(logn)。

static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;

为什么不将所有链表全部转化为红黑树呢?

答案在于:
(1)链表结构简单,而红黑树结构复杂,在数量少的情况下,未必数组+链表的性能比数组+链表+红黑树差。
(2)第二个是HashMap频繁的resize(扩容),扩容的时候需要重新计算节点的索引位置,也就是会将红黑树进行拆分和重组,这里涉及到红黑树的着色和旋转,这又是一个比链表结构耗时的操作,所以为链表树化设置一个阀值是非常有必要的。

put与get方法

put方法(重要)

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

这里会先调用hash方法计算key的哈希值,具体逻辑上文已经讲完了,继续看putVal方法。

putval方法

1.会判断哈希桶数组是否为空,为空就会初始化,哈希桶数组table的初始化不是在构造函数中进行的,而是在第一次put时进行的。

//看似是在给tab赋值,其实主要是判断table是否为空,为空可以去初始化。
 if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

2.接下来会通过(n-1)&hash计算出传入的key对应的哈希桶数组下标,具体逻辑上文已经讲过了,这里不再赘述。

i = (n - 1) & hash

接下来将p指向当前下标的哈希桶中的节点。p=null代表当前下标的哈希桶中没有存Node,也就是没有发生哈希冲突,因此直接创建一个链式节点,并存入对应下标的哈希桶中。

if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

3.接下来是发生哈希冲突的情况:

注意这里声明了一个新的节点e,这个节点很重要,后续代码会根据它是否为空来判断本次操作是新增了节点还是替换了原有节点的value值。

Node<K,V> e;

(1)如果哈希桶数组索引处的节点与新加入的节点具有重复的key则直接覆盖该处节点的value值,同时将e指向p。

if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;

(2)如果原本存储在桶中的节点是树节点,就交由红黑树去解决这个哈希冲突,如果插入过程中发现新节点的key与已有的树中节点冲突则覆盖该处value。覆盖的方法是赋值给节点e,在后面判断节点e是否为null,不是null则说明需要覆盖。

else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

(3)如果存储在桶中的节点是链式节点。就遍历整个链表去寻找链表中是否有和新加入的节点具有重复的key的节点,如果发现key重复的节点的话,就直接将该原节点的value值更改为新加入节点的value,并结束循环;如果不存在的话,就将新节点插在链表的尾部

注意:如果p.next==null,说明已经找完了链表,就要在链表的尾部插入新加入的节点。插入后要判断链表长度是否超过树化的阈值,如果超过,就要将链表树化。(这里比较的是TREEIFY_THRESHOLD - 1就是将新加入的节点也计算在内)

与红黑树中解决哈希冲突类似:如果是插入新的节点,e会为null,如果是替换原节点的value,则e会指向那个节点

		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;
                }
            }

(4)如果e!=null,说明只是替换了原节点中的value值,因此,HashMap长度没有发生改变,因此不需要判断是否扩充,直接 return oldvalue 即可。

 if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }

4.走到这一步说明,HashMap中添加了新的键值对

如果当前的键值对个数已经超过阈值了,就需要去扩容。

++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;

简易流程图

在这里插入图片描述
完整代码:

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;
    }

get方法

返回getNode的结果,如果为空则说明哈希桶数组中不存在与所查找的key相同的节点,否则返回那个节点的值。

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

getNode方法

(1)判断哈希桶数组是否为空,为空直接返回null。

(2)通过tab[(n - 1) & hash]定位到哈希桶数组的下标,判断对应下标处哈希桶是否没有存储节点,没有则返回null。

(3)有存储节点就要去对应结构(链表或树)中按顺序寻找相同key值的节点,如果有的话就会返回该节点,否则返回null。

 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;
    }

扩容

resize函数

1.创建一个oldtab数组指向table,oldcap为table的长度(table为空则为0),oldThr则为当前的阈值。newCap和newThr是我们将要扩充后的容量与阈值。

2 .首先判断oldcap是否大于0大于0则代表本次resize是扩充否则则说明是初始化

(1) 扩充的情况:如果原本哈希桶数组的长度已经大于等于允许长度的最大值,则将扩充阈值赋值为Integer.MAX_VALUE,并直接返回,不进行扩充否则,就将数组的新容量扩充为从前容量的两倍(在扩充后的长度不大于MAXIMUM_CAPACITY的情况),同时将新扩充阈值也扩大两倍。(通过公式threshold = cap * loadFactor,容量阈值也扩容为两倍)。

 		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
        }

(2).初始化的情况

初始化包括以下两种情况,

1)设置了容量的情况
public HashMap(int initialCapacity)2)空构造方法的情况
public HashMap()

(2.1)如果设置了容量的话,就会直接让数组的新容量等于旧阈值

newCap = oldThr;

(2.2)空构造方法:数组的新容量赋值为默认大小16数组的新扩容阈值赋值为12(16*0.75)。

 newCap = DEFAULT_INITIAL_CAPACITY;
 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

接下来的代码是用于没有对新扩容阈值赋值的情况下,要给newThr赋值。

 if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }

3.更新当前阈值,以及以新容量创建新的哈希桶数组。

  threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;

4 .如果旧哈希桶不为空的话,将旧哈希桶数组中的值映射到新哈希桶数组。具体做法是遍历整个原哈希桶数组,如果当前下标的哈希桶中存放的节点为空则继续寻找,否则就要映射到新哈希桶数组。

这里主要讲哈希桶中存放有节点的情况:

(1)如果是单个节点,直接放入新哈希桶数组对应下标下即可。

//e是当前节点
 newTab[e.hash & (newCap - 1)] = e;

(2)如果是树形结构,则需要进行树拆分,并映射。

(3)如果是多个节点的链表结构,会将链表拆分为两段,并映射到新数组中。具体映射的下标分别为:原数组下标,原数组下标+原数组长度

为什么是两段呢,先举下面这个例子,假如原始哈希桶数组长度为16,而扩容后哈希桶数组长度为32。可以看到对应两个不同的hash值但计算出的结果都为2,因此都会存入下标为2的哈希桶中,当然他们的结构肯定是链表。

当哈希桶数组扩容后,长度为32,计算结果改变。可以看到hash1对应结果为18,而hash2对应结果仍然为2。因此需要将链表拆分为两段,一段放入新哈希桶数组的2下标处,另一段放入18下标处。

//由于n为16以及32,所以hash值我们省略至6位(原本为32位)
hash1:  010010
&
n-1(15):001111
i=2

hash2:  000010
&
n-1(15):001111
i=2

hash1:  010010
&
n-1(31):011111
i=2+16=18

hash2:  000010
&
n-1(31):011111
i=2

那么HashMap是如何区分究竟将哪个节点放入链表1,哪个节点放入链表2呢,它是通过如下代码。可以通过我们上边的例子看出,hash1的新下标为 18,而hash2的新下标仍然为2的原因在于他们的第五位是否为1而oldcap恰恰为010000,可以比较不同hash值的第五位

e.hash & oldCap

如果e.hash & oldCap==0,则代表第五位不为0,则该节点应放置在新数组的原下标位置故加入链表1;

e.hash & oldCap==1,则代表第五位为1,则该节点应放置在新数组的原下标+原数组长度位置故加入链表2。

最后的工作很简单,就将两个链表分别放置到新数组对应下标中即可。

完整代码

  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) // 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;
        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
                        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函数流程图

在这里插入图片描述

HashSet

HashSet内部是通过一个HashMap来实现的,与HashMap相似,内部的HashMap默认容量为16,负载因子为0.75。当然HashSet也可以通过传入参数来构造。

private transient HashMap<E,Object> map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();

我们来看几个常用的函数:

add函数

上文提到的PRESENT是一个工具人,Hashset在add(E e)的时候,其实调用的就是HashMap的put方法,key是e,而value就是PRESENT。

public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }

我们知道HashMap的put方法是有返回值的,如果当前key所对应的哈希桶下标处没有存节点或没有相同的key的节点,那么就会插入新的节点。插入新节点会返回null,而替换节点就会返回节点的旧value。

 HashSet<Integer>set = new HashSet<>();
        System.out.println(set.add(1));%false
        System.out.println(set.add(1));%true
        HashMap<Integer,Integer>map = new HashMap<>();
        System.out.println(map.put(1,2));%null
        System.out.println(map.put(1,3));%2

contains方法

HahSet方法也是依赖于HashMap的containsKey方法,该方法最后会通过getNode的返回值来判断。

 public boolean contains(Object o) {
        return map.containsKey(o);
    }

HashMap的containsKey。

public boolean containsKey(Object key) {
        return getNode(hash(key), key) != null;
    }

总结

1.HashMap内部解决哈希冲突总结

HashMap内部通过数组+链表数组+红黑树解决哈希冲突。HashMap的哈希桶数组存放的是Node<K, V>节点对象,哈希桶数组长度要求必须是2的幂次方

正常情况下是一个桶中存放一个节点,而发生哈希冲突时,一个桶中存放多个节点,节点可以是链表也可以是红黑树。链表结构简单,但查找效率低:O(n),红黑树结构复杂,查找效率高O(logn),因为红黑树是自平衡的二叉搜索树。但由于要多次调用resize方法,将哈希桶中的红黑树移动到新的哈希桶数组中比较耗时,因此不能当发生哈希冲突时,就存储为红黑树结构。

因此HashMap中设置了阈值来判断存储为链表还是存储为红黑树。HashMap中链表树化的条件是:链表中节点大于等于8且哈希桶数组长度大于等于64树化链表的条件是:树中节点小于等于6

2.构造函数总结

HashMap无参数构造时默认容量为16,负载因子为0.75,扩充阈值为12(哈希桶数组长度超过该阈值会扩容);

HashMap传入容量构造时,默认负载因子为0.75,但扩充阈值则会通过tableSizeFor(int cap)计算(该方法会返回大于传入值的最小的2次幂,比如传入6,会返回8)**。与无参数构造不同的是,当第一次构造时,容量 = 旧的扩充阈值,扩充阈值 = 容量*负载因子。

HashMap传入容量与负载因子时,与传入容量构造类似,只不过会将负载因子赋值为传入的负载因子,传入容量构造内部也是调用的这个方法。

注意:HashMap在构造函数处不会进行初始化操作

3.hash函数与哈希桶数组对应下标的计算

首先明确一点:给定一个key值,需要计算其hashcode以便找到其对应哈希桶数组的下标。HashMap通过hash函数返回key值对应的hashcode,并计算其对应哈希桶数组的下标

//hash函数
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
//计算下标    
i=hash&(n-1

(1)key的hashCode值是32位的,而一般哈希桶数组的长度较小(小于等于2^16),在进行与计算时,计算结果不会与hashCode值的高16位有关(两个不同的hash值,低16位没有区别,高16位有区别,而当数组长度较小时,计算结果相同,产生哈希冲突)。为了防止这种情况,hash函数返回的是hashCode值与hashCode值右移16位的异或结果。这样做后,高16位不变,低16位变为高16位与低16位的异或值,这样可以有效地避免上述的哈希冲突。

(2)i=hash&n-1计算下标的原因:n为2的n次方,以n=16举例,n-1=15,15的二进制为01111,而16的二进制为10000,与计算只与1位有关,那么如果是hash&n,结果只与hash值的第五位有关而hash&n-1,结果就与第一、二、三、四位有关,因此,可以减少一些哈希冲突

4.put函数总结

HashMap在构造函数中不会初始化,而是在第一次put时初始化

put函数调用putVal方法,传入key与value的同时,也传入通过hash函数计算出来key的hash值

put函数首先会判断哈希桶数组是否为空为空会调用resize进行初始化;接下来会通过hash&(n-1) 计算当前hash值对应的哈希桶数组下标

如果没有出现哈希冲突当前下标处哈希桶没有存储节点),则直接创建新节点并把新节点放入哈希桶中即可。

如果发生哈希冲突,则需要遍历树状结构/链表结构如果其中存在与当前传入key值的hash相同且通过equals比较相等的节点,则覆盖该节点的value,并结束遍历,否则,将该节点插入到链表/树状结构的末尾

最后判断是否新增了节点如果新增节点,要判断是否需要扩容没有新增节点(只是替换了value值),就不需要判断,直接返回

5.get函数总结

get函数调用getNode函数,查找过程与put过程类似,getNode函数会返回节点或返回null,最终由get函数返回节点的value或null。

首先通过hash&n-1计算出哈希桶数组的下标,之后去判断当前桶中是否存有节点,没有存则直接返回null,否则继续寻找。

如果当前哈希桶存储的只有一个节点,直接比较key是否相同,如果相同则直接返回该节点;如果存储的是一个链表或红黑树,则遍历整个结构,去判断是否有key相同的节点,有则返回该节点,否则返回null。

6.resize函数总结

调用该函数有两种情况:(1)初始化;(2)扩容

(1)初始化:分为HashMap是设置容量构造和HashMap是无参数构造。设置容量构造,已经设置了阈值,直接将数组新容量赋值为该阈值,新扩充阈值设为新容量*负载因子。无参数构造,将数组新容量赋值为16,新扩充阈值设置为12。

(2)扩容:如果旧数组容量已经超过当前允许最大长度,则将扩充阈值设置为Integer.MAX_VAULE,并结束。否则,将数组新容量扩充至旧数组容量的2倍,并将数组新扩充阈值也扩充至2倍

接下来会创建新哈希桶数组,需要将旧数组中的节点映射至新数组

遍历旧数组,对应每个位置的哈希桶,如果桶中存在节点,则根据节点类型进行处理

如果只有一个节点,则直接把该节点放置到新数组的[hash&(newcap-1)]位置

如果是树形结构,则将树拆分并映射到新数组中;

如果是多个节点的链表,则将链表拆分为2个链表,分别放置在新数组的[原下标]处与[原下标+原数组长度]处。具体节点放置在哪里根据hash&oldcap是否等于0决定,等于0的放置在原下标,大于0的放置在原下标+原数组长度处。

分为两个链表的原因如下:

//由于n为16以及32,所以hash值我们省略至6位(原本为32位)
hash1:  010010
&
n-1(15):001111
i=2

hash2:  000010
&
n-1(15):001111
i=2
//而n扩充至32后,计算结果就改变了,变为:2和2+16(原数组长度)
hash1:  010010
&
n-1(31):011111
i=2+16=18

hash2:  000010
&
n-1(31):011111
i=2

6.重写hashcode与equals方法的原因

如果HashMap中的使用自定义的类作为key的话,就需要重写该类的hashCode方法与equals方法。
原因如下:HashMap在判断key是否相同,会通过hashCode方法与equals方法共同判断

if (e.hash == hash &&
           ((k = e.key) == key || (key != null && key.equals(k))))
                   return e;

但equals方法和hashCode方法都是默认调用Object类的Object中的equals方法是对比两个对象的引用。Object中的hashCode则会默认返回对象的内存地址。

由于是两个对象,因此引用(内存地址)肯定不同,因此HashMap就会默认这是两个key。

Student stu1 = new ArrayListDemo().new Student("Mrlj", "B");
Student stu2 = new ArrayListDemo().new Student("Mrlj", "B");
HashMap<Student, String> hMap = new HashMap<>();

所以我们自定义的类必须重写equals与hashcode方法,定义自己的规则。

		@Override
		public boolean equals(Object o) {
			if (o instanceof Student) {
				Student stu = (Student)o;
				return (name+sex).equalsIgnoreCase(stu.getName().trim()+stu.getSex().trim());
			}
			return false;
		}
		@Override
		public int hashCode() {
			return (name+sex).hashCode();
		}

7.HashMap中的key可以是null,value也可以是null。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null。

当key是null的话,会返回0,那么也就是默认存储到哈希桶数组的首个位置。

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

8.HashMap为什么线程不安全

1.7的resize

简单来说,由于之前的resize考虑将将原哈希桶数组中的节点逆序存放在新哈希桶数组中,所以当两个线程同时进行resize操作时,会导致产生环形链路

具体代码如下:

void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
}

单线程情况下:
在这里插入图片描述
多线程情况下,同时resize就会出现环路,当我们使用get操作时就会出现死循环。

假如当前情况:线程1在执行resize操作时,只执行到e指向3,而next指向7。之后就到线程二操作,线程二将链表逆序存储(7->3)。
在这里插入图片描述
然后切换回线程1,首先将e插入线程1的哈希桶数组下标3处,然后指向e=next,(next是7)也就是继续去处理节点7。

处理节点7时,节点7的next是节点3,首先将节点7插入哈希桶数组下标3处(7.next = newTable[3]; newTable[3] = 7;),之后让e指向next(e=next),代表接着处理节点3。
在这里插入图片描述
处理节点3,首先3.next=null,也就是本次会结束循环,然后将节点3插入到3的位置处,并将3.next=7。**这样就形成环路,当使用get操作时会进入死循环
在这里插入图片描述

1.8的put

当两个线程同时调用put,且key所计算出的hash值相等时,会出现数据丢失的情况,后执行线程所创建的节点会覆盖掉前一个线程创建的节点。

例子:以当前hash值对应的哈希桶下标没有存储节点,可以直接放入,也就是调用newNode创建新节点。

if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

那么假设我们线程1和线程2都进入当前的判断,并都在创建新节点。那么后执行线程所创建的节点就会覆盖前一个线程创建的节点,而造成数据丢失的问题。

9.HashTable与HashMap的区别

首先HashTable是线程安全的,而HashMap是线程不安全的。HashTable几乎所有的public方法都是synchronized。
其次HashMap允许key和value为null,而HashTable不允许。HashTable遇到为null的时候会直接报NullPointerException

参考链接

深入理解Hash函数

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章