刚看到这个标题的时候,我第一反应是:这大概率是一个准备面试的朋友,或者刚写完几个工具类的同事搜出来的问题。因为在真实业务里,“键值对”三个字往往会把人带偏到 HashMap 上去,可你再仔细读一遍——“就只有2个属性的”——其实人家要的只是一个能同时装下两个值、且能按“键”取“值”的小对象,而不是一个大而全的 Map 容器。
Java 里这类的选择还真不少,但质量参差不齐,有些是 JDK 自带的,有些藏在第三方库里,有些只存在于特定的 Android 环境下。而且,这个问题的背后还藏着一个很容易被忽略的设计判断题:到底是直接用现成的 Pair 类,还是自己定义一个只有两个字段的业务类?这篇我把能想到的都给捋一遍,包括每个类的适用场景、坑点、示例代码,以及面试时怎么答才能让你跟只会背八股文的人区分开。
1. 先把需求看清:“键值对”不等于“Map集合”
很多人一看到“键值对”就开始背 HashMap 的知识点,这是最大的误区。HashMap 的语义是“一个容器里装了很多对”,而你现在的问题是“就只有2个属性”,说白了就是两个值之间的一对一关系。这两者的应用场景、内存开销、代码可读性完全不同。
1.1 需要“两个属性绑在一起”的常见场景
我梳理了一下平时开发中最常遇到的情况,基本可以归成三类:
- 方法要返回两个值。Java 的方法签名只允许返回一个对象,但现实里你经常要同时拿到“状态码”和“提示信息”,或者“结果数据”和“分页信息”。用 Map 塞两个 key 是一种解法,但调用方每次都得用魔法字符串去取,很容易写错。
- 循环里临时配对。比如遍历一个列表,把每个元素的 id 和 name 组成一对传给下一个方法处理。这时候你没有理由专门定义一个全局类,也不想为了这种临时配对搞一张 Map 出来。
- 作为缓存或事件消息的单条数据。这种场景往往只需要一对数据,却被塞进了 Map,导致代码里充满
map.get("userId")这样含义模糊的调用。
在这些前提下,你要的不是 Map 这种“集合结构”,而是一个二元组。真正合适的东西应该是承载两个属性、能让你直接通过键拿到值的对象,而不是一个完整的数据结构。
1.2 用单个元素的 Map 到底有多别扭
先承认,Java 新手用 HashMap 解决这个问题太正常了,我自己刚工作时也干过这种事。代码写出来大概是这样:
Map<String, String> result = new HashMap<>(); result.put("status", "success"); result.put("message", "操作完成"); // 取的时候还得用字符串 String status = result.get("status"); String message = result.get("message");用多了问题就来了:
- 魔法字符串无处不在。取值的 key 一旦拼错,运行时才发现为 null,编译期完全帮不上忙。
- 语义不清晰。看代码的人第一眼根本不知道这个 Map 里到底存了哪几个键,你需要翻遍所有 put 的地方才能拼出完整画面。
- 性能开销不划算。HashMap 内部有桶数组、Node 节点、扩容机制,为了存一对数据,你无形中创建了一堆多余的对象。在循环里这么写,GC 压力直接翻倍。
- 迭代别人写的这种 Map 更要命。你永远不知道里面会不会多出一个你没见过的键,因为 Map 本身的约束力约等于零。
所以如果你真的只是想存一对数据,用 Map 不是“能不能”的问题,而是“值不值得”的问题。在代码评审里看到单条数据 Map,我一般都会建议对方换掉,理由不是报错,而是它让整个代码的意图变得模糊了。
那不用 Map 用什么?下面从 JDK 自带的方案开始。
2. JDK 原生选手:AbstractMap.SimpleEntry 与 SimpleImmutableEntry
如果你不想引入任何第三方依赖,那么最标准的“只有两个属性”的键值对类,就是java.util.AbstractMap里的两个静态内部类:SimpleEntry和SimpleImmutableEntry。
很多人对Map.Entry接口很熟,但并不知道可以直接new出它的实现类。其实SimpleEntry从 Java 1.6 开始就存在了,只是日常写业务的时候很少被单独拎出来用。
2.1 SimpleEntry:可改写、可单独使用的标准键值对
先看最简单的用法:
import java.util.AbstractMap; import java.util.Map; public class SimpleEntryDemo { public static void main(String[] args) { Map.Entry<String, Integer> entry = new AbstractMap.SimpleEntry<>("age", 18); System.out.println(entry.getKey()); // age System.out.println(entry.getValue()); // 18 // 可以直接改 value entry.setValue(21); System.out.println(entry.getKey() + " -> " + entry.getValue()); // age -> 21 } }关键信息有几点:
getKey()拿第一个属性,也就是键。getValue()拿第二个属性,也就是值。setValue()可以修改值,但注意这个方法是Map.Entry接口定义的,理论上它主要服务于 Map 内部更新条目,而不是给外部消费者用的。
SimpleEntry本身也是Map.Entry实现,所以它可以被塞回HashMap吗?技术上可以,因为put单个Map.Entry时,HashMap 会读取它的 key 和 value。但在实际使用中,我们更多是把它当作独立的一对数据来传递,相当于“免容器版的 Map 条目”。
我实际项目中见得最多的用法,是把它当方法返回值:
public Map.Entry<String, Integer> getMaxScore() { return new AbstractMap.SimpleEntry<>("math", 98); }调用方直接getKey()、getValue(),比返回一个 List 或者 Map 都清爽。不过这个方法签名看起来还是有点“通用”,看代码的人未必能一眼看懂 key 到底是什么。
2.2 SimpleImmutableEntry:只读的键值对,不怕被改
SimpleImmutableEntry跟SimpleEntry几乎一模一样,唯一的区别是不能调用 setValue 修改值。示例:
import java.util.AbstractMap; import java.util.Map; public class ImmutableEntryDemo { public static void main(String[] args) { Map.Entry<String, String> entry = new AbstractMap.SimpleImmutableEntry<>("token", "abc123"); System.out.println(entry.getKey()); // token System.out.println(entry.getValue()); // abc123 // 这行会抛 UnsupportedOperationException entry.setValue("def456"); } }如果你做的是配置下发、事件通知这类数据,一旦创建就不希望被中途篡改,用SimpleImmutableEntry非常合适。它跟SimpleEntry在线程安全上的区别,本质上不在于这个类本身有多线程保护,而是因为“值引用不可替换”,所以在只看不改的语义下,共享起来更安全。
不过这里要给你提个醒,也是很多人踩过的坑:
注意:
SimpleImmutableEntry只是不让替换 value 引用,并不是深不可变。如果你的 value 本身是一个可变对象,比如一个ArrayList,那你照样能拿到这个 list 然后往里加元素。不可变这个概念,要区分“引用不可改”和“对象内容不可改”。
2.3 两个 JDK 原生类的共同弱点
SimpleEntry和SimpleImmutableEntry作为 JDK 自带方案,胜在零依赖、代码简单。但它们有一个共同的尴尬:字段名太抽象。
你想一想,如果你要保存的是“姓名+年龄”,那getKey()返回姓名还是年龄?getValue()返回的又是哪个?如果不查 put 的地方,或者不看调用处注释,读代码的人很容易搞混。
而且它们没有任何方法能告诉你“键的类型和值的类型分别是什么业务含义”,一切都靠约定。所以我的建议是:临时传递、纯粹为了省事时用它们;一旦这两个属性在业务里反复出现,就别偷懒了,还是自己定义一个类更稳妥。
3. 第三方 Pair 家族:Apache Commons、JavaFX 与 Android
JDK 之外,实际开发里还有几个常见的 Pair 类值得了解。它们有的来自 Apache 老牌工具库,有的曾经跟 JDK 绑定过,有的则是 Android 开发者的老朋友。
3.1 Apache Commons Lang3:功能最全的 Pair 工具
如果你项目里已经用了 Apache Commons Lang3,那它提供的Pair系列应该是使用最顺手的。坐标如下:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.14.0</version> </dependency>核心类在org.apache.commons.lang3.tuple包下,包括抽象类Pair,以及两个具体实现ImmutablePair和MutablePair。
最常见用法是:
import org.apache.commons.lang3.tuple.Pair; public class PairDemo { public static void main(String[] args) { // of 方法默认返回 ImmutablePair Pair<String, Integer> pair = Pair.of("age", 18); // 两套读取 API 都可以 System.out.println(pair.getLeft()); // age System.out.println(pair.getValue()); // 18 System.out.println(pair.getKey()); // age System.out.println(pair.getRight()); // 18 } }Pair.of静态工厂方法返回的是一个不可变对,如果需要修改 value,可以显式用MutablePair.of:
MutablePair<String, Integer> mutablePair = MutablePair.of("age", 18); mutablePair.setValue(20); System.out.println(mutablePair);Apache 这套方案比 JDK 自带强在哪?我觉得主要是三点:
- 同时提供 Left/Right 和 Key/Value 两套命名,你可以选更能表达当前语义的那一套读法。
- 实现了 equals、hashCode、toString、compareTo,调试和放进集合的时候很省心。
- 实现了 Serializable,在某些需要序列化传递的场景下直接可用。
代价是引入一个第三方依赖。如果你的项目本来就有 Commons Lang3,那用它没毛病;如果就是为了一个键值对单独引一个包,我个人觉得不划算。
3.2 JavaFX 与 Android 各自的 Pair
还有一个很多人不知道的点:javafx.util.Pair曾经是 JDK 的一部分。在 JDK 8 到 JDK 10 时代,你什么都不用引,直接就能:
import javafx.util.Pair; Pair<String, String> pair = new Pair<>("name", "Tom"); System.out.println(pair.getKey()); // name System.out.println(pair.getValue()); // Tom但请注意,从 JDK 11 开始 JavaFX 从 JDK 中剥离了,所以现在你在新版 JDK 里直接用javafx.util.Pair是会编译报错的。这个类更多是历史遗留项目里出现,新项目一般不会遇到。
Android 环境也有一个android.util.Pair,它的读取方式不太一样,用的是公共字段first和second:
import android.util.Pair; Pair<String, Integer> pair = new Pair<>("age", 18); String key = pair.first; Integer value = pair.second;这种“暴露字段”的写法更直接,但也意味着你没有getKey()、getValue()这种方法约束,外部代码能直接改字段(如果字段不是 final)。好在 Android 的 Pair 构造完之后,两个字段是 final 的,所以基本还是只读语义。
至于键盘事件里的keyCode、KeyEvent.KEYCODE_*那些定义,属于 Android 输入系统的事件键值,跟我们要讨论的“Java 键值对类”是两码事,别混为一谈。
3.3 对比这些 Pair 类,该怎么选
我把主流候选方案放在一起对比一下,方便你直接参考:
| 类 | 来源 | 可变性 | 读取方式 | 适合场景 |
|---|---|---|---|---|
AbstractMap.SimpleEntry | JDK 1.6+ | 可变 | getKey/getValue | 临时传递、单条键值对、Map 实现内部 |
AbstractMap.SimpleImmutableEntry | JDK 1.6+ | 不可变 | getKey/getValue | 只读传递、常量、事件数据 |
javafx.util.Pair | JDK 8~10 自带,之后移除 | 不可变 | getKey/getValue | 老项目兼容 |
org.apache.commons.lang3.tuple.Pair | 第三方库 | 抽象类,有不可变/可变实现 | getLeft/right、getKey/value | 已引入 Commons Lang3 的项目 |
android.util.Pair | Android SDK | 字段 final,基本只读 | first/second 公共字段 | Android 开发 |
| 自定义类或 record | 自己写 | 看实现 | 业务字段名 | 绝大多数真实业务代码 |
这里“可变性”我统一指“能否替换 value 的引用”,不是说里面的对象内容不能改。你再看一眼这个表就会发现,除了自定义类,其他方案的语义多多少少都带着“通用二元组”的味道,缺少业务含义。
4. 面试官真正想听到的回答:自己定义一个只有两个属性的类
这个问题十有八九出现在 Java 基础面试里。我见过太多候选人一上来就背“HashMap 是基于数组加链表加红黑树实现的”,却完全没意识到面试官问的是“只有两个属性”的场景。下面说说我对这道题的理解。
4.1 为什么“自己写一个类”反而更优
先设一个很常见的场景:你需要在方法里返回“用户 id 和用户名称”。用SimpleEntry写是这样的:
Map.Entry<Long, String> result = new AbstractMap.SimpleEntry<>(1001L, "张三"); Long userId = result.getKey(); String userName = result.getValue();代码能跑,但你调用getKey()的时候,脑子里必须要加一层“key 是 id”的映射。如果这个方法被十几处代码调用,这层隐式映射就会在每一次阅读时增加认知负担。
换成自己定义的类之后:
public class UserBrief { private final Long userId; private final String userName; public UserBrief(Long userId, String userName) { this.userId = userId; this.userName = userName; } public Long getUserId() { return userId; } public String getUserName() { return userName; } }或者用 Lombok 的@Value,代码更短。这样带来的收益是非常明确的:
- 语义自解释。方法返回类型
UserBrief一看就知道是“用户摘要信息”,getUserName()也不会让你猜。 - 类型安全。两个字段的类型被固定,编译期就能检查。而
Pair<String, String>无法约束第一项是 id 还是 name。 - 便于扩展。后续如果还要加一个
avatarUrl字段,在类里加一个字段和构造参数即可,所有调用点都能感知到变化;如果一开始用 Map,新增字段意味着新增一个魔法字符串 key,后果不堪设想。 - 几乎没有额外成本。一个二十行的类在 Java 项目里完全不是负担,反而是 Java 面向对象的基本功。
所以,除非两个属性只是 ** 凑巧要绑在一起、用完就扔**,否则自定义类几乎永远是更优解。这也是经验丰富的开发者不会第一条就推荐HashMap的原因。
4.2 用 record 一句话解决“只有2个属性”
如果你用的是 JDK 14 及以上,那么答案里一定要提record。这是 Java 原生支持“我只想定义两个字段”的极简语法:
public record UserBrief(Long userId, String userName) {}就这么一行,构造器、equals、hashCode、toString自动生成,字段默认是 private final。调用方式:
UserBrief user = new UserBrief(1001L, "张三"); System.out.println(user.userId()); // 注意 record 的访问方法是 userId() System.out.println(user.userName());record特别适合这种“数据载体”的场景,因为它本身定位就是不可变数据聚合体,不需要任何繁琐的样板代码。我在新项目里,凡是遇到“只有两个属性、要来回传递”的数据,第一选择基本都是record。
注意:
record的 getter 不是getUserId(),而是userId()。如果你从 JavaBean 那一套切过来,刚开始容易写顺手了直接敲getUserId()然后报编译错误。这个需要一点适应时间,但适应之后就会觉得这才是 Java 该有的简洁。
4.3 怎么回答才能不落俗套
如果是面试题,我会建议你按这个顺序来组织答案:
- 先把问题定义清楚。面试官问的是“只有2个属性”的键值对,不是“键值对集合”。所以先指出这一点,说明你清楚 Map 和二元组之间的边界。
- 给出标准答案。JDK 自带的就是
AbstractMap.SimpleEntry和AbstractMap.SimpleImmutableEntry,可以单独 new 出来当键值对用。 - 再提第三方方案。如果允许引入依赖,Apache Commons Lang3 的
Pair/ImmutablePair/MutablePair也很常用;JavaFX 和 Android 也各有一版Pair,但要注意版本和平台限制。 - 最后落点要高级。谈谈“如果这两个属性具有明确业务含义,自定义类或者 record 往往比任何通用 Pair 都好”。这一句话就能跟只会背 API 的候选人拉开差距。
这样回答,既展示了知识面,又体现了设计判断力,面试官很难不点头。
最后再分享一个小技巧。如果你的项目里有人一直在用“只有一个元素的 HashMap”,评审的时候不要只说“这样不对”,直接把SimpleEntry或record的代码示例甩过去。你会发现,很多这种用法并不是同事不会别的,而是他们压根不知道 Java 里还有这么轻量的键值对类。把这个认知缺口补上,代码能清爽一大截。