Java循环结构避坑指南:for、while、break、性能一次讲透
看到这个标题第一反应是循环有什么好写的但真在开发和面试里泡久了你会发现能把循环写明白的人还真不多。很多工作三五年的Java工程师遇到多层嵌套、循环中删除元素、break和return混用这些场景照样会写出隐蔽的Bug。这篇文章不打算从教科书角度复述语法而是结合我实际写代码、查问题、面试别人的经验把Java循环结构里那些你以为你懂了其实没懂的细节一次讲透。无论你是刚入门的初学者还是准备跳槽想查漏补缺的开发者这篇文章应该都能给你一些不一样的视角。1. 三个基本循环的语法与语义辨析Java里一共提供了三种循环结构for、while、do-while。很多人觉得它们只是语法糖的区别随便换着用就行但实际工程里选错循环结构代码的可读性和健壮性会差很多。首先要搞清楚三者的语义差别才能真正选对。1.1 for循环的结构与执行顺序for循环的真正结构是四个部分初始化语句、条件判断、循环体、迭代更新。关键点是执行顺序先执行初始化然后判断条件条件为true才进入循环体循环体执行完再执行迭代更新更新完再次判断条件。这个顺序很多人会弄反尤其是刚学的时候以为先执行循环体再更新这是错的。有一个非常经典的排查案例某个定时任务里用for循环遍历一个List在循环体内部对列表做了remove操作结果发现总是有一两个元素没被处理到。原因就是for循环的迭代更新在循环体之后执行当remove掉当前元素后后面的元素下标前移而迭代更新又让i自增等于跳过了下一个元素。这种Bug极难排查因为数据量小的时候可能看不出来数据量大了才会偶现。从工程角度for循环最适合已知循环次数的场景。比如遍历数组、遍历集合、执行固定次数的重试逻辑。因为初始化、条件、更新都集中在头部一行代码结构非常紧凑。但要注意for循环的初始化变量作用域只在循环内循环结束后无法访问如果需要在循环外使用循环变量的终值需要在外部声明。我再补充一个容易忽略的点for循环的迭代更新表达式不仅仅可以是i还可以是任何合法表达式。比如i 2表示步长为2i--表示倒序遍历。实际开发中倒序循环用得不少典型场景是从列表末尾开始匹配某个条件找到就立刻break此时倒序比正序效率更高。1.2 while与do-while先判断还是先执行的问题while循环的核心语义是先判断后执行。条件为true才进入循环体如果一开始条件就是false循环体一次都不会执行。这个特性决定了while适合不确定循环次数依赖某个外部条件的场景比如读取输入流直到EOF、轮询某个开关状态、等待某个队列中有数据。do-while则是先执行后判断。无论条件是否为true循环体至少执行一次。这个特性在工程里有一个典型场景用户输入校验。比如让用户输入一个数字如果输入不合法就重新提示这里无论用户第一次输入什么都要先执行一次输入操作然后再判断是否合法。用do-while写这个逻辑比用while干净得多。看一个对比示例// while版本需要先初始化一个非法值 Scanner scanner new Scanner(System.in); int num -1; while (num 0) { System.out.print(请输入非负数); num scanner.nextInt(); } // do-while版本逻辑更自然 int num; do { System.out.print(请输入非负数); num scanner.nextInt(); } while (num 0);从代码可读性来说do-while版本更直观因为它把至少执行一次这个语义直接表达出来了。不过实际项目中do-while用的频率远低于for和while很多团队甚至规定尽量不用do-while原因是一旦循环体内出现异常do-while的至少执行一次特性会让排查者多思考一层。我个人的建议是能用for表达清晰的就用for无法预知次数的用while确实需要先做一次再判断的再考虑do-while不要为了炫技而滥用。1.3 三种循环结构的选型对照为了让你在写代码时能快速决策我把选型逻辑总结成一张对照表。这张表也是我平时在代码评审时对新人讲得最多的一份参考。维度forwhiledo-while循环次数是否已知通常已知未知依赖条件未知依赖条件最少执行次数0次0次1次适用场景遍历数组/集合、固定次数操作读取流、轮询状态、条件循环用户输入校验、先处理后判断代码结构初始化、条件、更新都在头部初始化在外部更新在体内初始化在外部更新在体内可读性最高较高一般容易让读者想到为何至少执行一次选型不当最典型的反面教材是本来要遍历一个集合并处理每个元素结果有人用while加手动下标下标忘记自增导致死循环。这种Bug放在for循环里基本不可能出现因为迭代更新和条件判断写在一起扫一眼就能发现。所以我的经验是遍历优先for条件不确定用while先执行一次再判断才用do-while这个顺序就是日常开发中的默认决策链。2. 循环控制流break、continue、return与label的边界循环体的控制流切换是面试高频区也是实际写代码中最容易翻车的地方。很多人break、continue、return混着用以为自己跳出了某层循环结果却完全不是那么回事。这一节把每个控制流关键字的行为边界和适用场景讲清楚。2.1 break与continue的细微差别break的作用是立即终止当前循环注意是当前循环不是所有循环。如果在嵌套循环的内层循环里写了break它只会跳出内层循环外层循环继续执行。continue的作用是跳过本次迭代的剩余部分进入下一次迭代。很多人容易混淆的是continue和break在多层嵌套中的行为。举个例子for (int i 0; i 3; i) { for (int j 0; j 5; j) { if (j 2) { break; // 跳出的是内层j循环 } System.out.println(i i , j j); } }这段代码的输出中j只能取0和1当j等于2时break跳出内层循环但外层i循环继续执行。这个逻辑很基础但我在代码评审时确实见过有人在这个地方写出了break实际是想退出外层循环的误解代码最后的结果就是一通抓狂的调试。continue的经典应用场景是过滤不符合条件的元素。比如遍历一批订单跳过金额为负的异常订单只处理正常订单。用continue可以把过滤和处理两个逻辑垂直分离代码比if嵌套更扁平。2.2 return在循环中的真实含义return在循环中会直接结束整个方法并返回指定的值或void。它在循环中的行为和break有本质区别break只结束循环return结束的是整个方法。这意味着如果在循环里写了return循环后面的所有代码都不会执行。这里有一个经验之谈在循环中使用return会让代码的提前退出路径变多阅读时需要逐条跟踪返回值所以很多团队规范里会建议循环中尽量用break配合外部变量来结束循环避免使用return。但也有例外比如在循环中搜索一个唯一匹配项找到后马上返回这种方式反而可读性更好省去了额外定义一个结果变量。// 推荐搜索唯一匹配项时直接return public User findUser(ListUser users, String id) { for (User user : users) { if (id.equals(user.getId())) { return user; } } return null; }这个写法简洁清晰没有多余的中间变量也没有break后再走到方法末尾的那段逻辑。我个人的建议是循环内return不是绝对禁止但前提是这个方法本身足够单一return的路径含义明确如果方法体比较复杂还是靠break加外部变量来收敛比较好。2.3 label标签跳出多层循环的正确姿势Java中的label标签是一个容易被忽略的语法特性。它的作用是用一个标识符给某个代码块命名然后通过break label;或continue label;直接跳出到该代码块。说实话这个特性在日常业务开发中很少用到但在某些算法场景和框架源码阅读中会见到一旦遇到能看懂能省不少事。经典的例子是跳出双层循环outer: for (int i 0; i 10; i) { for (int j 0; j 10; j) { if (i * j 42) { System.out.println(找到了: i i , j j); break outer; } } } // 执行到这里时两层循环已经全部退出注意label必须放在要跳出的那层循环之前break outer会直接跳出带outer标识的那层循环。没有label的话这段逻辑通常需要用一个flag变量加两层判断才能实现代码明显繁琐得多。我建议你至少要认识这个语法面试时提到如何跳出多层循环这个问题能说出label这个方案会是一个加分项。2.4 死循环的正确写法与退出条件设计死循环无限循环在Java中有两种标准写法while(true)和for(;;)。两者语义完全相同纯风格差异。我个人习惯用while(true)因为可读性更直白但也有不少框架源码比如Netty的某些事件循环用for(;;)这纯粹看团队风格。选择一个团队统一的写法即可不要混用。死循环的真正难点是退出条件的设计。我的经验是死循环内部必须至少有一个明确的break出口并且在写死循环之前先想清楚什么条件下退出。如果没有想清楚就写while(true)很容易出现进程挂死。举一个现实中的案例某后台任务需要不断从消息队列拉取消息如果队列为空则sleep一秒再继续。这个场景用死循环很自然但退出条件设计得非常小心——服务关闭时要能优雅退出。通常的做法是维护一个volatile的关闭标志private volatile boolean running true; public void run() { while (running) { Message msg queue.poll(); if (msg ! null) { handle(msg); } else { Thread.sleep(1000); // 注意处理InterruptedException } } } public void shutdown() { this.running false; }这种写法的好处是外部只需要把running置为false循环就会在下一次判断时退出不会卡在sleep中出不来当然这里需要处理好InterruptedException。如果直接break退出在很多场景下反而不好控制。所以死循环本身不可怕可怕的是没有设计好退出条件。3. 嵌套循环的实战场景与复杂度陷阱嵌套循环是循环结构里最能拉开代码水平差距的部分。能写出清晰的嵌套循环和把嵌套循环写得一团乱麻差别非常大。这一节用三个经典场景来说明九九乘法表、冒泡排序、以及一个工程中更常见的二维数组处理。3.1 冒泡排序的完整实现与优化思路冒泡排序是Java面试里出现率极高的基础题。它的核心思路是重复地遍历待排序序列每次比较相邻两个元素如果顺序错误就交换直到没有需要交换的元素为止。因为较小的元素会像气泡一样慢慢浮到序列顶端所以叫冒泡排序。最基础的实现是这样的public static void bubbleSort(int[] arr) { int n arr.length; for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; } } } }这里有一个细节内层循环的j边界是n - 1 - i因为每一轮排序后最大的元素已经确定移动到末尾不需要再参与下一轮比较。如果这里写成j n - 1代码不会出错但会多做很多无意义的比较也就是一个性能优化点。冒泡排序还有一个常用的优化加一个swapped标记如果某一轮遍历中没有发生任何交换说明序列已经有序可以直接提前终止。这个优化在处理基本有序的数组时效果非常明显最理想的情况复杂度可以降到O(n)。public static void bubbleSortOptimized(int[] arr) { int n arr.length; boolean swapped; for (int i 0; i n - 1; i) { swapped false; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; swapped true; } } if (!swapped) { break; } } }从嵌套循环的视角看冒泡排序是一个非常典型的外层循环控制轮次、内层循环控制比较范围的结构。面试时如果能顺手讲出这个优化点通常能体现你对复杂度分析的敏感性。我面试别人的时候如果候选人能把冒泡排序写对我就会追问这个swapped优化能主动说出来的并不多。3.2 九九乘法表从打印逻辑看循环嵌套的边界控制九九乘法表是个入门的嵌套循环练习但很多人第一次写还是会卡壳。它的逻辑是外层循环控制行数从1到9内层循环控制每行输出的列数从1到当前行号。for (int i 1; i 9; i) { for (int j 1; j i; j) { System.out.print(j * i (i * j) \t); } System.out.println(); }关键点在于内层循环的条件j i这个条件决定了第i行只输出i个表达式形成一个三角形的形状。很多人写错的地方是把内层条件写成了j 9结果每行都输出9个表达式变成了矩形而不是三角形。从教学意义上讲九九乘法表最核心的价值是让你体会内层循环边界依赖于外层循环变量这种模式。这个模式在真实工程中非常常见比如遍历二维三角矩阵、生成上三角或下三角矩阵的运算都和这个逻辑同源。我在实际项目中遇到过类似的场景需要计算一张二维表中每个格子右上方向所有格子的累加值。这本质上就是一个依赖行列关系的嵌套循环边界条件一旦搞错计算结果就完全偏离预期。所以九九乘法表不是无聊的练习题它是理解嵌套循环边界控制的最直观模型。3.3 二维数组遍历与按行列处理的常见坑二维数组的遍历本身不复杂真正容易踩坑的是行和列的顺序搞反或者在遍历过程中试图动态修改数组大小。Java的二维数组本质上是数组的数组所以int[][] matrix的matrix.length是行数matrix[i].length是第i行的列数。在非矩形二维数组每行列数不同中内层遍历必须用matrix[i].length作为边界。int[][] matrix { {1, 2, 3}, {4, 5}, {6, 7, 8, 9} }; for (int i 0; i matrix.length; i) { for (int j 0; j matrix[i].length; j) { System.out.print(matrix[i][j] ); } System.out.println(); }如果把内层条件写成j matrix[0].length一旦碰上不规则数组就会抛出ArrayIndexOutOfBoundsException。这是个很隐蔽的坑因为如果前几行的列数恰好相同可能在特定数据下永远不报错一旦遇到更短的行就瞬间崩溃。这种Bug在真实项目中排查起来相当费劲因为异常信息指向的是matrix[i][j]这行但问题的根源在内层循环边界。还有一类坑和行优先与列优先有关。Java的二维数组默认是行优先存储即同一行的元素在内存中连续。如果遍历时外层循环用列、内层循环用行性能会因为缓存命中率下降而明显变差。在数据量达到百万级时这个性能差距可以达到数倍。所以写二维数组遍历时保持外层行内层列是默认习惯。4. 循环中的性能陷阱与代码异味循环是程序性能的放大器循环体里多一次无谓的对象创建可能被放大几千倍循环体里多一次数据库查询可能直接把接口拖垮。这一节专门讲循环里的性能陷阱和代码异味帮助你不写那种功能正确但一看就想重构的代码。4.1 循环体内创建对象与字符串拼接的坑最常见的性能陷阱就是在循环体内创建不必要的对象。比如循环一万次每次都new一个SimpleDateFormat来处理日期这个成本是非常可观的。SimpleDateFormat本身不是线程安全的但如果是在循环内局部创建线程安全是保证了性能却浪费了。更好的方案是把SimpleDateFormat定义成static final配合ThreadLocal使用或者在Java 8直接用DateTimeFormatter它是线程安全的。字符串拼接也是一个典型的循环内陷阱。很多人习惯用拼接String result ; for (int i 0; i 10000; i) { result i ,; // 每次循环都创建一个新的String对象 }这个写法在循环次数少的时候看不出来但循环一上万性能会急剧下降。原因是String是不可变对象每次都会创建一个新的字符串对象旧的字符串对象变成垃圾等待回收。优化方式是使用StringBuilderStringBuilder sb new StringBuilder(); for (int i 0; i 10000; i) { sb.append(i).append(,); } String result sb.toString();这里我额外提一个点JVM的编译器在循环外做简单的字符串拼接时会通过编译期优化自动把转成StringBuilder但在循环内部这种优化通常不会发生。所以不要指望编译器帮你优化直接在循环外用StringBuilder是最稳妥的做法。4.2 循环中的IO操作与数据库查询批量化循环里做IO操作是另一个高频性能陷阱。比如要更新一万条记录很多人会这样写for (Order order : orderList) { orderDao.update(order); // 每次循环都执行一次数据库更新 }这个写法在数据量小的时候没问题但数据量大时会产生上万次数据库往返每次都要经历连接获取、SQL解析、执行、结果返回接口响应时间会呈线性恶化。正确的做法是批量化把要更新的数据批量收集用一条批量SQL或批量更新接口执行。MyBatis Plus中的updateBatchById、JDBC中的addBatch和executeBatch都是为这种场景设计的。同样的逻辑也适用于文件写入、网络请求等所有涉及外部资源的操作。更进一步如果循环体内的操作之间没有强依赖关系还可以考虑用并行流或线程池来加速。但并行化会引入线程安全和顺序一致性等新问题我的建议是先做批量化不要在项目初期就上并发。因为批量化是确定性收益并发则存在不确定性风险。4.3 遍历集合时安全删除元素的方式遍历集合时删除元素是Java中经典的并发修改问题。直接在一个普通for循环里用list.remove(i)会带来下标错位的问题而用foreach遍历时调用list.remove则会抛出ConcurrentModificationException。安全删除的方式有三种第一种是用迭代器的remove方法IteratorString iterator list.iterator(); while (iterator.hasNext()) { String item iterator.next(); if (condition(item)) { iterator.remove(); // 安全删除的是当前迭代到的元素 } }第二种是Java 8引入的removeIf这是最简洁的写法list.removeIf(item - condition(item));第三种是倒序遍历后按索引删除。这种方式也安全因为倒序删除不会影响前面元素的下标for (int i list.size() - 1; i 0; i--) { if (condition(list.get(i))) { list.remove(i); } }这三种方式中removeIf代码量最小且语义清晰我推荐优先使用。迭代器的方式在需要边删除边做复杂逻辑时更灵活。倒序遍历在普通for循环中比较直观也适合面试时口述。需要特别注意不要在foreach循环里做删除操作这是会抛异常的高危写法。4.4 循环与异常处理的持久伤try-catch该放在哪里循环里放try-catch的位置直接决定异常发生时程序的走向。常见的错误写法是在循环体内部捕获异常for (int i 0; i 100; i) { try { process(i); } catch (Exception e) { log.error(第{}个处理失败, i, e); // 吞掉异常继续往下走 } }这种写法在业务上有时是合理的比如批量处理数据时不想因为单条失败而中断整个批次。但要注意它的隐含后果异常被吞掉后失败的记录没有任何补偿机制数据一致性问题会在这时候埋下隐患。如果希望单条失败不影响整体但要保证最终一致更稳的方式是把失败数据单独记录到一张表或一个队列里整体跑完后统一重试。而不是在catch里打一行日志就完事。相反如果把try-catch放在循环外面try { for (int i 0; i 100; i) { process(i); } } catch (Exception e) { log.error(批量处理失败当前已处理到第{}个, i, e); }这条路径的语义是一条失败整体终止。这种模式适合要么全部成功要么全部失败的强一致性场景。事务型批量任务就很典型。两种方式没有绝对的对错但必须与业务需求对齐。我见过最头疼的代码就是循环里吞异常、外部又套一个大try想感知失败结果异常被内层吞了外层根本感知不到数据对不上账时排查起来想摔键盘。5. 增强for循环与Stream迭代现代Java的循环写法从Java 5引入foreach增强for循环开始循环的写法就进入了一个新阶段。Java 8带来Stream之后集合遍历又多了一种函数式风格。这一节谈谈现代Java开发中我们应该如何在传统循环、foreach和Stream之间做出选择。5.1 foreach的底层原理它是语法糖还是新的循环结构foreach本质上不是一种新的循环结构而是编译器提供的语法糖。它底层依赖Iterable接口的iterator()方法获取迭代器然后通过迭代器的hasNext()和next()完成遍历。所以任何实现了Iterable接口的类都可以用foreach遍历这也是为什么我们可以直接遍历List、Set但不能直接遍历普通数组以外的非Iterable结构。有一点需要特别留意foreach中拿不到当前遍历的下标。如果业务逻辑需要用到下标有几种选择改用传统for循环、用List的indexOf不推荐会有重复元素匹配错误、或者用一个外部计数器变量自增。我在代码里见过有人为了在foreach里拿下标额外声明一个int index 0然后每次自增这个思路可以但要注意如果循环体内有continue计数器自增要放在continue之前否则下标会对不上。foreach还有一个限制它不能在循环体内通过迭代器的方式删除元素。原因前面说过直接调用list.remove会触发ConcurrentModificationException。foreach循环里唯一安全的删除方式是先收集要删除的元素循环结束后再统一删除ListString toRemove new ArrayList(); for (String item : list) { if (condition(item)) { toRemove.add(item); } } list.removeAll(toRemove);这个方式的缺点是会多占用一份列表内存但胜在思路清晰也不容易出错。5.2 forEach方法与Stream API函数式循环的取舍Java 8的List.forEach()方法和Stream操作给集合遍历提供了新的选择。list.forEach(item - doSomething(item))本质上是普通循环的简写但它有一个隐藏的限制forEach是一个终端操作里面不能直接修改被遍历集合的结构否则同样会走ConcurrentModificationException的路径。另外forEach里如果用了lambda表达式修改外部局部变量需要包装成数组或原子类因为lambda捕获的变量必须是effectively final。Stream的filter、map、collect组合起来可以写出一套声明式的流水线。比如说要从一个订单列表中筛选出金额大于100的然后按用户分组求和MapString, BigDecimal sumByUser orderList.stream() .filter(order - order.getAmount().compareTo(BigDecimal.valueOf(100)) 0) .collect(Collectors.groupingBy(Order::getUserId, Collectors.reducing(BigDecimal.ZERO, Order::getAmount, BigDecimal::add)));传统的三层循环嵌套加中间变量在这里可能会让人头晕但Stream的阅读顺序是自上而下的每一步做什么一目了然。这是Stream在可读性上的核心价值。但Stream也不是万能的。当循环体内有复杂的业务流程、有需要按顺序逐步执行的副作用操作比如发送通知、写日志、调用外部接口用Stream强行实现反而会让代码变得难以调试。我个人的判断标准是如果这个循环纯粹是在做查询-转换-聚合数据优先Stream如果循环体像一段完整的故事流程保持传统的for循环更合适。性能方面在数据量不大的业务场景里Stream的开销和普通循环差距很小不需要为此牺牲可读性但如果在性能敏感的底层组件里普通循环仍然更有优势。5.3 并行流与循环性能的深入对比Stream有一个普通循环很难替代的能力parallelStream()可以自动并行处理集合数据利用多核CPU提升处理速度。但并行流有个重要前提数据之间必须没有共享的可变状态否则会出现线程安全问题。换句话说如果你在并行流里更新一个共享的Map或List结果可能不符合预期甚至抛出异常。一个典型的不安全写法ListInteger result new ArrayList(); orderList.parallelStream().forEach(order - { result.add(order.getAmount()); // 共享的ArrayList线程不安全 });这里多个线程同时往ArrayList里add轻则结果缺失重则数组越界崩溃。正确的方式是用Collectors.toList()或ConcurrentHashMap等线程安全容器。从性能角度看并行流只在数据量大且单条处理成本高的情况下有收益。如果每条数据的处理只是简单计算并行流的线程调度开销反而会让性能变差。我建议不要为了炫技而用并行流项目里真正需要并行处理时优先考虑线程池加任务拆分因为线程池的并发模型更容易控制和排查问题。6. 面试与代码评审中常见的循环易错点最后把面试和代码评审中高频出现的循环易错点集中过一遍。这些内容未必是完整面试题但都是实际面试中我真实问过、代码评审里真实出现过的细节。如果你想用这篇文章来准备面试这一节值得多读两遍。6.1 经典循环输出题你能准确说出执行结果吗先来看一段代码你可以试着在心里推演输出结果再看答案int i 0; for (i 0; i 5; i) { if (i 3) { continue; } if (i 4) { break; } System.out.print(i); } System.out.println( end i i);输出结果是012 end i4。这里有两个考点一是continue让i3时跳过System.out.print二是break在i4时跳出循环此时i已经递增到4。注意break之后i的最终值是4而不是5因为break跳出的时机是在循环体结束之前迭代更新i还没来得及执行。另一个高频考点是for循环里变量作用域for (int i 0; i 10; i) { // do something } // System.out.println(i); // 这里会编译报错找不到符号很多初学者会误以为循环结束后的i还能访问实际上i的作用域仅限于for循环内部。如果在循环外需要i的终值必须在外部声明变量。6.2 循环中浮点数精度问题与边界条件用浮点数作为循环条件是另一个经典坑。比如for (double d 0.0; d ! 1.0; d 0.1) { System.out.println(d); }这段代码很可能变成死循环。原因是0.1在二进制浮点数中无法精确表示每次累加都有微小误差d可能永远无法精确等于1.0。正确的做法是用整数计数器然后在需要时转换成浮点数for (int i 0; i 10; i) { double d i * 0.1; System.out.println(d); }这个经验在编写数值计算相关的循环时尤其重要。不要用浮点数判断相等改用整数计数或BigDecimal可以避免大量诡异的问题。边界条件方面还有一个经典错误叫做少一次或者多一次。比如遍历数组时用i arr.length会越界用i arr.length才正确。遍历集合时同理i list.size()而不是i list.size()。这种错误在写for (int i 0; i list.size(); i)时极易悄悄出现代码量大的时候一眼根本看不出来。6.3 循环内调用方法修改集合或状态的隐患循环体内调用方法修改集合本身也是一个需要警惕的场景。比如ListString list new ArrayList(Arrays.asList(a, b, c)); for (String s : list) { if (a.equals(s)) { list.add(d); // 运行时会抛出ConcurrentModificationException } }foreach循环内部使用的迭代器会维护一个modCount修改次数标记。当一个线程在迭代过程中对集合进行结构性修改modCount发生变化迭代器在下一次调用next()时就会发现modCount和预期值不一致于是抛出ConcurrentModificationException。这个机制不是bug而是fail-fast设计目的是尽早暴露并发修改问题。在真实项目中这类问题往往隐藏得更深。比如循环内调用的某个方法看似和当前集合无关但底层偷偷往集合里加了元素循环走到下一步才抛出异常排查起来非常费劲。所以我建议在循环开始前把需要遍历的集合快照引用传给方法或者把所有集合修改统一放到循环结束后。如果必须边遍历边修改优先用迭代器的remove或者收集待处理列表在循环后统一处理。6.4 我看代码时最怕见到的循环写法讲几个我实际在代码评审里见过、每次都让我头大的循环写法希望你避开。第一个是循环体里套了巨长逻辑五十行起步。这种写法最大的问题是难以定位Bug因为你不知道问题出在哪一步。破解方法很简单把循环体逻辑拆成独立方法循环里只做取数据调方法。这样既方便单测也方便定位异常。第二个是死循环里只靠一个if加break退出而且break条件埋在深层嵌套里。一旦条件设计不周全程序就是真死循环。破解方法是把退出条件单独提取成一个isExit()方法每次循环开头处判断一次。可读性会好很多。第三个是在循环里调用Thread.sleep()处理重试但没有合理的重试上限也没有退避策略。比如每三秒查一次状态如果一直失败会无限轮询。破解方法是给重试加上次数上限和指数退避达到上限后走失败分支。第四种是循环里创建匿名内部类或lambda造成每次迭代都生成一个新对象。如果这个循环恰好又很热GC压力会明显上升。破解方法很简单把内部类提取为静态内部类或者普通方法避免每次循环都重建。我见过最让人崩溃的一种写法是循环里既catch异常又吞异常还在循环外看不到任何日志。这种代码一旦线上出问题基本只能靠猜。如果真的需要失败后继续至少把异常内容和上下文记录到日志或待处理队列里方便事后复盘。7. 从循环结构到编程素养的个人体会写了这么多年Java我对循环结构最大的体会是循环是最简单的语法但也是最暴露编码习惯的地方。一个程序员的循环写得怎么样基本能看出他的代码功底。我面试别人的时候常让候选人手写一个遍历集合并安全删除指定元素。这个题目看起来很简单但很多人会在foreach里直接remove然后一脸困惑地说我明明删了怎么会抛异常。能主动说出Iterator.remove()或者removeIf的候选人通常对Java集合机制的理解更深一层。日常写代码时我会刻意遵守几条循环相关的小原则。第一循环边界条件优先写成小于而不是小于等于这样能最大化避免越界问题。第二循环体尽量短超过十行的逻辑拆出去做独立方法。第三循环内的外部资源操作数据库、文件、网络一律批量化。第四循环里不使用浮点数作为条件判断。第五不在循环里处理那些可以在循环外统一处理的逻辑比如集合的批量复制、批量删除。这些原则看上去很零碎但积累起来它们能帮你写出更稳的代码。尤其是循环体尽量短这一条长期收益非常明显代码可读性提高可测试性增强Bug率下降也让同事在review你代码的时候少薅头发。关于循环结构的深入理解最后能转化为的其实就是这些日常的、细小的好习惯。