HashMap 源码解读(JDK1.8)

一、HashMap说明

基于哈希表的 Map 接口实现。此实现提供所有可选的map操作,并允许空值和空键。(HashMap 类大致等同于 Hashtable,只是它不支持同步并且允许空值。)此类不保证插入键值的顺序;特别是,它不保证顺序会随着时间的推移保持不变。

此实现为基本操作(获取和放置)提供恒定时间性能,假设哈希函数在存储桶中正确分散元素。对集合视图进行迭代所需的时间与 HashMap 实例的“容量”(存储桶数)加上其大小(键值映射数)成正比。因此,如果迭代性能很重要,则不要将初始容量设置得太高(或负载系数太低),这一点非常重要。

HashMap 的实例有两个影响其性能的参数:初始容量和负载因子。容量是哈希表中的存储桶数,初始容量只是创建哈希表时的容量。负载系数是哈希表在容量自动增加之前允许达到的多满程度的度量。当哈希表中的数量超过负载因子与当前容量的乘积时,将重新哈希哈希表(即重建内部数据结构),使哈希表中的存储桶数扩容为之前两倍。作为一般规则,默认负载系数(0.75) 在时间和空间成本之间提供了很好的权衡。较高的值会减少空间开销,但会增加查找成本(反映在 HashMap 类的大多数操作中,包括 get 和 put)。在设置其初始容量时,应考虑映射中的预期条目数及其负载系数,以便最大限度地减少重新散列操作的数量。如果初始容量大于最大条目数除以负载因子,则不会发生重新哈希操作。如果要将许多映射存储在 HashMap 实例中,则创建具有足够大容量的映射将允许更有效地存储映射,而不是让它根据需要执行自动重新哈希以增加表。请注意,存储具有相同 hashCode() 的许多键将使哈希表性能降低。为了减轻影响,当键具有可比性时,此类可能会使用键之间的比较顺序来帮助中断关系(树化)。

请注意,此实现类不支持同步,意味着它不是线程安全的。如果多个线程同时访问HashMap,并且至少有一个线程在结构上修改了映射,则必须在外部同步该map。(结构修改是添加或删除一个或多个映射的任何操作;仅更改与实例已包含的键关联的值不是结构修改。这通常是通过在封装map的某些对象上进行同步来实现的。如果不存在这样的对象,则应使用 Collections.synchronizedMap()方法装饰map。这最好在创建时完成,以防止意外地不同步访问map:

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

通过“集合方法”类返回的迭代器都是快速失败的:如果在创建迭代器后的任何时间对映射进行结构修改,则除了通过迭代器自己的 remove 方法之外,迭代器将抛出 ConcurrentModificationException。因此,面对并发修改,迭代器会快速而干净地失败,而不是冒着在未来不确定的时间出现任意、非确定性行为的风险。请注意,无法保证迭代器的快速故障行为,因为一般来说,在存在不同步的并发修改的情况下,不可能做出任何硬保证。Fail-fast 迭代器会尽最大努力抛出 ConcurrentModificationException。因此,编写一个依赖于此异常的正确性的程序是错误的:迭代器的快速故障行为应仅用于检测错误。

二、成员变量

HashMap继承了Map抽象类,实现了Map、Cloneable、Serializable接口

HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

几个重要参数:

默认初始化容量:16

DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

最大容量:2^30
HashMap的容量为2的幂次方,扩容机制为<<1,既扩容为原来的2倍。Integer类型数据占据4字节,除掉一个最高位的符号位后,最大为2^30。

MAXIMUM_CAPACITY = 1 << 30;

默认负载系数
当存储的数据数超过容器的长度*负载系数时,触发扩容

DEFAULT_LOAD_FACTOR = 0.75f;

扩容的阈值
容器存储的个数超过扩容的阈值就会触发扩容

// 容器的长度*负载系数
int threshold;

树化的阈值
使用树而不是列表的容器计数阈值。将元素添加到至少具有这么多节点的容器时,容器将转换为树。该值必须大于 2,并且应至少为 8,以符合取消树化时在收缩时转换回普通容器。

TREEIFY_THRESHOLD = 8

取消树化的阈值
在调整大小操作期间取消树化(拆分)的容器计数阈值。应小于TREEIFY_THRESHOLD,并且最多6个用于在去除元素时进行收缩检测。

UNTREEIFY_THRESHOLD = 6;

Nede数据节点
容器中存储数据的格式

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

TreeNode数据节点
HashMap的内部类,转化为红黑树时存储数据

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    
    
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
        TreeNode(int hash, K key, V val, Node<K,V> next) {
    
    
            super(hash, key, val, next);
        }
}

底层数据结构:
在这里插入图片描述
图片转载自网上:原出处链接

三、HashMap为何要树化

