JAVA基础 - 初识HashMap


大纲:

  1. 基本概念和原理

    • 什么是 HashMap
    • HashMap 的基本工作原理是什么?
    • HashMapHashTableTreeMap 的区别是什么?
  2. 底层数据结构

    • HashMap 的底层数据结构是什么?
    • 为什么选择这种数据结构?
    • 如何处理哈希冲突?
  3. 哈希函数

    • HashMap 的哈希函数是如何工作的?
    • 为什么要使用 (h = key.hashCode()) ^ (h >>> 16) 这样的哈希函数?
    • 这种哈希函数的优点和缺点是什么?
  4. 寻址算法

    • HashMap 的寻址算法是如何工作的?
    • 为什么要使用 (n - 1) & hash 这样的寻址算法?
    • 这种寻址算法的优点和缺点是什么?
  5. 扩容机制

    • HashMap 的扩容机制是如何工作的?
    • 什么时候会触发扩容?
    • 扩容的过程中会发生什么?
  6. 线程安全性问题

    • HashMap 是线程安全的吗?
    • 如果不是,那么在多线程环境下如何使用 HashMap
    • ConcurrentHashMapCollections.synchronizedMap() 是如何解决 HashMap 线程安全问题的?
  7. 性能优化

    • 如何优化 HashMap 的性能?
    • 初始化大小、加载因子等参数的选择对性能有什么影响?
  8. 使用注意事项

    • 在使用 HashMap 时需要注意哪些问题?
    • 哪些场景下应该使用 HashMap,哪些场景下不应该使用 HashMap
  9. Java 8中的改进

    • 在Java 8中,HashMap 有哪些改进?
    • 这些改进对 HashMap 的性能有什么影响?

1. 基本概念和原理

1.1 什么是 HashMap

HashMap 是 Java 中的一种基本数据结构,它属于 Java 集合框架的一部分。它用于存储键值对,其中每个键都是唯一的。HashMap 的主要优点是它允许以常数时间复杂度进行插入、删除和定位操作,这是通过使用哈希表实现的。

1.2 HashMap 的基本工作原理是什么?

HashMap 的工作原理基于哈希表。当我们向 HashMap 添加一个元素时,HashMap 会使用哈希函数计算键的哈希码,这个哈希码决定了元素在哈希表中的位置。如果两个元素的键产生相同的哈希码,那么它们会被放在同一个桶中,这种情况称为哈希冲突。HashMap 通过链表和红黑树(Java 8之后)解决冲突。

1.3 HashMapHashTableTreeMap 的区别是什么?

HashMapHashTableTreeMap 都是 Java 中的映射数据结构,但它们有一些关键的区别。HashMap 允许键和值为 null,而 HashTable 不允许。HashMap 是非线程安全的,而 HashTable 是线程安全的。而 TreeMap 是基于红黑树的,它的键必须实现 Comparable 接口,元素的插入和查找时间复杂度为 $O(\log n)$,而 HashMapHashTable 的插入和查找时间复杂度通常为 $O(1)$。

2. 底层数据结构

2.1 HashMap 的底层数据结构是什么?

HashMap 的底层是由数组和链表(或红黑树)组成的哈希表。数组的每个元素都是一个链表或红黑树的头节点。当我们插入一个新的键值对时,HashMap 会计算键的哈希值,然后用这个哈希值决定键值对在数组中的位置。如果该位置已经有其他键值对(哈希冲突),那么新的键值对就会被添加到这个位置的链表或红黑树中。

2.2 为什么选择这种数据结构?

这种数据结构的选择主要基于以下两个原因:首先,数组的索引可以直接映射到哈希值,使得查找操作的时间复杂度为 $O(1)$。其次,使用链表或红黑树可以解决哈希冲突的问题。链表适合处理冲突较少的情况,而红黑树适合处理冲突较多的情况(即链表长度大于一定阈值时)。

2.3 如何处理哈希冲突?

