HashMap源码深度解析(核心方法:put、get、resize,初始容量和负载因子,哈希碰撞,HashMap 如何处理哈希碰撞?)

HashMap源码深度解析

基于哈希表的Map接口(哈希表)实现,提供了所有可选的映射操作,是以key-value存储形式存在,并允许空值和空键,具有高效的插入、查找和删除操作。它的核心数据结构是一个数组,数组中的每个元素是一个链表或红黑树的头节点。HashMap 通过哈希函数将键映射到数组的索引位置,并通过链表或红黑树解决哈希冲突。当元素数量超过阈值时,HashMap 会自动扩容,以保持较低的负载因子,从而保证操作的效率。

HashMap类大致相当于Hashtable,不同之处在于它是不同步的,并且允许为空,这意味着它不是线程安全的,HashMap中的映射不保证映射的顺序;特别是,它不能保证顺序在一段时间内保持不变。

HashMap源码

HashMap 的核心数据结构是一个数组,数组中的每个元素是一个链表或红黑树的头节点。这个数组被称为 table,它的类型是 Node<K,V>[],其中 Node 是 HashMap 的内部静态类,表示键值对的节点。

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
}

说明:
(1)Cloneable 空接口,表示可以克隆。 创建并返回HashMap对象的一个副本。
(2)Serializable 序列化接口。属于标记性接口。HashMap对象可以被序列化和反序列化
(3)AbstractMap 父类提供了Map实现接口。以最大限度地减少实现此接口所需的工作。

补充:通过上述继承关系可以发现一个很奇怪的现象, 就是HashMap已经继承了AbstractMap而AbstractMap类实现了Map接口,那为什么HashMap还要在实现Map接口呢?同样在ArrayList中LinkedList中都是这种结构。

HashMap继承关系如下图所示:
在这里插入图片描述

HashMap类图如下图所示:
在这里插入图片描述
在这里插入图片描述

核心方法

(1)put 方法

put 方法用于将键值对插入到 HashMap 中。它的主要步骤如下:
1、计算键的哈希值。
2、根据哈希值找到对应的数组索引。
3、如果该索引处的节点为空,则直接插入新节点。
4、如果该索引处的节点不为空,则遍历链表或红黑树,检查是否存在相同的键。如果存在,则更新值;如果不存在,则插入新节点。
5、如果链表长度超过阈值(默认是8),则将链表转换为红黑树。

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    
    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;
    }

(2)get 方法

get 方法用于根据键查找对应的值。它的主要步骤如下:
1、计算键的哈希值。
2、根据哈希值找到对应的数组索引。
3、如果该索引处的节点为空,则返回 null。
4、如果该索引处的节点不为空,则遍历链表或红黑树,查找是否存在相同的键。如果存在,则返回对应的值;如果不存在,则返回 null。

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

(3)resize 方法

resize 方法用于扩容 HashMap。当 HashMap 中的元素数量超过阈值时,resize 方法会被调用,将数组容量扩大为原来的两倍,并重新计算每个元素的索引位置。

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

(4)其他重要方法

1、remove(Object key):删除指定键对应的键值对。
2、containsKey(Object key):检查是否包含指定键。
3、containsValue(Object value):检查是否包含指定值。
4、keySet():返回所有键的集合。
5、values():返回所有值的集合。
6、entrySet():返回所有键值对的集合。

HashMap特点

(1)存取无序的
(2)键和值位置都可以是null,但是键位置只能是一个null
(3)键位置是唯一的,底层的数据结构控制键的
(4)jdk1.8前数据结构是:链表 + 数组,jdk1.8之后是 : 链表 + 数组 + 红黑树
(5)阈值(边界值) > 8 并且数组长度大于64,才将链表转换为红黑树,变为红黑树的目的是为了高效的查询。

HashMap组成结构

JDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引值相同)而存在的(“拉链法”解决冲突,在JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为 8)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。

补充:将链表转换成红黑树前会判断,即使阈值大于8,但是数组长度小于64,此时并不会将链表变为红黑树。而是选择进行数组扩容。

