HashMap源码分析

1. HashMap概述

本文基于JDK11的HashMap撰写。

1.1 数据结构

HashMap的数据结构由数组链表构成。
在这里插入图片描述

JDK1.8中加入了红黑树(优化查找效率)

1.2 类结构

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {}
  • AbstractMap:Map集合的可重用代码

  • Cloneable:标识此类可以拷贝

  • Serializable:标识此类可以序列化

1.3 线程安全性

HashMap是线程不安全的,其源码中并无保证线程安全的相关代码。

1.4 效率

得益于多线程,使得HashMap性能要比HashTable高一点。

不过,需要注意的是HashTable基本被淘汰,尽量避免使用它。

1.5 其它

  • HashMap存储的元素是唯一性的。

  • HashMap存储的元素是无序性的。

  • HashMap键值对可以为null,但为null的键只能存在一个。

2. HashMap.Node

为便下文引用,HashMap.Node在此章节中统一简写为Node

2.1 Node概述

NodeHashMap中的一个静态内部类,它的作用是封装存储元素。

如上1.1章节所述,HashMap是以数组链表组成的,其数组,链表类型就是Node

2.2 Node类结构

static class Node<K,V> implements Map.Entry<K,V> {
    // 元素哈希值
    final int hash;
    // 元素键
    final K key;
    // 元素值
    V value;
    // 链表结构,用于链接下一个元素
    Node<K,V> next;
}

Map.Entry接口,用于规范可迭代元素。

3. HashMap常用方法刨析

3.1 put

描述

描述:向HashMap中添加一个映射,如果在此之前存在相同的键则会覆盖原值并返回原值,否则返回null

执行流程图

在这里插入图片描述

源码分析

public V put(K key, V value) {
    // 元素在存储数组中的索引是通过特定的hash相关运算取得
    return putVal(hash(key), key, value, false, true);
}
// 取键的hash
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 此方法是HashMap存储元素的核心方法
// hash:hash地址
// key:键
// value:值
// onlyIfAbsent:如果存在相同键,传入true则不会覆盖原值,反之
// evict:此参数大多用于HashMap的实现类
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
    // tab:存储数组
    // p:标识当前链表节点
    // n:标识当前存储数组的长度
    // i:标识链表在存储数组中的索引
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 第一次调用put方法 或 存储数组中没有元素时
    if ((tab = table) == null || (n = tab.length) == 0)
        // resize方法用来给数组扩容
        // 此处调用resize是给数组初始化容量
        n = (tab = resize()).length;
    // 存储数组中i索引的值为null,则创建链表(头节点)
    // so important!
    // 存储数组的长度 - 1 & 键的哈希值 = 元素在数组中的索引
    if ((p = tab[i = (n - 1) & hash]) == null)
        // newNode方法用来创建节点(链表头节点)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 链表头节点相等(对应的键不为null)
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 转换成红黑树后,添加元素时进入此if代码块
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 遍历链表
            for (int binCount = 0; ; ++binCount) {
                // 链表的下一个节点为null
                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;
            // 参阅onlyIfAbsent参数
            if (!onlyIfAbsent || oldValue == null)
                // 修改当前链表节点的值
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    // 防止并发修改
    ++modCount;
    // 新增后的长度大于下一次准备扩容的长度时,则扩容。
    // threshold:临界值,下一次的扩容长度
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

3.2 get

描述

根据key从HashMap中获取对应value。

执行流程图

在这里插入图片描述

源码分析

public V get(Object key) {
    Node<K,V> e;
    // 如果存在此键则返回对应的value,否则返回null
    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);
        }
    }
    // 先决条件不通过或不存在该键则返回null
    return null;
}

3.3 remove

描述

根据key从HashMap中删除对应value。

执行流程图

在这里插入图片描述

源码分析