当两个不同的键的哈希值相同时,会发生哈希冲突。HashMap 使用链地址法处理哈希冲突,即将哈希值相同的键值对链接在一起形成一个链表。在 Java 8 中,如果链表的长度超过一定阈值(默认为 8),那么链表就会被转换为红黑树,以提高搜索效率。

3. 哈希函数

3.1 HashMap 的哈希函数是如何工作的?

HashMap 的哈希函数主要是用于计算键的哈希值。在 HashMap 中,哈希函数的主要作用是将任意长度的输入(即键)通过哈希算法转换成固定长度的输出,即哈希值。这个哈希值用于确定键值对在数组中的位置。

3.2 为什么要使用 (h = key.hashCode()) ^ (h >>> 16) 这样的哈希函数?

这种哈希函数是为了进一步确保对象分布均匀,减少哈希冲突的概率。key.hashCode() 是通过调用键的 hashCode 方法获取哈希值,(h >>> 16) 是将哈希值右移16位。通过异或操作 ^,可以在不改变哈希值的情况下,使得高位和低位混合,从而达到更好的分布效果。

3.3 这种哈希函数的优点和缺点是什么?

优点是能够使得哈希值的分布更均匀,减少哈希冲突的概率,从而提高 HashMap 的性能。缺点是计算稍微复杂一些,可能会增加一些计算开销,但考虑到其带来的性能提升,这个开销通常是可以接受的。

4. 寻址算法

4.1 什么是 HashMap 的寻址算法?

当我们往 HashMap 中添加一个元素(键值对)时,首先需要确定这个元素应该存储在数组的哪个位置,这一决定由寻址算法来实现。具体来讲,这个算法会使用哈希函数处理过的哈希码(hash code)来决定键值对在数组的存储索引位置。

4.2 为什么要使用 (n - 1) & hash 这样的寻址算法?

HashMap 的数组长度总是2的幂次方,这样设计是为了提高寻址效率。算法 (n - 1) & hash 中,n 是数组的大小,hash 是键的哈希码。由于 n 是2的幂次方,n - 1 的二进制表示将为一系列的1。这时候,n - 1hash 进行 & 操作可以看做是对 hash 进行余数操作,这种方式快速地定位了元素所在的桶的位置,同时确保了分布的均匀性。如果简单地使用取模运算(即hash % n),会由于%操作本身较低的效率而降低寻址速度。

4.3 这种寻址算法的优点和缺点是什么?

优点

  • 高效:与常规的取模运算相比,位运算具有更高的效率。
  • 分布均匀:因为长度是2的幂次方,所以索引生成的结果经过位运算后更加分散。

缺点

  • 大小限制:数组的大小必须是2的幂次方,这会对内存设计造成一定的约束。
  • 负载变重时性能降低:随着数据量变大,冲突增多,性能可能变差一些。但通常情况下,合理的扩容和负载因子可以在一定程度上缓解这个问题。

5. 扩容机制

5.1 什么是 HashMap 的扩容?

当我们谈论 HashMap 的扩容机制时,我们指的是它增加内部存储结构的大小以适应更多元素的过程。正如人们需要更大的居住空间来放置更多的家具一样,HashMap 需要更多的空间来存储更多的键值对(键-值对)。

5.2 HashMap 的扩容机制是如何工作的?

HashMap 中,当我们将键值对加入集合中,如果存储的数量达到某个阈值(默认是桶的数量的 0.75,也就是加载因子的75%),HashMap 就会开始一个扩容的过程。在这个过程中,内部的存储桶数量会增加到原来的两倍,这样做是为了减少潜在的哈希冲突,以及维持集合的存取效率。

5.3 什么时候会触发扩容?

类似于一艘船超重可能会沉没,HashMap 如果超过负荷就需要扩容。扩容通常发生在插入操作之后检查到 HashMap中存储的元素数量超过了“容量 * 加载因子”所定义的阈值。加载因子默认值是0.75,这代表了当桶(bucket)有75%被占用时进行扩容,这可以保证空间和时间效率的平衡。

