Java面试八股文宝典:初识数据结构-数组的应用扩展之HashMap

前言

除了基本的数组,还有其他高级的数据结构,用于更复杂的数据存储和检索需求。其中,HashMap 是 Java 集合框架中的一部分,用于存储键值对(key-value pairs)。HashMap 允许我们通过键来快速查找和检索值,类似于字典或关联数组的概念。HashMap 在实际编程中广泛应用于各种场景,包括缓存、数据库索引、数据存储等。

HashMapHashTable

HashMapHashTable 都是键值对存储的数据结构,它们可以用于快速查找和检索数据。虽然它们在用法上很相似,但也存在一些重要的区别:

  • HashMap HashMap 是 Java 集合框架中的一部分,它允许空键(key)和空值(value),并且是非线程安全的。HashMap 使用了哈希表的数据结构,能够在常数时间内查找元素。

  • HashTable HashTable 也是键值对存储的数据结构,但它不允许空键和空值,而且是线程安全的。HashTable 使用了类似于 HashMap 的哈希表实现,但在多线程环境下性能更好。

示例代码 - 使用 HashMap

让我们看一下如何使用 HashMap 存储和检索数据:

// 创建一个 HashMap
HashMap<String, Integer> scores = new HashMap<>();

// 插入键值对
scores.put("Alice", 95);
scores.put("Bob", 88);
scores.put("Charlie", 92);

// 检索值
int aliceScore = scores.get("Alice"); // 获取 Alice 的成绩

HashMap底层代码解析

理解 HashMap 内部工作原理的关键部分是研究其核心方法,如 resizetreeifyBinputVal。以下是这些方法的简要源码示例和解释:

1. resize 方法

resize 方法用于对 HashMap 进行扩容。当键值对数量达到阈值时,调用 resize 方法进行扩容,通常将容量翻倍。

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table; // 保存旧的桶数组
    int oldCap = (oldTab == null) ? 0 : oldTab.length; // 旧容量
    int oldThr = threshold; // 旧的阈值
    int newCap, newThr = 0; // 新容量和新阈值

    if (oldCap > 0) { // 如果旧容量大于0
        if (oldCap >= MAXIMUM_CAPACITY) { // 如果旧容量已经达到最大值
            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 { // 如果没有指定容量和阈值,使用默认值
        newCap = DEFAULT_INITIAL_CAPACITY; // 默认初始容量
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 默认阈值
    }

    if (newThr == 0) { // 如果新阈值为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; // 如果只有一个节点,直接放入新桶位置
                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;
                        if ((e.hash & oldCap) == 0) { // 如果哈希码对旧容量求与为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; // 返回新的桶数组
}
  • resize 方法首先计算新的容量和阈值。
  • 如果旧表(oldTab)不为空,它将遍历旧表的桶,根据哈希值重新分配节点到新表(newTab)中。
  • 如果节点的数量超过一定阈值,它可能会将链表转化为红黑树,以提高性能。
2. treeifyBin 方法

treeifyBin 方法用于将链表转换为红黑树,以提高查找性能。该方法通常在链表长度超过8时被调用。

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize(); // 扩容
    else if ((e = tab[index = (n - 1) & hash]) != null) { // 获取当前位置的节点
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null); // 创建红黑树节点
            if (tl == null)
                hd = p; // 如果链表为空,将红黑树节点设置为头节点
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);

        if ((tab[index] = hd) != null) // 如果头节点不为空,将链表转化为红黑树
            hd.treeify(tab);
    }
}
  • treeifyBin 方法首先检查表的容量,如果小于 MIN_TREEIFY_CAPACITY,则会调用 resize 方法进行扩容。
  • 如果表的容量足够大,它将遍历桶中的链表,将每个节点替换为红黑树节点,并构建红黑树。
3. pubVal 方法

putVal 方法是 HashMap 中用于向哈希表中添加键值对的核心方法。

