【Map篇】HashMap详解

目录

        • 成员变量属性
        • 构造函数
        • put()
        • remove()
        • get()
        • 问题1:HashMap 初始容量为什么设置为16?
        • 问题2:HashMap 指定初始容量时,为什么要强制改为2的整数次幂?
        • 问题3:HashMap 扩容,为什么是原容量的两倍?
        • 问题4:HashMap 的getNode方法,为什么永远先判断槽位上的头节点?

HashMap是Java中常用的数据结构之一,它是基于哈希表实现的,用于存储管理键值对。HashMap内部是由一个Entry数组和一个链表组成的,数组用于存储数据,链表用于解决哈希冲突。HashMap会根据实际需要,动态地调整数组的长度,以保证高效的查找、插入和删除操作。
【Map篇】HashMap详解_第1张图片
其主要特点包括:

  • 高效性:HashMap内部采用哈希表实现,查找和插入操作的时间复杂度是O(1),具有高效性。
  • 线程不安全:HashMap是非线程安全的,如果多个线程同时并发访问同一个HashMap对象,可能会出现数据不一致的情况。
  • 支持null键和null值:HashMap允许键和值为null,但是需要注意,在使用时要特别小心,避免空指针异常。
  • 无序性:HashMap中键值对的存储是无序的,与添加顺序无关。

总之,HashMap是一种高效的数据存储和查找结构,它在Java中得到广泛应用,特别是在缓存、索引、状态跟踪等领域。
【Map篇】HashMap详解_第2张图片

源码分析(JDK1.8)

成员变量属性

 /**
  * 默认初始容量(必须是2的幂次方)
  * 指的是HashMap内部数组的大小
  */
 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

 /**
  * 最大容量,如果任何一个带参数的构造函数隐式指定了更高的值,则使用该容量
  * 必须是2的幂次方,最大不能超过1073741824
  */
 static final int MAXIMUM_CAPACITY = 1 << 30;

 /**
  * 构造函数中未指定时使用的负载系数
  * 指的是当HashMap内部数组的元素个数达到数组大小与负载因子的乘积时,会自动扩容
  */
 static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
 * 红黑树转成链表的长度阀值
 * 当链表的长度小于6时,此时的红黑树会重新转换成链表结构
 */
 static final int UNTREEIFY_THRESHOLD = 6;

 /**
  * 链表转化成红黑数的长度阈值
  * 当数组的长度大于等于64,且链表的长度大于8时,链表转红黑树
  */
 static final int TREEIFY_THRESHOLD = 8;

 /**
  * 将链表转话为红黑树的数组长度阀值
  */
 static final int MIN_TREEIFY_CAPACITY = 64;
 
 /**
  * 哈希桶,在
  */
 transient Node<K,V>[] table;

构造函数

  1. 创建一个空的HashMap,默认初始化大小为16,负载因子为0.75
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}
  1. 创建一个指定初始容量的HashMap,默认负载因子为0.75
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
  1. 创建一个指定初始容量和负载因子的HashMap。
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;  //负载因子 0.75
    //创建一个2的幂次容量大小的数组(对指定的容量值,取最大的2次幂)
    this.threshold = tableSizeFor(initialCapacity);  
}
  1. 创建一个包含指定Map中所有映射关系的新HashMap
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;  //负载因子 0.75
    putMapEntries(m, false);
 }

put()

HashMap中的put方法用于将一个键值对(key-value)映射添加到HashMap中。其中,key表示要存储的键,value表示要存储的值。该方法的返回值为新添加的值

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

【Map篇】HashMap详解_第3张图片

