吊打面试官(十五)--Java语言中HashMap类一文全掌握
导读
在Java中,HashMap是一个非常重要的集合类,广泛应用于各种编程场景。以下将详细解析HashMap的使用场景、底层原理、容易出错的问题、常见面试题以及相关代码示例。祝大家面试必过,吊打面试官。
HashMap的使用场景
快速查找:当需要根据键快速检索值时,HashMap是非常合适的选择,例如缓存实现、字典数据结构等。
数据去重:可以利用HashMap的键唯一性来实现数据的去重操作。
HashMap的底层原理
1. 数据结构
数组 + 链表/红黑树:HashMap的底层是一个数组,每个数组元素称为桶(bucket)。每个桶可以存储一个链表的头节点或红黑树的根节点。当多个键的哈希值映射到同一个桶时,它们会以链表的形式存储。当链表长度超过一定阈值(默认为8)时,链表会转换为红黑树以提高查找效率。
Node类:HashMap内部使用一个静态内部类Node来表示键值对。每个Node对象包含四个字段:hash(键的哈希值)、key(键)、value(值)和next(指向下一个Node的引用)。
2. 哈希函数
哈希码计算:HashMap使用键的hashCode()方法来计算哈希码。为了减少哈希冲突,HashMap会对哈希码进行扰动函数处理,即将哈希码的高16位和低16位进行异或操作。
索引计算:通过哈希码和数组长度计算元素在数组中的索引位置。计算公式为:index = hash & (length - 1)。这个公式利用了位运算的特性,要求数组长度必须是2的幂。
3. 插入和查找
插入:当插入一个键值对时,HashMap首先计算键的哈希值并找到对应的桶。如果桶为空,直接将新节点插入桶中。如果桶不为空,遍历链表或红黑树,如果找到相同的键,则更新其值;否则,在链表末尾或红黑树中插入新节点。
查找:当查找一个键时,HashMap首先计算键的哈希值并找到对应的桶。然后遍历链表或红黑树,直到找到相同的键或遍历完所有节点。
4. 扩容
触发条件:当HashMap中的元素数量超过阈值(容量 * 负载因子)时,会触发扩容操作。默认的负载因子为0.75。
扩容过程:创建一个新的数组,其容量是原数组的两倍。然后将原数组中的元素重新哈希并迁移到新数组中。这个过程涉及到链表和红黑树的拆分和重新组织。
5. 线程安全
HashMap不是线程安全的。在多线程环境下,如果多个线程同时修改HashMap,可能会导致数据不一致或其他未定义行为。在这种情况下,可以使用ConcurrentHashMap或
Collections.synchronizedMap()方法来保证线程安全。
HashMap使用中容易出现的问题
空指针异常:HashMap允许使用null作为键和值,但在某些情况下,如果不注意处理null值,可能会导致空指针异常。
线程安全问题:HashMap不是线程安全的。在多线程环境下,如果多个线程同时修改HashMap,可能会导致数据不一致或其他未定义行为。
在这种情况下,可以使用ConcurrentHashMap或
Collections.synchronizedMap()方法来保证线程安全。
哈希冲突:如果哈希函数设计不佳或数据分布不均匀,可能导致大量哈希冲突,从而降低HashMap的性能。
HashMap常见的面试题
HashMap的性能如何?
在理想情况下,HashMap的插入、删除和查找操作的时间复杂度为O(1)。但在哈希冲突严重的情况下,性能可能会降低。
Java 8对HashMap进行了哪些重要的改进和优化?
1. 引入红黑树优化:
当链表长度超过一定阈值(默认为8)时,链表会转换为红黑树,以提高查找效率。红黑树是一种自平衡二叉搜索树,能够在O(log n)时间内完成查找、插入和删除操作,从而解决了链表过长导致的性能问题。
2. 改进的哈希函数:
Java 8对键的哈希值进行了重新的扰动计算,将高16位和低16位进行异或操作,以减少哈希冲突,提高桶的分布均匀性。
3. 尾插入法:
在处理哈希冲突时,Java 8采用尾插入法,将新元素添加到链表的尾部,而不是头部。这减轻了在重新调整HashMap大小时(例如在扩容操作中)发生死循环的风险。
4. 懒初始化:
Java 8的HashMap采用了懒初始化的策略,即只有在第一次插入操作发生时,数组才会被初始化。这减少了HashMap的创建开销,特别是在HashMap创建后并未立即使用的情况下。
5. 新的遍历方法:
Java 8在Map接口中引入了新的遍历方法,如forEach和replaceAll,允许使用Lambda表达式来提供更加直接和灵活的遍历和修改操作。
6. compute、computeIfAbsent、computeIfPresent等新增方法:
这些新方法允许开发者更方便地更新HashMap中的值,使用Lambda表达式可以简化代码,提高可读性。
7. 性能优化:
在resize过程中,Java 8改进了transferring方法,避免了重新计算每个元素在数组中的索引位置,从而提高了扩容操作的效率。
Java 7及之前的版本中,HashMap在扩容时可能会导致死循环的原因?
1. 多线程环境:
在多线程环境下,如果多个线程同时对HashMap进行插入、删除或扩容操作,可能会导致链表结构被破坏。
2. 扩容过程:
当HashMap需要扩容时,它会创建一个新的数组,并将原数组中的元素重新分配到新数组中。在这个过程中,元素会在链表中移动,形成一个临时的双向链表。
3. 链表成环:
在多线程环境下,如果两个线程同时进行扩容操作,可能会导致链表中的节点被重复移动,从而形成环状结构。当其他线程在这个环状链表上进行遍历时,就会陷入死循环。
为了避免死循环问题,可以采取哪些措施?
1. 使用线程安全的HashMap实现:
在多线程环境下,可以使用ConcurrentHashMap或
Collections.synchronizedMap()方法来保证线程安全。
2. 避免多线程同时修改HashMap:
尽量确保在同一时刻只有一个线程对HashMap进行修改操作。
3. 升级到Java 8或更高版本:
Java 8及更高版本的HashMap已经修复了扩容时的死循环问题,因此在多线程环境下使用这些版本的HashMap可以避免死循环问题。
Java 8中HashMap的扩容过程主要有哪些步骤?
1. 确定新容量和阈值:
当HashMap中的元素数量超过阈值(容量 * 负载因子)时,会触发扩容。新的容量通常是原容量的两倍,新的阈值也会相应地更新。
2. 创建新数组:
根据计算出的新容量,创建一个新的Node数组。
3. 转移元素:
遍历原数组中的每个桶(bucket),将桶中的元素转移到新数组中。这个过程包括以下几种情况: 单个元素:如果桶中只有一个元素,直接将其放入新数组的相应位置。
链表:如果桶中的元素是链表,需要将链表拆分为两个链表(低位链表和高位链表),分别放入新数组的相应位置。这个拆分过程是根据节点的hash值与旧容量进行与操作的结果来决定的。
红黑树:如果桶中的元素已经转换为红黑树,调用`split`方法将树拆分为两个子树,并根据拆分后的结果将节点放入新数组的相应位置。如果拆分后的子树节点数量小于等于UNTREEIFY_THRESHOLD(默认为6),则将子树转换回链表。
4. 更新引用:
将HashMap内部的数组引用更新为新数组,并更新阈值。
5. 帮助GC:
扩容完成后,旧数组不再被引用,可以被垃圾回收器回收。
扩容的代码展示:
```java
final Node<K,V>[] resize() {
// 获取当前HashMap的底层数组(旧数组)
Node<K,V>[] oldTab = table;
// 获取旧数组的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 获取旧数组的阈值
int oldThr = threshold;
// 初始化新数组的容量和新阈值
int newCap, newThr = 0;
// 根据旧数组的情况计算新数组的容量和新阈值
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
// 如果旧数组容量已达到最大值,将阈值设置为Integer.MAX_VALUE,不再扩容
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 如果旧数组容量未达到最大值且大于默认初始容量,将新容量设置为旧容量的两倍,新阈值设置为旧阈值的两倍
newThr = oldThr << 1;
}
else if (oldThr > 0) // 如果旧阈值大于0(表示初始容量设置在阈值中),将新容量设置为旧阈值
newCap = oldThr;
else { // 如果旧容量和旧阈值都为0,使用默认的初始容量和阈值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果新阈值为0,根据新容量和负载因子计算新阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 更新阈值和新数组的引用
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 如果旧数组不为空,遍历旧数组并迁移元素到新数组
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 如果桶中只有一个元素,直接将其放入新数组的相应位置
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果桶中的元素是红黑树,调用split方法拆分树并迁移节点
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 如果桶中的元素是链表,将链表拆分为两个链表并迁移节点
else {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 根据节点的hash值与旧容量进行与操作的结果,将节点分配到低位链表或高位链表
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 将低位链表和高位链表分别放入新数组的相应位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 返回新数组
return newTab;
}
```
扩容过程确保了HashMap在元素数量增加时能够保持高效的性能,同时通过链表和红黑树的转换,优化了不同情况下的查找效率。
HashMap的扩容策略?
1. 扩容触发条件:当HashMap中的元素数量超过阈值(即容量乘以负载因子)时,会触发扩容操作。默认的负载因子为0.75,初始容量为16,因此当元素数量达到12时,会触发第一次扩容。
2. 扩容过程:
创建新数组:扩容时,HashMap会创建一个新的数组,其容量是原数组的两倍。
重新计算哈希值:旧数组中的每个元素会重新计算哈希值,并根据新的容量重新分配到新数组中的对应位置。
数据迁移:在重新分配过程中,如果桶中是链表,会将链表分为两部分,分别放入新数组的对应位置。如果桶中是红黑树,会进行树的拆分和重新组织。
3. 优化措施: 尾插法:
在JDK 8中,HashMap采用尾插法来避免在扩容过程中形成死循环,确保链表的顺序不被破坏。
红黑树优化:当链表长度超过一定阈值(默认为8)时,链表会转换为红黑树,以提高查找效率。扩容时,红黑树也会被重新散列和分配。
HashMap的负载因子是什么,如何使用?
HashMap的负载因子(Load Factor)是一个浮点数,用于衡量HashMap在其容量(Capacity)范围内可以填充的程度。
负载因子的计算公式为:
```负载因子 = 元素数量 / 容量```
其中,元素数量表示HashMap中已存储的键值对的数量,容量表示HashMap的底层数组(Node数组)的大小。负载因子是一个重要的参数,因为它影响着HashMap的性能。
当负载因子较高时,HashMap中的元素分布得更密集,这可能导致更多的哈希冲突,从而降低查找、插入和删除操作的性能。
当负载因子较低时,元素分布得更稀疏,哈希冲突的概率降低,但这也意味着HashMap需要更大的底层数组来存储相同数量的元素,从而增加了内存消耗。在Java 8中,HashMap的默认负载因子为0.75。这是一个在性能和内存消耗之间的折衷值。如果你希望在内存消耗和性能之间进行权衡,可以在创建HashMap时自定义负载因子。但请注意,设置过高的负载因子可能导致性能下降,而设置过低的负载因子可能导致内存浪费。
创建自定义负载因子的HashMap的示例:
```java
import java.util.HashMap;
public class CustomLoadFactorExample {
public static void main(String[] args) {
float customLoadFactor = 0.5f;
HashMap<String, Integer> mapWithCustomLoadFactor = new HashMap<>(16, customLoadFactor);
}
}
```
在这个示例中,我们创建了一个具有自定义负载因子0.5的HashMap。这意味着当HashMap中的元素数量达到其容量的50%时,将触发扩容操作。
在HashMap的扩容过程中,容量超过了MAXIMUM_CAPACITY(即1 << 30,这是HashMap允许的最大容量),会怎么样?
1. 设置阈值为最大值:
如果新容量超过了MAXIMUM_CAPACITY,那么会将阈值(threshold)设置为Integer.MAX_VALUE。这意味着HashMap将不再进行扩容,因为已经达到了允许的最大容量。
2. 保持当前容量:
在这种情况下,新容量不会被设置为超过MAXIMUM_CAPACITY的值,而是保持当前的容量不变。也就是说,HashMap的容量不会超过MAXIMUM_CAPACITY。
3. 返回旧数组:
由于不再进行扩容,方法会直接返回当前的旧数组,而不创建新数组。这样,HashMap将继续使用现有的数组来存储元素。
以下是相关的代码片段:
```java
if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE;
return oldTab; // 返回旧数组,不再扩容
}
```
如何优化HashMap的性能
1. 合理设置初始容量:
在创建HashMap时,如果能够预估存储的元素数量,设置一个合适的初始容量可以减少扩容次数,提高性能。初始容量的选择应基于预期的元素数量和负载因子。
2. 调整负载因子:
负载因子决定了何时进行扩容。默认负载因子为0.75,可以根据具体应用场景调整。较低的负载因子会减少冲突,提高查找效率,但会占用更多内存;较高的负载因子则会减少内存消耗,但可能增加冲突的概率。
3. 确保hashCode均匀分布:
键的hashCode()方法生成的哈希值需均匀分布,减少哈希冲突。避免使用质量不高的哈希函数,防止大量键映射到相同的槽位上,造成性能瓶颈。
4. 避免频繁的扩容操作:
扩容是一个耗时的操作,可以通过预估HashMap需要存储的元素数量来设置合适的初始容量,从而减少扩容操作的频率。
5. 选择合适的HashMap变体:
根据具体需求选择合适的HashMap变体,如LinkedHashMap(保留插入顺序)、TreeMap(有序键值对)或ConcurrentHashMap(线程安全)。
结语
以上内容就是关于HashMap使用中所能想到的相关问题的内容,如有遗漏或错误,欢迎留言指正。