这样做的目的是因为数组比较小,尽量避开红黑树结构,这种情况下变为红黑树结构,反而会降低效率,因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡 。同时数组长度小于64时,搜索时间相对要快些。所以综上所述为了提高性能和减少搜索时间,底层在阈值大于8并且数组长度大于64时,链表才转换为红黑树。具体可以参考 treeifyBin方法。

当然虽然增了红黑树作为底层数据结构,结构变得复杂了,但是阈值大于8并且数组长度大于64时,链表转换为红黑树时,效率也变的更高效。

HashMap实例有两个影响其性能的参数:初始容量和负载因子

容量是哈希表中桶的数量,初始容量是创建哈希表时的容量。负载因子衡量的是哈希表在容量自动增加之前允许达到的满程度。当哈希表中的条目数超过负载因子和当前容量的乘积时,将对哈希表进行重新哈希(即重建内部数据结构),使哈希表的桶数大约增加一倍。 作为一般规则,默认负载因子(0.75)在时间和空间成本之间提供了一个很好的权衡。较大的值减少了空间开销,但增加了查找成本(反映在HashMap类的大多数操作中,包括get和put)。因此,如果迭代性能很重要,那么不要将初始容量设置得太高(或负载因子设置得太低),这一点非常重要。 在设置map的初始容量时,应考虑map中的预期条目数及其负载因子,以尽量减少rehash操作的数量。如果初始容量大于最大条目数除以负载因子,则不会发生重散列操作。

如果要将许多映射存储在HashMap实例中,那么使用足够大的容量创建它将比让它根据需要执行自动重新散列来增长表更有效地存储映射。请注意,对相同的hashCod()使用多个键肯定会降低任何散列表的性能。为了改善影响,当键具有可比性时,该类可以使用键之间的比较顺序来帮助打破关系。 注意,这个实现是不同步的。

HashMap多个线程并发访问:线程不安全

HashMap 类在多线程环境下不是线程安全的,如果多个线程同时访问 HashMap,并且至少有一个线程对其进行结构上的修改,就可能会出现问题。

多线程访问时的同步要求

当多线程操作 HashMap 且有线程进行添加或删除键值对(结构修改)时,需要外部同步机制来保证线程安全。例如,可以使用 synchronized 关键字对一个包含 HashMap 的对象进行同步,以避免数据不一致或错误。如果没有合适的对象用于同步,可以使用 Collections 类的 synchronizedMap 方法将 HashMap 包装成一个线程安全的映射。建议在创建 HashMap 时就进行包装,以确保从一开始就避免多线程访问带来的问题。

Map m = Collections.synchronizedMap(new HashMap(...));

迭代器的快速失败(fail-fast)特性

HashMap 提供的各种集合视图(如 keySet、values、entrySet)所返回的迭代器具有快速失败特性。一旦迭代器创建后,若 HashMap 的结构被修改(除了通过迭代器自身的 remove 方法进行的修改之外),迭代器将抛出一个 ConcurrentModificationException 异常。从而避免了可能出现的不确定行为。

哈希碰撞,HashMap 如何处理哈希碰撞?

哈希碰撞

哈希碰撞,也被叫做哈希冲突,指的是当不同的键(Key)经过哈希函数计算后,得到了相同的哈希值。由于哈希函数会把无限的键空间映射到有限的哈希值空间,所以哈希碰撞是无法避免的。

举个例子,假设有一个简单的哈希函数:hash(key) = key % 10。当 key1 = 12 和 key2 = 22 时,经过哈希函数计算后,hash(12) = 2,hash(22) = 2,这就产生了哈希碰撞。

HashMap 如何处理哈希碰撞?

HashMap 运用链地址法来处理哈希碰撞。当发生哈希碰撞时,新的键值对会被添加到对应索引位置的链表或者红黑树中。在 Java 8 之后,当链表长度超过 8 且数组长度大于 64 时,链表会转换为红黑树,以此提升查找效率。

HashMap 借助键的哈希码和扰动处理来计算数组索引,使用链地址法处理哈希碰撞,从而高效地存储和查找键值对。

HashMap通过键(Key)进行哈希的过程

