HashMap 是 Java 中是一个常用的数据结构,它实现了 Map 接口,用于存储键值对(key-value pairs)。HashMap 的底层实现主要依赖于哈希表结构,结合了数组和链表(在Java 8及其之后的版本中,也引入了红黑树)来存储数据。
import java.util.HashMap;
public class CustomHashMapWithLoadFactorExample {
public static void main(String[] args) {
// 创建一个初始容量为 64,负载因子为 0.5 的 HashMap
HashMap map = new HashMap<>(64, 0.5f);
// 向 HashMap 中添加一些键值对
map.put(1, "Apple");
map.put(2, "Banana");
map.put(3, "Orange");
// 打印 HashMap 中的内容
System.out.println(map);
}
}
hashCode()
方法的结果与数组长度减一进行按位与操作 (n - 1) & hash
,这里 n 是数组的长度。通过这种组合方式,HashMap 实现了平均情况下常数级别的存取效率。然而,在最坏的情况下(例如,所有的键都哈希到了同一个桶),性能可能会退化至 O(n)。因此,良好的哈希函数对于维持 HashMap 的性能至关重要。
HashMap
中,哈希冲突是指不同的键通过哈希函数计算后得到了相同的哈希值,从而映射到了哈希表的同一个位置。HashMap
主要采用的方法。具体来说:
链表:当两个或多个键计算出相同的哈希值时,这些键值对会被存储在一个链表中。这个链表连接到哈希表对应的桶位上。这意味着每个桶位实际上是一个链表的头节点,可以链接任意数量的元素。查找、插入和删除操作的时间复杂度都是 O(n),这里的 n 是链表中元素的数量。
红黑树转换:为了优化性能,在 Java 8 及以后版本中,如果链表长度超过一定阈值(默认为8),链表将被转换为红黑树。这是因为随着链表的增长,线性搜索效率会变得越来越低。而红黑树是一种自平衡二叉查找树,能够在最坏情况下也保证 O(log n) 的时间复杂度。当红黑树的节点数降到某个阈值(默认为6)之下时,又会退化为链表。
HashMap
还通过优化哈希函数来尽量减少冲突的可能性。Java 中的 HashMap
并不直接使用对象的 hashCode()
方法返回的值作为最终的哈希值,而是对该值进行了进一步的处理,目的是使其分布更加均匀,从而降低哈希冲突的概率。虽然 HashMap
主要使用链地址法,但开放定址法也是一种常见的解决哈希冲突的方法,尽管它并未直接应用于 HashMap
的实现中。该方法试图找到另一个空闲的位置来存放发生冲突的元素,而不是形成链表。开放定址法有多种形式,包括线性探测、二次探测和双重散列等。
线性探测:当发生冲突时,检查哈希表中的下一个位置(即当前索引加一),直到找到一个空闲的位置为止。
二次探测:与线性探测类似,但它不是逐个检查下一个位置,而是按照某个二次函数来决定接下来要检查的位置。
双重散列:使用第二个哈希函数来生成步长,以确定在遇到冲突时应检查的下一个位置。
HashMap
中的 put(K key, V value)
方法用于将指定的键值对插入到映射中。以下是该方法的具体执行流程:
计算哈希值:首先,通过调用 key.hashCode()
方法来计算键的哈希值。为了减少不同数据分布下哈希碰撞的概率,还会对该哈希值进行进一步的扰动处理(即重新计算哈希),以得到最终的哈希值。
确定桶的位置:根据上述计算出的哈希值,使用 (n - 1) & hash
公式(其中 n 是 HashMap 的容量)来确定元素应该放置在哪个桶(bucket)中。这是因为 HashMap 的底层实现是一个数组,每个索引位置可以看作是一个桶。
检查是否已经存在相同的键:如果目标桶为空,则直接将新节点放入该桶;如果桶中已经有元素,则需要遍历链表(或红黑树)来查找是否存在具有相同键的节点。
扩容检查:在插入新节点之后,会检查 HashMap 是否需要扩容。具体来说,当元素的数量超过当前容量与负载因子的乘积时,HashMap 会自动扩容至两倍大小,并重新分配所有现有的键值对到新的桶中。这是为了保持较低的哈希冲突概率和较高的性能。
插入新节点:如果没有发生哈希冲突,或者在解决冲突后,新节点会被添加到链表的末尾或红黑树中。如果链表长度达到阈值(默认为8),并且当前容量大于等于最小树形化容量(默认为64),则链表会被转换成红黑树以提高查询效率。
返回结果:最后,put()
方法返回之前与给定键关联的值,如果没有之前的映射关系,则返回 null
。
通过这个过程,HashMap
能够高效地存储和检索键值对,同时也能很好地处理哈希冲突。需要注意的是,在多线程环境下直接使用 HashMap
可能导致数据不一致或其他并发问题,此时应考虑使用 ConcurrentHashMap
或者其他线程安全的 Map 实现。
查找数据的过程大致如下:
hashCode()
方法计算出该键的哈希值。hash & (length-1)
),这样可以确保结果落在数组的索引范围内。equals()
方法来比较每个节点的键是否等于目标键。// 示例代码:查找数据
HashMap map = new HashMap<>();
map.put("apple", 1);
Integer value = map.get("apple"); // 如果存在键 "apple",则返回对应的值;否则返回 null
删除数据的过程与查找类似,但除了找到目标键之外,还需要将其从链表或树结构中移除:
// 示例代码:删除数据
HashMap map = new HashMap<>();
map.put("apple", 1);
map.remove("apple"); // 移除键为 "apple" 的键值对
hashCode()
和 equals()
方法,以确保正确地处理相同逻辑相等的对象具有相同的哈希值并且能够互相比较为相等。HashMap
使用了红黑树来优化过长的链表,所以在实际应用中这种情况很少发生。Java中的HashMap
使用了一种称为“动态扩容”的机制来确保在插入新键值对时,哈希表的性能保持在一个较高的水平。下面是对HashMap
扩容机制的详细解释:
HashMap
在创建时内部数组(桶)的大小,默认是16。HashMap
中存储的键值对数量超过了当前容量乘以加载因子时,就会触发扩容操作。例如,如果初始容量是16,加载因子是0.75,则当HashMap
中有超过12个元素时(16 * 0.75 = 12),将会触发扩容。
当满足扩容条件时,HashMap
会执行以下步骤进行扩容:
创建新的数组:首先,它会根据当前容量创建一个新的更大的数组。默认情况下,新数组的容量是原数组的两倍(即2次幂)。
重新哈希:接着,将旧数组中的所有键值对重新分配到新的数组中。这是因为扩大后的数组长度发生了变化,键值对的位置(即它们所在的桶)也可能发生变化。这个过程被称为“rehashing”。
更新属性:最后,更新HashMap
的一些属性,如当前容量、阈值等,以便未来继续准确地判断是否需要再次扩容。
虽然没有直接展示扩容过程的代码(因为这是自动发生的),但可以通过下面的示例看到如何设置初始容量和加载因子,并观察其影响:
Map 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
的良好性能,但它也是一个相对耗资源的过程,因为它涉及到创建新数组和重新分配所有的键值对。因此,在预计数据量的情况下,合理设置初始容量和加载因子可以有效减少扩容次数,提升效率。
在Java 8及之后的版本中,HashMap
引入了红黑树来优化在大量哈希冲突情况下的性能。具体来说,当一个桶(bucket)中的链表节点数超过一定阈值(默认为8)时,该桶会从链表转换为红黑树,以提高查找、插入和删除操作的效率。这是因为红黑树的查找时间复杂度是O(log n),而链表则是O(n)。
以下是HashMap
中关于链表转红黑树的主要规则:
链表长度检查:每当向HashMap
中添加新的键值对时,如果发现某个桶中的链表长度超过了树化阈值(TREEIFY_THRESHOLD,默认为8),则考虑将该桶中的链表转换为红黑树。
容量检查:但是,在实际进行树化之前,HashMap
还会检查当前的容量是否达到了最小树形化容量(MIN_TREEIFY_CAPACITY,默认为64)。如果未达到这个容量,HashMap
更倾向于执行扩容操作而不是树化。这是为了避免过早地进行树化,因为随着容量的增加,通过扩容可以减少哈希冲突的概率。
树化过程:一旦满足上述两个条件,HashMap
就会调用treeifyBin()
方法将指定桶中的链表转换为红黑树结构。
虽然没有直接展示树化过程的代码(因为它是自动发生的),但可以通过下面的示例观察到这一行为:
Map 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),那么这些特定桶中的链表将会被转换为红黑树。
值得注意的是,这种转换是为了应对极端情况下的性能优化,并不是日常使用的常态。在大多数情况下,合理的初始容量和加载因子设置可以有效地避免频繁的树化操作。