☰
Java Map获取Key全指南:视图与副本、遍历方式与性能选型
2026/10/13 3:49:10 网站建设 项目流程

做了几年业务开发,几乎每天都会和 Map 打交道。很多人觉得“获取 Map 的 key 值”是个再简单不过的操作,直接map.keySet()一把梭就完事了。但真把需求铺开——要导出 key 列表?要过滤一部分 key?边遍历边删?还得兼顾性能?——不同写法的差距一下就出来了。

这篇文章我从实际开发的角度,把获取 Map 的 key 值的几种常见方式、它们背后的原理、适用场景、以及我踩过的一些坑,一次性讲透。不管你是刚接触 Java 集合的新手,还是写了好几年代码但没细想过这些细节的开发者,都能在这里找到对应的答案。

1. 先想清楚一件事:你拿到的是“视图”还是“副本”

聊获取 key 的方式之前,我建议你先搞清楚keySet()返回的到底是什么东西。很多人在这上面栽过跟头,不是语法不会,而是对这个返回值的性质理解有偏差。

1.1 keySet() 返回的是视图(View)而非拷贝

map.keySet()返回的是一个Set<K>,但这个 Set 本质上不是一份独立的数据快照,而是 Map 内部结构的“投影”。换句话说,它就像一个实时联动的窗口——你通过这个 Set 看到的内容,永远跟 Map 当前的状态保持一致。

Map<String, Integer> map = new HashMap<>(); map.put("A", 1); Set<String> keys = map.keySet(); System.out.println(keys); // 输出 [A] map.put("B", 2); System.out.println(keys); // 输出 [A, B],注意 keys 不需要重新获取,自动就变了 map.remove("A"); System.out.println(keys); // 输出 [B],视图跟着变动

这个特性在日常开发里非常有迷惑性。举个例子:我当年给某个后台管理系统做导出功能时,先用keySet()拿到了一份 key 集合,准备分页处理。结果在遍历过程中别的线程往 Map 里写了新数据,我手里的“快照”突然多了几个 key,导致分页总数对不上。查了半天才发现,原来这个 Set 根本不是当时的副本,而是一直连着 Map 本体。

如果你确实需要一份在某一时刻冻结的 key 列表——比如要传给另一个线程、要多次独立操作,或者不想受到后续 Map 修改的影响——那就要手动创建副本,通常用new ArrayList<>(map.keySet())或者Set.copyOf(map.keySet())(Java 9+,但要注意Set.copyOf要求元素非空)。这点是很多初学者完全没意识到的,但也恰恰是面试里最爱考的隐藏点。

提示:判断一个 API 返回的是视图还是副本,最直接的办法是看 JavaDoc。keySet()的文档明确写了“The set is backed by the map”——记住“backed by”这个词,凡是出现它,就说明是视图,改动会双向联动。

1.2 什么时候必须显式拷贝一份 key 集合

我总结了自己实际遇到的情况,符合下面任何一条,都建议你直接拷贝:

  • 要跨线程传递 key 集合。比如主线程收集 key,交给异步线程处理,这时视图的实时联动反而会导致并发问题。
  • 要在拿到 key 集合之后,再对原 Map 做结构性修改。比如你打算遍历 key 的同时删掉一半条目,直接遍历视图很容易触发并发修改异常。
  • 要多次遍历集合,且每次遍历之间 Map 都可能变化。比如处理一批 key 时要分批查询、分批写日志,中间任何一次 Map 变动都会让结果不可预期。
  • 需要随机访问,或者要对列表做排序等 List 操作。keySet()返回的是 Set,不保证顺序(HashMap 场景),也没有get(index)方法,转成List会更灵活。

我见过不少老代码在处理类似需求时,为了省一行代码直接用map.keySet()做业务参数传递,结果出了诡异问题。这里真心提醒一句:省下的代码只有一行,排查的代价可能是好几个小时。该拷贝就拷贝,别犹豫。

2. 五种主流取 key 的方式逐个拆解

