HashMap 是 Java 集合框架中最常用的映射表实现,几乎所有 Java 开发者都用过它存储键值对。但你是否想过:为什么它的查询效率那么高?为什么允许 null 键值?为什么多线程下会出问题?

本文将从 HashMap 的核心特性出发,结合 JDK 8 源码解析其实现原理,最后探讨实际使用场景与避坑指南,建立对 HashMap 的全局认知。

一、HashMap 核心特性

HashMap 本质是基于哈希表的键值对存储结构,其核心特性可概括为:

  1. 键值对存储:以 key-value 形式存储数据,通过 key 快速查询 value
  2. 哈希表结构:底层由「数组 + 链表 + 红黑树」组成,平衡查询与插入效率。
  3. 允许 null 键值:支持 keyvaluenullkey 仅允许一个 null)。
  4. 无序性:存储顺序与插入顺序无关,遍历结果不保证有序。
  5. 线程不安全:多线程并发修改可能导致数据错乱或死循环。
  6. 动态扩容:当元素数量达到阈值时,自动扩容为原来的 2 倍。
  7. 高效性:理想情况下,putgetremove 操作的时间复杂度为 O(1)

二、源码解析:特性如何通过代码实现?

HashMap 的源码围绕「哈希表的高效操作」设计,逐个特性拆解其实现逻辑。

特性 1:键值对存储——Node 节点与 put 方法

HashMap 的核心功能是存储 key-value 键值对,这一特性通过内部的 Node 类和 put 方法实现。

源码证明:
  1. 存储单元:Node
    HashMapNode 类封装键值对,每个 Node 包含哈希值、键、值、下一个节点引用(用于链表):

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;    // key 的哈希值(预计算,提高效率)
        final K key;       // 键(不可变,确保哈希值稳定)
        V value;           // 值
        Node<K,V> next;    // 下一个节点(链表结构)
    
        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
    
        // getKey()、getValue()、setValue() 等方法省略
    }
    
  2. 核心方法:put(K key, V value)
    put 方法负责将键值对存入哈希表,流程可概括为:「计算哈希值 → 确定存储位置 → 处理哈希冲突 → 插入节点」:

    public V put(K key, V value) {
        // 计算 key 的哈希值,调用 putVal 实际处理
        return putVal(hash(key), key, value, false, true);
    }
    
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 1. 若哈希表(table 数组)未初始化,先初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 2. 计算索引 i = (n - 1) & hash,若该位置为空,直接插入新 Node
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            // 3. 该位置已存在节点(哈希冲突),处理冲突
            Node<K,V> e; K k;
            // 3.1 若 key 相同(哈希值+equals),直接覆盖 value
            if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 3.2 若当前节点是红黑树节点,调用树的插入方法
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 3.3 否则是链表,遍历链表
            else {
                for (int binCount = 0; ; ++binCount) {
                    // 3.3.1 找到链表尾部,插入新节点
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 若链表长度超过 8,尝试转为红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // TREEIFY_THRESHOLD = 8
                            treeifyBin(tab, hash);
                        break;
                    }
                    // 3.3.2 若链表中存在相同 key,跳出循环准备覆盖
                    if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // 4. 若存在相同 key,覆盖 value 并返回旧值
            if (e != null) {
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e); // 空实现,供 LinkedHashMap 重写
                return oldValue;
            }
        }
        ++modCount; // 修改次数(用于快速失败机制)
        // 5. 若元素数量超过阈值,触发扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict); // 空实现,供 LinkedHashMap 重写
        return null;
    }
    

结论:通过 Node 封装键值对,put 方法处理哈希计算、冲突解决和插入逻辑,实现了键值对的存储功能。

特性 2:哈希表结构——数组 + 链表 + 红黑树

HashMap 底层不是单纯的数组或链表,而是数组(哈希桶)+ 链表 + 红黑树的混合结构,这种设计是为了平衡「哈希冲突」带来的性能问题。

