Java HashMap底层结构和工作原理

HashMap 是 Java 中是一个常用的数据结构,它实现了 Map 接口,用于存储键值对(key-value pairs)。HashMap 的底层实现主要依赖于哈希表结构,结合了数组和链表(在Java 8及其之后的版本中,也引入了红黑树)来存储数据。

底层结构:

1. 数组(Buckets)

  • Bucket:HashMap 内部维护了一个数组,数组中的每个元素称为桶(bucket),每个桶可以存放一个键值对或是一个指向链表/红黑树节点的引用。
  • 默认容量:默认情况下,HashMap 的初始容量是16个桶,但这个值可以通过构造函数自定义。例如:
    import java.util.HashMap;
    
    public class CustomHashMapWithLoadFactorExample {
        public static void main(String[] args) {
            // 创建一个初始容量为 64,负载因子为 0.5 的 HashMap
            HashMap<Integer, String> map = new HashMap<>(64, 0.5f);
            
            // 向 HashMap 中添加一些键值对
            map.put(1, "Apple");
            map.put(2, "Banana");
            map.put(3, "Orange");
    
            // 打印 HashMap 中的内容
            System.out.println(map);
        }
    }

2. 链表

  • 当两个不同的键通过哈希函数计算后得到相同的哈希值时,这种情况称为哈希冲突。HashMap 处理哈希冲突的方式之一就是使用链表。具体来说,如果多个键映射到同一个桶位置,这些键值对会以链表的形式链接起来,每一个新元素都被添加到链表的末尾。

3. 红黑树

  • 自 Java 8 开始,为了优化性能,当桶内链表长度超过一定阈值(默认为8)时,链表会被转换成红黑树。红黑树是一种自平衡二叉查找树,能够在最坏情况下提供 O(log n) 的操作时间复杂度,这显著提高了处理大量数据时的效率。

4. 哈希函数

  • 每当向 HashMap 添加一个键值对时,都会首先计算该键的哈希值。哈希值决定了键值对应该被放置在数组的哪个位置。具体的计算方法是对键的 hashCode() 方法的结果与数组长度减一进行按位与操作 (n - 1) & hash,这里 n 是数组的长度。

5. 负载因子

  • 负载因子是 HashMap 在扩容之前允许的最大“填满”程度的一个比例。默认负载因子是0.75,意味着当 HashMap 中的元素数量达到了容量的75%,就会触发扩容操作,通常将容量翻倍,并重新分配所有元素。

通过这种组合方式,HashMap 实现了平均情况下常数级别的存取效率。然而,在最坏的情况下(例如,所有的键都哈希到了同一个桶),性能可能会退化至 O(n)。因此,良好的哈希函数对于维持 HashMap 的性能至关重要。

工作原理

1. HashMap中的哈希冲突:

哈希冲突是什么:
  • HashMap 中,哈希冲突是指不同的键通过哈希函数计算后得到了相同的哈希值,从而映射到了哈希表的同一个位置。
解决方式:
  • 1. 链地址法(Separate Chaining)这是 Java HashMap 主要采用的方法。具体来说:
    • 链表:当两个或多个键计算出相同的哈希值时,这些键值对会被存储在一个链表中。这个链表连接到哈希表对应的桶位上。这意味着每个桶位实际上是一个链表的头节点,可以链接任意数量的元素。查找、插入和删除操作的时间复杂度都是 O(n),这里的 n 是链表中元素的数量。

    • 红黑树转换:为了优化性能,在 Java 8 及以后版本中,如果链表长度超过一定阈值(默认为8),链表将被转换为红黑树。这是因为随着链表的增长,线性搜索效率会变得越来越低。而红黑树是一种自平衡二叉查找树,能够在最坏情况下也保证 O(log n) 的时间复杂度。当红黑树的节点数降到某个阈值(默认为6)之下时,又会退化为链表。

  • 2. 哈希函数优化
    • 除了上述直接处理冲突的方法外,HashMap 还通过优化哈希函数来尽量减少冲突的可能性。Java 中的 HashMap 并不直接使用对象的 hashCode() 方法返回的值作为最终的哈希值,而是对该值进行了进一步的处理,目的是使其分布更加均匀,从而降低哈希冲突的概率。
  • 补充:开放定址法(Open Addressing)

    虽然 HashMap 主要使用链地址法,但开放定址法也是一种常见的解决哈希冲突的方法,尽管它并未直接应用于 HashMap 的实现中。该方法试图找到另一个空闲的位置来存放发生冲突的元素,而不是形成链表。开放定址法有多种形式,包括线性探测、二次探测和双重散列等。

    • 线性探测:当发生冲突时,检查哈希表中的下一个位置(即当前索引加一),直到找到一个空闲的位置为止。

    • 二次探测:与线性探测类似,但它不是逐个检查下一个位置,而是按照某个二次函数来决定接下来要检查的位置。

    • 双重散列:使用第二个哈希函数来生成步长,以确定在遇到冲突时应检查的下一个位置。

