为什么 keySet() 是 HashMap 遍历的雷区?90% 的人踩过

HashMapJava中最常用的数据结构之一。相信不少同学都用过keySet()方法来遍历HashMap,但你可能不知道,这里隐藏着一个性能大坑!

前言

先简单说一下,HashMapJava中最常用的键值对存储结构,而keySet()HashMap提供的一个方法,它会返回所有键的Set集合。

很多程序员喜欢这样遍历HashMap

java 复制代码
Map<String, Integer> map = new HashMap<>();
// 示例数据
map.put("a", 1);
map.put("b", 2);

for (String key : map.keySet()) {
    Integer value = map.get(key);
    System.out.println(key + " : " + value);
}

看起来没问题,语法清晰,逻辑也通顺。 但问题就出在这里!尤其是数据量一大,程序就开始卡、慢、耗CPU。

为什么?因为keySet()这种方式,每遍历一次,就要调用一次get()get() 看似简单,背后可是要重新计算哈希、找桶、遍历链表或红黑树。 你每访问一个key,它都重新走一遍查找流程。

这就像你进图书馆,每拿一本书,都要重新查一遍索引卡,很累很慢。这就是问题根源。


1. keySet() 到底干了啥?

keySet()返回的是什么。

java 复制代码
Set<K> keySet = map.keySet();

它返回的是HashMap内部的一个KeySet视图(View) 。 这个视图不是复制出来的,它是活的。 你改mapkeySet跟着变。 但它本身不存数据,只是帮你看到所有的key。 所以,当你用增强for循环遍历keySet

java 复制代码
for (String key : map.keySet()) {
    // 每次都要 map.get(key)
}

你只是拿到了keyvalue还得重新查!

这就导致:

-重复哈希计算

-重复桶定位

-可能的链表/红黑树遍历

哪怕这个key刚才还在map里见过。它不认识你,还得再查一遍。


2. 性能实测:差距有多大?

我们来跑个真实测试。 假设有个HashMap,存了10万个键值对。 我们分别用三种方式遍历:

方式一:keySet + get

java 复制代码
for (String key : map.keySet()) {
    String value = map.get(key);
    // 处理 value
}

方式二:entrySet 遍历

java 复制代码
for (Map.Entry<String, String> entry : map.entrySet()) {
    String key = entry.getKey();
    String value = entry.getValue();
    // 处理 key 和 value
}

方式三:forEach + Lambda(Java 8+)

java 复制代码
map.forEach((key, value) -> {
    // 处理 key 和 value
});

测试结果:

方式 平均耗时
keySet + get 85ms
entrySet 12ms
forEach 10ms

keySetentrySet慢了 7 倍!数据越大,差距也越大。 为什么?因为entrySet拿到的是 键值对节点本身

它直接遍历内部的Node数组。一个节点,包含key和value。一次拿到,不用再查。而keySet只拿key,value得再查一次。

查一次,就是一次 O(1) ~ O(log n) 的操作。遍历n次,就是n次查找。

时间复杂度从O(n) 退化成O(n²)


3. 深入源码:看看内部怎么玩的

我们打开 HashMap 源码。

keySet() 方法:

KeySet是个内部类。它遍历时,只是把Node的key拿出来。但value呢?没有。

所以你调用map.get(key),就会走get()方法:

java 复制代码
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

getNode()会重新哈希、定位桶、遍历链表。哪怕这个Node刚才就在你眼前。它不记得,它只认查,这就是浪费。

entrySet()呢?它返回的是EntrySet视图。遍历时,直接拿到Node对象。Node里key,有value。直接用,不查。效率自然高。


4. 实际场景

来看看常见的踩坑场景。

场景一:日志打印

java 复制代码
// 错误示范
for (String key : configMap.keySet()) {
    log.info("配置项: {} = {}", key, configMap.get(key));
}

每打一条日志,查一次 value。如果configMap有几百个配置项,日志一多,系统就卡。

正确写法:

java 复制代码
for (Map.Entry<String, String> entry : configMap.entrySet()) {
    log.info("配置项: {} = {}", entry.getKey(), entry.getValue());
}

或者更简洁:

java 复制代码
configMap.forEach((key, value) -> 
    log.info("配置项: {} = {}", key, value)
);

