(三)HashMap详解

613 阅读19分钟

往期推荐

HashMap概述

HashMap是实现Map接口的用于映射key-value键值对的双列集合,在JDK1.8中其底层是基于数组+链表+红黑树实现的,是非线程安全的集合类。

HashMap底层结构

  • 以下该图可以帮助大家更好地理解HashMap的结构和其中的一些内部类

image.png

先导知识

什么是“哈希碰撞” 在HashMap有一个Node<K,V>类型的数组table(也称哈希桶)用来存储Node对象,在哈希桶中每一个位置只能存放一个Node对象,当哈希桶table指定位置index已经存储了一个Node对象,此时又有新的Node对象需要存储到index位置,就会出现“哈希碰撞”

处理“哈希碰撞”的方法

  • 拉链法,将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。JDK1.8之前的HashMap就是使用拉链法来解决“哈希碰撞”问题
  • 线性探测法

红黑树

  • 红黑树是一种特殊的AVL树(平衡二叉树),都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能,红黑树除了满足一般的平衡二叉树的要求外,红黑树还需满足以下要求(其实就是红黑树自动维持平衡所需要具备的规则):
  1. 每个结点必须带有颜色,要么黑色,要么红色。
  2. 根节点一定是黑色的。
  3. 每个叶子结点都带有两个空的黑色孩子结点。
  4. 每个红色结点的左右孩子结点都是黑色结点(即从根节点到叶子结点的所有路径上,不存在两个连续的红色结点)
  5. 从任意结点到其所能到达的结点的所有路径含有相同数量的黑色结点。

image.png

红黑树优势

  1. 自平衡:红黑树在插入和删除节点时,可以通过旋转操作来保证树的平衡性,从而保证了搜索、插入和删除操作的效率。
  2. 高效插入和删除:由于红黑树具有自平衡的特点,在插入和删除某个节点时,红黑树需要进行旋转操作来保证树的平衡性。这些旋转操作的次数是有限的,而且是和树的高度有关的,所以它们可以在O(logN)的时间复杂度内完成。这意味着,红黑树的插入和删除操作非常高效。
  3. 高效查找:红黑树也是一种高效的查找数据结构,因为它的节点是按照一定的顺序排列的,所以对于任何一个节点x,它的左子树的所有值都小于x,右子树的所有值都大于x。这样,在查找某个节点时,它只需要从根节点开始向下查找即可,时间复杂度为O(logN)。
  4. 可以用作有序映射:由于红黑树具有按照顺序排列的特点,所以它可以用来实现有序映射(即,根据键值对中的键排序),这在很多应用中都非常有用。例如,在实现字典树、后缀树等数据结构时,就需要使用有序映射。

HashMap详解

HashMap类图

image.png

HashMap属性

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
   
    //默认初始容量
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    
    //HashMap最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    
    //默认加载因子,HashMap扩容用到
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
    //树化阈值,HashMap中链表树化的先决条件之一,链表长度要>=8
    static final int TREEIFY_THRESHOLD = 8;
    
    //取消树化阈值(当红黑树的节点<=6时,取消树化,红黑树转换回链表
    static final int UNTREEIFY_THRESHOLD = 6;
    
    //最小树化容量,Hash桶的数量(即table数组长度)要大于64,链表树化先决条件之一
    static final int MIN_TREEIFY_CAPACITY = 64;
    
    //Node数组,`也称为哈希桶数组`,首次使用时会进行初始化
    //需要时会进行扩容,长度总是2的整数次幂
    transient Node<K,V>[] table;
    
    //映射用于保存键值对的集合,静态内部类Node实现了Map.Entry<K,V>接口
    transient Set<Map.Entry<K,V>> entrySet;
    
    //此映射中键值对的数量
    transient int size;
    
    //此映射发生结构性变化的次数
    transient int modCount;
    
    //size的阈值,threshold = size * 负载因子(DEFAULT_LOAD_FACTOR)
    //当HashMap中的键值对数量大于threshold时会触发扩容
    //如当默认size=16、负载因子为0.75
    //那么当HashMap中键值对的数量>=12时,就会触发扩容
    //因此。threshold就是HashMap的扩容阈值
    int threshold;
    
    //哈希表的负载因子,主要用于HashMap的扩容
    final float loadFactor;
}