2. put方法的具体工作流程:

HashMap 中的 put(K key, V value) 方法用于将指定的键值对插入到映射中。以下是该方法的具体执行流程:

  1. 计算哈希值:首先,通过调用 key.hashCode() 方法来计算键的哈希值。为了减少不同数据分布下哈希碰撞的概率,还会对该哈希值进行进一步的扰动处理(即重新计算哈希),以得到最终的哈希值。

  2. 确定桶的位置:根据上述计算出的哈希值,使用 (n - 1) & hash 公式(其中 n 是 HashMap 的容量)来确定元素应该放置在哪个桶(bucket)中。这是因为 HashMap 的底层实现是一个数组,每个索引位置可以看作是一个桶。

  3. 检查是否已经存在相同的键:如果目标桶为空,则直接将新节点放入该桶;如果桶中已经有元素,则需要遍历链表(或红黑树)来查找是否存在具有相同键的节点。

    • 如果找到了相同键的节点,则更新该节点的值,并返回旧值。
    • 如果没有找到相同键的节点,则在适当的位置(链表尾部或红黑树)插入新的节点。
  4. 扩容检查:在插入新节点之后,会检查 HashMap 是否需要扩容。具体来说,当元素的数量超过当前容量与负载因子的乘积时,HashMap 会自动扩容至两倍大小,并重新分配所有现有的键值对到新的桶中。这是为了保持较低的哈希冲突概率和较高的性能。

  5. 插入新节点:如果没有发生哈希冲突,或者在解决冲突后,新节点会被添加到链表的末尾或红黑树中。如果链表长度达到阈值(默认为8),并且当前容量大于等于最小树形化容量(默认为64),则链表会被转换成红黑树以提高查询效率。

  6. 返回结果:最后,put() 方法返回之前与给定键关联的值,如果没有之前的映射关系,则返回 null

通过这个过程,HashMap 能够高效地存储和检索键值对,同时也能很好地处理哈希冲突。需要注意的是,在多线程环境下直接使用 HashMap 可能导致数据不一致或其他并发问题,此时应考虑使用 ConcurrentHashMap 或者其他线程安全的 Map 实现。

3. 查找和删除

查找数据

查找数据的过程大致如下:

  1. 计算哈希值:首先,通过调用键对象的 hashCode() 方法计算出该键的哈希值。
  2. 找到桶的位置:然后,使用这个哈希值确定键值对应该放置在哈希表(内部数组)中的哪个桶(bucket)。这通常是通过将哈希值与数组长度减一进行按位与操作完成的(即 hash & (length-1)),这样可以确保结果落在数组的索引范围内。
  3. 遍历链表或树:如果多个键映射到了同一个桶(即发生了哈希冲突),这些键值对会被存储在一个链表或者红黑树中。此时,需要遍历这个链表或树,通过调用键对象的 equals() 方法来比较每个节点的键是否等于目标键。
// 示例代码:查找数据
HashMap<String, Integer> map = new HashMap<>();
map.put("apple", 1);
Integer value = map.get("apple"); // 如果存在键 "apple",则返回对应的值;否则返回 null

删除数据

删除数据的过程与查找类似,但除了找到目标键之外,还需要将其从链表或树结构中移除:

  1. 找到桶的位置:同样,先根据键的哈希值找到它所在的桶。
  2. 遍历链表或树:接着,在相应的链表或树中寻找要删除的键值对。
  3. 移除键值对:一旦找到了匹配的键值对,就将其从链表或树中移除,并更新哈希表中的引用。如果被移除的是最后一个元素,则直接清除该桶的引用。
// 示例代码:删除数据
HashMap<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.remove("apple"); // 移除键为 "apple" 的键值对