JDK1.8之前的HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了节解决哈希碰撞(两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引值相同)而存在的(“拉链法”解决冲突)。

JDK1.8之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为8)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。

1. 树化的意义

当位于一个链表中的元素较多,即hash值相等但是内容不相等的元素较多时,通过key值依次查找的效率较低。而jdk1.8中,哈希表存储采用数组+链表+红黑树实现,当链表长度(阀值)超过 8 时且当前数组的长度 > 64时,将链表转换为红黑树,这样大大减少了查找时间。jdk8在哈希表中引入红黑树的原因只是为了查找效率更高。
hash 表的查找,更新的时间复杂度是 O(1),而红黑树的查找,更新的时间复杂度是 O(log 2n),TreeNode 占用空间也比普通 Node 的大,如非必要,尽量还是使用链表。
但是红黑树可以用来避免 DoS 攻击,防止链表超长时性能下降O(n),树化应当是偶然情况,是保底策略。

2. 树化规则

HashMap里面定义了一个常量TREEIFY_THRESHOLD = 8,当链表长度超过树化阈值 8 时,先尝试调用resize()方法进行扩容来减少链表长度,如果数组容量已经 >=64(MIN_TREEIFY_CAPACITY),才会进行树化,Node节点转为TreeNode节点(TreeNode也是HashMap中定义的内部类)。

TreeNode除了Node的基本属性,还保存了父节点parent, 左孩子left,右孩子right,还有红黑树用到的red属性。

四、扩容机制

1. 什么时候需要扩容?

一般情况下,当HashMap中的元素个数超过数组长度loadFactor(负载因子)时,就会进行数组扩容,loadFactor的默认值是0.75,这是一个折中的取值。较高的值会减少空间开销,但会增加查找成本;较低的值会加大空间消耗

2. 如何进行扩容?

HashMap在进行扩容时使用 resize() 方法,每次扩容的容量都是之前容量的2倍。HashMap的容量是有上限的,必须小于1<<30,即1073741824。如果容量超出了这个数,则不再增长,且扩容阈值会被设置为Integer.MAX_VALUE(2^31-1,即永远不会超出阈值触发扩容了)。

看看扩容源码:

final Node<K,V>[] resize() {
    
    
    // 定义变量存储map数组
    Node<K,V>[] oldTab = table;
    // 定义变量存储原数组长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 定义变量存储扩容阈值
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
    
    
        // 如果原数组长度超过HashMap最大容量,设置扩容阈值为Integer.MAX_VALUE,并且不再扩容
        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
    }
    // 若扩容前容量为0,且扩容阈值不为0,则设置容器容量为扩容阈值
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {
    
                   // zero initial threshold signifies using defaults
        // 如果为第一次创建时扩容,设置容量为默认初始化容量,扩容阈值为DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 若扩容阈值为0,则计算扩容阈值
    if (newThr == 0) {
    
    
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 设置HashMap扩容阈值为新计算的扩容阈值
    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) {
    
    
                // 设置原数据为空,减少gc
                oldTab[j] = null;
                // 若next为空,表示不是链表存储
                if (e.next == null)
                    // e.hash & (newCap - 1)计算新容器的存储索引位置
                    newTab[e.hash & (newCap - 1)] = e;
                // 若e属于TreeNode,既红黑树存储
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 链表存储,获取链表下的每个节点
                else {
    
     // preserve order
                    // 扩容后索引位置保持不变:loHead:头节点,loTail:尾节点
                    Node<K,V> loHead = null, loTail = null;
                    // 扩容后索引位置变成原索引位置+oldCap:hiHead:头节点,hiTail:尾节点
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 循环读取链表节点
                    do {
    
    
                        next = e.next;
                        // e.hash & oldCap==0,索引位置保持不变
                        if ((e.hash & oldCap) == 0) {
    
    
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 否则,索引位置为原索引位置+oldCap
                        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;
}

3. 容量发生变化的几种情况?

  • 空参数的构造函数:实例化的HashMap默认内部数组是null,即没有实例化。第一次调用put方法时,则会开始第一次初始化扩容,长度为16。
  • 有参构造函数:用于指定容量。会根据指定的正整数找到不小于指定容量的2的幂数,将这个数设置赋值给阈值(threshold)。第一次调用put方法时,会将阈值赋值给容量,然后让阈值=容量X负载因子(因此并不是我们手动指定了容量就一定不会触发扩容,超过阈值后一样会扩容!!)
  • 如果不是第一次扩容,则容量变为原来的2倍,阈值也变为原来的2倍。

五、HashMap的索引是如何计算的?

  • 首先,计算对象的 hashCode(),得到原始hash值
  • 再进行调用 HashMap 的 hash() 方法进行二次哈希,得到二次hash值
  • 最后 & (capacity – 1) 得到索引(使用二次hash值和数组容量 - 1进行位与运算)。一般获取索引位置是通过取模运算(hash%(capcity-1)),源码做了优化使用hash&(capcity-1),hash%(capcity-1)等于hash&(capcity-1)前提是capcity是2的幂次方。
static final int hash(Object key) {
    
    
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

扩展:

数组容量为何是 2 的 n 次幂?

  • 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
  • 扩容时重新计算索引效率更高:hash(原始hash) & oldCap(原始容量) == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap(原始容量)

为什么要进行二次 hash?

  • 二次 hash() 是为了综合高位数据,让哈希分布更为均匀
  • 二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash
  • 容量是 2 的 n 次幂 这一设计计算索引效率更好,但 hash 的散列性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable。

六、插入数据源码分析

HashMap map = new HashMap();
在实例化以后,底层创建了长度是16的一维数组Node[] table。
..可能已经执行过多次put...
map.put(key1 value1);

首先,调用key1所在类的hashCode()计算key1哈希值,此哈希值经过某种算法计算以后,得到在Node数组中的存放位置。如果此位置上的数据为空,此时的key1-value1添加成功。----情况1

如果此位置上的数据不为空,(意味着此位置上存在一个或多个数据(以链表或红黑树形式存在),比较key1 和已经存在的一个或多个数据的哈希值:

如果key1的哈希值与已经存在的数据的哈希值都不相同,此时key1-value1添加成功。----情况2

如果key1的哈希值和已经存在的某一个数(key2-value2)的哈希值相同,继续比: 调用key1所在类的equals(key2)

如果equals()返回false: 此时key1-value1添加成功。---- 情况3

如果equals()返true: 使用value1替换value2。

在这里插入图片描述
图片转载自网上:原出处链接

现在来看看源码:

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

/**
 * Implements Map.put and related methods
 *
 * @param hash hash for key // hash值
 * @param key the key // key值
 * @param value the value to put // value值
 * @param onlyIfAbsent if true, don't change existing value // 是否更改原来存在的值,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;
    // 判断table是否为空,并赋值给tab。若数组为空或数组长度为零,调用resize()初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        // 调用扩容方法,返回扩容后的数组赋值给tab
        // 若为空参构造,第一次扩容默认生成容量为16的数组容器
        n = (tab = resize()).length;
    // 计算key的索引位置,若索引位置为空,则直接放入key-value的Node
    // 将当前索引的值赋值给p
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 若索引位置存在数据,说明hash相同,则比较两个数据的key是否相等
    else {
    
    
        Node<K,V> e; K k;
        // 判断key是否相等,若相等则将p赋值给e, 最终会替换原来的value为新的value
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 若key不相等,则使用拉链法或红黑树进行追加
        // 判断节点p是否属于红黑树
        else if (p instanceof TreeNode)
            // 红黑树的插入方法
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 说明节点是链表,进行链表插入
        else {
    
    
            // 循环遍历,遍历到尾节点插入,binCount计算链表的长度
            for (int binCount = 0; ; ++binCount) {
    
    
                // 将当前节点的next赋值给e,若e为空代表到了末尾
                if ((e = p.next) == null) {
    
    
                    // 插入数据
                    p.next = newNode(hash, key, value, null);
                    // 若链表长度大于等于TREEIFY_THRESHOLD,转换为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 判断hash以及key是否相等,若相等结束循环最终会替换原来的value为新的value
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 若e不为空,则代表存在着相同key的Node
        // 若onlyIfAbsent为false或为空,则替换value为新的value,返回旧的值
        if (e != null) {
    
     // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 插入后的回调方法
            afterNodeAccess(e);
            return oldValue;
        }
    }
    // map修改次数更新
    ++modCount;
    // 若插入后容量大于扩容阈值,则调用扩容方法
    if (++size > threshold)
        resize();
    // 插入后的回调方法
    afterNodeInsertion(evict);
    return null;
}

// 替换给定哈希的容器索引中的所有链接节点为红黑树,除非表太小,在这种情况下会调整大小。
final void treeifyBin(Node<K,V>[] tab, int hash) {
    
    
    int n, index; Node<K,V> e;
    // 表长度小于最低树化容量,则不转红黑树,进行扩容
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    // 索引位置链表转红黑树
    else if ((e = tab[index = (n - 1) & hash]) != null) {
    
    
        TreeNode<K,V> hd = null, tl = null;
        // 生成双向链表
        do {
    
    
            // 将Node转化为TreeNode
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
    
    
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
        	// 树化
            hd.treeify(tab);
    }
}

文章参考:

详细理解HashMap数据结构,太齐全了!

猜你喜欢

转载自blog.csdn.net/weixin_44863237/article/details/133268401