HashMap的静态内部类Node

  • 在HashMap中有一个属性table,它代表哈希桶数组,哈希桶数组是一个Node类型的数组,用于存放HashMap主要的数据元素,table存储的即为Node类型的数据。HashMap的静态内部类Node实现了Map.Entry<K,V>接口,表示一个键值对,在Node内部类中有一个属性next,当HashMap在哈希桶中出现“哈希碰撞”,HashMap会使用链表的方式来处理,而这个next正是用来串连那些在同一个哈希桶位置产生哈希冲突的元素。
static class Node<K,V> implements Map.Entry<K,V> {
    //哈希值
    final int hash;
    //保存key值
    final K key;
    //保存value值
    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;
    }
    //返回键值对中的key
    public final K getKey()        { return key; }
    
    //返回键值对中的value
    public final V getValue()      { return value; }
    
    //重写toString方法
    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;
    }
    
    //重写键值对的equals()方法
    public final boolean equals(Object o) {
        //如果是同一个对象
        if (o == this)
            //返回true
            return true;
        //如果o是Map.Entry类型的对象
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            //用Object的equals()方法判断对应键是否相等,对应值是否相等
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                //键、值相等则返回true
                return true;
        }
        //o不是Map.Entry返回false
        return false;
    }

}    

HashMap的静态内部类TreeNode

  • JDK1.8中对HashMap的“哈希碰撞”做了优化,当链表大于一定长度后就会转换为红黑树,HashMap的静态内部类TreeNode就是链表转化为红黑树后红黑树的基本存储单元。(一句话:红黑树的一个结点就代表一个TreeNode对象) TreeNode部分代码如下:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    //父节点
    TreeNode<K,V> parent;
    //左孩子
    TreeNode<K,V> left;
    //右孩子
    TreeNode<K,V> right;
    //前一个结点
    TreeNode<K,V> prev;
    //红黑树结点的颜色,true代表红色,false代表黑色
    boolean red;
    //TreeNode的构造方法
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }

    
    final TreeNode<K,V> root() {
        for (TreeNode<K,V> r = this, p;;) {
            if ((p = r.parent) == null)
                return r;
            r = p;
        }
    }

HashMap构造方法

  • 构造一个空的 HashMap ,默认初始容量(16)和默认负载因子(0.75)
public HashMap() {
    //使用默认的负载因子,其余都使用默认值
    this.loadFactor = DEFAULT_LOAD_FACTOR; 
}
  • 构造一个空的 HashMap具有指定的初始容量和默认负载因子(0.75)
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
  • 构造一个空的HashMap具有指定的初始容量和负载因子
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        //如果自定义初始容量小于0,则抛出initialCapacity异常
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        //如果自定义的初始容量大于HashMap所能支持的最大容量
        //则将HashMap的最大支持容量作为HashMap的初始容量
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        //如果自定义的负载因子<=0 或者是非数值类型的,则抛出异常
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    //确定负载因子loadFactor
    this.loadFactor = loadFactor;
    //返回大于指定初始容量initialCapacity且最近的2的整数次幂的整数
    //如当自定义初始容量为12时,会返回大于12且距离12最近的2的整数次幂的整数16
    this.threshold = tableSizeFor(initialCapacity);
}
  • 构造一个新的 HashMap与指定的相同的映射 Map 。
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

HashMap的put()方法

  • HashMap的put(K key, V value)方法将一组键值对存放在映射中,如果该映射中已经包含与该键相对应的键值对,就会更新该键值对的值(即有相同的键,则覆盖旧的值)。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
  • 在put()方法中首先会调用hash(Object key)方法来计算键的哈希值,该方法会调用Object的hashCode()方法来计算哈希值h并将计算得到的哈希值h和h的高16(h>>>16,即为无符号右移16位,即得到h的高16位)做异或运算,得出的结果即为键的哈希值。