具体实现过程如下:

  1. 首先,根据key计算出hashCode
  2. 然后,根据hashCode计算其在 table 数组中的索引位置index
  3. 如果该位置没有Entry,则直接将 key 和 value 存储到该位置上
  4. 如果该位置已经存在Entry,则遍历链表或红黑树,查询是否存在相同的 Key,如果有,则替换其值;若没有,则添加到链表或红黑树的末尾(JDK1.8以前是头插法)
  5. 如果链表长度超过了8且数组长度大于64时,则将链表转换成红黑树
  6. 如果HashMap的负载因子超过了0.75,那么就会触发resize扩容操作,将HashMap的容量扩大一倍,并重新计算每个Entry的位置。此时,所有的键值对都需要重新插入HashMap中。。
  7. 返回更新前 Key 对应的值,如果不存在,则返回 null

remove()

HashMap的remove方法用于从数组中删除键值对,并返回其对应的值;删除方法有两个,删除指定key和删除指定key和指定value,二者的区别在于,

  • 校验维度不一致: 前者只需要匹配key即可,后者需要匹配key和value;
  • 返回信息不一致:前置返回key对应的节点信息中的value信息,没匹配上返回null;后者如果key和value都匹配上了节点,那么返回true;否则返回false
  • 删除逻辑不一致:前者直接通过key去匹配待删节点,然后执行删除操作;后者先通过key去匹配待删节点,然后校验value值是否匹配,最后进行删除操作
  1. 删除指定key
public V remove(Object key) {
     Node<K,V> e;
     return (e = removeNode(hash(key), key, null, false, true)) == null ?
         null : e.value;
 }

final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        //如果数组table不为null,且hashCode对应的index槽位有节点
        Node<K,V> node = null, e; K k; V v;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            //槽位头节点匹配上,直接返回
            node = p;
        else if ((e = p.next) != null) {
            //槽位头节点未匹配上,但是next有指向,说明当前槽位存在hash冲突,结构可能是链表或者红黑树
            if (p instanceof TreeNode)
                //是红黑树,查找待删除节点
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                         //是链表结构,遍历链表上所有的节点,查找待删除节点
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
             //通过key匹配上了节点,如果需要匹配value,则继续校验匹配节点中的value是否与指定value一致               
            if (node instanceof TreeNode)
                //红黑树删除节点
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                //链表结构,如果待删除节点是头结点,则将其下一个节点作为新的头节点
                tab[index] = node.next;
            else
                //将待删除节点的前一个节点的next指向待删除节点的下一个节点
                p.next = node.next;
            
            //更新modCount和size的值    
            ++modCount;
            --size;
            //调用afterNodeRemoval方法处理删除节点后的操作
            //此方法会校验,如果删除后的红黑树节点小于等于6时,会重新将红黑树转成链表
            afterNodeRemoval(node);
            return node; //返回删除节点
        }
    }
    //数组为null,或者key未匹配上待删节点,直接返回null
    return null;
}
  1. 删除指定key和指定value
default boolean remove(Object key, Object value) {
    //通过key匹配节点
    Object curValue = get(key);
    if (!Objects.equals(curValue, value) ||
        (curValue == null && !containsKey(key))) {
        //匹配上的节点中的curValue与指定value不匹配或者key未匹配上节点
        return false;
    }
    //通过key匹配上了待删节点,执行删除操作
    remove(key);  //此处源码看上一个删除介绍
    return true;
 }

get()

HashMap中的get方法用于从映射中检索与指定键相关联的值

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

/**
 * 通过key值获取对应节点信息
 */
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))))
           //通过哈希值和key匹配上了头节点,直接返回头节点
           return first;
       if ((e = first.next) != null) {
           //与头节点不匹配,但是头节点的next不为空,说明头节点的尾部有其他节点,此数组槽位是一个链表结构
           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))))
                   //链表,通过key依次遍历链表节点
                   return e;
           } while ((e = e.next) != null);
       }
   }
   //如果槽位头节点为空,说明该槽位上不存在任何节点,直接返回null
   return null;
 }