/**
 * Associates the specified value with the specified key in this map.
 * If the map previously contained a mapping for the key, the old
 * value is replaced.
 *
 * @param hash hash for key
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; // 声明桶数组
    Node<K,V> p; // 声明当前节点
    int n, i; // n为桶数组的长度,i为计算出的槽位索引

    // 如果桶数组为空或长度为0,需要初始化
    //判断tab是不是为空,如果为空,则将容量进行初始化,也就是说,初始换操作不是在new HashMap()的时候进行的,而是在第一次put的时候进行的
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    // 计算槽位索引:初始化操作以后,根据当前key的哈希值算出最终命中到哪个桶上去,并且这个桶上如果没有元素的话,则直接new一个新节点放进去
    if ((p = tab[i = (n - 1) & hash]) == null) // 如果当前槽位为空
        tab[i] = newNode(hash, key, value, null); // 直接创建新节点并放入槽位
    else {
        Node<K,V> e; K k;
        // 先判断一下这个桶里的第一个Node元素的key是不是和即将要存的key值相同,如果相同,则把当前桶里第一个Node元素赋值给e,这个else的最下边进行了判断,如果e!=null就执行把新value进行替换的操作
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p; // 如果找到相同键的节点,将e指向该节点
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            for (int binCount = 0; ; ++binCount) {
                // 如果遍历到链表尾部仍未找到相同键的节点,将新节点添加到链表尾部
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 如果链表长度达到阈值,将链表转化为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break; // 如果找到相同键的节点,退出循环
                p = e;
            }
        }
        /**
         * 只要e不为空,说明要插入的key已经存在了,覆盖旧的value值,然后返回原来oldValue
         * 因为只是替换了旧的value值,并没有插入新的元素,所以不需要下边的扩容判断,直接
         * return掉
         */
        if (e != null) { // 如果找到相同键的节点
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value; // 替换值
            afterNodeAccess(e);
            return oldValue; // 返回旧值
        }
    }
    ++modCount;
    /**
     * 判断容量是否已经到了需要扩充的阈值了,如果到了,则进行扩充
     * 如果上一步已经判断key是存在的,只是替换了value值,并没有插入新的元素,所以不需要判断
     * 扩容,不会走这一步的
     */
    if (++size > threshold)
        resize(); // 如果超过阈值,进行扩容
    afterNodeInsertion(evict);
    return null; // 返回null表示插入成功
}

putVal 方法用于将键值对放入HashMap中。此方法涵盖了查找、替换、插入和扩容等关键操作,是HashMap内部工作的核心部分。

注意事项

在使用 HashMap 时,有一些重要的注意事项和最佳实践,以确保正确性和性能。以下是一些关键的注意事项:

  1. 线程安全性HashMap 不是线程安全的数据结构,如果在多线程环境下使用,需要考虑采取适当的同步机制,或者使用线程安全的替代品,如 ConcurrentHashMap

  2. 键的不可变性HashMap 中的键应该是不可变的对象。如果键发生了变化,可能导致无法正常获取或删除值。

  3. 哈希冲突:哈希冲突是指不同的键映射到相同的哈希桶。为了处理冲突,HashMap 使用链表或红黑树。为了获得良好的性能,尽量避免大量哈希冲突,可以考虑使用良好的哈希函数或合适的数据分布。

  4. 哈希函数重写:如果自定义对象作为键,应该确保重写了 hashCode()equals() 方法,以确保正确的哈希和相等性比较。

  5. 初始化容量和负载因子:在创建 HashMap 时,可以指定初始容量和负载因子。根据预期的键值对数量,选择适当的初始容量可以提高性能。负载因子是用于触发扩容的阈值,通常选择合适的默认值即可。

  6. 遍历:在遍历 HashMap 时,尽量不要修改其结构(添加或删除键值对),否则可能会导致不确定的行为或异常。

  7. Null 键和 Null 值HashMap 允许键和值为 null。但要小心处理 null 键,以防止 NullPointerException

  8. 性能考虑HashMap 在查找操作上有很好的性能,但在插入和删除操作上可能会有较差的性能,特别是在存在大量哈希冲突时。在需要频繁插入和删除的场景中,可以考虑使用 LinkedHashMapConcurrentHashMap

  9. 扩容代价HashMap 在达到负载因子阈值时会自动进行扩容,这涉及到重新分配键值对到新的桶数组。频繁的扩容操作可能会影响性能,因此应根据应用的需求选择适当的初始容量和负载因子。

  10. equals 和 hashCode 方法:如果自定义对象作为键,确保正确实现了 equalshashCode 方法,以便正确地比较和查找键。

