做Java开发这些年要说写得最多的代码集合遍历绝对排得上前三。接口层查完数据库要把List拼成返回结构算法题里要遍历HashMap统计字符频率日常代码里处处都是for循环和Iterator的身影。我见过不少刚入门的同学List能用for i顺手跑起来一碰到Set、Map就发懵数组和集合的区别也说不清面试被问到“HashMap有哪几种遍历方式”更是容易卡壳。这篇就把我这些年用下来的Java集合遍历经验完整梳理一遍围绕三大遍历技巧——Iterator迭代器、增强for循环、Stream函数式遍历——展开讲清楚它们各自的原理、代码怎么写、踩过哪些坑、面试怎么答。适合刚学Java的基础同学也适合准备面试的应届生想系统查漏补缺的老手也可以快速扫一遍。1. 先理清集合家族的底细为什么遍历这门课躲不掉1.1 数组和集合的本质差异先把这个想明白很多人学Java时第一个接触的数据结构是数组于是脑子里天然形成一种惯性遍历嘛不就是for循环加下标arr[i]一个个取出来。这个思路在数组上行得通但一到集合就有点不灵了。原因很简单数组是定长的创建后大小固定而且每个元素都有连续的下标天然支持随机访问集合则完全是另一套东西ArrayList底层虽然是数组但对外提供的是动态扩容的语义HashSet内部是哈希表元素压根没有“第几个”这种下标说法。我记得有个从C#转Java的朋友问过我C#里数组和集合的区别放到Java里应该怎么理解其实两边思路是一样的。数组强调的是“连续存储、固定长度、下标访问”集合强调的是“动态增减、无需关心容量、多种内部结构”。在Java里数组用.length拿长度集合用.size()拿大小数组允许基本类型直接存集合只能存对象int要包成Integer才能放进去。这些差异直接决定了遍历方式数组可以放心用下标集合却不保证每个实现都有下标。// 数组遍历下标直取 int[] arr {1, 2, 3}; for (int i 0; i arr.length; i) { System.out.println(arr[i]); } // ArrayList遍历虽然有get(i)但语义已经不同 ListInteger list new ArrayList(Arrays.asList(1, 2, 3)); for (int i 0; i list.size(); i) { System.out.println(list.get(i)); }ArrayList用get(i)没问题因为底层就是数组。LinkedList呢它虽然也有get(i)但每次调用都要从链表头或尾开始挨个找这个代价非常大。所以在集合世界里下标遍历从来不是通用方案这才有了迭代器、增强for这些更通用的遍历思路。理解这一点后面讲三大遍历技巧就顺理成章了。1.2 Collection与Map两大阵营遍历方式的分水岭Java集合框架整体分成两大阵营Collection和Map。Collection下面又分List、Set、Queue特点是存放单个元素Map存放的是键值对比如HashMap的每个条目都是一个Entry里面既有key又有value。这个区分很关键因为Map不是Collection的子接口它没有实现Iterable所以增强for不能直接遍历Map对象本身只能通过keySet()、entrySet()、values()这些视图去遍历。很多人一上来就写for (String key : map)结果编译报错一脸困惑。本质原因就是Map本身不是“一系列元素”的集合而是“一系列键值对”的集合Java没有让Map直接实现Iterable应该是刻意为之的。你想想如果让Map实现Iterable那迭代时每次拿出来的到底是key还是value还是Entry说不清楚。所以设计者给了三个入口要key走keySet()要keyvalue走entrySet()只要value走values()。这也是为什么我说“遍历”这件事在Java集合里值得单独拿出来学。数组只有一种遍历姿势List有两三种Set和Map又各有各的玩法。但只要抓住了Iterator、增强for、Stream这三条主线再诡异的集合也能很快找到对应的遍历方法。下面的章节就一条一条拆开讲。2. 三大遍历技巧拆解从Iterator到Stream一个比一个省事2.1 第一种Iterator迭代器所有集合的通用访问协议Iterator是Java集合框架里最底层的遍历协议。不管你是ArrayList、HashSet还是TreeSet只要实现了Collection接口就一定能拿到一个Iterator。它的使用模式非常固定先用iterator()拿到迭代器循环里用hasNext()判断还有没有下一个再用next()取出下一个元素。ListString list new ArrayList(Arrays.asList(a, b, c)); IteratorString it list.iterator(); while (it.hasNext()) { String item it.next(); System.out.println(item); }这里有个细节新手容易忽略next()不只是“读取当前元素”它会把迭代器的游标移动到下一个位置。所以它叫“下一个”而不是“当前”。你可以把Iterator想象成阅卷老师手里的答题卡hasNext()是问“还有没有下一张”next()是把下一张翻上来并读取内容。调用一次next()游标就走一步重复调用会一直往前走这也是为什么迭代器基本是一次性的。Iterator最大的优势是通用你写一个方法参数是Collection类型不管调用方传的是List还是Set都能用同样的方式遍历。其次是安全删除Iterator接口自带remove()方法能在遍历过程中安全地删除当前元素这是后面要详细讲的面试高频考点。缺点是代码看起来啰嗦每行都是在“操作游标”读起来没有业务感。所以工程上直接用Iterator写遍历的人其实不多它更多是作为底层协议被增强for悄悄使用。2.2 第二种增强for循环Java代码里最常见的遍历写法增强for是Java 5引入的语法糖写起来极其简洁。for (String item : list) { System.out.println(item); }别被这种简洁骗了编译成字节码之后它本质上就是在用Iterator遍历。编译器帮你做了“拿到Iterator、创建while循环、调用hasNext和next”这一套繁琐工作。所以增强for能遍历的对象必须实现Iterable接口数组也可以因为JVM对数组遍历做了特殊处理。这就是为什么for (String key : map)会编译报错——Map没有实现Iterable。增强for的优点是显然是代码短、可读性好读起来像是“对集合里的每一个item做点什么”很贴近自然语言。但它有两个明显的局限第一拿不到下标如果你需要在遍历过程中知道当前是第几个元素增强for做不到只能退回普通for循环第二遍历过程中不能直接调用集合的remove方法否则会抛出ConcurrentModificationException。关于第二个坑我见过太多人踩了。就在循环体里写了句list.remove(item)心想“我删一个元素总行吧”结果运行直接报错。原因在于增强for背后是迭代器迭代器创建时记录了集合的修改次数modCount如果你在循环体里用集合自己的remove方法改动了结构迭代器在下一次next()时发现modCount对不上立刻放弃治疗抛异常。这个机制叫fail-fast后面有一整节专门讲它。2.3 第三种Stream与forEach方法函数式风格的舒适区Java 8之后集合的遍历又多了一种写得很舒服的方式集合自带的forEach方法和Stream API。list.forEach(item - System.out.println(item)); list.stream().forEach(item - System.out.println(item));这两行有什么差别集合自带的forEach是Collection接口的default方法底层本质上还是用迭代器遍历只是把“遍历每个元素并执行一段逻辑”这件事用Lambda封装好了。而stream().forEach()是先拿到一个Stream对象再做遍历Stream不止能forEach还能filter、map、sorted、collect一整套管道操作。ListString result list.stream() .filter(item - item.startsWith(a)) .map(String::toUpperCase) .collect(Collectors.toList());这才是Stream真正强的地方它把“遍历”这种底层操作升华成“声明式地描述你想对数据做什么”。想过滤写filter想转换写map想收集成List写collect。对新人来说可能一开始不太适应Lambda的写法但只要用两周就会觉得回不去了。Map也有自己的forEach而且签名很体贴直接给你key和value两个参数map.forEach((key, value) - System.out.println(key value));这里接收的是一个BiConsumer函数式接口两个参数分别是key和value。对比一下增强for至少要for (Entry entry : map.entrySet())再手动getKey()、getValue()forEach的写法确实清爽很多。使用Stream有几个小坑要提醒一是parallelStream().forEach()不保证处理顺序如果你既想并行又想按顺序输出得用forEachOrdered()二是不要在Lambda里修改外部集合变量Stream的设计理念强调无副作用你硬往外部List里add元素在多线程场景下会出问题代码也失去了可读性。3. HashMap遍历全攻略好几种写法到底怎么选3.1 keySet、entrySet、values三个入口怎么选HashMap遍历是面试八股里的常客几乎每次都会被问“HashMap有哪些遍历方式”。想回答全面得先理解它的三个视图入口。keySet()返回所有key组成的Set集合entrySet()返回所有键值对Entry组成的Set集合values()返回所有value组成的Collection集合。MapString, Integer map new HashMap(); map.put(a, 1); map.put(b, 2); map.put(c, 3); // 入口一keySet遍历key需要value时再map.get(key) for (String key : map.keySet()) { System.out.println(key map.get(key)); } // 入口二entrySet一次拿到key和value for (Map.EntryString, Integer entry : map.entrySet()) { System.out.println(entry.getKey() entry.getValue()); } // 入口三values只关心value for (Integer value : map.values()) { System.out.println(value); }面试时你如果能答出这三个入口再补一句“日常开发推荐entrySet”基本就过关了。为什么不推荐keySet因为keySet遍历时想拿value还得再调一次map.get(key)等于多了一次哈希查找。对HashMap来说一次get平均是O(1)差距还不算大但如果你用的是TreeMap一次get是O(log n)这个额外开销就会被放大。与其纠结微小的性能差异不如养成用entrySet的习惯遍历一次就拿到完整数据代码意图也更清楚。values()的使用频率相对低一些但有一个场景很典型你只想统计value的总和或者想把所有value收集到另一个List里这时候遍历values()就非常合适。需要注意的是values()返回的Collection是Map的一个视图你在这个视图上删除元素会直接反映到原Map上。3.2 四种主流的Map遍历写法横向对比把日常开发中能见到的Map遍历写法整理一下主要有四种entrySet加增强for、entrySet加Iterator、keySet加增强for再用get取值、map.forEach配合Lambda。遍历写法能否同时拿到key和value遍历中能否安全删除代码复杂度适用场景entrySet 增强for能不能直接remove低纯读取的日常遍历entrySet Iterator能能用it.remove()中遍历时需要删除元素keySet 增强for get能但多一次查找不能直接remove低习惯于keySet或必须用key查其他数据map.forEach(Lambda)能不能直接remove很低纯读取追求代码简洁前三种都是传统写法第四种是Java 8之后的函数式写法。很多人面试时能背出这四种但要问它们各自的差异和适用场景就容易卡壳。我建议这样记如果只是读数据用forEach或entrySet增强for都行看团队风格如果要在遍历中删除数据用Iterator或者removeIf如果只需要key集合做去重判断之类keySet更直观。还有一个写法值得一提就是removeIf。它不算遍历方式但和Map遍历删除经常一起出现。map.entrySet().removeIf(entry - entry.getValue() 10);这行代码会把value小于10的键值对全部删除。removeIf是Java 8给Collection接口加的default方法内部用的就是Iterator remove()的组合所以它不会抛ConcurrentModificationException是把“遍历并删除”这件事做得最优雅的方式。3.3 遍历HashMap时正确删除元素的姿势HashMap遍历时删除元素最容易犯的错是在增强for里直接调用map.remove(key)。for (String key : map.keySet()) { if (map.get(key) 10) { map.remove(key); // 运行时会抛ConcurrentModificationException } }原因和List一样keySet()返回的Set视图背后挂着一个迭代器你在循环里改了HashMap的结构迭代器立刻感知到modCount变化下一次访问就抛异常。面试时把这个报错场景讲出来会让面试官觉得你是真写过代码的而不是只背了理论。正确姿势有三种。第一种是IteratorIteratorMap.EntryString, Integer it map.entrySet().iterator(); while (it.hasNext()) { Map.EntryString, Integer entry it.next(); if (entry.getValue() 10) { it.remove(); } }第二种是上面说过的removeIf一行搞定。第三种是“先收集、后删除”遍历时把要删除的key放进另一个List遍历结束后统一调用map.keySet().removeAll(list)。第三种方式看起来很绕但在嵌套遍历等复杂场景下反而最保险因为它完全避开了在遍历过程中修改Map结构的问题。我个人在真实项目中用得最多的是removeIf因为它短小、直观、安全。但如果删除条件很复杂或者需要在删除同时做其他业务操作我会切回Iterator毕竟Iterator的remove()能保证“删除的是当前迭代到的那个元素”语义最明确。4. 性能对比与工程选型别再纠结哪种遍历最快4.1 不同集合类型下的实测表现群里经常有人问“增强for和Stream到底哪个快”这种问题其实很容易把人带偏。我先说结论在绝大多数业务场景下三种遍历方式的速度差异可以忽略真正的差距只在特定数据结构和特定写法之间才明显。对ArrayList底层是数组普通fori是最快的因为直接按下标访问数组没有迭代器对象的包装开销Iterator和增强for因为本质是一个东西速度几乎一样略慢一点点Stream最慢因为它要额外创建Stream对象、处理Lambda调用但这个差距在百万级数据下通常也只是毫秒级完全可以接受。对LinkedList底层是双向链表普通fori是最差的选择没有之一。因为LinkedList的get(i)每次都要从链表头或尾开始沿着链表找你写一个for (int i 0; i list.size(); i) list.get(i)整体复杂度直接变成O(n²)100万条数据可能要跑几秒甚至更久。这种时候用增强for、Iterator或Stream它们都是沿着链表指针一步步走整体O(n)效果天差地别。对HashMapentrySet遍历比keySet再加get遍历要快原因前面说过keySet多了一次key的hash查找。虽然HashMap的get是O(1)但乘以遍历次数数据量大时还是能感受到差异的。我把常见的性能结论整理成一张表方便随手查集合类型推荐遍历方式不推荐遍历方式备注ArrayList增强for / fori无明显不推荐fori略快但可读性差一些LinkedList增强for / Iterator / Streamfori get(i)fori的O(n²)是灾难HashSet / TreeSet增强for / IteratorforiSet无下标只能用迭代类遍历HashMapentrySet或forEachkeySet get避免二次查找并发场景集合并发集合自带迭代器普通集合 手写锁注意弱一致性4.2 我日常开发中的选型习惯性能对比看完你会发现真正的结论是选遍历方式的核心依据不是“快”而是“意图清晰、操作安全”。我给自己定了一套很朴素的选择标准分享出来供你参考。第一需要拿着下标做事情的时候比如要隔一个取一个或者需要和另一个长度相同的List按位置同步操作那就老老实实写普通fori不用纠结。第二遍历过程中需要删除元素直接放弃增强for改成Iterator或removeIf。第三只是从头到尾读一遍元素、不关心下标我默认写增强for代码最直观团队里没人看不懂。第四遍历的同时要做过滤、转换、分组、累加这类操作用Stream因为强行用for循环写这些逻辑会让代码膨胀得很难看。第五遍历Map我默认用entrySet或者forEach很少用keySet加get。还有一个工程上的习惯要强调不要为了“看起来高大上”强行用Stream。如果一个遍历逻辑用增强for三行就能写明白你非要套一堆Stream操作符只会让同事在心里骂人。可读性永远是第一位的性能优化应该建立在真实出现的瓶颈上而不是想象出来的。5. 高频面试考点与实战避坑速查5.1 遍历时删除元素一个现象三个解法面试官最喜欢问的题目之一就是给你一个List遍历时要把奇数删掉怎么写这道题考察的不只是API熟练度更是对迭代器机制的理解。先看错误示范ListInteger list new ArrayList(Arrays.asList(1, 2, 3, 4, 5)); for (Integer num : list) { if (num % 2 ! 0) { list.remove(num); // 运行到这里就抛ConcurrentModificationException } }这代码一跑就炸。那正确解法有哪些我总结三个按实用程度排序。解法一IteratorIteratorInteger it list.iterator(); while (it.hasNext()) { Integer num it.next(); if (num % 2 ! 0) { it.remove(); } }解法二removeIflist.removeIf(num - num % 2 ! 0);解法三收集后统一删ListInteger toRemove new ArrayList(); for (Integer num : list) { if (num % 2 ! 0) { toRemove.add(num); } } list.removeAll(toRemove);三种都能达到目的但适用场景不同。Iterator适合在删除的同时还要做额外业务操作灵活性最高removeIf适合条件简单、就只想删除的场合代码最干净收集后统一删适合嵌套遍历的场景比如外层遍历List内层遍历Map根据复杂条件决定删哪些元素这时候先收集再统一处理最不容易出错。这里还藏着一个List特有的坑当你试图删除Integer元素时list.remove(num)里的num到底是下标还是值如果num是int类型它会被当成下标处理如果num是Integer类型它才会按值删除。所以遍历一个ListInteger时如果写list.remove(3)删的很可能是下标为3的元素而不是值为3的元素。想按值删要么用Iterator要么用list.remove(Integer.valueOf(3))。这个问题在面试里出现频率极高值得单独记一下。5.2 modCount、fail-fast与并发集合那些事聊遍历删除就绕不开modCount和fail-fast这两个概念。ArrayList、HashMap这些非并发集合内部都维护一个叫modCount的字段它记录集合被“结构性修改”的次数。什么是结构性修改添加元素、删除元素、清空集合都算而set修改某个位置的已有值不算因为集合大小没变。当迭代器被创建时它会保存当时的modCount到自己的expectedModCount字段。每次调用next()之前迭代器都会检查集合的modCount和expectedModCount是否一致不一致就说明“在你迭代的过程中有别人动了这个集合的结构”于是立刻抛ConcurrentModificationException。这个机制就叫fail-fast意思是“快速失败”与其让你在错误的道路上越走越远不如在一开始就停下来报警。理解了这个机制你就会明白为什么用iterator.remove()是安全的因为迭代器的remove()方法在删除后会把expectedModCount同步更新为最新的modCount相当于告诉迭代器“这个修改是我自己做的不用大惊小怪”。而集合自己的remove方法不会做这个同步操作迭代器下次检查时就炸了。顺便再说一点fail-fast检测到的是并发修改问题但它本身并不是一套并发控制方案。它只能“发现问题”不能“解决问题”。如果在真实的多线程场景下需要共享集合正确的做法是用并发集合类比如CopyOnWriteArrayList、ConcurrentHashMap。这些集合的迭代器是弱一致性的它们不会抛ConcurrentModificationException但也意味着迭代过程中不一定能看到其他线程刚刚做的修改读取到的是某个“差不多最新”的状态。面试时能区分fail-fast和fail-safe是加分项。5.3 一个可以直接抄的通用遍历工具方法最后给你一个实战中经常能用到的工具方法。写业务代码时经常要把一个集合的元素拼成字符串打日志或者统一打印出来。用传统方式要写好几行循环用Stream一行就能解决public static T String joinToString(CollectionT collection, String delimiter) { return collection.stream() .map(String::valueOf) .collect(Collectors.joining(delimiter)); }调用的时候就很舒服ListString list Arrays.asList(a, b, c); System.out.println(joinToString(list, , )); // 输出a, b, c这个泛型方法能接住所有Collection的子类List、Set、Queue全部通用不用为每种集合写一遍。它的核心思路正好呼应了前面讲的三大遍历技巧集合给了你多种遍历手段而你要做的是选择当前场景最简单、最安全、最可读的那种。最后分享一点我个人的使用体会。刚工作时我偏爱普通fori觉得下标可控、心里踏实后来发现大多数遍历根本没用到下标增强for写起来明显更清爽。再后来Java 8普及我在新项目里逐渐把“纯遍历过滤转换”的场景都换成了Stream代码行数少了一半不止可读性反而更好。踩过最深的一次坑就是在foreach里顺手写了remove线上报CME排查半天才回过神。现在我的习惯是凡是要在遍历中删除元素一定先问自己一句——这里到底该用Iterator还是removeIf绝不在增强for里直接remove。希望这篇梳理能让你少走这些弯路。