static final int hash(Object key) {
    int h;
    //如果key不为null,则返回异或操作的结果,作为键的哈希值
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

在这里你可能会问,为什么通过key所属类型重写的hashcode()方法计算键的hash值后还要在与高16为进行异或运算呢?其实这么做是为了在哈希桶数组的table的长度比较小的时候,也能够保证考虑到高低位都参与到hash的计算中,高16位参与运算可以更好的均匀散列,减少碰撞,进一步降低“哈希碰撞”的几率,同时不会有太大的开销。

  • 计算完键的哈希值后,put()方法会调用putVal()实现元素添加 过程描述:
  1. 第一步,putVal()方法会先判断哈希桶table是否为空或者长度n是否为0,满足其中一个条件,说明是第一次添加键值对,则会调用resize()方法进行扩容,resize()方法后面单独拎出来讲。

  2. 第二步,通过键值对的键的哈希值hash哈希桶数组的长度-1& 运算 得出键值对在哈希桶数组中的存储下标 i ,将哈希桶数组table[i]元素赋值给p,并判断p是否为空,为空则说明该位置还没有元素,走第三步;不为null,则走第四步

  3. 第三步,如果第二步的判断p为空,则新建一个Node对象存储在table[i]位置并让e指向i位置上的结点p,然后走第六步

  4. 第四步

  • 4.1: 如果i位置上的结点p的键的哈希值等于传入的键值对的键的哈希值相等并且,( i 位置上的结点p的键传入的键值对的键相等或者( 传入的键值对的键不为空并且传入的键值对的键与 i 位置上的结点p的键通过Node内部类中重写的equals()方法比较相等)),则走第五步对原值进行覆盖,让e指向i位置上的结点p,然后走第五步
  • 4.2: 否则,说明key不同(key的哈希值相同,而key不同),则产生"哈希碰撞",判断i 位置上的结点p是否为TressNode类型,如果是说明i位置存储的是一棵红黑树,则调用putTreeVal()方法进行红黑树的插入操作。
  • 4.3: 否则,说明i位置存储的是链表,则遍历链表进行链表的插入操作,在遍历过程中会判断链表是否满足树化条件,满足则进行树化,也会判断链表中是否已经存在相同的键值对,存在则跳出循环,走第五步
  1. 第五步,如果结点e(结点e为与我们传入的键值对产生冲突的结点)不为空,则获取结点e的旧值,并将其作为putVal()方法的返回值,如果允许修改e的值或者e的值不为空,则更新结点e的value值(value值即为我们传入的键值对的value值)。
  2. 第六步,只有当不走第五时,才会到第六步,说明原映射中不存在对应键值对,则能够将传入的新的键值对添加成功,因为添加成功,所以需要将HashMap的结构性变化次数modCount+1,然后会判断新增键值对后HashMap中的键值对数量是否大于扩容阈值threshold,是则调用resize()进行扩容(在putVal()中有可能会调用两次resize()方法,除此处外,另一处为在第一步时可能会调用),否则putVal()方法就返回null。

至此,HashMap的整个putVal()方法执行流程就结束啦.....

总结起来就是(浓缩的都是精华) 当我们往HashMap中put元素时,利用key的hashCode重新hash计算出在哈希桶数组table中的存储下标,如果出现哈希值相同的key,存储时,此时有两种情况,一种情况为两个键值对的key相等,则覆盖原始值,另一种情况为两个键值对的key不相等,则产生“哈希碰撞”,则将当前的键值对放入链表中(该过程可能会树化)。

/**
 * hash: 键的哈希值
 * key: 键
 * onlyIfAbsent: 如果为true,不修改已有键值对的值,默认为false,在第五步中使用到
 * evict: 如果为false,则表示哈希表处于创建模式
 * 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;
    //第一步:
    //如果哈希桶table为空或者,哈希桶table长度为0,则调用resize()方法扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        //将扩容后的Node<K,V>类型数组赋值给tab,并将扩容后的数组长度赋值给n
        n = (tab = resize()).length;
        
    //经过上面的if语句,tab指向table数组,n为table数组的长度
    //第二步:
    //计算键值对存储下标,并判断该位置是否已经有元素
    if ((p = tab[i = (n - 1) & hash]) == null)
        //第三步:
        //哈希桶i位置上没有元素,则新建Node对象,并存储到哈希桶数组i位置上
        tab[i] = newNode(hash, key, value, null);
    else {
        //第四步:
        //产生“哈希碰撞”,两个键值对需要存储在哈希桶数组的同一个位置上。
        Node<K,V> e; K k;
        //如果i位置上的结点p的键的哈希值等于传入的键值对的键的哈希值相等
        //并且,i位置上的结点p的键与传入的键值对的键相等或者(传入的键值对的键不为空并且传入的键值对的键与i位置上的结点p的键通过equals()方法比较相等)
        //第4.1
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            //以上条件成立(key相同)
            //将i位置上的结点p赋值给e,走第五步,覆盖原始值
            e = p;
        //第4.2
        //否则,说明key不同(key的哈希值相同,而key不同),则产生"哈希碰撞"
        //如果i位置上的结点p是TressNode类型的
        else if (p instanceof TreeNode)
            //调用putTreeVal()方法进行红黑树的插入操作
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //第4.3
        //如果i位置上的结点p不是TressNode类型的,则进行链表插入操作
        else {
            //遍历链表找到插入位置
            for (int binCount = 0; ; ++binCount) {
                //如果e=p.next为null,则说明p为链表的尾结点
                if ((e = p.next) == null) {
                    //新建一个Node,并让尾结点p的next指向我们新建的结点
                    p.next = newNode(hash, key, value, null);
                    //新结点添加到链表尾部后
                    //判断链表长度+1(+1是因为新增的结点没有计算到binCount中)是否大于等于TREEIFY_THRESHOLD(树化阈值=8)
                    if (binCount >= TREEIFY_THRESHOLD - 1) 
                        //链表长度>=树化阈值8,则调用treeifyBin()方法进行树化
                        treeifyBin(tab, hash);
                    break;
                }
                //p不为尾结点
                //如果e的key的哈希值与传入的键值对的key的哈希值相等,
                //并且 (e的key与传入的键值对的key相等 或者传入的键值对的key不等于null并且传入的键值对的key 等于e的key )
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    //说明在链表中已经存在键相同的键值对,则跳出当前for循环(链表上发送“哈希碰撞”)
                    break;
                //如果上面两个if都不满足,修改p的引用,进入下一个循环,继续链表的遍历
                p = e;
            }
        }
        
        //第五步
        //如果结点e不为空
        if (e != null) {
            //获取结点e的值value赋值给oldValue,作为putVal()方法的返回值
            V oldValue = e.value;
            //如果允许修改旧的值或者旧的值为空
            if (!onlyIfAbsent || oldValue == null)
                //更新结点e的值为新值
                e.value = value;
            //预留方法,可以设置回调
            afterNodeAccess(e);
            //返回旧的value值
            return oldValue;
        }
    }
    
    //第六步
    //由于不存在哈希碰撞,能够键新的键值对添加到HashMap中
    //所以HashMap的结构性变化次数modCount+1
    ++modCount;
    //如果新增键值对后HashMap中的键值对数量大于扩容阈值threshold,是则进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    //返回null
    return null;
}

HashMap扩容

何时扩容 在HashMap的putVal()方法中有两个地方使用到resize()扩容方法,当向HashMap中添加键值对时,会判断当前哈希桶数组table的长度,如果是第一次往HashMap中添加键值对时(第一次添加前哈希桶数组table是空的,长度为0),会触发一次扩容,默认设置哈希桶数组table容量为16;否则,当往HashMap中添加完成后,会判断当前容器中元素总数size是否大于等于扩容阈值(扩容阈值threshold=size * 负载因子(DEFAULT_LOAD_FACTOR)),如果成立则触发扩容。

怎么扩容 首先,先创建一个新的空的哈希桶数组,长度为原哈希桶数组table的2倍(左移一位)。然后将原来的所有键值对通过hash算法重新定位到新的哈希桶数组中(该过程中会经过一系列复杂的操作,在这里就不进行描述),最后将新的哈希桶数组赋值给table,取代原来的哈希桶数组。

JDK1.7 和 JDK1.8中HashMap的区别

  • JDK1.8中当发生哈希碰撞时将键值对插入到链表时采用的尾插法的方式(但1.8的HashMap仍然会出现线程安全问题。),而JDK1.7采用的是头插法,会改变原来数据的插入顺序,在多线程下使用transfer方法扩容时(JDK1.7的扩容方法为transfer),可能会出现环形链表,造成死循环。

  • JDK1.8中引入了红黑树,目的是避免单条链表过长而影响查询效率

  • JDK1.7中无冲突时,键值对存放在数组;冲突时,存放链表在链表。JDK1.8无冲突时,存放在哈希桶数组中,冲突并且链表长度 < 8 则存放在单链表,冲突并且链表长度 > 8则树化并存放在红黑树中。

JDK1.7中HashMap链表头插法造成循环链表的过程

假设有A、B两个线程,对HashMap进行扩容,扩容前的HashMap如下:

image.png

在单线程的情况下,正常的扩容后HashMap应该如下:

image.png

而在多线程环境下对HashMap进行扩容的过程如下:

  1. A线程先进行扩容操作,当transfer()方法执行到newTable[i] = e时,A线程被挂起,造成如下:

image.png

  1. A线程被挂起后,B线程获得时间片完成正常的扩容操作,此时内存中的newTable和table中的Entry都是主存中最新值:7.next=3,3.next=null

image.png

  1. B线程完成扩容后,A线程获取时间片继续之前的执行,继续完成刚刚扩容时的第一个while循环

image.png

  1. A线程进行第二轮while循环

image.png

  1. A线程进行第三轮while循环,出现环形链表

image.png

HashMap为什么线程不安全

从上面的putVal()方法中可以看出,假设在多线程下,有A、B两个线程,当A线程执行到第三步时 ( 完成if判断,但未执行元素插入 tab[i] = newNode(hash, key, value, null ) ),由于时间片耗尽或其他原因导致A线程被挂起,而B线程得到时间片后在该下标处插入了元素,完成了正常的插入,然后A线程再次获得时间片,由于之前已经进行了哈希碰撞的判断,所以此时不会再执行if判断,而是直接进行插入,这就导致了B线程插入的数据被A线程覆盖了,从而导致线程不安全。

如何解决HashMap线程不安全

  • 使用线程安全的Map集合类
Collection.SynchronizedMap(Map)
Hashtable
ConcurrentHashMap

当HashMap的key为Object(或者是自定义类)时为什么要重写hashcode与equals方法

  • Object中的hashCode()方法和equals()方法
public native int hashCode();

public boolean equals(Object obj) {
    return (this == obj);
}
  • 在Java中,Object是所有类的父类,Object类的hashCode()方法是根据对象的内存地址值来计算出一个整形的哈希值,当自定义类没有重写超类的hashCode()方法时,就会使用Object的hashCode()方法,所以即使定义两个相同含义的对象,但是他们都具有不同的内存地址,所以他们的hashCode就不可能相等。而如果只重写hashcode()不重写equals()方法,当使用equals()比较时,也是直接调用Object中的equals()方法,只是进行内存地址的比较,所以它们也不可能相等。

  • 通过上面的解释,回到我们的问题中答案就很清晰了:当HashMap调用插入或获取方法时,需要将待插入或待获取的键值对的键key的哈希值哈希桶数组中的键值对的键的哈希值比较,如果不重写hashCode()方法,那么它们的key就永远不会相等,而通过重写后的hashCode方法判断相等后,则会通过equals方法比较它们的key是否相等,同样如果不重写equals()方法,两个key值通过Object的equals()方法进行比较也永远不会相等。

以上就是对HashMap的详细描述,如有错误,还望大佬们留言指正,共同进步....

默认标题_动态分割线_2021-07-15-0.gif