5.4 扩容的过程中会发生什么?

HashMap 扩容时,首先,内部的存储桶数组大小会增加到原来的两倍,并创建一个新的桶数组来替代旧的。然后 HashMap要把已有的数据迁移到这个新的数组中去, 每个已存在元素的位置也许会改变,因为它们的哈希可能会在新数组中映射到不同的位置。这是一个成本较高的操作,因为它涉及到重新计算每个元素的哈希并定位到新的桶中,入不敷出会显著降低性能,所以应当尽可能避免频繁扩容。

5.5 扩容为什么重要,以及它的影响?

扩容是维持 HashMap 性能的一个重要过程。适时的扩容可以防止哈希冲突的增加,冲突过多会导致数据的存取效率变差(从常数时间退化到线性时间)。不过,频繁的扩容也会带来性能下降,因为扩容是一个重量级的操作,所以合理设置初始容量和加载因子是非常重要的。

6. 线程安全性问题

6.1 HashMap 是线程安全的吗?

不,HashMap 不是线程安全的。这意味着如果多个线程同时尝试修改 HashMap 的结构,例如添加或删除键值对,而没有进行适当的外部同步,那么 HashMap 的一致性可能被破坏,导致不可预知的行为或数据丢失。

6.2 如果不是,那么在多线程环境下如何使用 HashMap

在需求允许的情况下,可以使用 Collections.synchronizedMap() 方法将 HashMap 包装起来创建一个同步的映射。这样可以保证多个线程操作这个集合时,对集合的操作是互斥的。例如,通过下面的代码段可以创建一个同步的 HashMap

Map<K,V> map = Collections.synchronizedMap(new HashMap<K,V>());

不过需要注意,虽然每个方法调用都被同步了,但是对集合的复合操作仍然需要外部同步管理来防止并发问题。

另一个选择是使用 ConcurrentHashMap 类,这是 java.util.concurrent 包提供的一种线程安全的HashMap实现。它通过分割数据结构以及利用细粒度的锁和同步器,比如 LockReadWrite 锁,在保持高并发性能的同时提供线程安全性。

6.3 ConcurrentHashMapCollections.synchronizedMap() 是如何解决 HashMap 线程安全问题的?

ConcurrentHashMapCollections.synchronizedMap() 都提供了对多线程环境下 HashMap 操作的同步控制,但他们的实现方式和设计目标不一样。

  • Collections.synchronizedMap(): 它提供了一种同步的map,保护方法调用,防止多个线程同时对 HashMap 进行修改引发不一致状态。虽然它确保线程安全,但在高并发场合下,性能可能会下降,因为每次只有一个线程能够访问map。
  • ConcurrentHashMap: 这是一个为高并发优化的线程安全HashMap实现。它使用了一个分段锁(Segmentation Lock)机制,这种机制允许多个线程并发地访问不同段的buckets,从而降低锁竞争,提高并发度。此外,访问和更新操作不需要锁住整个map,只需要部分对锁的控制,并且读操作过程中通常完全不需要锁,从而具有更高的并发性能。

7. 性能优化

7.1 如何优化 HashMap 的性能?

HashMap 进行性能优化涉及到为它提供合适的初始化参数和良好的使用实践。一个很重要的策略是合理预估存储元素数量,并相应地初始化 HashMap 的初始容量(initial capacity),这样可以减小扩容次数。还应注意选择合适的加载因子(load factor),对于执行多次插入操作的 HashMap,较低的加载因子可以减少哈希冲突,但同时会增加内存占用。

7.2 初始化大小、加载因子等参数的选择对性能有什么影响?

确定合适的初始化大小和加载因子可以极大影响 HashMap 的性能。HashMap 的默认加载因子 是 0.75,这是时间和空间成本的一种折中选择。较高的加载因子可能会减少内存使用,但同时增加了哈希冲突,从而减少性能,特别是在填充程度较高的情况下尤其明显。如果对内存使用没有特别严格的要求,可以适当降低加载因子来得到更好的性能。
同样,初始容量若预设不当,可能会在HashMap在后期的使用过程中需要进行多次成本较高的扩容操作。反之,如果初始容量设置过大,又可能会浪费内存资源。假如能够较为精确估计HashMap将要存储的元素数量,设置一个适当的初始容量可以减少自动扩容的次数,从而优化性能表现。所以,找到合适的容量和加载因子是实现HashMap性能优化的关键。