源码证明:
  1. 数组(哈希桶)
    哈希表的主体是 transient Node<K,V>[] table;(数组),称为「哈希桶」。数组的每个元素是链表或红黑树的头节点,索引通过 key 的哈希值计算:

    // 计算索引:哈希值与数组长度-1 取模(等价于 hash % n,但效率更高)
    int index = (n - 1) & hash;
    
  2. 链表:解决哈希冲突
    当不同 key 计算出相同索引时(哈希冲突),HashMap 会将新节点挂在已有节点的后面(俗称拉链),形成链表。源码中通过 Nodenext 字段实现:

    // 链表插入新节点(putVal 方法片段)
    p.next = newNode(hash, key, value, null);
    
  3. 红黑树:优化长链表查询
    当链表长度超过阈值(TREEIFY_THRESHOLD = 8),且数组长度 >= 64 时,链表会转为红黑树(TreeNode),将查询时间复杂度从 O(n) 优化为 O(log n)

    // 树化方法
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        // 若数组长度 < 64,先扩容而非树化
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) // MIN_TREEIFY_CAPACITY = 64
            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); // 红黑树构建逻辑
        }
    }
    

    反之,当红黑树节点数量减少到 UNTREEIFY_THRESHOLD = 6 时,会转回链表,避免树结构的维护成本。

在 HashMap 中,链表转红黑树的阈值设为 8,红黑树转链表的阈值设为 6,这个设计并非随意选择,而是基于哈希冲突的概率分布、数据结构的性能平衡和避免频繁转换的抖动问题综合考量的结果。

  • 一、为什么链表转红黑树的阈值是 8?
    这个阈值的设定与哈希表中链表长度的概率分布密切相关。
    HashMap 的底层哈希表通过哈希函数将 key 映射到数组索引,理想情况下,key 的分布是均匀的,链表长度应该很短。但实际中,由于哈希函数的局限性或 key 本身的特性,可能出现哈希冲突,导致部分链表过长。
    Java 开发者在设计时参考了泊松分布(一种描述随机事件发生概率的分布模型):在理想的哈希函数下,链表中节点数量的概率分布近似泊松分布。根据计算,当负载因子为默认的 0.75 时:
    链表长度为 8 的概率约为 0.0000001(千万分之一);
    更长的链表(如 9、10)出现的概率更低。
    这意味着:在正常情况下,链表长度几乎不可能达到 8。一旦达到 8,说明该链表的哈希冲突异常频繁(可能是哈希函数设计不佳,或 key 的哈希值分布极不均匀),此时将链表转为红黑树(查询时间复杂度从 O(n) 优化为 O(log n))才有实际意义。
    简言之,8 是一个 “小概率事件阈值”—— 只有在极特殊情况下才会触发树化,避免不必要的性能开销(红黑树的维护成本比链表高)。
  • 二、为什么红黑树转链表的阈值是 6?
    这个阈值的核心目的是避免频繁的 “树化 - 反树化” 抖动。
    如果红黑树转链表的阈值也设为 8(与树化阈值相同),会出现一个问题:当红黑树的节点数量在 8 附近波动时(例如 8→7→8→7),会频繁触发 “红黑树→链表→红黑树→链表” 的转换。这种频繁转换会带来大量不必要的性能开销(红黑树的插入、删除需要旋转平衡,链表转树 / 树转链表也需要遍历节点)。
    将反树化阈值设为 6,可以在树化阈值(8)和反树化阈值(6)之间形成一个缓冲区间(6~8):
    当红黑树节点数量减少到 6 及以下时,才会转回链表(此时节点数量少,链表的性能更优);
    若节点数量在 6~8 之间波动(如 7→8→7),则不会触发转换,避免抖动。

结论:数组提供快速定位能力,链表处理少量哈希冲突,红黑树优化大量冲突场景,三者结合让 HashMap 在各种情况下都有较高效率。

特性 3:允许 null 键值——对 null 的特殊处理

HashMap 允许 keyvaluenull,其中 key 仅允许一个 null(重复插入会覆盖),这是通过对 null 键的哈希值特殊处理实现的。

