HashMap是 Java 项目里最常见的数据结构之一。开发时一行put、一行get,看起来几乎不需要学习;但“明明放进去了却取不到”“遍历顺序忽然变化”“多线程下偶尔丢数据”等问题,恰恰来自对它的契约理解不足。本文不背源码常量,而是从业务键、空值、容量和并发四条线梳理它的正确用法。
1. Map 存储的是映射,不是对象地址
Map<K,V>描述键和值之间的关系。两个不同实例只要按键的相等性规则相等,就会被视为同一个逻辑键。HashMap允许一个null键和多个null值。因此get(key) == null不能单独证明键不存在:它也可能表示键存在、值为null。
Map<String,String>map=newHashMap<>();map.put("u-1",null);System.out.println(map.get("u-1"));// nullSystem.out.println(map.containsKey("u-1"));// trueSystem.out.println(map.containsKey("u-2"));// false业务上若把null当作“没有结果”,最好明确规定是否允许存储空值。如果必须区分“未查询”“已查询但不存在”“查询成功”,一个可表达三种状态的结果类型通常比依赖空值更清晰。
另一个经常被忽略的约束是顺序:HashMap不保证遍历顺序。当前机器上输出看起来稳定,并不构成接口承诺。要保留插入顺序,选LinkedHashMap;要按键排序,选TreeMap并审视比较器与键的相等性语义。不要把排序操作交给前端“碰运气”修正。
2. equals 与 hashCode 是键的共同契约
哈希值帮助容器缩小查找范围,相等性用于判断候选键是不是目标。若两个键按equals相等,它们的hashCode必须相同;反过来,哈希值相同并不意味着相等。哈希碰撞是正常的,违反共同契约才是真正的错误。
例如订单由租户 ID 和订单号共同确定,用不可变record表示复合键很合适。以下代码可保存为MapDemo.java,用 JDK 21 编译运行:
importjava.util.HashMap;importjava.util.Map;publicclassMapDemo{recordOrderKey(StringtenantId,longorderId){}publicstaticvoidmain(String[]args){Map<OrderKey,String>status=newHashMap<>();status.put(newOrderKey("tenant-a",42L),"PAID");System.out.println(status.get(newOrderKey("tenant-a",42L)));}}两个OrderKey不是同一个对象,但组件值相同,记录类生成的equals和hashCode使查找成功。设计前仍须问清楚:租户 ID 是否区分大小写?订单号是否只在租户内唯一?输入中的前后空格要不要去掉?相等性契约写对了,也可能表达错误的业务身份。
若使用普通类,重写equals时应同步重写hashCode,并使用同一组稳定字段。尤其不要把状态、名称、更新时间等可变字段纳入哈希键。所谓“只改了订单状态”,在容器眼中可能是换了一个键。
3. 可变键为何会导致“遍历得到,查询不到”
对象放入映射时,容器会基于当时的哈希值安排位置。之后如果修改了参与哈希计算的字段,读取过程会按新哈希值寻找;对象可能还在旧位置。以下是故意制造问题的反例:
importjava.util.HashMap;importjava.util.Map;classMutableKey{Stringcode;MutableKey(Stringcode){this.code=code;}@Overridepublicbooleanequals(Objectother){returnotherinstanceofMutableKeykey&&code.equals(key.code);}@OverridepublicinthashCode(){returncode.hashCode();}}classMutableKeyDemo{publicstaticvoidmain(String[]args){Map<MutableKey,String>map=newHashMap<>();MutableKeykey=newMutableKey("A");map.put(key,"value");key.code="B";System.out.println(map.get(key));// 不应期待仍能查到}}解决办法不是在每次查询失败后遍历补救,而是让键成为稳定的值对象。注意final List<String> parts只能保证引用不变,列表本身仍可能变化。需要包含集合的复合键时,创建时做防御性复制,并避免向外暴露可修改的内部状态。
4. 容量和复杂度要结合真实数据看
一般情况下HashMap的查找和插入具有良好的预期效率,但它不是“任何输入都绝对 O(1)”的魔法。哈希分布、冲突、扩容、键的相等性计算成本都会影响耗时。大量数据预先加载、数量大致可知时,可以考虑预估初始容量以减少扩容;只有几个元素的短生命周期映射,过早计算容量反而降低可读性。
new HashMap<>(n)的n是初始容量参数,不是“永远可放 n 个元素且不会扩容”的保证。默认负载因子为 0.75。容量给得过大也会消耗内存,并可能增加遍历成本。性能问题应先用真实数据与分析工具定位,再判断瓶颈是哈希分布、对象分配、热路径调用,还是根本不该在这里建一张 Map。
源码中有桶、链表和树等实现细节,它们解释了运行时行为,却不应成为业务代码依赖的契约。不同 JDK 实现可以调整内部策略,只要公开 API 的语义保持不变。面试可以讨论内部结构,生产代码必须优先守住 API 边界。
5. 并发访问:线程安全方法不等于事务
HashMap不支持在缺乏外部同步时让多个线程并发修改。一个线程写入、另一个线程读取,也不能因为测试没有报错就认定安全。共享只读映射应在构建完成后安全发布,并确保之后不再修改。
需要并发映射时可以考虑ConcurrentHashMap。它不允许null键和值,这使get返回null可表示当前无映射。不过,容器支持并发不代表业务上的多步操作原子化:
// 错误思路:两个线程都可能先看到“不存在”,再各自加载。if(!cache.containsKey(id)){cache.put(id,load(id));}// 对单键的按需计算,使用容器提供的原子接口。Stringvalue=cache.computeIfAbsent(id,this::load);映射函数应简短,不要在函数内递归更新同一张表。计算得到null时不会建立映射;若需缓存“查询过但不存在”,应引入明确的非空结果表示。computeIfAbsent解决的是单键计算与插入的协调,不会替你实现过期时间、容量上限、跨进程一致性或失败重试。
此外,“先读取余额、再扣减、再写回余额”涉及业务事务,即便把余额存进ConcurrentHashMap,多行代码仍可能互相交错。这时需要原子计算方法、锁、数据库事务或其他与一致性要求匹配的机制。选择前先写清楚哪些操作必须作为一个整体发生。
6. 迭代修改与故障排查
遍历HashMap的同时直接调用map.remove(key),常会触发ConcurrentModificationException。迭代器的快速失败是帮助发现错误的机制,不能作为线程同步策略,也不能保证每次并发问题都被捕获。单线程遍历时删除当前元素,可调用迭代器的remove();批量条件删除可用entrySet().removeIf(...)。
遇到“放进去了却取不到”,依次检查:键字段是否被修改;equals与hashCode是否遵守同一身份定义;输入规范化是否一致;值为null是否被误判;是否存在无同步的并发访问。遇到“顺序改变”,先看是否错误依赖HashMap遍历顺序。相比盲目调大容量,这些检查更接近根因。
最后记住一个选择原则:先定义键的身份和生命周期,再定义顺序、空值与并发语义,最后选择HashMap、LinkedHashMap、TreeMap或ConcurrentHashMap。容器是业务契约的实现手段,不是契约本身。
7. 常见业务写法:缓存、计数与快照
HashMap既可以做临时数据整理,也常被放进缓存和统计代码。不同用途对一致性和生命周期的要求完全不同。临时 Map 可以在方法结束时丢弃;缓存 Map 必须说明何时失效;统计 Map 则要说明计数是否允许近似、是否需要持久化。把三者都叫“Map”,不代表可以使用同一套并发和清理策略。
在缓存场景中,最容易出现的是缓存穿透和无界增长。computeIfAbsent可以协调同一个键的加载,但它不会自动设置过期时间,也不会阻止不同键无限增加。若缓存容量由请求参数决定,就要增加最大容量、过期策略或定期清理;若“查不到”本身也是稳定结果,可以使用明确的空对象或缺失标记,避免每次请求都重复访问下游。
计数场景可以使用merge表达“没有就从初始值开始,有就合并”:
Map<String,Integer>counts=newHashMap<>();for(Stringword:words){counts.merge(word,1,Integer::sum);}并发计数时,ConcurrentHashMap的容器操作可以提供单键协调,但高并发下还要考虑计数热点。所有请求都更新同一个键,会形成竞争;这时可按分片计数、使用LongAdder或交给专门的指标系统。不要把一个容器安全方法误认为完整的高吞吐统计方案。
如果只想把构建完成的配置暴露给其他模块,可以返回快照而不是内部可变对象:
Map<String,String>snapshot=Map.copyOf(config);Map.copyOf得到的映射不允许通过其 API 修改,但其中的值对象是否可变仍取决于值本身。若配置包含可变列表或集合,还需要在边界处进行深层防御性复制。封装的重点不是让调用者“碰巧不修改”,而是从类型和生命周期上减少误用机会。
8. 用测试把键的契约固定下来
键对象至少应覆盖四类测试。第一类是值相同、实例不同的对象能否命中同一映射;第二类是参与身份判断的字段不同,是否一定被区分;第三类是键加入 Map 后外部状态变化时,代码是否能在编译或设计层面阻止这种变化;第四类是空值、大小写、空格和边界数字是否符合业务规范。
还可以构造一个故意返回相同哈希值的测试键,验证代码没有错误地把“哈希相同”当成“对象相等”。碰撞会降低效率,但不能改变结果。并发测试则要明确测试目标:是验证单次computeIfAbsent的加载次数,还是验证整个缓存的过期和刷新策略。不同问题需要不同测试,不要用一个压力测试替代所有语义测试。
代码审查时可快速检查以下问题:
- 键是否是不可变值对象,或者至少在放入之后不会再修改身份字段?
equals、hashCode和比较器是否描述同一个业务身份?- 是否错误依赖了
HashMap的顺序或内部容量? - 是否把
containsKey、get、put多步组合当成一个原子动作? - Map 是否有明确的最大规模、清理方式和所有权?
9. 一张简单的选型表
| 需求 | 常用实现 | 需要额外注意的地方 |
|---|---|---|
| 普通键值查找 | HashMap | 不保证顺序,不支持无同步并发修改 |
| 保留插入顺序 | LinkedHashMap | 顺序有维护成本,仍不是并发容器 |
| 按键排序 | TreeMap | 比较器必须与键的相等性和业务规则一致 |
| 并发键值访问 | ConcurrentHashMap | 不允许null,多步业务操作仍需额外协调 |
| 只读配置快照 | Map.copyOf | 值对象若可变,仍需深层复制或不可变设计 |
这张表不能替代容量、访问模式和一致性分析,但能避免一开始就选错容器。真正的优化顺序应是:先修正身份和并发语义,再用基准测试验证性能,最后才考虑参数微调。
参考资料:HashMap API、Map API、ConcurrentHashMap API。
欢迎使用Markdown编辑器
你好! 这是你第一次使用Markdown编辑器所展示的欢迎页。如果你想学习如何使用Markdown编辑器, 可以仔细阅读这篇文章,了解一下Markdown的基本语法知识。
新的改变
我们对Markdown编辑器进行了一些功能拓展与语法支持,除了标准的Markdown编辑器功能,我们增加了如下几点新功能,帮助你用它写博客:
- 全新的界面设计,将会带来全新的写作体验;
- 在创作中心设置你喜爱的代码高亮样式,Markdown将代码片显示选择的高亮样式进行展示;
- 增加了图片拖拽功能,你可以将本地的图片直接拖拽到编辑区域直接展示;
- 全新的KaTeX数学公式语法;
- 增加了支持甘特图的mermaid语法1功能;
- 增加了多屏幕编辑Markdown文章功能;
- 增加了焦点写作模式、预览模式、简洁写作模式、左右区域同步滚轮设置等功能,功能按钮位于编辑区域与预览区域中间;
- 增加了检查列表功能。
功能快捷键
撤销:Ctrl/Command+Z
重做:Ctrl/Command+Y
加粗:Ctrl/Command+B
斜体:Ctrl/Command+I
标题:Ctrl/Command+Shift+H
无序列表:Ctrl/Command+Shift+U
有序列表:Ctrl/Command+Shift+O
检查列表:Ctrl/Command+Shift+C
插入代码:Ctrl/Command+Shift+K
插入链接:Ctrl/Command+Shift+L
插入图片:Ctrl/Command+Shift+G
查找:Ctrl/Command+F
替换:Ctrl/Command+G
合理的创建标题,有助于目录的生成
直接输入1次#,并按下space后,将生成1级标题。
输入2次#,并按下space后,将生成2级标题。
以此类推,我们支持6级标题。有助于使用TOC语法后生成一个完美的目录。
如何改变文本的样式
强调文本强调文本
加粗文本加粗文本
标记文本
删除文本
引用文本
H2O is是液体。
210运算结果是 1024.
插入链接与图片
链接: link.
图片:
带尺寸的图片:
居中的图片:
居中并且带尺寸的图片:
当然,我们为了让用户更加便捷,我们增加了图片拖拽功能。
如何插入一段漂亮的代码片
去博客设置页面,选择一款你喜欢的代码片高亮样式,下面展示同样高亮的代码片.
// An highlighted blockvarfoo='bar';生成一个适合你的列表
- 项目
- 项目
- 项目
- 项目
- 项目1
- 项目2
- 项目3
- 计划任务
- 完成任务
创建一个表格
一个简单的表格是这么创建的:
| 项目 | Value |
|---|---|
| 电脑 | $1600 |
| 手机 | $12 |
| 导管 | $1 |
设定内容居中、居左、居右
使用:---------:居中
使用:----------居左
使用----------:居右
| 第一列 | 第二列 | 第三列 |
|---|---|---|
| 第一列文本居中 | 第二列文本居右 | 第三列文本居左 |
SmartyPants
SmartyPants 是一个文本转换工具,主要功能是将普通的 ASCII 标点符号自动转换为更美观的印刷体标点符号。例如:
| 原始符号 | 转换后 | 说明 |
|---|---|---|
"引号" | “引号” | 直引号变弯引号 |
'单引号' | ‘单引号’ | 直单引号变弯单引号 |
-- | – | 两个连字符变短破折号 |
--- | — | 三个连字符变长破折号 |
... | … | 三个点变省略号 |
创建一个自定义列表
- Markdown
- Text-to-HTMLconversion tool Authors
- John
- Luke
如何创建一个注脚
一个具有注脚的文本。2
注释也是必不可少的
Markdown将文本转换为HTML。
KaTeX数学公式
您可以使用渲染LaTeX数学表达式 KaTeX:
Gamma公式展示Γ ( n ) = ( n − 1 ) ! ∀ n ∈ N \Gamma(n) = (n-1)!\quad\forall n\in\mathbb NΓ(n)=(n−1)!∀n∈N是通过欧拉积分
Γ ( z ) = ∫ 0 ∞ t z − 1 e − t d t . \Gamma(z) = \int_0^\infty t^{z-1}e^{-t}dt\,.Γ(z)=∫0∞tz−1e−tdt.
你可以找到更多关于的信息LaTeX数学表达式[here][1].
新的甘特图功能,丰富你的文章
- 关于甘特图语法,参考 [这儿][2],
UML图表
可以使用UML图表进行渲染,例如下面产生的一个序列图:
- 关于UML图表语法,参考 [这儿][5],
流程图
- 关于Mermaid语法,参考 [这儿][3],
FLowchart流程图
我们依旧会支持flowchart.js的流程图语法:
mermaid语法说明 ↩︎
注脚的解释 ↩︎