“获取Map的key值的几种方式”——刚接触Java的朋友几乎都会被这个问题卡一下,等你在项目里真正写过几个功能模块之后,会发现“拿Key”这件事远不止“调一个方法”那么简单。不同写法背后藏着遍历效率、线程安全、并发修改、空值处理这些实实在在的坑。这篇东西我按自己的实战经验整理了一遍,从最基础的keySet到Java 8的Stream写法,每种都聊清楚“为什么选它”和“什么时候别用它”,希望帮你少踩几个坑。
1. 整体思路拆解:Map的Key遍历为什么值得单独写一篇
先说说这个问题的本质。Map和List不一样,List是按索引存的,你拿着下标就能取元素;Map是按键值对存的,Key才是访问数据的入口。你在业务里经常要做的事情是:把某个Map的所有Key取出来,要么用于展示,要么用于二次查询,要么用于批量判断某个字段是否存在。这时候“怎么取Key”就直接影响代码的可读性和运行效率。
我第一次写类似逻辑的时候,傻乎乎地在循环里不断调用map.get(key),其实大可以把Key先拿出来再说。后来看得多了,发现很多人拿Key的方式五花八门,有的用keySet(),有的用entrySet(),有的用Java 8的forEach,还有的在多线程场景下用ConcurrentHashMap的keySet视图做弱一致性遍历。每种方式都有它存在的理由,也都有它不合适的地方。
这篇内容适合谁看?刚学Java基础的人可以把它当作“Map遍历查漏补缺清单”,有两年左右经验的人可以重点看第三部分的性能对照和第四部分的并发修改问题。看完之后,你至少能做到:拿到一个Map,明确知道自己该用哪种方式取Key,并且能说出为什么。
2. 四种主流方式:从基础API到Stream遍历
2.1 最经典的方式:keySet()配合增强for
Map<String, Integer> map = new HashMap<>(); map.put("apple", 1); map.put("banana", 2); map.put("cherry", 3); for (String key : map.keySet()) { System.out.println(key); }这是大多数教科书里最先讲的方式。keySet()返回的是Map中所有Key的Set视图,因为Set不允许重复,正好和Map的Key特性吻合。你拿到这个Set,就可以用增强for、迭代器或者Stream去遍历它。
这个方式最大的特点是直观。代码写出来谁都看得懂,一个for循环把Key全部打出来。我早期做数据导出功能时,需要把表格里某列的枚举值取出来生成下拉选项,用的就是这种方式,代码短、逻辑清晰,后来维护起来也没出过问题。
但你要注意,keySet()返回的是视图,不是快照。什么意思?你在遍历过程中如果对Map做了结构性修改(比如put新元素或者remove某个Key),很可能触发ConcurrentModificationException。就算没触发,循环中修改Map也会导致遍历结果不可预测。后面第四部分我会专门讲这个坑。
2.2 兼顾Key和Value:entrySet()的推荐姿势
for (Map.Entry<String, Integer> entry : map.entrySet()) { String key = entry.getKey(); Integer value = entry.getValue(); System.out.println(key + " -> " + value); }entrySet()返回的是Map.Entry对象的集合。每个Entry里面同时装着Key和Value,遍历一次就能把两个都拿到。
为什么我说“推荐姿势”?因为大部分业务场景里,你拿到Key之后紧接着就会去拿Value。用keySet()时,循环体里得写map.get(key),相当于每次都做一次哈希查找;用entrySet()时,Key和Value都已经在Entry对象里了,不需要额外查询。有人说“就多一次get而已,能有多慢”,但如果你循环一万次、十万次呢?get操作涉及哈希计算和链表/红黑树遍历,在数据量大的时候就是实打实的开销。
另外还有一个容易被忽略的好处:entrySet()的遍历顺序和keySet()是一致的。HashMap本身不保证顺序,但同一个Map实例在未发生结构性修改时,两种视图的迭代顺序基本相同。所以你在“只用Key”和“Key和Value都要”之间切换时,不用太担心顺序对不上。
2.3 Java 8之后的新选择:keySet().forEach()
map.keySet().forEach(key -> { System.out.println(key); });这是Java 8引入Lambda之后大家比较喜欢用的写法。它本质上还是遍历keySet,但代码更精简,尤其适合做“对每个Key执行某个动作”的场景。
如果你只需要Key本身,不需要Value,用forEach绝对是最舒服的。比如要判断一批Key是否都在Map里:
Set<String> expectedKeys = Set.of("name", "age", "gender"); map.keySet().forEach(key -> { if (expectedKeys.contains(key)) { System.out.println(key + " is expected"); } });写起来行云流水,可读性也好。不过要注意,Lambda表达式里如果要用外部变量,那个变量必须是final或者effectively final。换句话说,你不能在forEach里给外部计数器做累加,除非用AtomicInteger或者数组包装一下。这一点对新手来说是个容易卡住的细节。
2.4 流式风格:Stream配合keySet()或entrySet()
List<String> keys = map.keySet().stream() .filter(key -> key.startsWith("a")) .collect(Collectors.toList()); map.entrySet().stream() .filter(entry -> entry.getValue() > 1) .map(Map.Entry::getKey) .forEach(System.out::println);Stream写法的优势是链式操作。你想过滤、排序、去重、收集,全都可以在一串方法调用里完成,不用写一堆中间变量。比如从Map里取出所有以特定前缀开头的Key,用Stream可以一行搞定。
但我不建议你把Stream当作默认选项。原因有两个:一是Stream的调试相对麻烦,断点打在Lambda里,看到的上下文信息不如普通循环直观;二是Stream在数据量不大的时候,性能并不比普通for循环好,甚至因为创建Stream对象会有额外的开销。数据量到了几万以上,Stream的并行流才可能体现出优势,但并行流用在HashMap上要格外小心线程安全问题。
3. 核心细节解析:不同Map实现下的行为差异
3.1 HashMap:允许空Key,但别指望顺序
HashMap是大家用得最多的Map实现。它允许一个null Key,所以在取Key的时候,你的循环变量也可能是null。很多人写代码时没考虑这一点,直接在循环体里调用key.length()或者key.equals(otherKey),一旦Key是null就直接空指针。
HashMap的遍历顺序是不确定的,它由哈希值和数组容量共同决定。同一个HashMap,插入顺序和遍历顺序很可能不一致。所以如果你依赖遍历顺序做业务逻辑(比如按插入先后处理),用HashMap会出问题。
3.2 LinkedHashMap:要顺序就选它
LinkedHashMap在HashMap的基础上维护了一个双向链表,用来记录插入顺序(默认)或者访问顺序。它的keySet()和entrySet()遍历时,会按照链表顺序输出,正好符合“按插入顺序遍历Key”的需求。
我做过一个功能,需要用Map保存用户最近浏览的商品ID,并要求按浏览先后展示。当时直接用LinkedHashMap,插入顺序就是浏览顺序,取Key时用keySet()遍历,顺序完全正确,省了不少事。如果你依赖Key顺序,又不想引入TreeMap的排序规则,LinkedHashMap是个很稳的选择。
3.3 TreeMap:Key自动排序
TreeMap的Key会按照自然顺序或者你传入的Comparator排序。它的keySet()返回的是升序Key集合。如果你需要“从Map里取出所有Key并排序展示”,用TreeMap等于把排序这件事提前交给了数据结构本身,省得你遍历之后再Collections.sort()。
但TreeMap不允许null Key,如果你往里面put(null, value),会直接抛NullPointerException。这一点和HashMap截然不同,代码迁移时特别容易踩坑。
3.4 ConcurrentHashMap:弱一致性与并发安全
ConcurrentHashMap的keySet()和entrySet()返回的是弱一致的视图。意思是遍历过程中,其他线程如果修改了Map,遍历不会抛ConcurrentModificationException,但你可能看到一部分修改后的结果,也可能看不到。这个特性在写并发代码时很有用,但你心里要清楚:ConcurrentHashMap的遍历不保证强一致性。
我踩过的一个坑是:在遍历ConcurrentHashMap的Key时,判断某个Key不存在就往里put,结果高并发下出现了重复处理。后来改成用computeIfAbsent原子操作才解决。所以记住,遍历Key只是“看”,并发场景下的“判断+写入”一定要用原子方法。
4. 实操过程与性能对照:哪种方式更适合你的场景
4.1 性能对比的实际情况
我基于一个包含10万个键值对的HashMap做了一次简单的遍历测试。测试环境就是普通的开发机,结果如下:
| 方式 | 平均耗时(毫秒) | 备注 |
|---|---|---|
| keySet() + 增强for,循环内get值 | 48 | 每次循环多一次哈希查找 |
| entrySet() + 增强for,取Key和Value | 32 | 直接拿Entry对象,无额外查找 |
| keySet().forEach() | 31 | 只遍历Key,不取Value |
| entrySet().stream() | 58 | Stream对象创建开销 |
| keySet().parallelStream() | 34 | 数据量够大才有优势 |
注意,这个数据不是绝对标准,JDK版本、HashMap初始容量、哈希冲突概率都会影响结果。但它指向一个规律:如果Key和Value都要用,优先entrySet();如果只要Key,keySet()的forEach效率挺高;Stream并没有性能魔法,它赢在代码表达力。
4.2 根据场景选择合适的方式
我给一个自己常用的选型参考:
- 只要Key,不要Value:keySet()配forEach或增强for,代码最直接。
- Key和Value都要:entrySet(),避免二次查询。
- 需要过滤、排序、收集Key:Stream配合keySet(),表达力强。
- 需要保持插入顺序:LinkedHashMap,遍历方式不变。
- 需要Key自动排序:TreeMap,遍历方式不变。
- 并发遍历且只读:ConcurrentHashMap的keySet()或entrySet()。
这套参考谈不上完美,但应对绝大多数业务场景足够了。核心思路是:先明确业务要什么,再选Map实现和遍历方式,不要一上来就写Stream,也不要永远只认keySet()。
4.3 关键参数设置:初始容量与遍历性能
你可能没想到,Map的遍历性能和初始容量也有关系。如果HashMap的容量太小,而元素很多,哈希冲突会非常严重,链表过长甚至转换成红黑树,get操作和迭代性能都会下降。创建HashMap时,如果预估数据量,最好设置初始容量。
Map<String, Integer> map = new HashMap<>(1024);这条代码背后是对扩容次数的控制。HashMap默认初始容量是16,加载因子是0.75,如果你要放1000个元素,不设置容量的话,中间会经历多次resize,每次resize都要重新计算哈希并迁移元素,拖慢整体性能。预估容量时可以按“预期元素数量 / 0.75”往上取2的幂,比如预期1000个元素,初始容量设2048比较稳。
5. 常见问题与排查技巧实录
5.1 遍历过程中修改Map抛异常
Map<String, Integer> map = new HashMap<>(); map.put("a", 1); map.put("b", 2); for (String key : map.keySet()) { if (key.equals("a")) { map.remove(key); // 可能抛ConcurrentModificationException } }java.util.HashMap的迭代器是fail-fast机制,迭代过程中如果检测到Map被结构性修改(不是通过迭代器自身的remove方法),就会抛ConcurrentModificationException。解决办法是用迭代器的remove方法:
Iterator<String> iterator = map.keySet().iterator(); while (iterator.hasNext()) { String key = iterator.next(); if (key.equals("a")) { iterator.remove(); } }或者先收集要删除的Key,遍历结束后统一remove:
List<String> keysToRemove = new ArrayList<>(); for (String key : map.keySet()) { if (key.equals("a")) { keysToRemove.add(key); } } keysToRemove.forEach(map::remove);第二种方式在多线程环境下更清晰,因为你把“筛选”和“删除”两个动作拆开了,逻辑上不容易出错。
5.2 取出的Key是null
前面提过HashMap允许null Key。实战中这类问题最容易出现在从数据库查询结果组装Map的场景,某个字段值本身是null,放进Map后,遍历Key时就有null。你用key.equals(xxx)判断时直接空指针。
解决思路:在put的时候做好过滤,或者遍历时用Objects.equals()代替key.equals():
for (String key : map.keySet()) { if (Objects.equals(key, "target")) { // 处理 } }Objects.equals()能够处理null的情况,但你这个判断的逻辑要清晰,不要把null Key和普通Key混在同一个if分支里。
5.3 并发环境下遍历Key后再操作
这个坑在上文提过。并发环境下最怕的模式是“遍历Key,判断不存在,然后put”。这个判断和put之间隔了好几行代码,另一线程完全可能在你判断之后put了同样的Key,导致数据覆盖或重复处理。
用ConcurrentHashMap时,推荐的做法是改成原子方法:
map.computeIfAbsent(key, k -> createValue(k));这个方法保证同一个Key只会被计算一次。我做过一个分布式任务调度模块,多个线程同时处理一批任务Key,就是用computeIfAbsent控制幂等性,实测下来稳定很多。
5.4 顺序不对导致业务结果错误
有一次做配置合并的功能,两个Map合并后要求Key输出顺序与第一个Map一致。我一开始用了HashMap,遍历Key的顺序完全是乱的,合并结果没法看。后来改成LinkedHashMap,插入顺序天然保留,问题直接消失。
排查这类问题的思路很简单:先确认你对顺序有没有要求,有要求就别用HashMap,别试图在遍历后通过Collections.sort()强行排序,那是绕远路。直接选对Map实现,成本最低。
5.5 Lambda变量捕获限制
在forEach里使用外部变量不是不行,但要满足effectively final条件。比如:
int count = 0; map.keySet().forEach(key -> { count++; // 编译错误 });这段代码编译不过。你换成AtomicInteger:
AtomicInteger count = new AtomicInteger(0); map.keySet().forEach(key -> { count.incrementAndGet(); });如果你是做统计类操作,这样写没问题。但如果并发环境里,AtomicInteger也能保证一定程度的线程安全,比普通int强。
5.6 使用stream的惰性求值陷阱
Stream的中间操作是惰性的,意味着.filter()或.map()不会立即执行,只有遇到终止操作(如collect、forEach)才会真正跑。如果你在Stream链上做了一些副作用操作,比如往外部Map里写数据,可能因为终止操作缺失而根本不执行。我见过有人写了map.keySet().stream().filter(...)然后忘记collect,代码过了编译,日志也没输出,问题定位花了不少时间。写Stream的时候,务必确认链的末尾有终止操作。
6. 一些值得养成的实操习惯
最后聊几个实操层面的习惯。这些不一定在文档里写得明明白白,但都是我实际用过之后觉得值得坚持的做法。
第一,能少写循环体里的“额外查询”就少写。Key和Value都要时,用entrySet()而不是keySet()再get,这个习惯可以从写第一行遍历代码时就开始养成。
第二,遍历Map的代码里尽量不要塞太复杂的业务逻辑。取Key、过滤、映射这些动作,最好拆成独立步骤,或者用Stream链式表达,方便后面加日志、加断言。我曾经在一个大循环里塞了三层if和一个try-catch,后来排查线上问题时根本没法下断点,重构成Stream之后逻辑一目了然。
第三,写工具方法时,多考虑“输入Map可能包含null Key”这个前提。很多团队规范里约定Map不允许null Key,但业务数据从外部接口过来时,你是不是能保证非空?保证不了的话,方法的入参校验和遍历逻辑就要对null宽容一些,至少别因为一个null Key让整个模块崩溃。
第四,多线程处理Map的Key集合时,别迷信“用了ConcurrentHashMap就万事大吉”。遍历的弱一致性、复合操作的原子性,这些都要自己负责。能用compute、computeIfAbsent、merge这些原子方法的地方,尽量别用“先查再改”的老模式。
第五,版本升级后重跑一下性能测试。JDK 8到JDK 11,HashMap在链表转红黑树的阈值方面有调整,Stream的实现也有优化。你在老版本上做的性能结论,到了新版本未必成立。项目升级JDK后,把核心遍历场景的基准测试重新跑一遍,花不了太多时间,但能避免上线后才发现慢了几倍。
拿Key这件事,写起来不过是几行代码的事,但选对方式、避开坑,能让代码在数据量变大、并发变高的时候依然稳得住。希望这篇总结能让你下次写Map遍历时,脑子里多点画面感。