源码证明:
  1. null 键的哈希值
    hash(key) 方法专门处理 null 键,返回哈希值 0:
    static final int hash(Object key) {
        int h;
        // 若 key 为 null,哈希值为 0;否则计算 key.hashCode() 的二次哈希
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    

h >>> 16 配合 ^ 运算,本质是用低成本的位操作提升哈希值的随机性:
让高位信息参与索引计算,避免因低位重复导致的冲突;
仅通过一次右移和一次异或,几乎不增加性能开销;
在数组容量较小时(日常开发的常见场景),效果尤为明显。
这是一种 “用最小代价换取更均匀哈希分布” 的设计,体现了 HashMap 源码对性能的极致优化。

由于 n 通常较小(默认 16,扩容后也是 32、64 等 2 的幂),n-1 的二进制是低位全为 1,高位全为 0。例如 n=16 时,n-1=15(二进制 0000 0000 0000 0000 0000 0000 0000 1111)。
此时,(n-1) & hash 实际上只用到了哈希值的低几位(如 n=16 时只用低 4 位)。如果直接使用 key.hashCode() 作为哈希值,会导致:
哈希值的高位信息被完全忽略;
若不同 key 的低几位相同(但高位不同),会计算出相同的索引,导致哈希冲突。
而 h ^ (h >>> 16) 通过混合高低位,让高位信息影响低位,从而让索引计算更均匀,减少冲突。

  1. null 键的存储位置
    由于哈希值为 0,null 键会被存储在数组索引 0 的位置:

    // 索引计算为 (n-1) & 0 = 0
    int index = (n - 1) & hash; // hash 为 0 时,index = 0
    
  2. null 键的唯一性
    当插入第二个 null 键时,会触发 key 相等的判断(key == null && key == null),从而覆盖原有值:

    // putVal 方法中判断 key 相等的逻辑
    if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
        e = p; // 若已存在相同 key(包括 null),则覆盖 value
    

结论:通过将 null 键的哈希值固定为 0,HashMap 实现了对 null 键的支持,且保证 null 键的唯一性。

特性 4:无序性——存储位置由哈希值决定

HashMap 的元素存储顺序与插入顺序无关,遍历结果是「哈希桶顺序 + 链表/树顺序」,这是因为存储位置由 key 的哈希值决定,而非插入时间。

源码证明:
  1. 无维护顺序的字段
    HashMap 中没有记录插入顺序的变量(如链表的前后指针),对比 LinkedHashMapEntry 类:

    // LinkedHashMap 的 Entry 类(维护插入顺序)
    static class Entry<K,V> extends HashMap.Node<K,V> {
        Entry<K,V> before, after; // 记录前后节点,维护顺序
        Entry(int hash, K key, V value, Node<K,V> next) {
            super(hash, key, value, next);
        }
    }
    

    HashMapNode 类只有 next 字段(用于解决冲突),没有顺序相关的指针。

  2. 遍历顺序由哈希桶决定
    HashMap 的遍历(如 keySet()values()entrySet())是按数组索引顺序遍历哈希桶,再遍历每个桶中的链表/红黑树,与插入顺序无关:

    // keySet 迭代器的核心逻辑(简化)
    public final Iterator<K> iterator() {
        return new KeyIterator();
    }
    final class KeyIterator extends HashIterator<K> {
        public final K next() { return nextNode().key; }
    }
    abstract class HashIterator<E> implements Iterator<E> {
        Node<K,V> next;        // 下一个节点
        Node<K,V> current;     // 当前节点
        int expectedModCount;  // 用于快速失败
        int index;             // 当前数组索引
    
        HashIterator() {
            expectedModCount = modCount;
            Node<K,V>[] t = table;
            current = next = null;
            index = 0;
            // 从数组索引 0 开始,找到第一个非空桶
            if (t != null && size > 0) {
                do {} while (index < t.length && (next = t[index++]) == null);
            }
        }
    
        final Node<K,V> nextNode() {
            Node<K,V>[] t;
            Node<K,V> e = next;
            // ... 省略快速失败检查 ...
            if ((next = (current = e).next) == null && (t = table) != null) {
                // 当前桶遍历完,继续下一个桶
                do {} while (index < t.length && (next = t[index++]) == null);
            }
            return e;
        }
    }
    

结论:由于 HashMap 不维护插入顺序,且存储位置由哈希值决定,因此遍历结果是无序的。

特性 5:线程不安全——缺乏同步机制

HashMap 是非线程安全的,多线程并发修改(如同时 putresize)可能导致数据错乱、死循环等问题,根源是其方法没有同步保护。

源码证明:
  1. 无同步修饰
    putgetresize 等核心方法均未使用 synchronized 或其他同步机制,多个线程可同时修改 table 数组或 size 等变量:

    // put 方法没有同步修饰
    public V put(K key, V value) { ... }
    
    // size 是普通变量,非 volatile
    transient int size;
    
  2. 扩容时的线程安全问题
    最经典的线程安全问题出现在 resize 方法(扩容)中。JDK 7 及之前的 transfer 方法在多线程扩容时,可能导致链表形成环(死循环);JDK 8 虽然修复了环的问题,但仍可能出现数据丢失:

    // JDK 8 resize 方法中迁移节点的逻辑(简化)
    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) {
            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;
    }
    

    多线程同时迁移节点时,可能导致节点被覆盖或丢失。

  3. 快速失败机制(fail-fast)
    HashMap 通过 modCount 变量检测并发修改:当迭代时发现 modCount 与预期值不一致,会抛出 ConcurrentModificationException,这也从侧面证明其不支持并发修改:

    // 迭代器中的快速失败检查
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    