注意事项

  • 在执行查找和删除操作时,必须重写键类的 hashCode() 和 equals() 方法,以确保正确地处理相同逻辑相等的对象具有相同的哈希值并且能够互相比较为相等。
  • 这些操作的时间复杂度平均为 O(1),但在最坏的情况下(如所有键都映射到同一个桶内),时间复杂度会退化至 O(n)。不过,由于 Java HashMap 使用了红黑树来优化过长的链表,所以在实际应用中这种情况很少发生。

4. HashMap 的扩容机制

Java中的HashMap使用了一种称为“动态扩容”的机制来确保在插入新键值对时,哈希表的性能保持在一个较高的水平。下面是对HashMap扩容机制的详细解释:

初始容量和加载因子

  • 初始容量:指的是HashMap在创建时内部数组(桶)的大小,默认是16。
  • 加载因子:是一个决定何时进行扩容的阈值,默认为0.75。即当HashMap中存储的键值对数量超过了当前容量乘以加载因子时,就会触发扩容操作。

例如,如果初始容量是16,加载因子是0.75,则当HashMap中有超过12个元素时(16 * 0.75 = 12),将会触发扩容。

扩容过程

当满足扩容条件时,HashMap会执行以下步骤进行扩容:

  1. 创建新的数组:首先,它会根据当前容量创建一个新的更大的数组。默认情况下,新数组的容量是原数组的两倍(即2次幂)。

  2. 重新哈希:接着,将旧数组中的所有键值对重新分配到新的数组中。这是因为扩大后的数组长度发生了变化,键值对的位置(即它们所在的桶)也可能发生变化。这个过程被称为“rehashing”。

  3. 更新属性:最后,更新HashMap的一些属性,如当前容量、阈值等,以便未来继续准确地判断是否需要再次扩容。

示例代码

虽然没有直接展示扩容过程的代码(因为这是自动发生的),但可以通过下面的示例看到如何设置初始容量和加载因子,并观察其影响:

Map<String, Integer> map = new HashMap<>(16, 0.75f);
for (int i = 0; i < 20; i++) {
    map.put("Key" + i, i);
}

在这个例子中,我们初始化了一个HashMap,设置了初始容量为16,加载因子为0.75。当我们向其中放入第13个元素时,HashMap会自动进行扩容操作。

扩容操作虽然有助于维持HashMap的良好性能,但它也是一个相对耗资源的过程,因为它涉及到创建新数组和重新分配所有的键值对。因此,在预计数据量的情况下,合理设置初始容量和加载因子可以有效减少扩容次数,提升效率。

5. HashMap的链表转红黑树机制

在Java 8及之后的版本中,HashMap引入了红黑树来优化在大量哈希冲突情况下的性能。具体来说,当一个桶(bucket)中的链表节点数超过一定阈值(默认为8)时,该桶会从链表转换为红黑树,以提高查找、插入和删除操作的效率。这是因为红黑树的查找时间复杂度是O(log n),而链表则是O(n)。

转换机制

以下是HashMap中关于链表转红黑树的主要规则:

  1. 链表长度检查:每当向HashMap中添加新的键值对时,如果发现某个桶中的链表长度超过了树化阈值(TREEIFY_THRESHOLD,默认为8),则考虑将该桶中的链表转换为红黑树。

  2. 容量检查:但是,在实际进行树化之前,HashMap还会检查当前的容量是否达到了最小树形化容量(MIN_TREEIFY_CAPACITY,默认为64)。如果未达到这个容量,HashMap更倾向于执行扩容操作而不是树化。这是为了避免过早地进行树化,因为随着容量的增加,通过扩容可以减少哈希冲突的概率。

  3. 树化过程:一旦满足上述两个条件,HashMap就会调用treeifyBin()方法将指定桶中的链表转换为红黑树结构。

示例代码

虽然没有直接展示树化过程的代码(因为它是自动发生的),但可以通过下面的示例观察到这一行为:

Map<String, String> map = new HashMap<>(16, 0.75f);
String key = "someKey";
for (int i = 0; i < 9; i++) {
    // 使用相同的key进行put操作会导致哈希冲突
    map.put(key + i % 8, "value" + i); // 这里故意制造哈希冲突
}

在这个例子中,我们故意使用了一个会导致哈希冲突的策略(即i % 8),这样在第9次插入时,至少有一个桶中的链表长度达到了8。如果其他条件也满足的话(如HashMap的容量已达到或超过64),那么这些特定桶中的链表将会被转换为红黑树。

值得注意的是,这种转换是为了应对极端情况下的性能优化,并不是日常使用的常态。在大多数情况下,合理的初始容量和加载因子设置可以有效地避免频繁的树化操作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值