说完了视图和副本的背景,下面进入正题。获取 Map 的 key 值,常见的写法无非下面五种。每种我都写了代码示例和适用场景,方便对照。

2.1 最基础:keySet() 配合 for-each 循环

这是绝大多数人第一个接触的写法,简单粗暴:

Map<String, Integer> map = new HashMap<>(); map.put("Java", 1); map.put("Python", 2); map.put("Go", 3); for (String key : map.keySet()) { System.out.println("Key = " + key); }

这个写法本质上做了两件事:调用keySet()拿到 key 的集合视图,然后用 for-each 语法糖遍历它。编译后的字节码其实是用迭代器实现的,所以 for-each 在这里可以安全地覆盖 Set 的遍历。

适用场景:你只关心 key,不关心 value;或者你拿到 key 之后需要做什么复杂的查找、校验、组装,value 要在后续业务逻辑中按需获取。这种场景下,keySet()遍历是最直接、语义最清晰的方式。代码读起来一目了然,团队协作时别人一看就知道你要的是 key 集合。

但注意,如果遍历体内部每次都调用map.get(key)去拿 value,那就不太划算了,这一点我在第三章会详细讲。总之,只遍历 key 本身,这个写法完全没问题。

2.2 更高效:entrySet() 一次性拿到 key 和 value

如果遍历过程中你既需要 key 又需要 value,那我会优先推荐entrySet():

for (Map.Entry<String, Integer> entry : map.entrySet()) { String key = entry.getKey(); Integer value = entry.getValue(); System.out.println("Key = " + key + ", Value = " + value); }

entrySet()返回的是Set<Map.Entry<K, V>>,每个元素同时持有 key 和 value 的引用。你不需要在遍历体内再调用map.get(key),直接从entry里取就行。

我来说说为什么推荐它。很多高中生水平的代码喜欢写成for (String key : map.keySet()) { int v = map.get(key); ... },这在数据量小的时候没问题,但一旦 Map 里塞了几万条数据,这种写法就会带来不必要的性能损耗。

简单估算一下:HashMap.get()的时间复杂度是 O(1),但这里有个前提——它要计算 key 的 hashCode、定位桶、可能还要比较 equals。如果你在遍历 N 个 key 时都调用一次get(),那就是额外做了 N 次哈希查找。而entrySet()直接通过迭代器拿到当前节点的 key 和 value,省去了这 N 次查找。数据量大时,这个差距会被放大到肉眼可见的程度。

我用一个生活化的类比来解释:keySet() + get(key)好像你有一张学生名单(key 列表),每念到一个名字,你都要跑去教务系统重新查一遍这个学生的成绩(value);而entrySet()相当于你直接拿到的是一张“姓名-成绩”对照表,念到哪一行,两边信息同时都有了。谁更高效,一目了然。

注意:如果你的遍历体里压根用不到 value,那就不要用entrySet()。因为创建Entry的迭代粒度是 kv 成对出现的,只取 key 却不看 value,多少有点“杀鸡用牛刀”,代码语义上也不够纯粹。真正的最佳实践是:只要 key 就用 keySet(),只要 value 就用 values(),key 和 value 都要才用 entrySet()。

2.3 Java 8 的 forEach 方法

Java 8 之后,Map 接口本身提供了forEach(BiConsumer)方法,可以直接对每个键值对执行操作:

map.forEach((key, value) -> { System.out.println("Key = " + key + ", Value = " + value); });

注意,这里用的不是keySet().forEach(),而是Map自己的forEach方法。它直接遍历内部的键值对集合,在函数式接口里同时给你 key 和 value 两个参数,写起来非常简洁。

那如果我只想取 key 呢?其实也可以配合流式写法:

map.keySet().forEach(key -> System.out.println("Key = " + key));

这里keySet()返回的 Set 实现了Iterable接口,所以可以直接调用forEach。