modCount 是实现 “快速失败”(fail-fast)机制的核心变量,用于记录集合结构性修改的次数(如 put、remove、clear 等改变元素数量的操作)。
当使用迭代器(如 entrySet().iterator())遍历 HashMap 时,迭代器会先记录当前的 modCount 值(称为 expectedModCount)。遍历过程中,每次访问下一个元素前,迭代器都会检查 modCount 是否与 expectedModCount 相等。
若不等,说明遍历期间集合被结构性修改(可能是其他线程修改,或当前线程在迭代中直接修改集合),此时迭代器会立即抛出 ConcurrentModificationException,终止遍历。

这种机制的目的是及时暴露并发修改问题,避免迭代器继续操作已被篡改的集合,防止数据不一致或不可预期的行为。但它并非线程安全的解决方案,仅能检测修改并快速失败,无法保证遍历的原子性。

需注意:迭代器自身的 remove 方法会同步更新 expectedModCount,不会触发异常;而直接调用集合的 remove 则会导致失败。
结论HashMap 未提供同步机制,多线程并发修改会导致数据问题,因此仅适用于单线程环境。

特性 6:动态扩容——resize 方法的实现

HashMap 会在元素数量超过阈值(threshold = 容量 * 负载因子)时自动扩容,新容量为旧容量的 2 倍,以保证哈希表的稀疏性,减少冲突。

源码证明:
  1. 扩容触发条件
    putVal 方法中,当元素数量 size 超过 threshold 时,调用 resize() 扩容:

    if (++size > threshold)
        resize();
    
  2. 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;
    
        // 1. 计算新容量和新阈值
        if (oldCap > 0) {
            // 若旧容量已达最大(1<<30),则阈值设为 Integer.MAX_VALUE,不再扩容
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 新容量 = 旧容量 * 2(左移 1 位)
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // 阈值也翻倍
        }
        else if (oldThr > 0) // 初始化时指定了阈值(构造方法传入 initialCapacity)
            newCap = oldThr;
        else { // 无参构造,使用默认值(容量 16,阈值 16*0.75=12)
            newCap = DEFAULT_INITIAL_CAPACITY; // DEFAULT_INITIAL_CAPACITY = 16
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // DEFAULT_LOAD_FACTOR = 0.75
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY) ?
                    (int)ft : Integer.MAX_VALUE;
        }
        threshold = newThr;
    
        // 2. 创建新数组,并迁移旧节点
        @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; // 帮助 GC
                    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;
                            // 哈希值与旧容量按位与,结果为 0 则留在原索引,否则迁移到新索引(j + oldCap)
                            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;
    }
    

    关键优化:链表迁移时通过 (e.hash & oldCap) == 0 判断节点新位置,避免重新计算哈希,效率更高。

结论:通过 resize 方法,HashMap 实现了动态扩容,保证哈希表始终有足够空间,维持高效的查询和插入性能。

tableSizeFor 方法的目的及位运算解析

