HashMap常用方法全解析:从底层结构到实战避坑指南

发布时间:2026/10/13 1:21:11
HashMap常用方法全解析:从底层结构到实战避坑指南 HashMap 这玩意儿只要是写过 Java 的人几乎天天都在用。但用了这么多年我发现一个挺有意思的现象很多人对它的认知停留在put和get两个方法上稍微复杂一点的场景就开始翻文档、搜博客甚至干脆绕道用别的集合。其实 HashMap 本身提供的方法相当丰富很多看似需要手写循环才能完成的操作它自带的 API 一行就能搞定而且性能还更好。这篇文章我打算把 HashMap 的常用方法从头到尾捋一遍不只是列个清单而是把每个方法的适用场景、底层逻辑、容易踩的坑都讲清楚。内容会覆盖创建与初始化、增删改查、遍历、批量操作、视图方法、默认方法以及一些实战中总结出来的技巧。不管你是刚接触 Java 集合的新手还是写了几年代码但想系统梳理一遍的老手应该都能从中找到一些之前没注意到的细节。1. 先搞清楚 HashMap 的底层结构1.1 数组加链表加红黑树的三层结构HashMap 的底层结构在 JDK 8 之后有了比较大的变化。简单来说它是一个数组数组的每个位置叫一个桶bucket桶里放的是链表或者红黑树。当你往 HashMap 里放一个键值对的时候它会先计算 key 的哈希值然后通过(n - 1) hash这个运算确定这个键值对应该放在哪个桶里。如果两个不同的 key 算出了相同的桶位置就发生了哈希冲突这时候它们会被放在同一个桶里形成链表。JDK 8 引入了一个重要的优化当同一个桶里的链表长度超过 8 且数组总容量不小于 64 时链表会转换成红黑树。红黑树的查找时间复杂度是 O(log n)而链表是 O(n)。这个优化在哈希冲突比较严重的情况下能显著提升性能。反过来当红黑树的节点数降到 6 以下时又会退化回链表。这个 8 和 6 的阈值设计是经过大量测试得出的经验值兼顾了转换成本和查询效率。理解这个结构对用好 HashMap 的方法很关键。比如为什么containsKey和containsValue的性能差异那么大因为containsKey只需要计算哈希值定位到桶然后在链表或红黑树里找 key 就行了而containsValue需要遍历整个 HashMap 的所有值时间复杂度是 O(n)。知道了底层结构这些方法的性能特征就一目了然了。1.2 哈希函数与扩容机制HashMap 的哈希函数并不是直接使用 key 的hashCode()返回值而是做了一次扰动运算(h key.hashCode()) ^ (h 16)。这个操作把哈希值的高 16 位和低 16 位做异或目的是让高位也参与到桶位置的计算中。因为数组容量通常不会太大(n - 1) hash实际上只用了哈希值的低位如果不做扰动高位的信息就浪费了会增加冲突的概率。扩容机制也是必须了解的。HashMap 默认的初始容量是 16负载因子是 0.75。当元素数量超过容量 × 负载因子时就会触发扩容容量翻倍。扩容时所有元素需要重新计算桶位置这个过程叫 rehash。JDK 8 对 rehash 做了优化因为容量是 2 的幂次扩容后元素要么留在原来的位置要么移动到原位置 原容量的位置不需要重新计算哈希值只需要看哈希值在新增的那一位上是 0 还是 1。如果你能预估 HashMap 要存多少元素最好在创建时就指定初始容量。计算公式是预期元素数量 / 0.75 1。比如你要存 100 个元素初始容量设为 134 左右比较合适这样可以避免多次扩容带来的性能损耗。2. 创建与初始化从构造方法说起2.1 四个构造方法的选型逻辑HashMap 提供了四个构造方法每个都有明确的适用场景。无参构造方法HashMap()使用默认的初始容量 16 和负载因子 0.75适合大多数不确定数据量的场景。指定初始容量的HashMap(int initialCapacity)适合能预估数据量的场景但要注意它内部会调用tableSizeFor方法把容量调整为最接近的 2 的幂次。比如你传 17实际容量会变成 32。指定初始容量和负载因子的HashMap(int initialCapacity, float loadFactor)用得比较少一般只有在特殊需求下才会调整负载因子。负载因子调高比如 0.9可以节省内存但增加冲突概率调低比如 0.5则相反。用 Map 构造的HashMap(Map? extends K, ? extends V m)会创建一个包含指定 Map 所有元素的 HashMap初始容量会根据传入 Map 的大小自动计算保证不会触发扩容。// 无参构造默认容量16负载因子0.75 MapString, Integer map1 new HashMap(); // 指定初始容量实际容量会被调整为2的幂次 MapString, Integer map2 new HashMap(100); // 指定初始容量和负载因子 MapString, Integer map3 new HashMap(100, 0.8f); // 用另一个Map初始化 MapString, Integer map4 new HashMap(existingMap);2.2 创建时指定容量的计算细节很多人知道要指定初始容量但具体指定多少却算不对。这里有个常见的误区以为传进去的数字就是实际容量。实际上tableSizeFor方法会返回大于等于传入值的最小的 2 的幂次。所以如果你传 100实际容量是 128而不是 100。更重要的是扩容阈值是容量 × 负载因子128 × 0.75 96也就是说存到第 97 个元素时就会扩容。正确的计算方式是如果你要存 N 个元素初始容量应该设为N / 0.75 1然后再向上取到 2 的幂次。比如存 100 个元素100 / 0.75 1 134.3向上取 2 的幂次是 256。但这样又有点浪费因为 128 的阈值是 96存 100 个元素会扩容一次到 256。所以更精确的做法是直接传 134tableSizeFor(134)返回 256一步到位。或者你可以传 128但要知道它会扩容一次。实际项目中如果对内存不是极度敏感我一般建议直接传预期元素数量让 HashMap 自己调整。比如预计存 100 个就传 100实际容量 128阈值 96存到 97 个时扩容到 256。这次扩容的成本其实可以接受而且代码更直观。3. 增删改查核心方法逐个拆解3.1 put 与 putIfAbsent 的区别与选择put方法是最常用的它的逻辑是如果 key 已存在覆盖旧值并返回旧值如果 key 不存在插入新值并返回 null。这里有个容易忽略的点返回 null 并不一定意味着 key 不存在因为 value 本身可能就是 null。所以如果你需要判断 key 是否存在应该用containsKey而不是看put的返回值。putIfAbsent是 JDK 8 引入的默认方法它的逻辑是如果 key 不存在或者对应的值为 null才插入新值否则不覆盖返回当前值。这个方法在缓存场景下特别有用。比如你要往 map 里放一个默认配置但不想覆盖已经存在的配置就可以用putIfAbsent。MapString, String config new HashMap(); config.put(timeout, 3000); // 用put会覆盖已有值 config.put(timeout, 5000); // timeout变成5000 // 用putIfAbsent不会覆盖 config.putIfAbsent(timeout, 5000); // timeout还是3000 config.putIfAbsent(retry, 3); // retry被插入值为3两者的性能差异也值得注意。put只需要一次哈希查找而putIfAbsent在 key 存在时需要先查找再判断虽然底层实现做了优化但理论上还是比put多一点点开销。所以在确定要覆盖的场景下直接用put就好。3.2 get 与 getOrDefault 的配合使用get方法返回 key 对应的值如果 key 不存在则返回 null。这个方法本身很简单但和getOrDefault配合使用能解决很多实际问题。getOrDefault在 key 不存在时返回一个默认值而不是 null。这在统计计数、累加求和等场景下非常方便。MapString, Integer counter new HashMap(); // 传统写法需要判断null Integer count counter.get(apple); if (count null) { count 0; } counter.put(apple, count 1); // 用getOrDefault简化 counter.put(apple, counter.getOrDefault(apple, 0) 1);不过getOrDefault有个小陷阱如果 key 存在但值为 null它返回的也是默认值而不是 null。因为底层实现是(v get(key)) ! null || containsKey(key) ? v : defaultValue当值为 null 时containsKey为 true但v是 null最终返回的还是 null。等等这里我需要再确认一下。实际上 JDK 8 的getOrDefault实现是V v; return (((v get(key)) ! null) || containsKey(key)) ? v : defaultValue;。如果 key 存在且值为 nullget(key)返回 null但containsKey(key)为 true所以整个条件为 true返回v也就是 null。所以getOrDefault在值为 null 时返回 null而不是默认值。这个细节在面试中经常被问到。3.3 remove 的两种重载与使用场景remove(Object key)删除指定 key 的键值对返回被删除的值。如果 key 不存在返回 null。remove(Object key, Object value)是 JDK 8 引入的只有当 key 对应的值等于指定 value 时才删除返回 boolean 表示是否删除成功。这个方法在并发场景下特别有用可以防止误删。MapString, String session new HashMap(); session.put(user1, tokenA); // 删除key为user1的条目 String removed session.remove(user1); // 返回tokenA // 条件删除只有值匹配时才删除 session.put(user2, tokenB); boolean deleted session.remove(user2, tokenC); // false值不匹配 deleted session.remove(user2, tokenB); // true删除成功remove(key, value)的底层实现是先找到 key 对应的节点然后比较值是否相等用Objects.equals相等才删除。这个比较是值比较不是引用比较所以对于 String 这类重写了 equals 的类只要内容相同就能匹配。3.4 replace 与 replaceAll 的批量替换replace(K key, V value)只有当 key 存在时才替换值返回旧值。replace(K key, V oldValue, V newValue)是三个参数的版本只有当前值等于 oldValue 时才替换。replaceAll则是对所有键值对执行一个函数批量替换值。MapString, Integer scores new HashMap(); scores.put(math, 80); scores.put(english, 90); // 替换math的分数 scores.replace(math, 85); // 返回80 // 条件替换只有当前值是80时才替换 scores.replace(math, 80, 88); // false因为当前已经是85 // 批量替换所有分数加5 scores.replaceAll((k, v) - v 5);replaceAll在需要对所有值做统一变换时非常高效底层是遍历所有节点并应用函数。但要注意如果函数中有副作用或者依赖外部状态可能会产生意想不到的结果。另外replaceAll过程中如果修改了 map 的结构比如增删元素会抛出ConcurrentModificationException。4. 遍历与视图keySet、values 和 entrySet4.1 三种视图方法的性能对比HashMap 提供了三个视图方法keySet()返回所有 key 的 Setvalues()返回所有 value 的 CollectionentrySet()返回所有键值对的 Set。这三个方法返回的都是视图不是拷贝对视图的修改会反映到原 HashMap 上反之亦然。遍历性能上entrySet()是最优的因为它在一次遍历中同时获取 key 和 value。如果用keySet()遍历再通过get(key)获取值相当于每次都要做一次哈希查找时间复杂度从 O(n) 变成 O(n) 次哈希查找。数据量大的时候这个差异非常明显。// 推荐entrySet遍历一次获取key和value for (Map.EntryString, Integer entry : map.entrySet()) { String key entry.getKey(); Integer value entry.getValue(); // 处理逻辑 } // 不推荐keySet遍历再get多一次哈希查找 for (String key : map.keySet()) { Integer value map.get(key); // 额外的哈希查找 // 处理逻辑 } // 只需要key时用keySet for (String key : map.keySet()) { // 只处理key } // 只需要value时用values for (Integer value : map.values()) { // 只处理value }4.2 遍历时删除元素的正确姿势遍历 HashMap 时删除元素是一个经典陷阱。如果用 for-each 循环直接调用map.remove(key)会抛出ConcurrentModificationException。原因是 for-each 底层用的是 Iterator而 HashMap 的 Iterator 是 fail-fast 的一旦检测到结构修改就会抛异常。正确的做法有两种一是用 Iterator 的remove方法二是用 JDK 8 的removeIf方法。Iterator 的remove方法会同步更新 modCount所以不会触发异常。removeIf则是 Collection 接口的默认方法底层也是用 Iterator 实现的但代码更简洁。// 错误写法会抛ConcurrentModificationException for (Map.EntryString, Integer entry : map.entrySet()) { if (entry.getValue() 60) { map.remove(entry.getKey()); // 抛异常 } } // 正确写法一用Iterator IteratorMap.EntryString, Integer it map.entrySet().iterator(); while (it.hasNext()) { Map.EntryString, Integer entry it.next(); if (entry.getValue() 60) { it.remove(); // 安全删除 } } // 正确写法二用removeIf map.entrySet().removeIf(entry - entry.getValue() 60);removeIf虽然简洁但要注意它是在 entrySet 视图上操作的删除的也是原 map 中的元素。另外removeIf的谓词中不要修改 map 的结构否则同样会出问题。4.3 entrySet 的 Map.Entry 对象复用问题这里要提一个很多人不知道的细节HashMap 的entrySet()返回的 Set 中每个Map.Entry对象在遍历过程中是复用的。也就是说Iterator 的next()方法返回的是同一个 Entry 对象只是每次更新了其中的 key 和 value 引用。这个设计是为了减少对象创建提升性能。但这个设计有个坑如果你在遍历过程中把 Entry 对象保存到一个集合里最后会发现集合里所有元素都是同一个对象值也是最后一次遍历的值。正确的做法是手动创建新的AbstractMap.SimpleEntry或者只保存 key 和 value。// 错误保存的Entry都是同一个对象 ListMap.EntryString, Integer entries new ArrayList(); for (Map.EntryString, Integer entry : map.entrySet()) { entries.add(entry); // 所有元素指向同一个Entry } // 正确创建新的Entry对象 ListMap.EntryString, Integer entries new ArrayList(); for (Map.EntryString, Integer entry : map.entrySet()) { entries.add(new AbstractMap.SimpleEntry(entry.getKey(), entry.getValue())); }5. 批量操作与默认方法JDK 8 带来的便利5.1 forEach 的遍历与注意事项forEach是 JDK 8 引入的默认方法接受一个BiConsumer函数式接口。它的代码非常简洁但有几个需要注意的地方。首先forEach中不能修改 map 的结构否则会抛ConcurrentModificationException。其次forEach的遍历顺序是不确定的因为 HashMap 不保证顺序。如果需要有序遍历应该用LinkedHashMap或者先对 key 排序。map.forEach((key, value) - { System.out.println(key value); }); // 如果需要有序遍历先排序 map.entrySet().stream() .sorted(Map.Entry.comparingByKey()) .forEach(entry - System.out.println(entry.getKey() entry.getValue()));forEach和 for-each 循环的性能差异不大但forEach的代码更简洁适合简单的遍历操作。如果遍历逻辑比较复杂或者需要 break/continue还是用传统的 for-each 循环更合适。5.2 compute 系列方法的实战应用compute、computeIfAbsent、computeIfPresent是 JDK 8 引入的三个强大的方法它们在处理复杂的更新逻辑时非常有用。compute根据 key 和当前值计算新值如果新值为 null 则删除该键值对。computeIfAbsent只在 key 不存在或值为 null 时计算新值。computeIfPresent只在 key 存在且值不为 null 时计算新值。MapString, ListString groups new HashMap(); // 传统写法需要判断key是否存在 ListString list groups.get(fruits); if (list null) { list new ArrayList(); groups.put(fruits, list); } list.add(apple); // 用computeIfAbsent简化 groups.computeIfAbsent(fruits, k - new ArrayList()).add(apple); // compute根据当前值计算新值 MapString, Integer counter new HashMap(); counter.put(apple, 1); counter.compute(apple, (k, v) - v null ? 1 : v 1); // apple变成2 // computeIfPresent只在key存在时更新 counter.computeIfPresent(apple, (k, v) - v 10); // apple变成12 counter.computeIfPresent(banana, (k, v) - v 10); // 不执行banana不存在computeIfAbsent在构建多值映射比如MapString, ListString时特别有用可以避免大量的 null 判断。但要注意computeIfAbsent的计算函数中不要修改 map 的结构否则会抛异常。另外如果计算函数返回 nullcomputeIfAbsent不会插入任何值。5.3 merge 方法的妙用merge方法可能是 HashMap 中最被低估的方法之一。它的签名是merge(K key, V value, BiFunction? super V, ? super V, ? extends V remappingFunction)。逻辑是如果 key 不存在直接插入 value如果 key 存在用 remappingFunction 计算新值如果新值为 null 则删除该键值对。// 统计单词出现次数 MapString, Integer wordCount new HashMap(); String[] words {apple, banana, apple, cherry, banana, apple}; for (String word : words) { wordCount.merge(word, 1, Integer::sum); } // 结果{apple3, banana2, cherry1} // 合并两个map的值 MapString, Integer map1 new HashMap(); map1.put(a, 1); map1.put(b, 2); MapString, Integer map2 new HashMap(); map2.put(b, 3); map2.put(c, 4); map2.forEach((k, v) - map1.merge(k, v, Integer::sum)); // map1变成{a1, b5, c4}merge在统计、累加、合并等场景下非常高效一行代码就能替代好几行的判断逻辑。但要注意remappingFunction 返回 null 时会删除该键值对这个行为有时候会出乎意料。6. 实战中的经验与避坑指南6.1 初始容量与负载因子的调优经验在实际项目中HashMap 的性能问题往往出在扩容上。我见过一个案例一个服务在启动后几分钟内频繁 Full GC排查后发现是一个缓存用的 HashMap 没有指定初始容量默认 16 的容量在数据量增长到几千时经历了多次扩容每次扩容都要重新哈希所有元素造成了大量的临时对象和 CPU 消耗。后来改成在创建时指定初始容量问题就解决了。具体的计算方式是如果预计存 5000 个元素初始容量设为5000 / 0.75 1 6667向上取 2 的幂次是 8192。这样在整个生命周期内都不会触发扩容。当然如果内存紧张也可以适当调高负载因子比如 0.85这样 8192 的容量可以存 6963 个元素但冲突概率会增加需要权衡。负载因子的调整要谨慎。0.75 是经过大量测试的平衡点调高会增加冲突和查找时间调低会浪费内存。除非有明确的性能测试数据支撑否则不建议随意调整。6.2 可变对象作为 key 的陷阱HashMap 的 key 最好是不可变对象比如 String、Integer 这些。如果用可变对象作为 key并且修改了对象的属性会导致哈希值变化从而再也找不到这个键值对了。因为 HashMap 是根据哈希值来定位桶的哈希值变了查找时就会去错误的桶里找。// 错误示例用可变对象作为key class MutableKey { private String value; // getter和setter Override public int hashCode() { return value.hashCode(); } Override public boolean equals(Object o) { // 基于value的比较 } } MapMutableKey, String map new HashMap(); MutableKey key new MutableKey(a); map.put(key, value1); key.setValue(b); // 修改了key的属性 map.get(key); // 返回null因为哈希值变了这个坑在面试中经常被问到实际开发中也时有发生。解决方案是使用不可变对象作为 key或者在修改 key 之前先从 map 中移除修改后再重新放入。6.3 线程安全问题与替代方案HashMap 不是线程安全的。在多线程环境下如果多个线程同时修改 HashMap可能会导致数据丢失、死循环JDK 7 的扩容问题或者ConcurrentModificationException。JDK 8 虽然修复了死循环的问题但数据丢失的风险依然存在。如果需要在多线程环境下使用 Map有几个选择Collections.synchronizedMap包装一个 HashMap性能一般ConcurrentHashMap是专门为并发设计的性能最好Hashtable是遗留类不推荐使用。选择哪个取决于具体的并发场景和性能要求。// 方案一Collections.synchronizedMap MapString, Integer syncMap Collections.synchronizedMap(new HashMap()); // 方案二ConcurrentHashMap推荐 MapString, Integer concurrentMap new ConcurrentHashMap(); // 方案三Hashtable不推荐 MapString, Integer hashtable new Hashtable();ConcurrentHashMap在 JDK 8 中采用了 CAS synchronized 的实现锁的粒度更细并发性能远好于Collections.synchronizedMap和Hashtable。如果并发量不大Collections.synchronizedMap也够用但要注意遍历时需要手动同步。6.4 常见问题速查表问题现象可能原因解决方案ConcurrentModificationException遍历时修改了 map 结构用 Iterator.remove 或 removeIfget返回 null 但 key 存在value 本身为 null用 containsKey 判断修改 key 后找不到值key 的哈希值变了用不可变对象作为 key性能突然下降频繁扩容或哈希冲突严重指定初始容量检查 key 的 hashCode多线程下数据丢失HashMap 非线程安全用 ConcurrentHashMapcomputeIfAbsent抛异常计算函数中修改了 map不要在计算函数中修改 map 结构6.5 一些容易被忽略的细节HashMap的size()方法返回的是键值对的数量不是容量。isEmpty()判断是否为空等价于size() 0但性能更好因为不需要计算具体数量。clear()清空所有键值对但不会缩容容量保持不变。containsKey和containsValue的性能差异很大前者是 O(1) 平均时间复杂度后者是 O(n)。如果频繁需要判断值是否存在考虑用两个 map 或者换一种数据结构。keySet()、values()、entrySet()返回的视图支持删除操作删除视图中的元素会同步删除原 map 中的对应元素。但视图不支持添加操作调用add会抛UnsupportedOperationException。MapString, Integer map new HashMap(); map.put(a, 1); map.put(b, 2); // 视图删除会同步到原map map.keySet().remove(a); // map中a被删除 // 视图不支持添加 map.keySet().add(c); // 抛UnsupportedOperationExceptionHashMap允许一个 null key 和多个 null value。null key 的哈希值被规定为 0所以它总是放在第一个桶里。这个特性有时候会被利用比如用 null 作为默认配置的 key但更多时候建议避免使用 null key因为可读性差容易出问题。7. 从方法到思维HashMap 的使用哲学写了这么多方法最后想聊点方法论层面的东西。HashMap 的方法设计其实体现了一种分层的思想最基础的是put、get、remove满足最基本的增删改查往上一层是putIfAbsent、getOrDefault、replace处理条件逻辑再往上是compute、merge、forEach支持函数式编程和批量操作。每一层都在前一层的抽象之上解决更复杂的场景。实际开发中我观察到很多人的问题不是不会用某个方法而是不知道该用哪个方法。比如统计计数有人用getput有人用getOrDefault有人用merge都能实现但代码的简洁度和性能有差异。我的建议是先保证正确性再追求简洁性最后考虑性能。大多数场景下merge和computeIfAbsent是更好的选择因为它们把检查-更新这个复合操作封装成了一个原子操作代码更短逻辑更清晰。另外不要为了用而用。有些场景用传统写法反而更直观比如简单的put和get没必要硬套compute。工具是为人服务的代码的可读性永远比炫技重要。我见过有人用merge写了一行非常复杂的 lambda结果三个月后自己都看不懂了这就得不偿失了。最后分享一个我个人的习惯每次用 HashMap 的时候都会问自己三个问题——数据量大概多少需不需要指定初始容量有没有并发风险这三个问题想清楚了大部分坑都能提前避开。至于具体用哪个方法用多了自然就形成肌肉记忆了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询