写日常业务代码的时候,Map大概是Java里出镜率最高的集合类了。前端传参数要转Map,数据库查询结果要转Map,缓存、分组、去重,处处都是它的身影。可一旦被问起"获取Map的key值有哪几种方式",或者你在代码评审里看到同事在for (String key : map.keySet())循环里一次次调map.get(key),随口问一句"这里为什么不用entrySet",你就会发现,这个看起来人人会用的API,背后其实藏着不少值得掰扯的门道。
这个标题本身不大,但"取key"这个动作可以拆出好几层问题:最基础的keySet()怎么写?想在遍历过程中删除某些key怎么办?Java 8之后forEach和Stream能不能更优雅?Map只支持按key查value,那反过来按value找key该用什么方案?不同写法之间的性能差距到底有多大?
这篇文章我打算从Map的数据结构设计讲起,把keySet、entrySet、Iterator、forEach、Stream、反向查找这几条路挨个走一遍。重点不是罗列API,而是讲清楚每种方式背后的原理、适用场景、以及我实际开发中踩过的坑。
1. Map的三种视图与keySet的本质:先理解数据结构再谈取key
1.1 三种视图各管什么
Map接口在设计之初,就给外界留了三个"观察窗口":
keySet():返回所有key组成的Set视图values():返回所有value组成的Collection视图entrySet():返回所有键值对Map.Entry组成的Set视图
这三个方法对应的数据类型不一样,用途也完全不同。keySet()解决的是"我只想知道这个Map里有哪些key",entrySet()解决的是"我既要key又要value",values()则是"我只需要value列表"。很多人把它们当成三种等价的遍历写法,其实它们服务的场景是错开的。
1.2 keySet返回的到底是什么
这里有个非常容易被忽略的点:keySet()返回的不是一份拷贝,而是一个视图(view)。Java官方文档里写得很清楚,返回的Set与Map底层数据共用存储,你往这个Set里做增删操作,会直接作用到原Map上。
Map<String, Integer> map = new HashMap<>(); map.put("user1", 88); map.put("user2", 92); map.put("user3", 75); Set<String> keys = map.keySet(); keys.remove("user1"); System.out.println(map); // {user2=92, user3=75},原Map也跟着变了我第一次意识到这个行为是在写一个缓存淘汰逻辑时,想临时拿到key集合做一次"预检查",结果发现只是remove了一下测试数据,原缓存就被改了。从那以后,凡是要把keySet()的结果往外传、交给其他方法处理的场景,我都会多留个心眼。
另外一个相关知识点是遍历顺序。HashMap本身不保证任何顺序,LinkedHashMap按插入顺序维护,TreeMap按key的自然顺序或自定义比较器排序。所以"取key的先后顺序"这个问题,答案完全取决于你用了哪个实现类:
| Map实现 | key遍历顺序 | 适用场景 |
|---|---|---|
HashMap | 无序,且不保证稳定 | 通用场景,读写性能最好 |
LinkedHashMap | 插入顺序 / 访问顺序 | 需要保持顺序的缓存(如LRU) |
TreeMap | 按key排序 | 需要有序遍历、范围查询 |
2. keySet()遍历取key:最基础的玩法与两个隐蔽细节
2.1 最直观的for-each写法
如果只是想把所有key打印出来、判断某个key是否存在、或者把key集合转成别的结构,keySet()配for-each就是最直接的写法:
Map<String, Integer> scores = new HashMap<>(); scores.put("user1", 88); scores.put("user2", 92); scores.put("user3", 75); for (String userName : scores.keySet()) { System.out.println("key: " + userName); }但一旦你还需要拿到每个key对应的value,新手最常见的写法就出现了:
for (String userName : scores.keySet()) { Integer score = scores.get(userName); // 每循环一次,做一次hash查找 System.out.println(userName + " -> " + score); }这段代码逻辑没错,小数据量下也完全够用,但它有一个隐含代价:每遍历一个key,都要调用一次get(),也就是一次哈希查找。对于HashMap来说单次get()是O(1),但如果Map很大,或者key的hashCode()实现不太理想导致哈希冲突严重,累计下来的查找开销就不容忽视了。换用entrySet()可以直接从Map.Entry里拿value,省掉二次查找,代码也更干净:
for (Map.Entry<String, Integer> entry : scores.entrySet()) { System.out.println(entry.getKey() + " -> " + entry.getValue()); }2.2 隐蔽细节一:视图不是快照,删除key会影响原Map
这个点在第1节提过,但在遍历场景下更值得强调。你遍历的是keySet()返回的视图,只要不是通过迭代器自身的remove()方法删除,任何对原Map结构的修改都会在遍历过程中产生不可预期的问题。比如有人想"顺便清空"Map:
for (String key : scores.keySet()) { scores.clear(); // 遍历还没结束,Map被清空了 }这段代码跑起来大概率会直接抛ConcurrentModificationException,即使侥幸没抛,遍历的行为也无法保证。正确做法是让视图自己的方法来做清空操作:
scores.keySet().clear(); // 等价于 scores.clear()2.3 隐蔽细节二:边遍历边删会炸
业务里最常见的需求是"把不满足条件的key删掉"。很多人图省事,直接在for循环里调用map.remove(key):
for (String key : scores.keySet()) { if (scores.get(key) < 80) { scores.remove(key); // 运行时抛 ConcurrentModificationException } }这段代码几乎必炸。for-each本质上是Iterator的语法糖,迭代器内部维护了一个expectedModCount,一旦发现在迭代过程中Map的结构被外部修改了,立刻抛出异常。正确做法是下面三种:
// 方式一:用迭代器的remove() Iterator<String> iterator = scores.keySet().iterator(); while (iterator.hasNext()) { String key = iterator.next(); if (scores.get(key) < 80) { iterator.remove(); } } // 方式二:先收集再统一删 List<String> toRemove = new ArrayList<>(); for (String key : scores.keySet()) { if (scores.get(key) < 80) { toRemove.add(key); } } toRemove.forEach(scores::remove); // 方式三:直接用JDK提供的removeIf scores.keySet().removeIf(key -> scores.get(key) < 80);方式三是我现在最常用的,removeIf内部就是通过迭代器安全删除的,一行搞定,语义清晰。你唯一要注意的是,removeIf的谓词里不要再对同一个Map做结构上的增删操作,否则照样会触发并发修改异常。
3. Iterator显式遍历与fail-fast机制:为什么你会遇到ConcurrentModificationException
3.1 显式Iterator与for-each的关系
很多人不知道,for-each写法在编译期会被等价地替换成Iterator遍历。所以下面两段代码本质上是同一个东西:
// 写法一:for-each for (String key : scores.keySet()) { System.out.println(key); } // 写法二:显式Iterator Iterator<String> iterator = scores.keySet().iterator(); while (iterator.hasNext()) { String key = iterator.next(); System.out.println(key); }既然如此,为什么还要单独掌握iterator的写法?因为只有拿到迭代器对象,你才能在遍历过程中安全地做删除操作——iterator.remove()是唯一被迭代器自己认可的删除方式。
3.2 fail-fast到底是怎么工作的
HashMap(以及ArrayList、HashSet等大多数集合类)的迭代器都有一个fail-fast机制:创建迭代器时,内部会记录当前Map的modCount(结构性修改计数)。每次调用next()和remove()时,迭代器都会检查modCount是否仍然等于自己记录的expectedModCount,只要不相等,就立即抛出ConcurrentModificationException。
这个机制的初衷是"快速失败",防止你在迭代过程中拿到脏数据。但也带来一个体验问题:异常抛出的时机和代码行数对不上,你明明写的是next()那一行报错,真正的问题却在前面某个remove()或put()里。排查这类问题,先要定位到操作集合的每一行代码,确认是否有循环外的结构性修改。
3.3 并发场景下的正确姿势
单线程里,iterator.remove()能解决大部分问题。但如果是多线程同时读写同一个HashMap,单靠迭代器就没辙了,依然会抛ConcurrentModificationException。这种场景下应该换ConcurrentHashMap:
Map<String, Integer> concurrentMap = new ConcurrentHashMap<>(); concurrentMap.put("user1", 88); // 其他线程可以同时put/remove for (String key : concurrentMap.keySet()) { System.out.println(key); }ConcurrentHashMap的迭代器是弱一致性的:它不会抛ConcurrentModificationException,允许在迭代过程中有其他线程修改Map,但迭代器不保证能看到最新的修改,也不保证把修改后的元素遍历出来。这正是并发场景需要的"宽松但安全"的语义。日常开发中,判断到底用HashMap还是ConcurrentHashMap,关键不是Map里有多少数据,而是有没有多个线程同时写。
4. Java 8之后的函数式取key:forEach、Stream流的正确打开方式
4.1 Map.forEach与BiConsumer
Java 8给Map接口添加了默认方法forEach(BiConsumer<? super K, ? super V> action),遍历时同时把key和value传给你,不需要再手动调用get():
scores.forEach((key, value) -> { System.out.println(key + " -> " + value); });它和for (Map.Entry<K, V> entry : map.entrySet())在底层是同一套遍历逻辑,同样受fail-fast机制约束。区别在于写法更简洁,尤其在需要同时处理key和value的场景下,可读性提升非常明显。但要记住:在forEach的lambda里不能安全地调用map.remove(key),这和for-each循环里直接删是一样的下场。
4.2 Stream流式取key的典型用法
当"取key"附带筛选、排序、转换等条件时,Stream就成了最趁手的工具。最常见的几种:
// 场景一:筛选出所有分数大于80的key,收集成List List<String> passed = scores.entrySet().stream() .filter(entry -> entry.getValue() >= 80) .map(Map.Entry::getKey) .collect(Collectors.toList()); // 场景二:按key排序后输出 scores.keySet().stream() .sorted() .forEach(System.out::println); // 场景三:把所有key收集到Set(当然key本身就不重复) Set<String> keySetCopy = scores.keySet().stream() .collect(Collectors.toSet());这里有个细节值得多说一句:Stream处理Map时,入口通常是entrySet().stream()而不是keySet().stream()。道理和第2节一样——entrySet()的流里能同时带着value走完filter、map等中间操作,最后再决定要不要取出key。如果一开始就用keySet(),那filter的条件里就只能再去map.get(key),等于把二次查找的代价又捡回来了。
另外,需要把key收集成List时,直接用构造器拷贝往往比Stream更高效:
List<String> keys = new ArrayList<>(scores.keySet());4.3 一个容易忽略的边界:forEach中不能安全删除
在forEach里做删除操作,很多人以为换了个写法就没事了。实际上照样抛ConcurrentModificationException:
// 错误示范 scores.forEach((key, value) -> { if (value < 80) { scores.remove(key); // 抛异常 } });而且这里还有个更隐蔽的问题:即便把remove换成iterator.remove(),在lambda表达式里也拿不到那个Iterator对象。所以正确的路线是走removeIf,或者先筛选出需要删除的key、收集成列表、再统一删除。我在代码评审里见过好几次这个错,基本都是把for循环机械地改成forEach时踩的雷。
5. 反向查找:根据value找key的四种方案与各自的坑
5.1 遍历entrySet做等值匹配
标准Map接口只支持按key找value,不支持按value找key。最朴素的反向查找就是遍历所有entry,逐个比较value:
public String findKeyByValue(Map<String, Integer> map, Integer target) { for (Map.Entry<String, Integer> entry : map.entrySet()) { if (Objects.equals(entry.getValue(), target)) { return entry.getKey(); } } return null; }这段逻辑很简单,但有几个坑。第一,value允许为null时,Objects.equals能正确处理,但如果你用entry.getValue().equals(target),遇到null value就直接空指针了。第二,HashMap不保证遍历顺序,所以如果多个key对应同一个value,返回的"第一个"key是不确定的,不能依赖这个结果做业务判断。第三,每次都遍历全表,时间复杂度O(n),在热路径上频繁调用会很难受。
5.2 手动维护反向Map
如果反向查找的频率很高,最简单的优化是"用空间换时间":提前维护一张value -> key的反向Map,查询直接O(1):
Map<Integer, String> reverse = new HashMap<>(); scores.forEach((key, value) -> reverse.put(value, key)); // 之后查某个分数对应的用户 String userName = reverse.get(88);但这种做法的前提是value本身不能有重复,一旦两个用户同分,后面的put会悄悄覆盖前面的记录。如果value有重复,就得把反向Map的值改成集合:
Map<Integer, List<String>> reverseMulti = new HashMap<>(); scores.forEach((key, value) -> reverseMulti.computeIfAbsent(value, v -> new ArrayList<>()).add(key));这里我用的是computeIfAbsent,比先get再判断再put的写法少两次Map访问,代码也更紧凑。手动维护反向Map的问题在于一致性:原Map每次put、remove、clear,反向Map都要同步更新,稍不注意就会出现两边数据对不上。我的建议是,要么封装成一个独立的小类,把正反向更新逻辑收拢在一起,要么直接上现成的工具库。
5.3 借助Guava的BiMap
Guava提供了专门解决这个问题的BiMap接口,最常用的实现是HashBiMap:
import com.google.common.collect.BiMap; import com.google.common.collect.HashBiMap; BiMap<String, Integer> biMap = HashBiMap.create(); biMap.put("user1", 88); biMap.put("user2", 92); Integer score = biMap.get("user1"); // 正向:key -> value String userName = biMap.inverse().get(88); // 反向:value -> keyinverse()返回的是原BiMap的反向视图,正反向之间是同步的,改任何一边,另一边都能看到。更妙的是BiMap在内部强制值也唯一,重复的value会直接抛IllegalArgumentException,从源头上避免了"一个值对应多个key"的歧义。如果确实需要保留重复值,可以用forcePut强制覆盖,但业务上就得自己约定"后写覆盖先写"的规则。
5.4 value重复时怎么办
回到根本问题:Map的key天然唯一,value却不唯一,所以"根据value找key"本身就是一个定义不严谨的操作。动手之前先想清楚你究竟要的是"这个value对应的某个key"还是"所有key"。前者用findKeyByValue或者BiMap;后者要么返回List<String>,要么直接换成Multimap这类支持一对多的结构。跳过这一步,后面写的所有代码都可能在边界case上出错。
6. 综合对比与选型:哪种方式在什么场景下最合适
6.1 一张表说清楚每种方式
把上面所有方式拉平对比,可以从"用途、代价、适合场景"三个维度快速选型:
| 使用方式 | 典型写法 | 适用场景 | 主要注意点 |
|---|---|---|---|
keySet()+ 只遍历key | for (K key : map.keySet()) | 只关心key本身 | 视图非快照,改动会作用到原Map |
keySet()+get(value) | 循环里调map.get(key) | 小Map、临时脚本 | 大Map下多一次哈希查找 |
entrySet() | for (Entry e : map.entrySet()) | 同时需要key和value | 最推荐,避免二次查找 |
Iterator+remove() | while (it.hasNext()) { it.remove(); } | 边遍历边删 | 唯一安全的遍历删除方式 |
removeIf | map.keySet().removeIf(predicate) | 按条件批量删除key | 谓词中不能再改Map结构 |
forEach | map.forEach((k, v) -> ...) | 同时处理key/value | lambda内不能安全删除 |
Stream | entrySet().stream().filter().map().collect() | 筛选、排序、聚合 | 不修改原Map,只产出新集合 |
反向Map /BiMap | inverse().get(v) | 频繁按value查key | 要处理value唯一性约束 |
6.2 我的选型习惯与几点忠告
最后聊点我实际写代码时的习惯。如果是普通业务逻辑、Map规模不大,我默认用entrySet(),因为key和value都能拿,逻辑完整,可读性也最好。只有当确确实实只需要key集合(比如统计key的数量、判断key是否存在、把key传给下游接口)时,才用keySet()。
关于Stream,我的态度是"该用才用"。筛选、排序、转集合这些操作,Stream确实表达力强;但如果只是简单遍历,强行套stream()反而显得绕。代码评审时经常看到有人一个三行的循环非要改写成两行流式,结果可读性不升反降。好的代码首先是让下一个维护的人容易看懂,其次才是"炫技般的简洁"。
还有一点要特别提醒:"取第一个key"这个需求要慎重。有人图省事直接写map.keySet().iterator().next(),一旦Map为空,这句必抛NoSuchElementException。更重要的是,HashMap本身根本没有"第一个"的概念,只有LinkedHashMap或TreeMap才谈得上确定的顺序。如果业务上要求"取最早插入的key"或"取最小的key",先检查你的Map是不是选对了实现类,再谈怎么写。
深挖"获取Map的key值"这个过程,你会发现它真正考验的是对Map接口设计意图的理解:三种视图各司其职,迭代器负责安全地修改,函数式写法简化表达,反向查找则是对数据结构不对称性的补充。把这些点串起来,以后写遍历逻辑的时候,基本不会再犯那种"一边遍历一边删、删完还一脸懵"的低级错误了。我自己就是在线上排查过两次ConcurrentModificationException之后,才彻底把iterator.remove()和removeIf刻进肌肉记忆里的。希望这篇梳理能帮你省掉那几个本不该踩的坑。