方法核心目的
tableSizeForHashMap 中的一个核心工具方法,其作用是计算大于或等于指定整数 cap 的最小 2 的幂值

HashMap 中,哈希表的容量(底层数组长度)必须是 2 的幂(如 16、32、64 等),这是为了通过 (n-1) & hash 高效计算数组索引(等价于取模运算但性能更优)。当用户指定初始容量(如 new HashMap(10))时,tableSizeFor 会将其调整为最接近的 2 的幂(如 16)。

核心代码与位运算解析
JDK 8 中 tableSizeFor 的实现如下:

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

关键位运算步骤解析(以 cap = 10 为例):

  1. 初始调整:n = cap - 1
    将输入值减 1(10-1=9),避免 cap 本身已是 2 的幂时多扩容一次(如 cap=16 时,减 1 后后续操作不会改变值)。

  2. 位运算填充高位:
    通过一系列无符号右移(>>>)和按位或(|)操作,将 n 从最高位的 1 开始,填充其后所有低位为 1:

    • n = 9(二进制 1001
    • n |= n >>> 1:右移 1 位得 0100,或运算后 1001 | 0100 = 1101(13)
    • n |= n >>> 2:右移 2 位得 0011,或运算后 1101 | 0011 = 1111(15)
    • 后续 >>>4>>>8>>>16 操作因高位已全为 1,结果不变。
  3. 最终调整:n + 1
    经过填充后,n 是“比目标值小 1 的最大全 1 二进制数”,加 1 后得到最小的 2 的幂(15 + 1 = 16)。

位运算的意义

  1. 高效计算 2 的幂:通过位运算替代循环判断,时间复杂度为 O(1)(固定 5 次移位操作),性能远高于遍历。
  2. 覆盖所有整数范围:连续右移 1、2、4、8、16 位,可确保 32 位整数中从最高位 1 到最低位的所有位都被填充为 1,适用于任何 int 类型的输入。
  3. 边界处理:最终判断若结果超过最大容量(MAXIMUM_CAPACITY = 1<<30),则返回最大值,避免溢出。

tableSizeFor 通过巧妙的位运算,快速将任意整数转换为最接近的 2 的幂,为 HashMap 提供了高效的容量初始化机制,是哈希表实现中“性能优化”与“数学特性”结合的经典案例。

三、大局观:HashMap 的设计哲学

HashMap 的设计体现了「时间与空间的平衡艺术」:

  • 哈希表的选择:数组提供 O(1) 的随机访问能力,但需要连续内存;链表/红黑树解决哈希冲突,平衡插入和查询效率。
  • 动态扩容机制:通过阈值(容量 * 负载因子)触发扩容,负载因子 0.75 是对「空间利用率」和「冲突概率」的折中(负载因子越低,冲突越少但空间浪费越多)。
  • 树化与反树化:当链表过长时转为红黑树(提高查询效率),过短时转回链表(降低维护成本),兼顾不同数据量场景。

这种设计让 HashMap 在大多数场景下都能提供接近 O(1) 的操作效率,成为 Java 中最常用的映射表实现。

四、典型使用场景

HashMap 适合以下场景:

  1. 快速键值查询
    如缓存系统(通过 key 快速获取缓存值)、配置表(通过名称快速获取配置项)等。例如:

    // 缓存用户信息
    Map<String, User> userCache = new HashMap<>();
    userCache.put("uid1001", new User("张三", 20));
    User user = userCache.get("uid1001"); // 快速查询
    
  2. 临时数据存储
    如处理请求时存储中间结果(无需持久化,用完即弃),例如:

    // 处理订单时存储临时计算结果
    Map<String, BigDecimal> orderStats = new HashMap<>();
    orderStats.put("totalAmount", calculateTotal());
    orderStats.put("discount", calculateDiscount());
    
  3. 去重或分组
    利用 key 的唯一性,实现快速去重或分组,例如:

    // 对列表去重
    List<String> list = Arrays.asList("a", "b", "a", "c");
    Map<String, Boolean> uniqueMap = new HashMap<>();
    for (String s : list) {
        uniqueMap.put(s, true);
    }
    List<String> uniqueList = new ArrayList<>(uniqueMap.keySet()); // ["a", "b", "c"]
    
  4. 替代多参数传递
    当方法需要传递多个参数时,可用 HashMap 封装,简化方法签名,例如:

    // 用 Map 传递查询参数
    Map<String, Object> params = new HashMap<>();
    params.put("pageNum", 1);
    params.put("pageSize", 10);
    params.put("keyword", "java");
    List<Article> articles = articleDao.query(params);
    

五、注意事项与避坑指南

HashMap 虽易用,但使用不当可能导致性能问题或逻辑错误,需注意以下几点:

1. 线程安全问题:避免多线程并发修改

HashMap 线程不安全,多线程环境下应使用:

  • ConcurrentHashMap:专为并发设计,效率高于同步的 HashMap
  • Collections.synchronizedMap(new HashMap<>()):通过同步包装器实现线程安全,但性能较低。

错误示例

// 多线程同时 put 可能导致数据错乱
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < 10; i++) {
    new Thread(() -> {
        for (int j = 0; j < 1000; j++) {
            map.put(Thread.currentThread().getName() + j, j);
        }
    }).start();
}

