吊打面试官(十五)--Java语言中HashMap类一文全掌握

吊打面试官(十五)--Java语言中HashMap类一文全掌握

经验文章nimo972025-05-26 0:18:033A+A-

导读

在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使用中所能想到的相关问题的内容,如有遗漏或错误,欢迎留言指正。


点击这里复制本文地址 以上内容由nimo97整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!
qrcode

尼墨宝库 © All Rights Reserved.  蜀ICP备2024111239号-7