一文理清HashMap的实现及细节

本文详细剖析了HashMap的实现原理,包括JDK1.7和1.8的区别。JDK1.7使用头插法,可能导致扩容时的链表环路问题,而JDK1.8改用尾插法并引入红黑树,提高了查询效率。同时,分析了HashMap的扩容策略、初始容量选择为2^n的原因以及负载因子的重要性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

最近阅读了许多HashMap实现及源码分析的文章,特意此文记录HashMap的知识点。
HashMap 底层由 数组 + 链表 组成,在 jdk1.7 和 1.8 中具体略有不同。

JDK1.7的HashMap

数据结构:图片来源
在这里插入图片描述

核心成员变量

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

  1. 初始化桶大小(1<<4,即:16),因为底层是数组,所以这是数组默认的大小。
  2. 桶容量最大值。
  3. 默认的负载因子(0.75)
  4. table 真正存放数据的数组。
  5. Map 中存放元素数量。
  6. 桶的容量大小,可在初始化时显式指定。
  7. 负载因子,可在初始化时显式指定。

负载因子

存放的键值对数量(size) = 桶容量(threshold) * 负载因子(loadFactor)时,会发生扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。因此最好提前预估 HashMap 的大小,尽量的减少扩容带来的性能损耗。

Entry

Entry是HashMap的一个内部类,用于保存键值对,实现HashMap中的链表,主要成员变量:

  • key:写入的键。
  • value: 写入的值。
  • next:开始的时候就提到 HashMap 是由数组和链表组成,所以这个 next 就是用于实现链表结构。
  • hash: 存放的是当前 key 的 hashcode。

桶初始大小为16的原因

要解释这个问题,首先要知道这个容量的用途。容量就是一个HashMap中"桶"的个数(数组的大小),当想要往一个HashMap中put一个元素的时候,需要通过一定的算法计算出应该把他放到哪个桶中。HashMap中通过以下两个方法实现计算一个元素对应的桶(数组的索引)

  • int hash(Object k):该方法主要是将Object转换成一个整型。
  • int indexFor(int h, int length):该方法主要是将hash生成的整型转换成链表数组中的下标。jdk1.8没有此方法,不过计算的方式相同。
static int indexFor(int h, int length) {
    return h & (length-1);
}

在保证length(容量)是2^n 的前提下,h & (length-1)相当于h % (length-1),即用位运算(&)代替取模运算(%)

Java之所有使用位运算(&)来代替取模运算(%),最主要的考虑就是效率。
位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。

为什么保证容量为2^n即使用位运算(&)来实现取模运算(%)

总结:因为位运算直接对内存数据进行操作,不需要转成十进制,所以位运算要比取模运算的效率更高,所以HashMap在计算元素要存放在数组中的index的时候,使用位运算代替了取模运算。而等价代替,前提是要求HashMap的容量一定要是2^n

由上述分析,容量只要为2^n即可,HashMap选择16的原因可能是个经验值。

既然一定要设置一个默认的2^n 作为初始值,那么就需要在效率和内存使用上做一个权衡。这个值既不能太小,也不能太大。太小了就有可能频繁发生扩容,影响效率。太大了又浪费空间,不划算。(官方未给出原因)

扩容

由上述分析:HashMap必须保证容量为2^n。因此在扩容时,HashMap会进行成倍的扩容(容量变为原来的2倍)。
扩容的步骤为:

  • 新建数组:创建一个新的Entry空数组,长度是原数组的2倍。
  • 重新计算hash:遍历原Entry数组,把所有的Entry重新Hash到新数组。

Put方法(头插法)

JDK1.7下的put方法添加新元素时使用头插法:即新来的值会成为头节点。

public V put(K key, V value) {
	//判断当前数组是否需要初始化
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    //如果 key 为空,则 put 一个空值进去。
    if (key == null)
        return putForNullKey(value);
    //计算键值hash值
    int hash = hash(key);
    //查找对应的桶的索引
    int i = indexFor(hash, table.length);
    //遍历链表
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        //遍历判断里面的 hashcode、key 是否和传入 key 相等,
        //如果相等则进行覆盖,并返回原来的值。
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    //添加新键值对(头插法),会判断是否需要扩容
    addEntry(hash, key, value, i);
    return null;
}

头插法的问题

使用头插法,在扩容时会反转链表上元素的顺序。在多线程及需要扩容的条件下,可能出现环形链表,造成死循环。
jdk1.7HashMap出现环路(有个例子,但我感觉不是特别清楚)
在这里插入图片描述

JDK1.8的HashMap

JDK1.7的HashMap在 Hash 冲突严重时,桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为 O(N)。
因此JDK1.8重点解决的此问题。
数据结构:图片来源
在这里插入图片描述

主要区别

  • 新的成员变量 TREEIFY_THRESHOLD 用于判断是否需要将链表转换为红黑树的阈值。链表长度大于等于该值时,会尝试转为红黑树(还需判断数组长度是否大于MIN_TREEIFY_CAPACITY
  • 新的成员变量 UNTREEIFY_THRESHOLD 用于判断是否需要红黑树转为链表的阈值。
  • 用Node代替Entry,在达到红黑树阈值时,将链表转为红黑树提高查询效率。
  • put方法添加新的元素时,由头插法改为尾插法。使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题。但在 HashMap 扩容的时候会调用 resize() 方法,此时并发操作仍然可能在一个桶上形成环形链表。
    JDK1.8下的HashMap依旧是线程不安全的,只是用尾插法代替头插法解决了JDK1.7时,容易出现环形链表的问题

转为红黑树的条件

默认情况下:链表长度大于 8(TREEIFY_THRESHOLD), 表的长度大于 64(MIN_TREEIFY_CAPACITY) 的时候会转化红黑树。

参考

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值