正确示例

Map<String, Integer> map = new ConcurrentHashMap<>(); // 线程安全

2. 初始容量设置:避免频繁扩容

HashMap 扩容时需要迁移所有节点,成本较高。若已知数据量,应在初始化时指定合适的容量,减少扩容次数:

// 已知约有 1000 个元素,初始容量设为 1000 / 0.75 ≈ 1334(向上取整)
Map<String, Object> map = new HashMap<>(1334);

公式:初始容量 = 预估元素数 / 负载因子(默认负载因子 0.75)。

3. 键的 hashCodeequals 方法:必须正确重写

HashMap 依赖 keyhashCode() 计算存储位置,依赖 equals() 判断 key 是否相同。若未正确重写,会导致:

  • hashCode() 相同但 equals() 不同的 key 存储在同一位置(冲突);
  • equals() 相同但 hashCode() 不同的 key 被视为不同键(重复存储)。

正确重写示例

class User {
    private String id;
    private String name;

    // 重写 hashCode:结合关键字段计算
    @Override
    public int hashCode() {
        return Objects.hash(id); // 以 id 作为唯一标识
    }

    // 重写 equals:关键字段相等则视为相同
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id);
    }
}

4. 避免使用可变对象作为键

key 是可变对象(如自定义类、Date 等),修改 key 的字段会导致 hashCode() 变化,后续无法通过该 key 找到对应值:

class MutableKey {
    private int value;
    // 省略构造器、getter、setter、hashCode、equals
}

// 错误示例:修改 key 导致无法查询
Map<MutableKey, String> map = new HashMap<>();
MutableKey key = new MutableKey(1);
map.put(key, "value");
key.setValue(2); // 修改 key 的字段,hashCode 变化
System.out.println(map.get(key)); // 输出 null(无法找到)

解决方案:使用不可变对象作为 key(如 StringInteger 等)。

5. 遍历方式选择:根据需求选高效方式

HashMap 有多种遍历方式,性能和适用场景不同:

  • entrySet():同时获取 keyvalue,效率最高;
  • keySet() + get(key):需二次查询,效率较低;
  • values():仅需 value 时使用。

推荐方式

// 高效遍历键值对
for (Map.Entry<String, Object> entry : map.entrySet()) {
    String key = entry.getKey();
    Object value = entry.getValue();
    // 处理逻辑
}

六、总结

HashMap 是基于「数组 + 链表 + 红黑树」的哈希表实现,通过哈希值快速定位元素,平衡了查询与插入效率。其核心特性包括键值对存储、支持 null 键值、无序性、线程不安全、动态扩容等,这些特性都能在源码中找到明确实现。

在实际使用中,HashMap 适合单线程环境下的快速键值查询、临时数据存储等场景,但需注意线程安全、初始容量设置、键的方法重写等问题。理解 HashMap 的设计原理,不仅能帮助我们写出更高效的代码,更能体会到数据结构设计中「时间与空间平衡」的智慧。


Studying will never be ending.

▲如有纰漏,烦请指正~~

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