1. 先回答一个绕不开的问题:遍历Map为什么被问得最多
做Java后端的人,几乎没有一天不和Map打交道。登录态存Redis前的本地缓存、接口参数校验的白名单、配置项的键值加载、报表统计的分组聚合——Map几乎是无处不在的基础设施。但有意思的是,很多写了三五年Java的人,你让他当场写一段遍历Map的代码,他大概率能写出来,可是你问他"为什么用entrySet而不是keySet""遍历的时候能不能直接remove""ConcurrentHashMap遍历时到底安不安全",往往就开始含糊了。
这三个问题,恰好就是面试八股文里关于Map遍历最常被追问的考点,也是实际开发中非常容易踩坑的地方。我见过线上故障就是因为有人在遍历HashMap时直接调了remove,导致ConcurrentModificationException,请求大面积报错。也见过统计接口因为用keySet之后又挨个get,数据量大时接口耗时翻了一倍。
所以这篇博文我不打算只列五种遍历方式然后各贴一段代码,那是任何教程网站都能给的。我想做的是把遍历这件事从"能跑"讲到"为什么这样跑",把每个遍历方式的底层机制、性能差异、适用场景、坑点都摊开来说。无论你是在准备面试,还是在维护一个QPS比较高的服务,这篇文章应该都能给你一些参考。
先给一个速览表,后面逐一展开:
| 遍历方式 | 核心代码 | 能拿到什么 | 是否支持遍历中删除 |
|---|---|---|---|
| entrySet | for (Map.Entry<K,V> e : map.entrySet()) | key和value | 支持(用Iterator) |
| keySet | for (K k : map.keySet()) | 只有key,value需要再get | 支持(用Iterator) |
| values | for (V v : map.values()) | 只有value | 支持(用Iterator) |
| Iterator | Iterator<Map.Entry<K,V>> it = map.entrySet().iterator() | key和value | 直接支持it.remove() |
| forEach + Lambda | map.forEach((k, v) -> {...}) | key和value | 不支持,会抛异常 |
这张表每行背后都有值得细说的地方,下面逐个来。
2. 五种主流遍历方式的底层原理与适用场景
2.1 entrySet:默认首选,最正统的遍历方式
Map<String, Integer> map = new HashMap<>(); map.put("order", 1200); map.put("refund", 320); map.put("coupon", 88); for (Map.Entry<String, Integer> entry : map.entrySet()) { String key = entry.getKey(); Integer value = entry.getValue(); // 业务处理 }为什么推荐默认用它?因为entrySet()返回的是Set<Map.Entry<K,V>>,这个Set里的每个元素同时持有key和value的引用。你在一次遍历里可以同时拿到两者,不需要额外查询。
关键点在Map.Entry本身。它不是把key和value复制一份打包,而是HashMap内部Node节点的一个视图。Node实现了Map.Entry接口,getKey()返回节点的key字段,getValue()返回节点的value字段。所以遍历entrySet本质上是直接操作哈希桶数组和链表/红黑树里的节点,没有额外的拷贝和查找开销。
我们经常在源码里看到遍历Map的逻辑是这么写的:
for (Map.Entry<String, String> entry : map.entrySet()) { String key = entry.getKey(); String value = entry.getValue(); }反编译之后,这段语法糖会变成:
Iterator<Map.Entry<String, String>> it = map.entrySet().iterator(); while (it.hasNext()) { Map.Entry<String, String> entry = it.next(); ... }也就是说for-each本质就是Iterator的语法糖。这个问题后面第4节还会展开,先记住这个结论。
2.2 keySet:直观但隐藏着一次get的开销
for (String key : map.keySet()) { Integer value = map.get(key); // 业务处理 }这段代码看起来非常直观:先拿到所有的key,再用key去map里取value。但有经验的人一眼就会皱眉:每循环一次,就调用一次map.get(key),而get本身就是一次哈希查找。
HashMap的get流程是:先计算key的hashCode,再定位到对应的哈希桶,如果是链表就沿着链表一个个比较,如果是红黑树就走树的查找。这个过程的平均时间复杂度是O(1),但注意,这是建立在哈希函数分布均匀的前提下的。一旦哈希冲突多了,链表的查找就退化成O(n),再加上你外层还要循环n次,整体就变成O(n^2)了。
当然,绝大多数情况下HashMap的哈希分布是均匀的,get的O(1)让整体复杂度依然是O(n),性能差距在数据量小的时候几乎看不出来。但有一个场景keySet会明显吃亏:当Map是TreeMap的时候。TreeMap的get是红黑树查找,复杂度是O(log n),你用keySet遍历再逐个get,整体就是O(n log n)。而entrySet遍历,因为节点本身就同时持有key和value,TreeMap的entrySet()同样能直接拿到值,整体是O(n)。
我自己做过一个不算严谨的测试:向一个TreeMap里放10万条数据,keySet遍历+get大约耗时35ms,entrySet遍历大约12ms。数据量再翻几倍,差距会更明显。所以如果Map是TreeMap,或者将来可能换成TreeMap,尽量用entrySet。
2.3 values:只关心value时的快捷路径
for (Integer value : map.values()) { // 业务处理 }如果业务逻辑只用得到value,不需要key,那values()是最简洁的。它返回的Collection<V>视图,背后还是那些节点,所以迭代效率也很高。
这个方式的适用场景典型的有:统计一个Map里所有订单金额的总和、把所有value收集成List、或者判断所有value是否满足某个条件。比如:
BigDecimal totalAmount = map.values().stream() .map(order -> order.getAmount()) .reduce(BigDecimal.ZERO, BigDecimal::add);有些人在只需要value的时候仍然用entrySet,然后只拿value不拿key,这倒也不算错,但代码上多了一点点无意义的key获取。更关键的是,values()返回的是视图,它支持迭代器的remove操作,这一点可能很多人不知道。后面第4节会提到。
2.4 Iterator:老牌方式,唯一的"遍历中安全删除"选项
Iterator<Map.Entry<String, Integer>> iterator = map.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<String, Integer> entry = iterator.next(); if (entry.getValue() < 100) { iterator.remove(); } }直接说结论:如果你需要在遍历Map的过程中删除元素,直接用Iterator的remove方法是唯一的正解。因为Iterator的remove会同步修改modCount,并把expectedModCount也更新掉,这样迭代器内部的状态是一致的,不会触发ConcurrentModificationException。
对比一下,如果你在for-each里直接调用map.remove(key),会发生什么?for-each虽然本质是Iterator,但它隐藏了Iterator对象。你调用map.remove(key)时,HashMap的modCount增加了,但迭代器内部的expectedModCount没变,下一次调用it.next()时就会检查到不一致,直接抛异常。
有意思的是,很多人以为在for-each里用map.remove()有概率不报错,其实那只是因为删的是倒数第二个元素时,循环正好结束,没有机会触发下一次next。这是一种侥幸,不是可靠行为。所以真心建议:要么用Iterator,要么先收集要删除的key,遍历完再统一删除。第二种方式更清晰:
List<String> keysToRemove = new ArrayList<>(); for (String key : map.keySet()) { if (map.get(key) < 100) { keysToRemove.add(key); } } keysToRemove.forEach(map::remove);这种方式在数据量不大的时候完全够用,而且可读性强。但如果数据量大,keysToRemove会占用额外内存,这也是一个取舍。Iterator方式则省掉了中间集合,性能上更有优势。
2.5 forEach + Lambda:Java 8的优雅写法,但有个隐藏限制
map.forEach((key, value) -> { // 业务处理 });Java 8引入的forEach让遍历Map变得极其简洁。它的底层实现其实很简单——Map接口提供了一个default方法:
default void forEach(BiConsumer<? super K, ? super V> action) { Objects.requireNonNull(action); for (Map.Entry<K, V> entry : entrySet()) { K k; V v; try { k = entry.getKey(); v = entry.getValue(); } catch (IllegalStateException ise) { throw new ConcurrentModificationException(ise); } action.accept(k, v); } }注意看,它内部已经帮你把entrySet()和getKey/getValue封装好了。但有一个关键点容易被忽略:它本质上还是在遍历entrySet,并且它不支持在遍历过程中做结构性修改。如果你在Lambda里直接调用map.remove(key),同样会抛ConcurrentModificationException,而且因为Lambda表达式暗含了函数式接口的约束,异常发生在Lambda内部,排查起来反而更隐蔽。
所以forEach适合的场景是:纯读取、纯计算、或者累加统计,不涉及删除。比如计算所有value的总和:
AtomicInteger total = new AtomicInteger(0); map.forEach((k, v) -> total.addAndGet(v));注意这里用AtomicInteger是因为Lambda里不能直接修改外部普通变量的值,它要求外部变量是final或effectively final的。这是一个很绕的坑,新手经常在这里卡住。用数组或者AtomicXxx可以绕过,但更好的做法是直接用Stream:
int total = map.values().stream().mapToInt(Integer::intValue).sum();3. 不只是遍历:遍历中选择性移除元素的正确姿势
3.1 一条remove引发的ConcurrentModificationException
我当年第一次踩这个坑是在一个定时任务里。任务要做的事情是:扫描一个本地缓存Map,把过期时间超过10分钟的缓存项删掉。我当时写的是:
for (String key : cacheMap.keySet()) { CacheEntry entry = cacheMap.get(key); if (entry.expired()) { cacheMap.remove(key); } }测试环境数据量小,跑了几次居然没出问题。上了生产之后,某个峰值时段直接抛出ConcurrentModificationException,任务执行失败,缓存越积越多,最后把内存顶爆了。这就是那个著名的"删倒数第二个元素恰好没事"的侥幸心理害了我。
为什么测试环境能过?因为数据量小,remove之后循环恰好在下一次next之前结束了,没有触发检查。但生产环境数据量大,一旦删除之后还要继续遍历,it.next()就会抛出异常。
3.2 fail-fast机制到底是什么
ConcurrentModificationException背后的机制叫fail-fast。HashMap内部维护了一个modCount字段,每次发生结构性修改(增加、删除、扩容等)都会自增。迭代器在创建时会保存一份expectedModCount = modCount。每次调用next()时,都会检查modCount != expectedModCount,不一致就抛出ConcurrentModificationException。
这个机制的设计初衷是:在迭代过程中,如果外部修改了Map的结构,那迭代器拿到的数据可能已经错乱,继续迭代下去会产生不可预期的结果。所以干脆尽快失败,把问题暴露出来。
注意"结构性修改"的定义:覆盖一个已存在key的value不算结构性修改,因为它不改变Map的大小,modCount不会增加。而put一个新key、remove一个已有key、clear等操作都会改变结构。
所以你在遍历里做map.put(key, newValue),如果key已经存在,其实是安全的;但如果key不存在,就会触发异常。这个边界很多人不清楚。
3.3 正确的移除方式对比
| 方式 | 代码示例 | 注意点 |
|---|---|---|
| Iterator | it.remove() | 唯一安全的遍历中删除 |
| 收集后删除 | keysToRemove.forEach(map::remove) | 无异常风险,但多一次循环 |
| removeIf | map.keySet().removeIf(predicate) | Java 8起可用,底层是Iterator |
| Stream filter | map.entrySet().removeIf(e -> ...) | 和removeIf本质相同 |
这里强烈推荐removeIf。它是Java 8引入的Collection默认方法,keySet()返回的Set本质上也是一个Collection,所以removeIf可以直接用:
map.keySet().removeIf(key -> map.get(key).expired());或者更语义化一点,直接在entrySet上操作:
map.entrySet().removeIf(entry -> entry.getValue().expired());removeIf内部就是用Iterator实现的:
default boolean removeIf(Predicate<? super E> filter) { Objects.requireNonNull(filter); boolean removed = false; final Iterator<E> each = iterator(); while (each.hasNext()) { if (filter.test(each.next())) { each.remove(); removed = true; } } return removed; }所以它天然是线程不安全的,但单线程环境下用起来非常方便,既简洁又不会踩ConcurrentModificationException的坑。
3.4 大规模遍历时的性能实测
我有一次帮业务方优化一个数据对账服务,其中有一步是把两个Map做比对,Map里有50万条左右的数据。最初实现是用keySet遍历做差集,我改成entrySet遍历后,这一步的耗时从120ms降到了80ms左右。可能有人觉得40ms差距不大,但在对账这种动辄循环多轮的场景下,累加起来就有体感了。
我做了一个粗糙的JMH风格对比(不是严格压测,仅供参考),数据量100万条:
| 方式 | 耗时(毫秒) |
|---|---|
| entrySet for-each | 68 |
| keySet for-each + get | 91 |
| values for-each | 50 |
| Iterator entrySet | 70 |
| forEach + Lambda | 75 |
values最快是因为它只处理value,没有key的拆包装包。entrySet和Iterator差不多,forEach因为Lambda的调用开销略慢一点点但基本可忽略。keySet+get明显更慢,多出来的时间就是那100万次哈希查找。
如果你的业务对性能有极致要求,而且Map很大,还有一个细节:在遍历前先用map.size()判断是否为空,避免无意义的循环。另外,如果Map是ConcurrentHashMap且并发写入频繁,遍历时拿到的数据可能不是严格一致的快照,但至少不会抛ConcurrentModificationException。这个后面第5节说。
4. 不同Map实现下的遍历差异:HashMap、LinkedHashMap、TreeMap、ConcurrentHashMap
4.1 从数据结构反推遍历顺序
很多人在遍历Map时默认拿到的是无序的,但严格来说这取决于你用哪个实现类。
- HashMap:遍历顺序不保证,而且可能随着扩容变化。同一个HashMap,rehash前后遍历顺序都可能不一样。这是它的哈希桶数组+链表/红黑树结构决定的。
- LinkedHashMap:维护了一个双向链表,遍历顺序默认是插入顺序。你put进去的顺序,遍历出来就是这个顺序。这非常适合做LRU缓存或需要保持插入顺序的场景。
- TreeMap:底层是红黑树,遍历顺序是key的自然顺序或你传入的Comparator决定的顺序。所以如果你需要有序遍历,TreeMap是你的选择。
- ConcurrentHashMap:遍历顺序和HashMap一样不保证,但它的迭代器是弱一致性的,不会抛ConcurrentModificationException。
这些差异在实际开发中影响很大。比如你要导出一个报表,要求按订单创建时间排序,如果Map是LinkedHashMap且按时间插入,那直接遍历就是有序的。如果你用了HashMap,就得再排一次序。
4.2 TreeMap遍历的性能代价
TreeMap的entrySet()迭代器走的是红黑树的中序遍历,每个next()都要沿着树的路径移动,比HashMap顺着数组+链表/红黑树的线性遍历慢不少。我实测10万条数据时,TreeMap的entrySet遍历大约比HashMap慢3到5倍。但这是数据结构本身决定的,如果你要的就是有序性,这个代价是值得的。
4.3 ConcurrentHashMap的弱一致性迭代器
ConcurrentHashMap的迭代器有个特点:它们不是在某个时间点上的一致快照。遍历过程中,如果其他线程修改了Map,迭代器不会抛异常,但它可能看到新的值,也可能看到旧的值,也可能漏掉一些新插入的元素。这就是弱一致性。
这意味着:如果你的业务逻辑要求"遍历时看到的一定是同一时刻的数据快照",那ConcurrentHashMap的遍历满足不了这个需求。你要么在遍历前用map.entrySet().toArray()做一个快照,要么用map.snapshot()之类的思路(实际上JDK没有直接的snapshot方法)。一个常见做法是:
Map<String, Integer> snapshot = new HashMap<>(concurrentMap); for (Map.Entry<String, Integer> entry : snapshot.entrySet()) { // 处理快照,安全 }但注意这个拷贝本身也是弱一致性的,它拷贝时的数据是那一刻的状态,但拷贝过程中可能有并发修改。不过对于大多数业务来说,这种程度的一致性已经够用了。
4.4 遍历中的空值问题
HashMap允许key和value都为null,但ConcurrentHashMap不允许任何一方为null,TreeMap不允许key为null但value可以为null,LinkedHashMap跟HashMap一样都允许。
这意味着你在遍历时,如果某个value是null,用entry.getValue().someMethod()就会NPE。一个稳妥的做法是使用Map.getOrDefault或者在遍历时判空。另外,如果业务上不允许空值,建议在put的时候就用Objects.requireNonNull(value)把问题挡在入口处,而不是在遍历时去判断。
5. 遍历源码级分析:JDK里HashMap的迭代器究竟做了什么
5.1 HashIterator的nextNode逻辑
进入正题,HashMap的迭代器实现非常有代表性。它内部有一个HashIterator类,所有的迭代器(KeyIterator、ValueIterator、EntryIterator)都继承自它。
abstract class HashIterator { Node<K,V> next; // next entry to return Node<K,V> current; // current entry int expectedModCount; // for fast-fail int index; // current slot HashIterator() { expectedModCount = modCount; Node<K,V>[] t = table; current = next = null; index = 0; if (t != null && size > 0) { do {} while (index < t.length && (next = t[index++]) == null); } } public final boolean hasNext() { return next != null; } final Node<K,V> nextNode() { Node<K,V>[] t; Node<K,V> e = next; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); if ((next = (current = e).next) == null && (t = table) != null) { do {} while (index < t.length && (next = t[index++]) == null); } return e; } }这段代码是理解HashMap遍历性能的钥匙。
构造方法里干了一件很关键的事:它从哈希桶数组的第0个位置开始,跳过所有为null的桶,找到第一个非空节点。这样hasNext()才能判断是否还有下一个元素。
nextNode()的逻辑是:如果当前节点的next不为null,继续沿着链表/红黑树走;如果当前节点的next为null,则跳到下一个非空的哈希桶。这其实就是在遍历"哈希桶数组 + 每个桶内部的链表/红黑树"这个复合结构。
注意expectedModCount = modCount在创建迭代器时就固定了。遍历中如果别人改了Map结构,modCount变化,nextNode()就会抛异常。
5.2 链表退化成红黑树时,遍历方式也变了
当某个桶的链表长度超过8(且数组长度超过64)时,链表会转成红黑树。但HashMap迭代器遍历红黑树时,并不是走树的递归中序遍历,而是通过TreeNode继承自LinkedHashMap.Entry的before/after指针来线性遍历。
换句话说,HashMap在链表转红黑树时,会保留一个双向链表结构。TreeNode既是红黑树节点,也是链表节点。迭代器走的是这个链表,而不是树。所以即使某个桶已经是红黑树,遍历它仍然是线性的,复杂度O(n),不会因为树的深度增加而变慢。
这个设计细节很多人不知道,但它解释了为什么HashMap的遍历性能在单个桶数据很多时依然可以接受。
5.3 为什么TreeSet迭代比HashSet慢3~5倍
同理,TreeMap的迭代走的是红黑树,每个next()都需要从当前节点找到后继节点。如果当前节点有右子树,就找右子树的最左节点;否则向上回溯。这个过程涉及指针移动和路径回溯,不像数组+链表那样直接。
我实测TreeMap迭代100万条数据大约需要150ms,而HashMap只要50ms左右。这3倍的差距主要来自节点的指针跳跃和缓存不友好。所以在不需要有序性的场景,别用TreeMap——这是性能优化里一个很常见的建议。
6. 实际项目中最容易踩的遍历坑:并发修改、NPE与排序问题
6.1 多线程遍历:不要并发修改Map结构
前面说的ConcurrentModificationException大多是单线程内自己改自己引发的。但多线程场景也会有这个问题:线程A在遍历HashMap,线程B在put或remove,A就会抛出ConcurrentModificationException。
解决方案:
- 用ConcurrentHashMap替代HashMap
- 或者对Map做加锁
- 或者copy一份再遍历
但注意,即使改用ConcurrentHashMap,遍历过程中看到的数据也不是严格的快照,可能出现"遍历了A、B、C,中途另一个线程删了B又加了D,最终遍历结果是A、C、D"这种弱一致效果。如果业务不允许这种情况,那你就需要手动拍快照。
6.2 遍历时的NPE:values为null的业务判断
假设你有一个Map<String, List >,遍历时想统计每个key的列表大小:
for (Map.Entry<String, List<String>> entry : map.entrySet()) { int size = entry.getValue().size(); // 如果value为null,这里就NPE }有的同事偷懒,直接把put(key, null)放进去了,遍历时立刻爆炸。比较好的做法是put的时候就不允许null,或者遍历时统一处理:
for (Map.Entry<String, List<String>> entry : map.entrySet()) { List<String> list = entry.getValue(); if (list != null && !list.isEmpty()) { // 业务处理 } }6.3 排序键值对的两种常见姿势
如果Map本身不是排好序的,但业务上需要按key排序输出,通常有两种做法:
- 用TreeMap重新存储
- 遍历后放进List再排序
Map<String, Integer> sortedMap = new TreeMap<>(map);或者:
List<Map.Entry<String, Integer>> entries = new ArrayList<>(map.entrySet()); entries.sort(Map.Entry.comparingByKey()); for (Map.Entry<String, Integer> entry : entries) { // 已按key排好序 }如果按value排序,只能用第二种:
entries.sort((e1, e2) -> e2.getValue().compareTo(e1.getValue()));注意这里排序的是Map.Entry对象的List,不是Map本身。如果你需要的是"按value排序后仍然保持键值映射",List+循环是比较直观的方式。
7. 盘点我自己用过的几种实际业务场景中的遍历模式
7.1 缓存清理:遍历+过期判断
我维护过一个本地缓存,用ConcurrentHashMap存储,value是一个带过期时间的对象。清理任务每5分钟跑一次:
long now = System.currentTimeMillis(); cacheMap.entrySet().removeIf(entry -> now - entry.getValue().getTimestamp() > MAX_AGE);一行代码搞定,简洁且不会踩并发修改的坑。ConcurrentHashMap的removeIf底层用的是它的弱一致性迭代器,多线程下也不会抛异常。
7.2 分组聚合统计
订单列表按用户ID聚合金额:
Map<String, BigDecimal> userAmountMap = new HashMap<>(); for (Order order : orderList) { userAmountMap.merge(order.getUserId(), order.getAmount(), BigDecimal::add); } for (Map.Entry<String, BigDecimal> entry : userAmountMap.entrySet()) { // 输出每个用户的订单总额 }merge方法比传统的containsKey判断+put简洁得多,而且天然支持并发。JDK 8+都建议用merge或computeIfAbsent。再配合entrySet遍历输出,整个流程非常顺手。
7.3 前端渲染Map数据
返回给前端一个有序Map,比如按指定顺序展示字段:
Map<String, Object> result = new LinkedHashMap<>(); result.put("code", 0); result.put("message", "success"); result.put("data", dataList);用LinkedHashMap而不是HashMap,保证输出顺序和put顺序一致。遍历时:
for (Map.Entry<String, Object> entry : result.entrySet()) { // 按顺序处理 }这个场景在写接口返回结构时很常见。
8. 给面试和写代码的最终建议
8.1 面试怎么答"遍历Map有几种方式"
面试官问这个问题,通常不只是想听你列五种方式,更想听你讲清楚这几种方式的区别和适用场景。我建议按这个顺序组织回答:
- 先说结论:有entrySet、keySet、values、Iterator、forEach + Lambda五种
- 然后按使用频率展开:entrySet最常用,keySet直观但需要额外的get,values只在只需要值时用,Iterator是唯一能在遍历中删除的正解,forEach最简洁但要注意不能结构修改
- 最后补充原理:for-each本质是Iterator,HashMap迭代器遍历的是数组+链表/红黑树,ConcurrentModificationException是fail-fast机制在保护迭代一致性
- 如果被追问"为什么keySet性能差",直接点出"每循环一次就多一次哈希查找"这个关键
这样回答既有广度也有深度,能给面试官留下"真的写过代码"的印象。
8.2 项目里怎么选遍历方式
我的经验法则:
- 只需要value,用values
- 需要key和value,优先entrySet
- 遍历中要删,用Iterator或removeIf
- 代码简洁优先,用forEach + Lambda,但要确认没有删除操作
- 数据量小(几百条以内),怎么遍历都行,可读性优先
- 数据量大(几十万条以上),entrySet和values有明显优势,避免keySet+get
8.3 一个我反复用的工具方法
最后分享一个我写在自己项目里的工具方法,用来把Map转成有序的字符串,调试时打印特别方便:
public static String formatMap(Map<String, Object> map) { if (map == null || map.isEmpty()) { return "{}"; } StringBuilder sb = new StringBuilder("{"); map.forEach((k, v) -> sb.append(k).append("=").append(v).append(", ")); if (sb.length() > 1) { sb.setLength(sb.length() - 2); } return sb.append("}").toString(); }它用forEach+Lambda实现,一行一行拼接,最后去掉末尾的逗号。这个方法在我排查接口返回时经常用到,比起直接map.toString(),自定义格式化更可控。
其实遍历Map这个事,说到底是两个层面的问题:第一层是"能不能遍历",这个层面只要写得对,五种方式都会。第二层是"遍历得好不好",这才是区分经验多与少的地方。理解底层的迭代器实现、理解fail-fast机制、理解不同Map实现的结构差异,才能真正做到遇事不慌,写出既正确又高效、还容易维护的代码。
我在实际工作中见过太多因为不了解遍历细节而付出的成本:一个线上OOM是缓存清理逻辑在遍历中抛了异常导致缓存没清掉,一个接口超时是因为大数据量下用了keySet+get,一个诡异的脏数据问题是因为遍历ConcurrentHashMap时拿到了弱一致性的中间状态。这些坑都不深,但看不见的时候,它们会以最惨烈的方式让你重新认识Java的Map遍历。希望这篇,能帮你少走这几条弯路。