8. 使用注意事项

8.1. 键值对的唯一性与null值的处理

HashMap中,每一个键值对被视为一个单独的实体,这被称作“映射条目”。每个键(Key)是独一无二的,而值(Value)可以重复。HashMap允许你将null作为一个键或者值,但是要特别小心,因为过多地使用null可能会降低映射的可读性,并可能导致潜在的错误。

8.2. 注意hashCode()equals()的实现

正确覆写hashCode()equals()方法是使用HashMap的关键。这是因为HashMap使用键对象的hashCode()方法来决定将键值对存储在哪个“桶”中。如果两个键被视为相等(即equals()方法返回true),它们的哈希码也必须相同。不恰当的实现可能会导致哈希冲突,进而影响HashMap的性能。

8.3. 理解加载因子与初始容量的影响

加载因子是HashMap性能的一个重要参数,它默认为0.75,这是时间和空间成本之间的一种折衷。如果加载因子设置得太低,会导致表的增长频繁,增加插入操作的开销;如果设置得太高,又会增加查找成本,

8.4. 适合使用 HashMap 的场景

HashMap 适合在键的顺序不重要,且需要快速插入、删除和定位元素的场合。例如,在实现字典、数据库索引、缓存和设置项时,HashMap都是一个很好的选择,因为它能够提供常数时间的性能,即 O(1),对于这些应用来说,这是非常有效的。

8.5. 不适合使用 HashMap 的场景

当数据的顺序很重要时,比如有序列表,HashMap就不是一个好的选择,因为它不保证键的顺序。另外,如果需要保证线程安全,在多线程环境下直接使用HashMap可能会导致不一致的行为。在这种情况下,你可能需要考虑使用ConcurrentHashMap或者通过其他方式来同步对HashMap的访问。此外,如果键对象的hashCode()实现不良,可能会导致频繁的哈希冲突,这也会降低HashMap的性能,因此在这种情况下也应避免使用HashMap

9. Java 8中的改进

在Java 8中,HashMap 引入了许多重要的性能改进,其中包括引入节点(Node)的概念以取代旧的Entry对象,实现链表和红黑树之间的平稳转换,以及对扩容操作的优化。

9.1. 变更数据结构:链表和红黑树

为了解决性能瓶颈,Java 8 对 HashMap 进行了改进,在存储结构上,当链表的长度大于一定阈值(默认是 8 )时,链表将被转换成红黑树结构,以减少搜索时间。如果后续操作又降低了元素数量,使得节点少于阈值,红黑树会退化回链表。这种结构上的调整,明显提升了 HashMap 在负载较高时的性能表现,特别是当出现了大量哈希冲突时。

9.2. 优化的哈希方法

此外,Java 8 在计算元素的存储位置时引入了更加优化的哈希方法。了解到在Java 8之前,哈希桶的索引是直接使用 hashCode() 的高位数据,而在Java 8中对哈希码的高位执行了额外的扰动函数,以提高低碰撞率。

9.3. 扩容相关改进

Java 8对 HashMap 进行扩容的行为也进行了改良,这包括在扩容过程中重新分布元素的方式。旧版本的 HashMap 在每次扩容时需要重新计算所有元素的位置,而Java 8优化了这一过程,某些情况下这种优化能够有效减少重新计算的开销和实现更快的扩容。

9.4. 并发场景的考量

还要注意的是,虽然HashMap 在Java 8 中进行了这些优化,但仍然不是线程安全的。如果想要在并发场景下无忧使用,我们应该考虑使用 ConcurrentHashMap


文章作者: 阿坤
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 阿坤 !
  目录