HashMap默认初始容量和负载因子
默认初始容量:HashMap 的默认初始容量是 16,这意味着在创建 HashMap 时,如果不指定初始容量,它内部的数组初始大小为 16。
负载因子:默认负载因子是 0.75。负载因子用于衡量 HashMap 何时进行扩容操作,当 HashMap 中存储的键值对数量超过 容量 * 负载因子 时,就会触发扩容操作,将数组大小扩大为原来的 2 倍。

在 Java 里,HashMap 是常用的哈希表实现,它借助键(Key)的哈希值来确定键值对在内部数组中的存储位置。以下是 HashMap 通过键进行哈希的具体步骤:

HashMap初始容量要求是 2 的幂次方,那为何初始容量要是 2 的幂次方?

(1)高效计算索引

在 HashMap 里,需要依据键(key)的哈希值来确定其在内部数组中的索引位置。计算索引的常见方式是用哈希值对数组长度取模,不过取模运算的性能相对较低。当数组长度是 2 的幂次方时,能够通过按位与运算 (n - 1) & hash 来替代取模运算,其中 n 是数组长度,hash 是键的哈希值。按位与运算的性能要比取模运算高很多。
例如,假设数组长度 n = 16(二进制为 0001 0000),n - 1 = 15(二进制为 0000 1111),此时按位与运算就能把哈希值的低位信息提取出来作为索引,既高效又能保证索引处于 0 到 n - 1 的有效范围内。

(2)使哈希分布更均匀

2 的幂次方的数组长度能让键值对在数组中分布得更均匀,从而降低哈希冲突的概率。由于按位与运算会用到哈希值的低位信息,而 2 的幂次方减 1 的二进制形式低位全是 1,这样能让更多的哈希值参与到索引的计算中。

当传入的初始容量不是 2 的幂次方时的处理方式?

若在创建 HashMap 对象时传入的初始容量不是 2 的幂次方,HashMap 会自动把它转换为大于等于该值的最小 2 的幂次方。在 Java 中,这一转换操作借助 tableSizeFor 方法来实现,代码如下:

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 - 1 的二进制表示中从最高位 1 开始的所有低位都置为 1,最后再加 1 就得到了大于等于 cap 的最小 2 的幂次方。

(1)计算键的哈希码(hashCode)

HashMap 首先会调用键对象的 hashCode() 方法,以此获取键的哈希码。这个哈希码是一个 32 位的整数。

# 示例代码,在 Java 中计算对象的哈希码
import java.util.HashMap;

public class HashExample {
    public static void main(String[] args) {
        String key = "example";
        int hashCode = key.hashCode();
        System.out.println("Hash code: " + hashCode);
    }
}

(2)对哈希码进行扰动处理

为了减少哈希碰撞的概率,HashMap 会对键的哈希码进行扰动处理。在 Java 8 及以后的版本中,扰动处理的方式如下:

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

此操作把哈希码的高 16 位和低 16 位进行异或运算,让哈希码的高位信息也能参与到数组索引的计算中,从而减少哈希碰撞的可能性。

(3)计算数组索引

经过扰动处理后,HashMap 会用扰动后的哈希值和数组长度减 1 进行按位与运算,进而得到键值对在数组中的索引位置。

// n 是数组的长度
index = (n - 1) & hash;  

如果数组长度 n 为 16,index为什么会得要0~15范围内的数字呢?

(1)HashMap 数组长度的特性

HashMap 要求数组长度是 2 的幂次方,比如 2、4、8、16、32 等。以 16 为例,它的二进制表示是 0001 0000,那么 arrayLength - 1 的二进制表示就是 0000 1111。

(2)按位与运算的规则

按位与运算(&)是对两个操作数的对应二进制位进行比较,只有当两个对应位都为 1 时,结果位才为 1,否则为 0。当用 (arrayLength - 1) (即 0000 1111)和 hashValue 进行按位与运算时,实际上是取 hashValue 的低 4 位作为结果。因为 0000 1111 除了低 4 位是 1 之外,其余位都是 0,所以 hashValue 中高于低 4 位的部分在按位与运算后都会变成 0。

(3)结果范围

低 4 位二进制数能表示的范围是从 0000 到 1111,对应的十进制数就是从 0 到 15。所以,(arrayLength - 1) & hashValue 的结果必然在 0 到 15 这个区间内。