具体实现过程如下:

  1. 首先,根据key计算出hashCode
  2. 然后,根据hashCode计算其在 table 数组中的索引位置index,也可称为槽位
  3. 如果该槽位不存在头节点,则说明key不存在,直接返回null
  4. 如果该槽位存在头节点,则继续与头节点匹配key,匹配上直接返回头节点中的value;如果不匹配,继续校验头节点的next是否有指向,如果有指向,则此槽位存在hash冲突;再判断该结构是链表还是红黑树结构,通过对应的方式取值;如果没有指向,说明此槽位只有一个节点,直接返回null

问题1:HashMap 初始容量为什么设置为16?

因为它可以保证在默认负载因子下(0.75)具有较好的性能空间利用率
默认情况下,HashMap 的负载因子为 0.75,这意味着在 HashMap 中存储的键值对数量如果超过容量的 0.75 倍时,就会自动进行扩容,而每次扩容都会将原先的键值对重新分配到新的桶中,这样就会浪费很多时间。因此,如果初始容量设置过小,会导致频繁的扩容,浪费时间和空间;如果初始容量设置过大,又会浪费大量的空间。而 16 恰好是一个 2 的整数次幂,所以 HashMap 内部使用位运算的方式进行哈希计算,速度比取模运算快,实现简单。因此,在默认负载因子下,初始容量为 16 是一个较好的选择。

问题2:HashMap 指定初始容量时,为什么要强制改为2的整数次幂?

/**
  * 返回一个2的指数次幂的容量值
  * 该值必须比指定容量cap大,且最接近cap的2的指数次幂
  * 例如:指定容量cap为11,那么创建的HashMap初始容量就是16
  */
 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;
 }

因为HashMap 在进行扩容时,会将容量扩大到原来的两倍,这样可以更快地进行一些位运算,提高运行的效率。为了实现这个扩容操作,HashMap 初始容量必须是 2 的整数次幂,否则在扩容时位运算方法会失效,导致性能降低
因此,为了确保 HashMap 的高效性能,在指定初始容量的时候,强制将其改为 2 的整数次幂。

问题3:HashMap 扩容,为什么是原容量的两倍?

在 Java 中的 HashMap 是基于哈希表实现的,哈希表的核心是数组,而数组的大小是固定的。当我们往 HashMap 中添加元素时,如果当前位置已经有元素了,则会发生碰撞,这个时候 HashMap 会依据一定的规则将元素添加到数组的其他位置。
如果想要提高 HashMap 的性能,就需要尽可能减少碰撞。一种有效的方式就是通过调整数组的大小,增加数组的容量,从而使得元素分布更加均匀,减少碰撞的发生。
而为了在扩容后保持哈希表中原有元素的位置不变,HashMap 采用了重新哈希的方法来处理。在重新哈希时,需要将原有元素重新计算哈希值,并放在新的哈希表中的相应位置上。如果新哈希表的大小不是原哈希表大小的两倍,则计算哈希值时需要进行更复杂的位运算,效率更低。因此,为了在扩容时能够尽可能提高性能,HashMap 选择将哈希表的大小扩大为原来的两倍。

问题4:HashMap 的getNode方法,为什么永远先判断槽位上的头节点?

在哈希表中,每个位置都可能有多个键值对,它们是通过链表或红黑树来存储的。在节点查找时,哈希表首先需要根据键的哈希值和表长算出键值对应的位置,称之为“槽位”(slot)。然后哈希表会在该槽位上查找对应的节点。
在哈希表中,每个链表(或红黑树)的第一个节点是该链表的头节点,也就是存储在槽位中的第一个节点。因此,如果某个键对应的节点存在于哈希表中,那么它一定存储在对应槽位的链表(或红黑树)中。
因此,在哈希表的getNode方法中,首先判断头节点是否等于目标节点,如果是,则直接返回头节点。这样可以快速判断目标节点是否位于对应槽位的链表(或红黑树)中,如果不是,则遍历链表(或红黑树)的所有节点寻找目标节点,直到找到或者遍历完整个链表(或红黑树)。这样可以提高查找效率

你可能感兴趣的:(Java,#,集合,java,开发语言)