我在实际项目里的经验是,forEach写起来舒服,但有两个点要特别注意:

  • 对局部变量的修改限制:Lambda 表达式里不能修改外部普通局部变量,因为编译器要求被捕获的变量必须是 effectively final。我早期写过类似下面这种代码:
int count = 0; map.forEach((k, v) -> count++); // 编译报错

正确做法是改用AtomicInteger,或者干脆用普通 for-each 循环。这个不是 bug,是语言设计上对 Lambda 的约束,理解就好。

  • 遍历过程中删除元素仍然要小心:在forEach里直接map.remove(key)同样会触发ConcurrentModificationException,原因和普通迭代器是一样的。后面第九章会专门说这个问题。

2.4 Stream 方式:过滤、排序、收集一条龙

如果你对集合要做的操作不只是“打印一下”,而是有过滤条件、排序需求、或者要转成另一种集合结构,那 Stream 就是最顺手的工具:

Map<String, Integer> map = new HashMap<>(); map.put("apple", 1); map.put("banana", 2); map.put("cherry", 3); List<String> result = map.keySet().stream() .filter(key -> key.startsWith("b")) .collect(Collectors.toList());

这里map.keySet()拿到 key 的 Set,然后就可以像流水线一样处理:filter筛选、sorted排序、collect收集、map转换…… 而且这套链式写法可读性很强,业务逻辑一目了然。

举一个业务场景:某促销系统里有一张 Map,存放着“商品编码 -> 库存”,现在要找出所有“库存在 100 件以下”且“商品编码以字母 A 开头”的商品列表。如果不用 Stream,你得写一个 for 循环,加两个 if 判断,再来一个 ArrayList 收集。用了 Stream 就是三行链式调用的事:

List<String> lowStockKeys = stockMap.keySet().stream() .filter(k -> k.startsWith("A")) .filter(k -> stockMap.get(k) < 100) .collect(Collectors.toList());

这里我故意保留了stockMap.get(k)的写法,因为过滤条件涉及 value,而 keySet 流里只有 key。如果你不想在流里频繁调用get,可以使用map.entrySet().stream()拿 kv 对,然后同时处理。但要注意,filter里对stockMap.get(k)的调用也会带来额外的哈希查找,数据量大的时候同样要权衡。是好是坏,看你取舍。

Stream 方式特别适合“以后可能还要改过滤条件”的场景。加一个筛选条件,就是链式调用里再加一行filter,维护体验比在 for 循环里堆 if 好太多。

2.5 迭代器方式:遍历时允许安全删除

前面提到的 for-each 和 forEach 看起来方便,但在“边遍历边删除”的场景下都会出问题。这时候,Iterator反而是最安全的选择:

Iterator<String> it = map.keySet().iterator(); while (it.hasNext()) { String key = it.next(); if (key.startsWith("temp")) { it.remove(); } }

这个写法的关键在it.remove()。它删除的是“当前迭代到的元素”,并且会把modCount同步更新,因此不会触发并发修改异常。相比之下,for-each 里直接调用map.remove(key),迭代器检测到modCount被改动,立刻抛异常给你看。

我的建议是:凡是遍历中需要删除元素的业务,优先使用Iterator.remove()。它不只是能删 key,在entrySet()迭代器里同样能删,而且删完之后 Map 的结构是正确的。很多老手写代码时会刻意使用迭代器而不是 for-each,就是为了保留这个删除能力。

另外,如果在迭代过程中你对 key 的判断条件比较复杂,可以先收集到临时列表,再统一删除:

List<String> toRemove = new ArrayList<>(); for (String key : map.keySet()) { if (key.startsWith("temp")) { toRemove.add(key); } } toRemove.forEach(map::remove);

这个思路就是“先记录,后操作”,完全不碰迭代器,也不会抛异常。适合删除条件依赖外部状态或者比较绕的场景。

3. 这几种方式的底层原理和性能差异

知道了写法之后,很多人会问:它们之间性能差距到底多大?底层实现又有什么不同?这一章我来聊聊背后的东西。

