• HashMap


    JDK 7 & JDK 8

    JDK 7 中,HashMap 由“数组 + 链表”组成,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。

    在 JDK 8 中,HashMap 由“数组 + 链表 / 红黑树”组成。链表过长,会严重影响 HashMap 的性能,而红黑树搜索的时间复杂度是 O(logn),而链表是 O(n)。因此,JDK 8 对数据结构做了进一步的优化,引入了红黑树,链表和红黑树在达到一定条件会进行转换:

    • 当链表超过 8 且数组长度超过 64 时会转红黑树。
    • 将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会优先进行数组扩容,而不是转换为红黑树,以减少搜索时间。
    • 当红黑树中的节点数量降到 6 个或以下时,红黑树会被转换回链表。

    为什么链表转为红黑树的阈值是 8?

    在 JDK 8 中,HashMap 将链表转换为红黑树的阈值是 8,是一个经验性的选择,旨在在性能和内存占用之间达到平衡。这个阈值的选择是根据一系列实验和性能测试得出的,并且它取决于多种因素,包括哈希冲突的发生频率、链表长度的影响、红黑树的性能等。

    以下是一些考虑因素:

    1. 哈希冲突频率: 阈值的选择会受到哈希冲突的频率影响。如果哈希冲突很少发生,那么链表的长度通常不会很长,因此没有必要将其转换为红黑树。如果哈希冲突频繁,链表长度可能会快速增长,因此需要更早地将链表转换为红黑树来维护性能。

    2. 性能考虑: 转换链表为红黑树是一项开销较大的操作,因此不应该在链表长度较短时就进行。只有当链表长度超过一定阈值时,红黑树的查找性能才能显著超越链表,因此这个阈值需要合理选择以获得性能提升。

    3. 内存占用: 红黑树比链表占用更多的内存。因此,在选择阈值时,需要权衡性能提升和内存占用之间的关系。将阈值设置得太低可能会导致内存消耗过多,而将阈值设置得太高可能会导致性能问题。

    理想情况下使用随机的哈希码,容器中节点分布在 hash 桶中的频率遵循泊松分布,按照泊松分布的计算公式计算出了桶中元素个数和概率的对照表,可以看到链表中元素个数为 8 时的概率已经非常小,再多的就更少了,所以在选择链表元素个数时选择了 8,是根据概率统计而选择的。

    为什么红黑树转为链表的阈值是6?

    在 JDK 8 中,HashMap 将红黑树转换回链表的阈值(从红黑树退化为链表的阈值)是 6。这个阈值的选择是为了在性能和内存占用之间找到平衡,以便在某些情况下降低内存消耗。

    以下是一些考虑因素:

    1. 性能考虑: 红黑树是一种自平衡的二叉搜索树,其查找性能通常比链表好。然而,红黑树的维护和遍历开销更大,因此不应该在链表长度很小的情况下继续使用红黑树。将阈值设置得太高可能会导致性能下降。

    2. 内存占用: 红黑树相对于链表来说更占内存。在某些情况下,例如在某个桶中只有少数几个键值对时,使用红黑树可能会浪费内存。因此,通过将阈值设置得较低,可以减少内存消耗。

    3. 退化防止: 红黑树是为了防止链表过长而引入的数据结构,但如果链表很短,使用红黑树可能不划算。因此,有必要设置一个相对较低的阈值,以便在链表较短时将其退化为链表。

    解决hash冲突的办法?HashMap用的是?

    1. 链地址法(Chaining):这是HashMap最常用的方法。每个哈希桶存储一个链表或者红黑树(在JDK 8中),用于存储具有相同哈希码的元素。当发生哈希冲突时,新元素被添加到对应桶的链表或红黑树中。

    2. 开放地址法(Open Addressing):这种方法不使用链表,而是在哈希表内部寻找下一个可用的位置来存储冲突的元素。线性探测、二次探测和双重散列是开放地址法的常见变种。

    3. 再哈希法(Rehashing):如果哈希表的负载因子(已存储元素数量与桶的数量的比值)超过了某个阈值,可以重新调整桶的数量,通常是扩大容量,然后重新哈希并将元素移动到新的桶中,以减少冲突的可能性。

    4. Cuckoo Hashing:这是一种基于多个哈希函数的技术,它使用两个或多个哈希表来解决冲突。如果发生冲突,元素会被移动到其他哈希表中。

    5. 线性探测哈希表:这是一种开放地址法的变种,其中元素按顺序查找下一个可用位置。如果该位置已被占用,则继续查找下一个位置,以此类推,直到找到可用位置或达到表的末尾。

    6. 分离链接哈希表:这是链地址法的一种形式,每个桶中存储一个链表,但链表可以是动态的,当链表变得过长时,可以将链表拆分成更多的桶,以降低链表长度。

    在HashMap实现中,常用的是链地址法,即每个哈希桶存储一个链表或红黑树来解决冲突。

    哈希桶是数组的一个元素?

    是的,哈希桶通常是哈希表内部数组的一个元素。在散列表(哈希表)数据结构中,哈希桶是存储键值对的容器,数组的每个元素就是一个哈希桶。每个哈希桶可以包含一个或多个键值对,通常使用哈希函数将键映射到特定的哈希桶。

    具体来说,哈希表的基本结构如下:

    1. 哈希表内部有一个固定大小的数组,每个数组元素就是一个哈希桶。

    2. 哈希函数将键映射到特定的哈希桶,确定了键值对在哪个桶中存储。

    3. 如果多个键映射到同一个桶(即发生哈希冲突),通常会使用链表、红黑树等数据结构来解决冲突,将键值对存储在相应的数据结构中。

    4. 当进行查找、插入或删除操作时,哈希函数确定了在哪个桶中执行操作,然后在该桶中执行相应的操作。

    这种组织方式允许哈希表在不同的桶中存储键值对,以提高检索性能和分散存储数据,同时处理哈希冲突。哈希桶的数量通常是固定的,但在需要动态调整大小的情况下,可以进行再哈希来改变桶的数量。

    每次数组扩容就会再hash?

    是的,在哈希表(例如HashMap)进行数组扩容时,通常会执行重新哈希的操作。重新哈希是指在扩容之后,将所有已存储的键值对重新计算哈希并将其分布到新的更大的数组中的不同桶中。

    这是因为扩容后,原来的哈希桶数量会发生变化,如果不进行重新哈希,那么已存在的键值对将无法准确地映射到新的桶中,从而导致哈希表不再正常工作。重新哈希确保了在扩容后,每个键值对都能够正确地映射到新的桶中,保持了哈希表的正确性和性能。

    重新哈希的过程可以比较耗时,因为需要对所有键值对执行哈希计算并将它们移动到新的桶中,但这是确保哈希表保持有效性的必要操作。一般情况下,重新哈希会伴随着数组的扩容而发生,以允许哈希表继续有效地处理更多的键值对。

    为什么解决 hash 冲突不直接用红黑树?而优先链表,再红黑树?

    1. 内存占用: 红黑树相对于链表来说,占用更多的内存空间。红黑树需要额外的节点存储以维护树结构,而链表只需要存储键值对。在许多情况下,哈希表的桶中只包含少数几个键值对,使用红黑树可能会浪费大量内存。

    2. 性能: 红黑树在查找操作上通常比链表更快,但在插入和删除操作上开销更大。对于短链表(包含少量元素)来说,直接使用链表可能在插入和删除时性能更好。

    考虑到这两个因素,选择先使用链表,然后在链表长度达到一定阈值时再转换为红黑树,可以在大多数情况下提供较好的性能和内存占用平衡。

    加载因子?默认是多少?为什么?

    HashMap的默认加载因子是0.75。加载因子是一个用于衡量哈希表空间利用率的指标,它表示已存储的元素数量与哈希表容量的比率。加载因子的选择是为了在性能和内存占用之间取得平衡。

    1. 性能优化: 较低的加载因子会导致哈希表的容量较大,从而浪费内存。而较高的加载因子可能导致哈希表容易发生哈希冲突,性能下降。0.75是在性能和内存占用之间找到的一个平衡点,通常能够提供较好的性能。

    2. 哈希冲突减少: 较高的加载因子会使哈希表更容易发生哈希冲突,因为桶中存储的键值对数量较多。0.75的加载因子可以在一定程度上减少哈希冲突的发生,提高了哈希表的性能。

    3. 内存占用: 0.75的加载因子在一定程度上控制了内存占用,不会让哈希表的容量过大。这对于内存受限的应用程序来说是有益的。

    HashMap 中 key的桶索引怎么计算的?

    1. 计算哈希码 : 首先,对于给定的key,会调用key对象的hashCode()方法来计算一个哈希码。哈希码是一个整数,用于标识key在哈希表中的位置。

    2. 计算桶索引: 接下来,使用哈希码和哈希表的容量(桶的数量)来计算桶索引。通常,这个计算会涉及取模运算(对哈希码取模容量),以确定key应该存储在哪个桶中。

    3. 处理哈希冲突: 如果发生了哈希冲突(即多个key具有相同的哈希码并被计算到了同一个桶中),HashMap会使用链表或红黑树来存储这些键值对,确保它们能够正确地被存储和检索。

    总之,HashMap中的key的存储索引是通过计算key的哈希码,并使用哈希码与容量取模的方式来确定的。这个索引决定了key在哪个桶中存储,然后在桶中处理哈希冲突。索引的计算过程是为了尽可能均匀地分布键值对,以提高HashMap的性能。

    数组的长度为什么是 2 的幂次方?

    在哈希表的设计中,选择将数组的长度设定为2的幂次方是为了利用位运算来计算桶索引,提高性能并均匀分布键。

    1. 位运算效率高: 通过使用位运算来计算桶索引,例如 (hashCode(key) & (capacity - 1)),相对于使用取模运算,位运算的效率更高。这是因为计算机中的位运算通常比取模运算更快。

    2. 均匀分布: 当数组长度为2的幂次方时,通过与操作,可以确保哈希码的各个位对桶索引的贡献都是均匀的。这有助于键的均匀分布在哈希表中,减少了哈希冲突的可能性,提高了性能。

    3. 简化索引计算: 2的幂次方的长度使得索引计算非常简单,只需对哈希码的低位进行位与操作即可,不需要进行复杂的取模计算。这可以降低计算的复杂度,提高了性能。

    4. 可扩展性: 使用2的幂次方作为数组长度还有一个优点,即在发生扩容时,可以方便地将容量翻倍,因为只需将哈希码的某些位用于计算桶索引即可。这种扩容方式更加高效。

    如果不是呢?

    如果数组的长度不是2的幂次方,而是任意正整数,那么仍然可以设计一个哈希表,但会面临一些可能的情况:

    1. 性能问题: 当数组的长度不是2的幂次方时,通常需要使用取模运算来计算桶索引。取模运算相对于位运算来说计算速度较慢,可能会影响哈希表的性能。

    2. 均匀分布问题: 如果不使用2的幂次方长度,可能会更难确保键的均匀分布。这可能导致一些桶过度拥挤,而其他桶很少被使用,从而增加了哈希冲突的可能性。

    3. 扩容问题: 当哈希表需要扩容时,确定新数组的长度可能更为复杂,因为不再具有方便的翻倍特性。这可能需要更多的计算和操作。

    put 流程?

    1. 计算键的哈希码(hashCode): 首先,put方法会调用键对象的hashCode方法来计算键的哈希码。哈希码是一个整数,用于确定键在哈希表中的位置。

    2. 计算桶索引(Bucket Index): 使用键的哈希码和哈希表的容量来计算桶索引。通常,这个计算涉及取模运算或位运算,以确定键应该存储在哪个桶中。桶索引决定了键值对的存储位置。

    3. 处理哈希冲突: 如果有多个键的哈希码映射到同一个桶中(即发生了哈希冲突),则HashMap使用链表或红黑树来存储这些键值对。新的键值对将被插入到桶中的链表或红黑树中。

    4. 检查是否需要扩容: 在插入新键值对之后,HashMap会检查当前的元素数量是否超过了加载因子(默认为0.75)乘以容量。如果是的话,HashMap会进行扩容操作。

    5. 扩容操作: 如果需要扩容,HashMap会创建一个新的更大的数组,然后将已有的键值对重新分布到新的数组中。这个过程通常涉及重新计算哈希码和桶索引。

    6. 插入键值对: 最终,新的键值对将被插入到哈希表的适当位置,要么是原始桶中,要么是扩容后的新桶中。

    扩容方式?

    HashMap在扩容时会进行以下步骤:

    1. 创建新的数组: 首先,HashMap会创建一个新的更大的数组,其大小通常是当前数组大小的两倍。新数组的大小必须是2的幂次方,以便后续计算桶索引时可以使用位运算。

    2. 重新计算桶索引: 然后,HashMap会遍历当前的数组中的每个桶,将其中的键值对重新计算哈希码和桶索引,然后将它们存储到新的数组中的合适位置。

    3. 替换旧数组: 一旦所有的键值对都被重新分配到新数组中,HashMap会将旧的数组替换为新的数组,从而完成了扩容操作。

    这个扩容方式有以下优点:

    • 通过将键值对重新分配到新数组中,HashMap可以保持哈希表的性能在可接受的范围内,不至于变得过于拥挤。

    • 选择将数组大小翻倍可以确保在扩容后哈希表的负载因子(已存储元素数量与容量的比率)保持在一个较低的水平,从而减少哈希冲突的可能性。

    需要注意的是,在扩容期间,由于需要将所有键值对重新计算哈希码和桶索引,以及复制到新数组中,可能会带来一定的性能开销。因此,扩容不是频繁进行的操作,但它确保了HashMap能够在容量不足时继续高效地存储键值对。

    HashMap的key一般都是什么类型?

    1. 不可变对象(Immutable Objects): 最常见的是使用不可变对象作为HashMap的key。这包括字符串(String)、枚举(Enum)、包装类(如Integer、Double)、不可变集合(如Java 9中的List.of()Set.of()返回的集合)等。不可变对象的特性确保了key在哈希表中的稳定性,不会发生变化。

    2. 自定义对象: 可以创建自己的类,只要该类正确实现了hashCode()equals()方法,就可以作为HashMap的key。确保这两个方法正确实现以维护key的唯一性和等价性。

    3. 枚举类型: 枚举类型天然具有唯一性,因此可以作为HashMap的key。

    4. 数组: 数组也可以作为key,但需要注意数组的hashCode()方法返回的哈希码是基于数组的内存地址,因此相同内容的不同数组会具有不同的哈希码。在大多数情况下,不推荐使用数组作为key。

    需要注意的是,作为HashMap的key的对象必须具备以下特性:

    • 可哈希性(Hashable):对象必须正确实现hashCode()方法,以便生成哈希码。
    • 唯一性:不同的key必须产生不同的哈希码,以避免哈希冲突。
    • 等价性:如果两个key通过equals()方法相等(等价),它们的哈希码应该相等。

    通常情况下,使用不可变对象作为HashMap的key是一个良好的实践,因为它们满足了上述要求,并且在使用过程中不会发生意外的变化。自定义类作为key时需要特别注意确保hashCode()equals()的正确实现,以维护HashMap的正确性。

    是线程安全的吗?为什么?

    HashMap在多线程环境下不是线程安全的。它是非同步的数据结构,不提供内置的线程安全保护措施。这意味着如果多个线程同时访问和修改同一个HashMap实例,可能会导致不一致的状态和潜在的数据损坏。

    1. JDK 7 中的扩容问题: 在JDK 7中,当多个线程同时对HashMap进行扩容操作时,可能会导致扩容操作出现竞态条件,从而导致HashMap的内部数据结构损坏,进而导致死循环或不一致状态。这是因为在JDK 7中,HashMap的扩容操作不具备足够的并发性。

    2. 元素丢失问题: 当多个线程同时尝试使用put方法向HashMap中添加元素时,可能会发生竞态条件,导致某些元素被覆盖或丢失。这是因为put方法不会提供同步保护,多个线程可能同时尝试修改同一个桶,导致其中一个线程的修改被覆盖。

    3. get为null问题: 当一个线程使用put方法向HashMap添加元素,同时另一个线程使用get方法来获取元素时,如果两个操作没有足够的同步保护,可能会导致get方法返回null。这是因为get方法可能在put方法还没有完全完成之前访问了HashMap,从而看到了不一致的状态。

    为了解决这些问题,可以采取以下措施:

    • 在JDK 8及更高版本中,HashMap的实现在一定程度上改进了并发性能,但仍然不是绝对线程安全的。如果需要在多线程环境中使用HashMap,可以考虑使用ConcurrentHashMap,它是专门为多线程并发设计的,提供了更好的线程安全性和性能。

    • 如果使用传统的HashMap,在多线程环境中需要进行额外的同步操作,如使用synchronized块或其他同步机制来确保线程安全。

    • 在多线程环境中,确保对HashMap的putget操作之间进行适当的同步或使用并发集合类,以避免出现不一致状态和数据丢失的问题。

  • 相关阅读:
    ROS2知识:编译系统ament_cmake
    基于Apache-DButils以及Druid(德鲁伊)与数据库交互实现的一个项目:满汉楼
    nacos权限绕过漏洞(nacos认证绕过安全漏洞)
    JS案例:实现一个简易版axios
    技术分享 | MySQL Shell 运行 SQL 的两种内置方法概述
    IO多路复用实现TCP客户端与TCP并发服务器
    面试官问我,Redis分布式锁如何续期?
    RAMAN 中 OPTIMIZATION 优化选项的作用
    vue_Delete `␍`eslint(prettier/prettier)
    聊一聊EGO-Planner膨胀系数的大小对无人机避障飞行的影响
  • 原文地址:https://blog.csdn.net/qq_43116031/article/details/133442723