public V remove(Object key) {
    Node<K,V> e;    
    // 如果HashMap中存在此键,则删除并返回key对应值,否则返回null。
    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;
    // 先决条件,不满足直接返回null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        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) {
            // 如果链表已经转换成红黑树,则进入此if代码块
            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)))) {
            // 红黑树的删除
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            // 链表头节点的删除
            else if (node == p)
                tab[index] = node.next;
            else
                // 链表其它节点的删除
                p.next = node.next;
            // 删除后的相关操作
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

4. HashMap要点解惑

4.1 (n - 1) & hash 分析

n 是存储数组的默认长度(容量), HashMap源码中可得知其初始值为 1 << 4

n - 1 = 15,二进制为 1111。

令一个数与 n - 1 做与运算,可以省去 hash 位级表示的第四位以上数字

例子

hash:   10110010
n-1:    00001111
y&(n-1):00000010

其特性与hashn - 1取模效果一致

hash:     10110010
n:        00010000
n%hash:   00000010

1 << 4,而不是写16。

这样写主要是直观,无论左位移几位,它都是2的幂,也就是2的n次方。

位运算的代价比取模运算性能高,资源消耗小很多。

HashMap作为最常用的集合,一点性能损失将会被放无限放大。

4.2 为什么 null 键位于第一位?

HashMap中存储元素是将其存放在数组中,其数组索引是经过特定hash运算得出。

null值经过hash运算得出来的值固定为0,那么null值始终排在数组第一位。

其实HashMap严格来讲并不是无序的,因为它按照hash规则生成索引。

4.3 为什么要转换成红黑树

因链表结构的特性,查找最坏的情况下时间复杂度是O(n)

如果是查找一个不存在的值,那么将会把整个链表遍历一遍,这是非常不合适的!

由此引申出红黑树,使用其优化查找效率,时间复杂度为O(log2n)

之所以转换成红黑树,主要原因是其它特性的二叉树会在极端情况下会导致二叉树不平衡。

不平衡的二叉树与链表几乎是同样的时间复杂度O(n)

例如:二叉树插入元素(1, 2, 3, 4)

为何不直接使用红黑树优化,而是链表转红黑树,主要原因是其左旋,右旋性能损耗太大。

这也是为什么链表长度大于等于8时会判断是否转换为红黑树,只有元素个数超出64时才会将链表转换成红黑树。

4.4 负载因子的作用

负载因子的作用主要是减少哈希碰撞,决定存储数组的空间利用率。

4.5 负载因子的值为什么是 0.75

负载因子值取值范围在0 ~ 1之间,但JDK默认为0.75主要原因如下。

取值范围必须合理,如果太大则会造成大量的哈希冲突,如果太小则空间利用率太低。

当负载因子为1时,则存储元素的个数达到了存储数组的长度则会进行扩容,那么这样哈希碰撞的可能极高,因为每个数组索引在最坏情况下都会被使用。

HashMap扩容时采用的公式:临界值(threshold) = 负载因子(loadFactor) * 容量(capacity)

根据 HashMap的扩容机制,它会保证 capacity 的值永远都是2的幂。

只有当负载因子为0.75时和任何2的幂乘积结果都是整数。

哈希碰撞解决参见 4.7 章节

4.6 为什么自定义类型做Key需要重写hashCode与equals

自定义类型当作HashMap的Key时,不重写hashCode与equals时是无法正常使用HashMap的。

因为HashMap的内部存储使用key的hash存储的,而hash是通过hashCode求得而来的,当hash相同的时候就会判断equals是否也相同,否则会当作哈希冲突而跳过当前键。

因为hashCode比较地址而不是逐个比较属性值,所以性能比equals高出很多。这也便是优先使用hashCode查找,后而使用equals二次比较的原因。

4.7 哈希碰撞是如何解决的

HashMap解决哈希碰撞用的是链地址法,也就是前文所说的数组链表。

但是,Hash碰撞太高的话,数组链表就会悄然的转换成链表。

在这里插入图片描述

所以为了提高查找效率就必须得减少哈希碰撞的几率,而减少哈希碰撞几率的方法就是4.5章节所述的负载因子。

4.8 为什么建议初始化长度是2的幂

主要原因是4.1章节的(n - 1) & hash,其运算的先决条件就是2的幂。

HashMap内部确保容量始终是2的幂。

static final int tableSizeFor(int cap) {
    // 获取二进制的cap前导0个数
    // 例子:cap = 16,则前导0个数为28
    // 28个0 + 4个1(15) = 32位(int的bit数)
    // 无符号右移高位统统补0,n + 1 决定了值始终为2的幂
    int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

4.9 头插入,尾插入

JDK8后采用尾插入

头插入就是插入的新元素总是放在链表的头部位置。

尾插入就是安装正常顺序放置的元素,总是放置在末尾节点。

为何JDK8之后改用尾插入?

此问题主要是因为HashMap扩容时,元素会重新进行hash,分配元素到新的存储数组中。此操作可能会导致原先的头节点会被分配至子节点。

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

所以头插入会导致无限循环,也就是死链

而尾插入并没有指向的问题,并不会出现无限循环(死链)现象。

4.11 扩容机制

  1. 创建一个新的空数组,长度为原数组长度的两倍。

  2. 遍历原数组,把所有的元素重新hash到新数组中。

此处为何不直接复制,而是重新hash?

如4.1章节中所述,(n - 1) & hash的值取决于数组长度。

数组扩容后长度改变,取得数组索引结果也是不同的。

所以必须得重新hash后,才能放置正确合理的索引位中。

4.12 HashMap为什么线程不安全

HashMap中并无维护线程安全相关的代码,例如synchronizedJUC

那么没有维护线程安全的代码,则在多线程情况下操作就有可能会造成如下情况。

如果其它线程put(1)时,另一个线程正好同时get(1),则可能获取到的值还是原值。

5. 引用

撰写本文时,查阅了部分文章。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值