3.1 HashMap 的结构决定了遍历顺序不固定

先简单提一下底层结构。以HashMap为例,它内部是一个数组(table)加链表(或红黑树)的组合。每个 key 经过 hash 后定位到桶(bucket),桶里再以链表或树形结构存储元素。

这个设计对遍历顺序的影响非常大:遍历顺序取决于元素在桶里的分布,而桶的分布由 hashCode 决定。所以你可能发现,同一批 key,往HashMap里放完再遍历,顺序跟插入顺序完全不同。这不是 Bug,而是设计使然——HashMap 根本不承诺顺序。

如果你对顺序有要求,有两个选择:LinkedHashMap保持插入顺序;TreeMap按键的自然顺序或自定义 Comparator 排序。这两个我在第四章会再提。

有人可能关心 Java 8 之后的红黑树优化。说句实话,红黑树是为了解决“大量 key 碰撞到同一个桶时,查找从 O(n) 退化为 O(logn)”的问题。它影响的是get()和put()的时间复杂度,对遍历的整体表现有一定帮助,但不是我们这次讨论的重点。理解遍历顺序不稳定这个特性就够了。

3.2 keySet() 和 entrySet() 为什么都不持有额外数据

从实现上讲,HashMap的keySet()返回的是内部类KeySet的实例,entrySet()返回的是EntrySet的实例。它们都只是持有外层 Map 的引用,并没有把 key 或者 entry 单独复制一遍。

这意味着什么?意味着遍历keySet()时,迭代器是在 Map 内部结构上直接游走,一个个摘出 key 来给你;遍历entrySet()时,迭代器也是游走内部结构,但摘出来的是包含 kv 的节点对象。就“遍历”这个动作本身而言,两者开销差异不大。

关键在于,如果你使用keySet()遍历后又调用map.get(key)拿 value,那每一次get都是一次独立的哈希查找。N 个 key 就是 N 次hash & 定位 & equals,这一部分开销在数据量大时会非常明显。

我在自己项目里简单对比过,同样是遍历一个有几十万条数据的 Map,只做遍历的keySet()和entrySet()几乎拉不开差距;但一旦keySet()循环体内加上map.get(key),总耗时明显上涨,最严重时能感觉到变慢一倍以上。不要相信那种所谓“keySet 永远比 entrySet 慢”的片面说法,要看你拿 key 之后做了什么。

3.3 fail-fast 机制和 modCount

很多人在遍历 Map 的时候遇到过ConcurrentModificationException,但不知道它从哪来的。这里就要提到modCount和 fail-fast。

modCount是 AbstractList、HashMap 等集合内部的一个修改计数器。每次对集合做结构性修改(比如 add、remove、clear,但覆盖已有 key 的 put 不算),modCount就会自增。创建迭代器时,迭代器会记录当时的modCount值;之后每次调用it.next(),迭代器都会比较当前modCount和记录值,不一致就立即抛出ConcurrentModificationException。

好消息是,这个机制能让错误尽早暴露,避免你在不确定的状态下继续操作。坏消息是,它依赖的是“快速失败”,而不是“绝对安全”。也就是说,如果多线程同时操作同一个 HashMap,即使你没有在单线程里边遍历边删,也可能因为另一个线程修改了 Map 而抛异常。

所以我的建议是:多线程共享并修改同一个 Map 的场景,别用 HashMap 硬扛,要么加锁,要么换并发集合。具体并发环境下的选择,第五章会讲。

提示:面试中被问到“为什么 for-each 遍历时删除元素会抛 CME”,标准答案就是 fail-fast + modCount。能再补充一句“迭代器自身的 remove 方法会同步 modCount,所以用 Iterator.remove 删除是安全的”,基本就能证明你是真懂而不是背概念。

4. 不同应用场景下,我会怎么选型

写法有很多,实际开发时到底怎么选?这一章我按具体需求来分类,相当于给一份“选择指南”。

4.1 只需要 key 列表本身