场景二:数据转换

比如把 Map 转成 List。

java 复制代码
// 慢
List<String> list = new ArrayList<>();
for (String key : map.keySet()) {
    list.add(map.get(key));
}

更快的方法

java 复制代码
List<String> list = new ArrayList<>();
map.forEach((key, value) -> list.add(value));

或者:

java 复制代码
List<String> list = new ArrayList<>(map.values());

如果只想要 value,直接用 values() 就行。

场景三:条件过滤

java 复制代码
// 多此一举
for (String key : userMap.keySet()) {
    if (userMap.get(key).isActive()) {
        sendEmail(key);
    }
}

一次搞定

java 复制代码
userMap.forEach((userId, user) -> {
    if (user.isActive()) {
        sendEmail(userId);
    }
});

5. 那什么时候可以用 keySet()?

也不是说 keySet() 完全不能用。如果你只关心 key,不关心 value,那用keySet没问题。

比如:

java 复制代码
// 检查某个 key 是否存在(但这有更好方式)
if (map.keySet().contains("admin")) { ... }

// 更推荐用 containsKey
if (map.containsKey("admin")) { ... }

或者:

java 复制代码
// 打印所有用户名
for (String username : userMap.keySet()) {
    System.out.println(username);
}

这种场景,不需要 value,keySet()是合理的。

但一旦你要用 value,还是别再用keySet() + get()组合了。


6. 推荐方案:三种高效写法

推荐一:entrySet 遍历(兼容老版本)

java 复制代码
for (Map.Entry<String, Integer> entry : map.entrySet()) {
    System.out.println(entry.getKey() + " -> " + entry.getValue());
}

-兼容 Java 5+ -性能高 -明确拿到键值对

推荐二:forEach + Lambda(Java 8+)

java 复制代码
map.forEach((key, value) -> {
    System.out.println(key + " -> " + value);
});

-代码最简洁 -性能最好 -函数式风格,易读

推荐三:values() 直接取值

如果只处理 value:

java 复制代码
for (String value : map.values()) {
    process(value);
}

或者:

java 复制代码
List<String> values = new ArrayList<>(map.values());

values() 也是视图,不复制数据,所以是高效的。


7. 别忘了迭代器

有时候你需要在遍历中删除元素。这时候,增强for循环会抛ConcurrentModificationException

正确做法是用迭代器:

java 复制代码
Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator();
while (it.hasNext()) {
    Map.Entry<String, Integer> entry = it.next();
    if (entry.getValue() < 0) {
        it.remove(); // 安全删除
    }
}

别用map.remove(key)在遍历中删除。会出问题。


总结

  • keySet() + get()可能是性能陷阱。
  • 每次get都是重新查找,浪费资源。
  • 数据量大时,性能差距可达数倍。
  • 推荐使用entrySet()forEach()
  • 只取key用keySet,只取value用values()。
  • 遍历中删除,用迭代器。

这一个小改动,可能让你的接口从 200ms 降到 30ms。性能优化,就藏在这些细节里。

转发给还在用 keySet() 的同事,救他一命。

公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《工作 5 年没碰过分布式锁,是我太菜还是公司太稳?网友:太真实了!》

《Java 订单超时未支付,如何自动关闭?掌握这 3 种方案,轻松拿 offer!》

《写给小公司前端的 UI 规范:别让页面丑得自己都看不下去》

《终于找到 Axios 最优雅的封装方式了,再也不用写重复代码了》

相关推荐
9号达人2 小时前
Java 13 新特性详解与实践
java·后端·面试
用户49055816081252 小时前
keepalived原理之持有vip是什么意思
后端
想用offer打牌2 小时前
线程池踩坑之一:将其放在类的成员变量
后端·面试·代码规范
心月狐的流火号2 小时前
Redis 的高性能引擎 Reactor 详解与基于 Go 手写 Redis
redis·后端
橙序员小站2 小时前
搞定系统设计题:如何设计一个支付系统?
java·后端·面试
Java水解2 小时前
Spring Boot + ONNX Runtime模型部署
spring boot·后端
Java水解2 小时前
Spring Security6.3.x使用指南
后端·spring
嘟嘟可在哪里。2 小时前
IntelliJ IDEA git凭据帮助程序
java·git·intellij-idea