做Java开发这些年,陆陆续续带过不少新人。几乎每个刚入门的同学都会在集合遍历上栽几个跟头:要么在循环里删元素删出并发修改异常,要么拿到HashMap不知道怎么下手,要么在面试时被问住"这两种遍历到底有什么区别"。其实Java集合遍历就三板斧:for循环、增强for、Iterator迭代器。把这三大遍历技巧吃透了,List、Set、Map这些集合的操作就会顺很多,面试里那些高频的"遍历八股"也基本都能拿捏住。这篇文章我按我自己带新人的思路来写,从原理到实战、从写法到坑点、再到面试答辩,争取让你看完就能直接上手。
1. 为什么遍历是个绕不开的话题
1.1 一个面试老问题背后的真实需求
先聊聊"遍历"这个词。说白了,就是把集合里的元素一个一个拿出来处理。你写业务代码时,要统计订单金额要遍历订单列表,要筛选用户信息要遍历用户集合,要对HashMap里的配置做批量操作还是遍历。可以说集合操作里八成以上的代码都和遍历有关。
面试官爱问"HashMap的遍历方式都有哪些",问"增强for和Iterator有什么区别",这些问题看着像背八股,实际考察的是你有没有真正理解集合的存储结构。List是有序的、有下标的,所以能用for循环;Set是无序的、元素唯一的,压根没有下标这一说,就只能靠迭代器或者增强for;Map是键值对映射,它有自己的一套Entry概念,遍历方式和Collection体系又不一样。理解了这个背景,你就知道为什么同一个Java里会有好几种遍历姿势——不是语言设计者闲得慌,而是不同数据结构的访问方式本来就不同,每种遍历技巧都有它不可替代的使用场景。
1.2 集合框架的底层本质:结构决定了遍历方式
Java的集合框架大体分两大体系:Collection和Map。Collection下面又分List、Set、Queue。List家族里有ArrayList和LinkedList两个主力军。ArrayList底层是数组,内存连续,按下标取值是O(1)的随机访问;LinkedList底层是双向链表,要找第N个元素只能从头部或尾部一个个走过去,时间复杂度是O(N)。这一条差异直接决定了传统for循环在这两种List上表现天差地别。
Set家族里HashSet底层依赖HashMap,无序且不可重复,根本没有下标概念;TreeSet底层是红黑树,有排序但是也没有下标。它们想遍历,只能靠迭代器或增强for。Map家族里HashMap底层是数组加链表加红黑树,有Hash桶的概念,它遍历的时候拿到的是一组一组的Entry,也就是"键值对"这个完整对象,而不是单独一个值。
我经常拿购物车打比方:ArrayList就像一排带编号的货架,你报个号就能直接走到对应位置拿东西;LinkedList像一条项链,你想拿第10颗珠子得从两头一颗一颗数过去;HashMap像一个按标签分类的储物柜,你打开柜子看到的是一个写着"标签-物品"的配对卡片。数据结构的物理形态决定了你能怎么去访问它,这就是为什么同一件事会有好几种做法。
2. 逐个拆解三大遍历技巧:写法、原理、坑点
2.1 传统for循环:最直白,但只认下标
传统for循环大家最早接触,写法也最简单:
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c")); for (int i = 0; i < list.size(); i++) { System.out.println("位置" + i + ": " + list.get(i)); }它的核心逻辑就是"我按位置来访问"。优势非常明显:你能拿到当前下标,能在遍历过程中根据位置做判断,能自由控制步长,比如隔一个取一个,也能从后往前倒序遍历。在需要修改某个位置元素的时候,它是最直接的方式:
for (int i = 0; i < list.size(); i++) { if (list.get(i).equals("b")) { list.set(i, "B"); } }但这个写法有个隐藏的大坑,也是新人最容易踩的:循环体里直接remove。如果你用for循环删除元素,删除后后面的元素会自动前移,下标就乱了。比如有["a","b","c","d"],你打算删掉所有等于"b"的元素,从i=0开始,删完"b"之后"c"移到下标1,此时i自增变成2,"c"就被跳过去了。这就是经典的"删除错位"问题。正确做法是删除后手动把i减回去:
for (int i = 0; i < list.size(); i++) { if (list.get(i).equals("b")) { list.remove(i); i--; // 删除后下标前移,必须回退一位 } }或者干脆倒序遍历,从尾部往头部删,因为删除后面的元素不影响前面元素的下标,这个技巧在很多算法题里都有用,比如力扣上那些"删除列表中元素"的题目。
另外一点要特别提醒:传统for循环跟LinkedList的兼容性很差。如果你拿一个几万元素的LinkedList去做list.get(i),每取一个元素都要从链表头或尾走一遍,这等于for循环里套了一个O(N)查找,整体直接变成O(N²)级别,数据量一上来就跑得想砸电脑。所以判断要不要用for循环,先看清楚集合底层是数组还是链表。
2.2 增强for循环:语法糖的甜与苦
增强for是Java 5开始支持的写法:
for (String s : list) { System.out.println(s); }它是专门给"只想老老实实把每个元素看一遍"的场景设计的。写起来极其简洁,不用管下标,不用关心集合长度,编译器会自动帮你处理迭代逻辑。实际在编译的时候,增强for会被转换成一个基于Iterator的循环,你可以把它理解成语法糖。也就是说增强for底层走的还是迭代器那一套。
用增强for有个非常舒服的地方是它适用于所有Iterable接口的实现类,不管是ArrayList还是HashSet,不管有没有下标,通通一个写法搞定。对新手来说,这是最不容易出错的遍历方式。
但它的"甜"也是有代价的。最大的坑就是遍历过程中不能对集合做结构性修改。所谓结构性修改就是增加元素、删除元素这类改变集合大小或结构的操作。下面的代码会抛ConcurrentModificationException:
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c")); for (String s : list) { if (s.equals("b")) { list.remove(s); // 运行时抛异常 } }原因我后面专门讲,简单说就是迭代器会检查集合的修改次数,发现你在遍历期间偷偷动了集合,立刻扔异常出来,这个机制叫fail-fast快速失败机制。它的设计初衷就是"如果数据在遍历中被改了,那后面拿到的数据可能就错了,与其让你用错误数据,还不如直接告诉你出问题了"。
除此之外,增强for拿不到当前下标。如果确实需要,可以自己维护一个计数器,但这样写就有点绕了,这种场景我更建议直接用传统for。
2.3 Iterator迭代器:唯一能在遍历中安全删元素的
Iterator是Collection体系里最通用的遍历工具。别看它接口就三个核心方法,但它在面试和实际开发中的地位非常高:
Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { String s = iterator.next(); System.out.println(s); }hasNext()判断还有没有下一个,next()取出下一个元素,两个方法配合就能把一个集合从前往后走完。Iterator的核心价值在于它提供了一个"不依赖下标"的通用遍历协议,只要是Collection的子类都能用同一套方式遍历,这就把ArrayList和HashSet统一起来了。
最关键的一点是,Iterator的remove()方法可以在遍历过程中安全地删除当前元素。为什么说安全?因为迭代器内部会维护一个expectedModCount,它自己调用remove()的时候会同步更新这个值,所以不会触发modCount校验失败。但需要注意,必须先next()再remove(),也就是说只能删除"刚取出来的那一个",不能上来就remove。
Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { String s = iterator.next(); if (s.equals("b")) { iterator.remove(); // 安全删除当前元素 } }这个场景在实战中非常常见,比如批量清理不符合条件的日志、过滤掉黑名单用户等。如果你在面试中被问到"遍历时怎么删除元素",答案就是迭代器的remove方法或者JDK 8的removeIf,千万别答"在for循环里调用list.remove",那是典型的坑人写法。
另外ListIterator是Iterator在List上的增强版,它多了previous()方法可以从后往前遍历,还支持add()和set()方法在遍历时插入和修改元素。但实际业务里用到它的频率不算高,了解即可。
2.4 Map的遍历:面试里问得最多的那张表
Map不属于Collection体系,但它同样有遍历的需求,而且面试里问得比List还多。HashMap至少有四种常见遍历姿势,我按推荐程度来排。
第一种,entrySet加增强for。这是最推荐的通用写法,遍历时直接拿到完整的Entry对象,同时包含键和值,不需要再单独查一次Map:
Map<String, Integer> map = new HashMap<>(); map.put("a", 1); map.put("b", 2); for (Map.Entry<String, Integer> entry : map.entrySet()) { System.out.println(entry.getKey() + " = " + entry.getValue()); }第二种,keySet加get。这种写法是先拿到所有键的集合,再逐个根据键去取对应的值:
for (String key : map.keySet()) { System.out.println(key + " = " + map.get(key)); }它的缺点是每拿到一个key都要通过get再查一遍HashMap,等于多了一次哈希查找。数据量小的时候没感觉,一旦是几十万条的大Map,这个开销就非常明显。所以常规场景我建议优先用entrySet。
第三种,values()只遍历值。如果你只关心值不关心键,可以直接:
for (Integer value : map.values()) { System.out.println(value); }这个就没什么好说的,用的时候要知道它拿不到键。
第四种,JDK 8的forEach写法。一行搞定,阅读性也最好:
map.forEach((key, value) -> { System.out.println(key + " = " + value); });这个写法底层也是基于entrySet的迭代,只是语法上更简洁。但要注意,这个forEach里面同样不能做put或者remove这种结构性修改。
Map的遍历还有一个细节:HashMap本身是无序的,遍历顺序不保证和插入顺序一致。如果业务上要求遍历顺序跟插入顺序一致,要用LinkedHashMap;要求按键排序,要用TreeMap。很多人在遍历Map时发现顺序不对,其实不是代码写错了,是容器选错了。
3. 三大遍历的对比和选型
3.1 能不能删、能不能改、能不能知道位置
我从实际开发的角度把三大遍历的适用边界整理成一个对比表,面试前也好用来回顾:
| 维度 | 传统for循环 | 增强for循环 | Iterator迭代器 |
|---|---|---|---|
| 是否支持按下标访问 | 支持,直接get(i) | 不支持,拿不到下标 | 不支持,只能顺序取 |
| 能否修改元素内容 | 能,set(i, xxx) | 能,但需要重新赋值给引用 | 能,ListIterator支持set |
| 能否删除当前元素 | 能但容易下标错乱,需要i-- | 不能,会抛并发修改异常 | 能,迭代器remove()安全 |
| 能否从后往前遍历 | 能,倒序i-- | 不能 | ListIterator支持previous() |
| 适用集合 | 只有有下标的List | 所有Collection | 所有Collection |
这个表看着简单,但能把背后逻辑说出来的人不多。传统for循环的"不能随便删",问题本质是删除后元素位移导致下标错位;增强for的"不能删",本质是fail-fast机制在保护你;Iterator能安全删,是因为它自己内部同步了修改计数。面试时你能把这三句话说出来,就和纯背答案的候选人拉开差距了。
还有一个高频细节是"遍历时到底能不能修改元素内容"。很多人把"修改元素内容"和"结构性修改"混在一起。增强for里你拿到的是集合元素的引用,如果你修改的是List中的某个对象内部的属性,比如user.setName("新名字"),这样完全没问题;但如果你执行list.add或list.remove,这就是结构性修改,会影响迭代器的遍历状态,所以会被拦截。这个边界搞清楚了,写代码的时候就能少踩很多莫名其妙的坑。
3.2 集合类型不一样,遍历姿势也得变
选遍历方式不能只看写法顺不顺手,还得看集合的底层结构。
ArrayList这种基于数组的List,三种遍历方式都适用,性能上也都很不错,区别只在于你需不需要下标、需不需要删除。LinkedList数组结构的List,我强烈建议别用传统for循环疯狂get,换成增强for或者Iterator。Set接口的集合没有下标也没有get方法,增强for和Iterator基本就是标配,想用传统for反而用不了。Map则要看你是要键、要值、还是要键值对,业务上需要什么就选对应的遍历入口。
我实际写代码时的选择逻辑很简单:如果只是想把每个元素看一遍做点处理,优先增强for;如果处理过程中要安全删除元素,直接用Iterator;如果必须知道下标位置或者要控制遍历步长,用传统for;遇到Map,无脑选entrySet或者Java 8的forEach,不要为了省那一行代码用keySet去多查一次map。按这个逻辑走,基本不会出大问题。
4. 常见坑与面试避雷指南
4.1 ConcurrentModificationException是怎么来的
这是集合遍历里出现频率最高的异常,同时也是面试问fail-fast机制最常见的切口。它的来历要深入到ArrayList源码里的两个变量去看:modCount和expectedModCount。
modCount是集合自己的"修改计数器",每次对ArrayList做add、remove这样的结构性修改,modCount都会加1。expectedModCount是迭代器内部保存的一个副本,在创建迭代器的时候,expectedModCount被初始化成当时的modCount。之后每一次调用next(),迭代器都会检查modCount和expectedModCount是否一致。如果发现不一致,说明在你遍历期间有人动了集合结构,真实的迭代状态已经不可靠了,于是果断抛出ConcurrentModificationException。
这就是fail-fast快速失败。它不像有些人以为的那样是多线程才有的事,单线程下你增强for里remove元素一样会触发这个异常。它的存在是为了防止你使用一个已经脏掉的迭代结果继续做业务,宁可直接报错,也不要给你一个半真半假的数据让你稀里糊涂地跑下去。
和fail-fast相对的是fail-safe。Java并发包里的CopyOnWriteArrayList就是典型代表,它遍历时基于一个副本数组,修改操作改的是新数组,所以迭代器不会抛ConcurrentModificationException。坏处是它牺牲了数据的实时性,遍历过程中拿不到最新的修改。这个对比也是面试常客,理解了原理就很容易记住。
4.2 空指针和循环内remove的翻车现场
除了并发修改异常,还有几个实际开发里反复出现的问题,我一个个说。
第一个是空指针。对null集合做增强for循环,会直接抛NullPointerException。很多人在从数据库查数据、从接口返回结果的时候,没有做空集合判断就直接遍历。建议养成习惯:拿到的集合先判空再遍历,或者用工具类判空,比如ListUtils.isEmpty()这类方法,避免在遍历这一步碰上空指针。
第二个是迭代器删除时忘记调用next()。Iterator定义的是"有了next才能remove",你在remove之前必须先next()。有的同学遍历时想删一个,结果逻辑写在hasNext()判断后、next()调用前,直接remove,就会抛IllegalStateException。Iterator的原理是每次remove都删除"最近一次next返回的那个元素",没有next过自然没有元素可删。
第三个是循环里remove时下标忘了减。我前面说过,for循环删除后有元素前移,不处理下标会跳过元素。这里最经典的场景是过滤集合里的某些值,比如你想把所有空字符串都删掉,结果删一个跳一个,最后剩下"b"、"d"这种漏网之鱼,调试半天才发现是下标问题。
第四个是Map遍历中做put或remove直接抛异常。Map的forEach和增强for遍历一样,遍历期间对Map做结构修改同样会触发ConcurrentModificationException。业务上常见的"遍历Map时想删掉若干键值对",正确写法是用iterator:
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(); } }4.3 面试怎么答:8个高频问题速查
结合我和候选人面谈的经验,把集合遍历这块面试官最可能问的问题整理成了一份速查表,每个问题我都给了参考回答思路。
| 问题 | 关键回答要点 |
|---|---|
| HashMap的遍历方式有哪些 | 至少说四种:entrySet、keySet、values、JDK 8的forEach,然后说推荐entrySet,因为一次拿到键值对,避免二次查表 |
| 增强for和Iterator有什么区别 | 增强for是语法糖,底层就是Iterator;增强for不能删除元素,Iterator可以安全删除 |
| 迭代器遍历时能不能删除元素 | 能,必须先next()再remove(),原理是迭代器会同步expectedModCount |
| fail-fast和fail-safe是什么 | fail-fast是在遍历中发现集合被修改就抛异常;fail-safe是在副本上遍历,不抛异常但读不到实时数据 |
| ArrayList和LinkedList遍历性能差异 | ArrayList按下标随机访问快,LinkedList用下标访问慢,适合迭代器顺序遍历 |
| forEach里能不能remove/put | 不能,会触发ConcurrentModificationException |
| 遍历时修改元素内容算不算修改 | 修改对象内部属性不算结构性修改,只有add/remove这类改变集合大小或结构的才算 |
| JDK 8的forEach和传统遍历的关系 | forEach是entrySet遍历的简化写法,本质上还是迭代器循环 |
回答这些问题的核心思路是:先说清楚是什么,再说清楚为什么。比如问fail-fast,你说它是在集合被修改时抛异常,加分项是补充一句"标记-检查机制,用modCount和expectedModCount比较来判断集合是否被修改"。这就是"知其所以然"和"背答案"的区别。
5. 进阶补充:从遍历到工程实战
5.1 遍历和删除的新工具:removeIf与Stream
JDK 8之后,如果你只是想遍历集合并删除符合某些条件的元素,其实有更省事的写法,用Collection默认方法removeIf,一行搞定:
list.removeIf(s -> s.startsWith("b"));removeIf的设计思路很巧妙,它内部就是基于迭代器遍历并调用remove()实现的,所以完全安全,不用你自己写Iterator那一套循环。这个写法在代码review的时候观感特别好,比一堆while+if+iterator简洁太多。
Map的批量删除可以配合entrySet的removeIf:
map.entrySet().removeIf(entry -> entry.getValue() < 0);它同样是一行搞定。如果你需要更复杂的过滤逻辑,那再考虑Stream的filter,它会返回一个新的集合,不影响原数据。但工程上有一个取舍:Stream的方式会产生新集合,内存开销更大;removeIf是原地过滤,不产生新集合,适合处理大列表。我在实际项目里,删除场景多数优先用removeIf,只有需要同时做其他转换和收集时才上Stream。
5.2 一个实战自查清单
给正在学集合遍历的同学一个自查清单,每一条都是我或者同事在项目里真实踩过坑换回来的:
第一,拿到集合先问自己:这个集合允许重复吗?有序吗?能按下标访问吗?根据答案决定遍历方式,而不是一个增强for走天下。第二,遍历过程中要删除元素,先把自己从"用for循环remove"的惯性思维里拉出来,改成Iterator或者removeIf。第三,遍历Map优先entrySet或forEach,想删键值对就用entrySet的iterator。第四,任何遍历代码都要考虑集合为null的情况,先判空再循环。第五,涉及大集合的遍历,注意集合底层是数组还是链表,选错遍历方式性能能差出几个数量级。
最后再分享一个我自己实战中的判断标准:遍历代码写完以后看一眼循环体。如果循环体里出现list.add、list.remove、list.clear这类改变集合结构的操作,立刻打住,基本可以确定写法有隐患,换成迭代器或者removeIf才是正解。这个习惯我保持了很多年,也带着组里的新人一直在这么做,基本能避开九成以上的遍历类线上bug。
集合遍历这件事,看起来基础得不能再基础,但越基础的东西越容易被人忽略细节。我见过太多工作两三年的人还在用错误的删除方式拿ArrayList做基底遍历,也见过不少面试者在一道简单"HashMap遍历方式"问题上翻车。说真的,把三大遍历技巧的代码细节和底层原理吃透,你的Java基础就稳了一大半。写代码这件事没有捷径,但把这些地基打扎实了,后面学并发、学框架、看源码都会顺畅很多。