总的来说,HashMap 是一个非常有用的数据结构,但在使用时需要谨慎考虑上述注意事项,以确保其正确性和性能。根据应用的需求,还可以考虑使用其他实现了特定场景需求的 Map 接口的实现类,如 ConcurrentHashMapLinkedHashMap 等。

JDK1.7和1.8对比

HashMap 在 JDK 1.7 和 JDK 1.8 中都有存在,但在 JDK 1.8 中进行了一些重要的改进。以下是 JDK 1.7 和 JDK 1.8 中 HashMap 的主要区别:

  1. 数据结构

    • JDK 1.7:JDK 1.7 中的 HashMap 使用数组 + 链表的数据结构。具体说,它使用数组存储桶(buckets),每个桶存储一个链表。这意味着当多个键映射到同一个桶时,它们会在同一个链表上存储。

    • JDK 1.8:JDK 1.8 中的 HashMap 在链表长度达到一定阈值(8)时,将链表转化为红黑树。这一改进在处理大量键值对时提高了查找性能,因为红黑树的查找时间复杂度为 O(log n)。
      在这里插入图片描述

  2. 哈希冲突解决

    • JDK 1.7:JDK 1.7 使用链表来解决哈希冲突。当多个键映射到同一个桶时,它们会形成一个链表,需要遍历链表来查找。

    • JDK 1.8:JDK 1.8 在链表长度达到一定阈值时,会将链表转化为红黑树,这大大提高了处理长链表的性能。

  3. 并发性能

    • JDK 1.7:JDK 1.7 中的 HashMap 不是线程安全的,如果多个线程同时操作一个 HashMap,可能会导致数据不一致或死锁等问题。为了在多线程环境下使用 HashMap,需要自行添加同步机制。

    • JDK 1.8:JDK 1.8 中的 HashMap 在处理并发操作时进行了优化。它引入了更高效的锁机制,例如分段锁和 CAS 操作,以提高并发性能。此外,JDK 1.8 还引入了 ConcurrentHashMap 类,专门用于高并发环境。

  4. 迭代性能

    • JDK 1.7:JDK 1.7 中的 HashMap 在迭代时性能较差,因为即使没有哈希冲突,它也需要遍历整个桶数组,包括空桶。

    • JDK 1.8:JDK 1.8 中的 HashMap 在迭代时性能得到了提升,特别是在没有哈希冲突的情况下。这是由于它使用了更好的数据结构和算法来加速迭代。

  5. 空间利用

    • JDK 1.7:JDK 1.7 中的 HashMap 对空间的利用不是很高,因为桶的数量必须是 2 的幂次方,可能会导致浪费空间。

    • JDK 1.8:JDK 1.8 中的 HashMap 在一定程度上改进了空间利用,通过采用树结构来存储哈希冲突的键值对,减少了空间浪费。
      在这里插入图片描述

总的来说,JDK 1.8 中的 HashMap 在性能和并发性能上有重大改进,特别是在处理大量数据和高并发访问时表现更优越。因此,如果使用 Java 8 或更高版本,通常建议使用 JDK 1.8 中的 HashMap 实现。但要注意,如果需要在多线程环境中使用 HashMap,最好考虑使用 ConcurrentHashMap 或其他线程安全的数据结构。

结语:

本文已经提供了有关 HashMap 的深入信息,包括其底层代码和注意事项。下一章将继续探讨 HashTable,以便能够全面了解这两个重要的数据结构。

如果您有任何疑问、建议或更正,请随时都可以留言或私信作者,我们将非常乐意为您提供帮助并改进文章。期待在下一章中继续分享有价值的知识。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值