如果目的只是拿到所有 key,比如输出到日志、传给前端下拉框、遍历判断存在性等,直接用keySet()就好,没必要牵扯 value。代码最简洁,语义也最清楚。

有一种很常见的误用,我必须拎出来单独说:判断 Map 中是否存在某个 key,正确写法是map.containsKey(key),而不是把keySet()遍历一遍挨个比对。我见过有人写:

for (String k : map.keySet()) { if (k.equals(target)) { // 找到了 } }

这个写法不仅代码冗余,时间复杂度也是 O(n) 起步,而containsKey直接利用哈希定位,平均 O(1) 就出结果了。只要是一个合格的 Map 使用姿势,都应该用后者。

4.2 同时需要 key 和 value

首选entrySet()。不管是 for-each 还是entrySet().stream(),都比在keySet()循环里反复get()来得干净且高效。上面的代码已经演示过了,这里不再重复。

4.3 需要按某种顺序输出 key

HashMap 不保证顺序,如果你要“按照插入顺序”或者“按照 key 大小排序”,那就得选择合适的 Map 实现。

  • LinkedHashMap:维护了双向链表,遍历顺序就是插入顺序(还可以通过构造参数改成访问顺序,配合重写removeEldestEntry可以实现 LRU 缓存)。如果你要响应式地记录“用户最近浏览的 N 个商品”,这个结构就很好用。
  • TreeMap:按键的自然顺序(或用 Comparator 排序)存储。要是业务上需要按照 key 的字母顺序导出配置,那 TreeMap 比 HashMap 再手动 sort 要优雅得多。
  • 如果你不想换 Map 实现,也可以用 Stream 排序:
List<String> sortedKeys = map.keySet().stream() .sorted() .collect(Collectors.toList());

这种情况下你拿到的是一个有序 List,适合临时对齐顺序。注意sorted()没有参数时,需要 key 实现 Comparable 接口,否则要传一个 Comparator。

4.4 并发环境中获取 key

并发场景是很多开发者的心病,我多说几句。

先说ConcurrentHashMap。它的迭代器是弱一致性的:迭代过程中不会抛出ConcurrentModificationException,但不保证能看到迭代期间新增的元素,也不保证一定能反映出迭代开始后最新状态。简单说,它牺牲了一点实时性,换来了高并发下的安全性。如果你在并发环境下遍历 key 做统计、做快照,用ConcurrentHashMap的keySet()是安全的选择。

再说Collections.synchronizedMap()。它包装出来的 Map 在遍历时必须由外部手动加锁,否则多个线程同时遍历/修改时,仍然可能出问题。官方文档的规范写法是:

Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>()); synchronized (syncMap) { for (String key : syncMap.keySet()) { // 安全遍历 } }

注意这里锁的对象必须是syncMap本身,而不是其他任意对象。这个细节很容易被忽略,一旦锁错对象,整个同步机制就形同虚设。

最后说一下多线程同时修改 HashMap 的后果。不仅仅是可能抛 CME,极端情况下还可能让链表成环(Java 8 之前 resize 时更容易触发),导致 CPU 飙升到 100%。虽然 Java 8 改了头插法,成环问题大幅缓解,但绝不意味着你可以放心地在多线程下裸用 HashMap。我的原则很简单:没有一个明确的并发理由,就用单线程;一旦有并发,就老老实实选并发集合或加锁。

5. 常见问题与排查技巧实录

这一章是我实际开发中踩过坑、帮别人排查过问题的集中记录,每一条都有血有泪。

5.1 空指针:map 本身为 null

if (map.keySet().contains("key")) { // 如果 map 为 null,这一行直接 NPE }

这行代码看着没有问题,但一旦map是 null,调用map.keySet()就会直接抛NullPointerException。业务代码里最容易被忽略的就是这种上游可能返回 null 的情况。

我的习惯是:对来源不可控的 Map,先判空再操作。

if (map != null && map.containsKey("key")) { // 业务逻辑 }

别嫌啰嗦,空指针就是在这种不起眼的地方埋伏你的。

5.2 自定义对象作为 key:hashCode 和 equals 必须重写

把自定义对象作为 Map 的 key 时,很多人会忘记重写hashCode()和equals()。如果不重写,默认的Object.hashCode()是基于内存地址的。这意味着两个内容完全相同的对象,会因为是两个不同实例而被当成两个 key,导致你用“看起来一样”的对象去map.get()时,可能什么都取不到。

举个实际场景:某系统中用“订单号+商品编码”组合作为 key。很多新人直接用两个字段拼成 String,这没问题;也有人图省事用一个内部类而不重写 hashCode/equals,结果查数据时一脸懵。

我是这么处理的:自定义 key 类一律重写hashCode()和equals(),并且保证参与equals的字段和参与hashCode的字段一致。否则 equals 相同但 hashCode 不同,哈希表照样找不到桶。这个约束务必记牢。

5.3 遍历过程中增删元素

总结一下遍历时修改集合的几种安全方式:

  • 使用Iterator.remove(),可安全删除当前元素。
  • 先收集要删除的 key,遍历结束后统一删除。
  • 使用ConcurrentHashMap的迭代器,在并发修改下有一定容忍度。
  • Java 8 的Map.removeIf(如果用的是keySet()或entrySet(),Java 8 的 Collection 接口有removeIf方法):
map.keySet().removeIf(key -> key.startsWith("temp"));

removeIf内部也是基于迭代器实现的,所以能安全移除。这个 API 很多人不知道,但真的很实用。

下面这个表格,是我工作中经常用到的速查表,顺手整理出来:

需求场景推荐方式注意点
只取 keykeySet()返回视图,不是副本
key 和 value 都要entrySet()避免二次get()
遍历中删除Iterator.remove()/removeIf不要在 for-each 里直接 remove
过滤、排序、转 ListkeySet().stream()复杂条件可链式追加
并发遍历ConcurrentHashMap迭代器弱一致性,不抛 CME
需要稳定顺序LinkedHashMap/TreeMap按插入序或自然序
判断 key 是否存在containsKey()不要遍历 keySet 比较

5.4 关于其他语言中的类似操作

既然标题写的是“Map”,我想顺带提一下其他语言里类似的操作,让跨语言开发者也能快速对标。

Python 里,dict.keys()返回的是一个视图对象dict_keys,也会随着原字典的变化而自动更新。如果你想拿到独立列表,需要显式list(d.keys())。这一点和 Java 的keySet()视图逻辑很像,都强调“视图 vs 副本”的差别。

JavaScript 里,Map.prototype.keys()返回一个迭代器对象,可以用for...of遍历,也可以用Array.from(map.keys())转数组。它同样不是静态快照,而是惰性求值的迭代器。

看到没有,跨语言对比之后,核心概念是一致的。理解了 Java 的“视图”思维,你再去看 Python 的dict_keys、JS 的迭代器,会发现都是同一个道理。这也是我为什么在一开始就花大篇幅讲视图和副本的原因。

我个人在实际开发里的习惯是:只要项目里同时存在“遍历 key”和“遍历 key-value”的需求,我会优先统一用entrySet(),因为大部分场景下 kv 成对处理的机会更多。但我也保留了一个底线——如果某段代码只关心 key,我绝不为了一点“可能以后要用 value”的猜测去写entrySet()。代码的语义清晰度比那一点微弱的性能差异更重要。

最后再分享一个我从小用到大的小技巧:当你拿不准 Map 某个 API 的返回行为时,别靠猜,打开源码看一眼。比如keySet()的实现类KeySet,里面清清楚楚写着它是视图、迭代器怎么工作、remove方法如何联动。源码不会骗人,网上那些二手博客反而经常抄错。

这篇文章写到这里,该覆盖的点都已经说透了。剩下的就是你在自己项目里多写、多测、多踩几个坑——踩过了,才会真正记住“视图不是副本”“遍历时别乱删”这些原则。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询