如果把顺序语句看成“做完一步,再做下一步”,循环解决的就是另一类问题:同一套动作要做很多次,但每次都必须知道“现在处理到哪里、何时停止、结果累积到什么程度”。
很多循环错误表面上只差一个符号:< 写成 <=,更新放错一行,或把结束标记也算进平均值。真正的原因通常不是语法没背熟,而是没有把循环看成一个持续变化的状态系统。这一篇会从状态、不变量和边界出发,再进入 while、do-while、for、嵌套循环、随机数与文件逐行处理。
代码约定:带 public class 的代码围栏可以保存为同名文件后独立编译;较短围栏是放在当前讲解所指 main 方法中的片段。示例按 Java SE 26 的正式规则组织。
循环写得好不好,不只看它能否跑出一次正确结果。我们还要能回答四个问题:开始前状态是什么、每轮为何可以继续、这一轮让状态怎样前进、停止后能推出什么结论。
一个可控循环通常有四个环节:
例如,输出 1 到 5:
int number = 1; // 初始化
while (number <= 5) { // 条件检查
System.out.println(number); // 处理一轮
number++; // 更新状态
}第一次检查时 number 是 1;最后一次进入循环体时是 5;执行完这一轮更新为 6,再检查得到 false,于是离开循环。循环结束后的 6 不是“多处理了一次”,而是证明停止条件已经成立的状态。

循环的四拍节奏:初始化一次,随后反复检查条件、执行循环体并更新状态。
循环不变量是一条在每轮开始时都成立的陈述。它不是 Java 关键字,而是我们推理代码的工具。
对上面的循环,可以选用这条不变量:
每次检查条件时,1 到
number - 1已经按顺序输出,而number是下一个候选值。
我们分三步检查它:
初始化后 number 等于 1,还没有数字被输出,所以“1 到 0 已输出”可以理解为空范围,不变量成立。
假设某轮开始时不变量成立。循环体输出当前 number,随后将它加 1,于是下一轮开始时,已输出范围正好扩展到新的 number - 1。
退出时 number 等于 6,结合不变量可知 1 到 5 都已输出;条件为假又保证没有输出 6。
这种“初始化成立—每轮保持—退出推出结果”的思路,之后会反复用于累加、搜索、文件处理和嵌套循环。
不变量说明“每轮做得对”,进度量说明“不会永远做下去”。上例可以用 6 - number 作为进度量:每轮减少 1,并且最终到达 0。
如果更新写成 number--,条件 number <= 5 会一直成立,进度量反而越来越大。此时语法完全合法,但算法没有终止保证。
审查循环时,把“正确性”和“终止性”分开问:循环体是否保持不变量?更新是否让某个有下界的量持续接近终点?这两问比盯着缩进更容易抓住根因。
循环最常见的更新是让整数增加或减少 1。Java 提供 ++ 与 --:
count++; // 等价于把 count 的当前值加 1,再存回 count
count--; // 等价于把 count 的当前值减 1,再存回 count当它们单独成为一条更新语句时,前缀和后缀对变量的最终结果相同:
int a = 5;
int b = 5;
++a;
b++;
System.out.println(a); // 6
System.out.println(b); // 6差别只在表达式还要使用这次运算的值时出现:
int x = 5;
int before = x++; // before 得到旧值 5,随后 x 变为 6
int y = 5;
int after = ++y; // y 先变为 6,after 得到新值 6在循环更新区写 i++ 或 ++i 都会让 i 增加 1。为了减少同时“取值并修改”的脑内负担,我建议把更新独立成一处,不把多个 ++ 塞进复杂表达式。
整数递增不会在越界时自动报错。Integer.MAX_VALUE + 1 会绕回最小整数。后面会看到,这可能让一个看似接近终点的循环重新跑回负数区间。
如果控制变量只服务于一个 for,直接在循环头声明:
for (int i = 0; i < 4; i++) {
System.out.println(i);
}
// 这里不能再使用 ii 的作用域包括条件、更新区和循环体,不包括循环之后。较小的作用域能防止后续代码误改或误用它。
while 的控制状态通常要在条件之前建立,因此变量常声明在循环外:
int attempts = 0;
while (attempts < 3) {
System.out.println("第 " + (attempts + 1) + " 次尝试");
attempts++;
}Java 编译器会检查确定赋值:读取局部变量之前,每一条可能到达这里的路径都必须已经给它赋值。
int result;
int n = 3;
while (n > 0) {
result = n * 2;
n--;
}
// System.out.println(result); // 编译错误我们肉眼知道当前 n 是 3,但编译规则按结构分析:while 可能执行零次,因此不能保证 result 已赋值。可以在循环前给出有意义的初值,或者重新组织代码,让结果只在确定产生的路径中使用:
int result = 0;
int n = 3;
while (n > 0) {
result = n * 2;
n--;
}
System.out.println(result);不要为了“哄过编译器”随手填一个会污染业务含义的值。初值应该参与循环不变量,例如计数器从 0 开始、乘积从 1 开始、是否找到从 false 开始。
while 是入口判断循环:
while (条件) {
// 条件为 true 时执行
}执行顺序是“检查—循环体—回到检查”。如果第一次检查就是 false,循环体执行 0 次。条件表达式必须是 boolean,或能拆箱为 boolean 的 Boolean;后者如果是 null,拆箱会在运行时失败,所以循环条件优先使用基本类型 boolean。
假设程序只接受 0 到 100 的整数成绩。若用户第一次输错,if 只能再给一次机会;第二次仍错就会漏过去。while 能把“输入合法”变成离开循环的条件:
import java.util.Scanner;
public class ScoreValidation {
public static void main(String[] args) {
Scanner input = new Scanner(System.in);
System.out.print("请输入 0 到 100 的整数成绩:");
int score = input.nextInt();
while (score < 0 || score > 100) {
这个版本专注于范围验证,假定用户输入的是整数。若还要防止“八十”之类非整数 token,可先用 hasNextInt() 检查并用 next() 消耗无效 token;不要只检查却不消耗,否则同一个坏 token 会被永远看见。
while (!input.hasNextInt()) {
String badToken = input.next();
System.out.println("“" + badToken + "”不是整数,请重试。");
}
int value = input.nextInt();Scanner.nextInt() 读取的是 token,不会吃掉行尾。紧接着调用 nextLine() 时,常会先得到剩余的空行。需要混合读取数字和整行文本时,要么明确消费行尾,要么统一使用 nextLine() 再解析。
下面三个条件看似相近,退出含义却不同:
条件最好描述“为什么还要继续”,更新则要破坏这个继续理由。若条件依赖 index,循环体里却从不改变 index,就该立刻警惕。
do-while 把条件放在循环体之后:
do {
// 先执行循环体
} while (条件);末尾分号是语法的一部分。因为第一次条件检查发生在循环体之后,所以循环体至少执行一次。
import java.util.Scanner;
public class MenuLoop {
public static void main(String[] args) {
Scanner input = new Scanner(System.in);
String choice;
do {
System.out.println();
System.out.println("1. 查看学习记录");
System.out.println("2. 新增学习记录");
System.out.println(
这里必须先显示一次菜单,用户才有机会产生 choice。do-while 让结构与需求顺序一致,也让 choice 在条件检查之前确定赋值。
先按意图选,而不是按“哪种都能写”选:
它们的计算能力可以互相替代,但可读性并不相同。强行把一个菜单写成带复杂“预热”的 while,或者把输入直到结束写成塞满多个空槽的 for,都会增加读者的还原成本。

次数未知且可能不执行用 while;先做一次再决定用 do-while;计数边界清楚用 for。
基本 for 的结构是:
for (初始化; 继续条件; 更新) {
// 循环体
}它的顺序不是从左到右无限循环,而是:
for (int i = 0; i < 5; i++) {
System.out.println(i);
}这段代码输出 0、1、2、3、4。i 更新到 5 后,条件为假,循环结束。
如果需求是“处理 1 到 5,包含两端”,先写成值域 1, 2, 3, 4, 5,再翻译:
for (int number = 1; number <= 5; number++) {
System.out.println(number);
}如果需求是“处理 5 个从 0 开始的下标”,值域是 0, 1, 2, 3, 4:
for (int index = 0; index < 5; index++) {
System.out.println(index);
}倒序同样先确定最后一个要处理的值:
for (int seconds = 5; seconds >= 1; seconds--) {
System.out.println(seconds);
}初始化、条件、更新在语法上都可以省略,for (;;) 因而是无限循环。但初学阶段不建议为了“短”而省略能表达边界的部分。
当数据来自数组或实现了 Iterable 的对象,且不需要下标时,可以使用增强 for:
int[] scores = {86, 92, 75};
int sum = 0;
for (int score : scores) {
sum += score;
}
System.out.println(sum); // 253循环变量 score 每轮接收一个元素值。对基本类型来说,修改 score 不会改回数组元素:
for (int score : scores) {
score = 0; // 只改变本轮局部变量
}需要下标、需要原地替换元素,或需要按两个序列的对应位置处理时,基本 for 更合适。
continue 会跳过本轮剩余循环体,但基本 for 随后仍执行更新,再检查条件:
for (int i = 0; i < 5; i++) {
if (i == 2) {
continue;
}
System.out.println(i);
}输出 0、1、3、4,i 不会卡在 2。这一点与把更新手写在 while 循环体末尾不同。
循环体通常不只是“打印当前值”,还要把每轮信息合并进一个持续状态。最常见的两个角色是:
long sum = 0;
int count = 0;
for (int value = 1; value <= 100; value++) {
sum += value;
count++;
}
double average = (double) sum / count;
System.out.println("总和:" +
强制转换把除法提升为浮点除法。若直接写 sum / count 且两者都是整数,小数部分会先被截掉,再赋给 double。
当输入项数未知时,可以约定一个不可能成为正常数据的值表示结束。这个值叫哨兵。
下面的程序读取非负学习分钟数,以 -1 结束:
import java.util.Scanner;
public class StudyAverage {
public static void main(String[] args) {
Scanner input = new Scanner(System.in);
long totalMinutes = 0;
int count = 0;
System.out.print("输入学习分钟数,-1 结束:");
int minutes = input.nextInt
这里先读取一次,让第一次条件检查有值可看,这叫给循环“预热”。每轮末尾再读取下一项。读到 -1 后,条件立即为假,所以 -1 不会进入总和,也不会进入计数。

计数器记录有效数据个数,累加器保存总和;哨兵 -1 只负责结束循环,不参与统计。
count。int 相加可能先溢出,使用 long 只能扩大范围,不能取消上限。如果溢出必须被显式发现,可以用 Math.addExact(total, value),它会在整数结果超出类型范围时抛出异常。异常处理会在后续专章展开,这里先知道普通 + 与 ++ 不会自动报警。
while (scanner.hasNextInt()) 表示“只要输入源里还有整数 token 就继续”;while (value != -1) 表示“只要业务协议尚未收到结束值就继续”。前者由输入源结束,后者由数据中的一个约定结束。两者可以组合,但含义不同。
break 和 continue 都会让当前循环体不再按普通顺序执行:
break:立即结束它所直接位于的循环,控制流转到循环之后。continue:结束当前这一轮,转到当前循环的“继续点”。for (int number = 1; number <= 10; number++) {
if (number == 4) {
continue; // 跳过 4,for 随后执行 number++
}
if (number == 8) {
break; // 直接离开循环,不再处理 8 及之后的数
}
System.out.println(number);
}输出是 1、2、3、5、6、7。
当循环的主要任务是搜索,第一个匹配项出现后继续扫描没有意义:
int[] values = {14, 27, 35, 42, 58};
int target = 42;
int foundIndex = -1;
for (int i = 0; i < values.length; i++) {
if (values[i] == target) {
foundIndex = i;
初值 -1 表示“尚未找到”。循环结束后,foundIndex >= 0 就是搜索成功的判断。这里的 break 只承担一个清楚职责:结果已经确定,结束搜索。
看下面这段有问题的代码:
int i = 0;
while (i < 5) {
if (i == 2) {
continue; // i++ 被跳过,i 永远停在 2
}
System.out.println(i);
i++;
}while 的继续点是条件检查,没有自动更新区。到 i == 2 后,每轮都在更新前跳走,形成死循环。可以把更新移到分支之前,或更直接地改写控制逻辑:
int i = 0;
while (i < 5) {
if (i != 2) {
System.out.println(i);
}
i++;
}不带标签的 break 与 continue 只作用于直接包住它们的循环。Java 也支持带标签的跳转,但它会让读者跨层追踪控制流。除非确实需要一次跳出多层搜索,否则优先考虑布尔标志、提取方法,或重新组织循环条件。
看到 while (true) 不必立刻判定为错误,但要能快速找到所有退出路径,并确认每条业务路径最终能到达其中一条。若退出条件能自然写在循环头,就让它直接出现在循环头。
循环体本身可以再包含循环。理解嵌套循环时,先给两层各分配一个职责:
下面输出 3 行 4 列坐标:
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 4; column++) {
System.out.printf("(%d,%d) ", row, column);
}
System.out.println();
}每次外层固定一个 row,内层 column 都从 0 重新初始化,完整跑 4 次。总执行次数是 3 × 4 = 12。

外层决定三批行任务,内层每批走完四列,因此总次数是 3 × 4 = 12。
如果每一轮外层的内层次数相同,可以相乘:
for (int row = 1; row <= 9; row++) {
for (int column = 1; column <= 9; column++) {
System.out.printf("%4d", row * column);
}
System.out.println();
}循环体核心乘法执行 9 × 9 = 81 次。
若内层上界依赖外层,不能直接把两个上界相乘:
int visits = 0;
for (int row = 1; row <= 4; row++) {
for (int column = 1; column <= row; column++) {
visits++;
}
}
System.out.println(visits); // 1 + 2 + 3 + 4 = 10每个 row 对应的次数分别是 1、2、3、4,总数要逐行相加。
分析两层循环时,可以列出外层值、内层值与本轮动作:
轨迹表还能暴露初始化位置错误。如果把 column 声明在外层之前且忘记每批重置,后面的行可能一次也不执行。
for (int row = 1; row <= 3; row++) {
for (int column = 1; column <= 5; column++) {
if (column == 3) {
break;
}
System.out.printf("(%d,%d) ", row, column);
}
System.out.println();
}每行只打印第 1、2 列,但外层仍会完成 3 行。若业务要求“一旦任何位置匹配就停止全部搜索”,要把这个跨层退出意图写得非常明确,而不是误以为普通 break 会自动处理。
循环最常见的 bug 不是复杂算法,而是处理集合的边界与更新方向不一致。
处理从 0 开始的 size 个位置时,优先使用 [0, size):
for (int index = 0; index < size; index++) {
// 处理 index
}它有三个好处:
size - 1;size - 0。若业务值域明确包含右端,例如“1 到 10 的整数”,number <= 10 也完全合理。关键不是一律用哪个符号,而是先写清是否包含右端。
i 增大到上界,更新却让它减小。continue 或分支让关键更新一直被跳过。这个循环尤其危险:
for (int i = 0; i <= Integer.MAX_VALUE; i++) {
// ...
}每个 int 都小于或等于 Integer.MAX_VALUE。当 i 从最大值递增时,它会绕回 Integer.MIN_VALUE,条件仍为真,因此循环不会按预期结束。
不要用“跑一下看看会不会停”来证明终止。输入规模大、随机路径不同或整数溢出时,短测试可能掩盖问题。应明确进度量、类型范围与退出条件。
写完循环后,至少手算这些情况:
调试时,不要只打印最终答案。临时记录每轮开始的关键状态:
System.out.printf(
"轮次=%d, index=%d, sum=%d%n",
iteration,
index,
sum
);然后把真实轨迹与手算轨迹对照,寻找第一处不一致。第一处偏离往往就是错误发生的位置;后面几十行异常输出只是它的连锁反应。
Java 的随机 API 生成的是序列。循环每调用一次生成方法,就消费序列中的下一个值。
RandomGenerator.nextInt(origin, bound) 返回的整数范围是 [origin, bound)。模拟六面骰子时:
import java.util.Random;
import java.util.random.RandomGenerator;
public class DiceRolls {
public static void main(String[] args) {
RandomGenerator random = new Random();
for (int roll = 1; roll <= 10; roll++) {
int die = random.nextInt(1
下界 1 可能出现,上界 7 不会出现,所以结果恰好是 1 到 6。直接使用带起点和上界的方法,比手写取模或强制转换更清楚,也避免不均匀分布等细节错误。

[1, 7) 包含下界 1、不包含上界 7;固定种子固定的是序列起点,同样的调用顺序可复现同一序列。
RandomGenerator random = new Random(20260715L);对 Random 来说,相同种子加上相同的方法调用顺序,会产生相同序列。这对测试很有价值:一次随机失败可以在本机和别处重放。
固定种子不表示“每轮得到同一个数”。生成器内部状态会随着调用前进;它表示从同一个起点走同一条序列。
Random first = new Random(42L);
Random second = new Random(42L);
for (int i = 0; i < 5; i++) {
int a = first.nextInt(1, 7);
int b = second.nextInt(1,
RandomGenerator.getDefault() 适合不关心具体算法的普通用途,但默认算法未来可以变化。需要跨实现、跨版本稳定重放时,明确使用 new Random(seed) 更容易说明预期;同时还要保持调用顺序一致。
运行 100 次仍可能一次都没有掷出 6,这不是代码必然错误。更稳定的测试分三层:
统计分布是否合理需要更大样本和统计判断,不能用“六个数看起来差不多”替代。
普通模拟与可复现测试可以使用 Random 或 RandomGenerator。密码、令牌等安全用途需要专门的安全随机源,不要把本节示例直接用于安全敏感数据。
文件处理天然是一个循环:
现代 Java 使用 Path 表示路径,用 Files 打开读写资源。Path 只是“文件位置”的描述,不保证对应文件一定存在。
下面的程序读取 UTF-8 文本,统计总行数、非空行数和各行的 UTF-16 代码单元总数,并把摘要写入另一个文件。行尾符会被 readLine() 去掉,因此不计入这个总数:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class TextFileSummary {
public static void main(String[] args) {
Path inputPath = Path.of("data", "notes.txt");
Path outputPath = Path.of(
readLine() 在读到文件末尾时返回 null,行尾符本身不包含在返回字符串中。line.length() 返回的是 UTF-16 代码单元数量:补充字符会占两个代码单元,它也不是 UTF-8 编码后的字节数。若需求是统计 Unicode 码点,应改用 line.codePointCount(0, line.length());若需求是统计用户看到的字形,还要进一步处理组合字符和字素簇。赋值放在条件中可以避免“循环前读一次、循环末尾再读一次”的重复,但它仍遵循同一模型:取得下一项,再判断是否存在。

逐行读取时,空字符串仍是有效的一行,只有 null 才表示 EOF;资源按与声明顺序相反的次序关闭。
圆括号中的资源都实现了 AutoCloseable。离开 try 时,无论是正常完成、break、return 还是抛出异常,Java 都会尝试关闭它们。多个资源按声明的相反顺序关闭。
这不是“程序结束时系统总会替我关”的同义说法。及时关闭会归还文件句柄,并确保缓冲数据被处理。文件 Scanner、BufferedReader、PrintWriter 都应该明确管理生命周期。
PrintWriter 的打印方法通常不会直接抛出 I/O 异常,所以需要时用 checkError() 查询错误状态。上例在关闭前检查并主动转成 IOException,让统一的 catch 路径能够报告失败。
不带打开选项的 Files.newBufferedWriter 会创建不存在的文件;若普通文件已存在,会从长度 0 开始写,也就是覆盖原内容。若必须防止覆盖,可以显式使用 StandardOpenOption.CREATE_NEW:
import static java.nio.file.StandardOpenOption.CREATE_NEW;
import static java.nio.file.StandardOpenOption.WRITE;
var writer = Files.newBufferedWriter(
outputPath,
StandardCharsets.UTF_8,
CREATE_NEW,
WRITE
);完整程序仍应把这个 writer 放进 try-with-resources。若文件已经存在,打开操作会失败,而不是悄悄擦除内容。
Files.readAllLines(path) 适合明确很小、一次装入内存更方便的文本。文件可能很大时,BufferedReader.readLine() 循环只保留当前行,内存占用更可控。
Files.lines(path) 会返回惰性行流,而且流内部仍持有打开的文件,所以也必须放在 try-with-resources 中。流 API 会在后续内容展开;当前章节用显式循环更容易观察状态和错误边界。
面对一个“读取很多数据并给出结果”的需求,先别急着写 for 或 while。先把任务拆成状态、单轮动作与结束协议。
假设要处理一批温度:
先写输入协议:温度来自哪里,哪些值合法,靠固定条数、哨兵还是文件末尾结束。
再列持久状态:有效条数 count、总和 sum、当前最大值 max,以及当前输入 value。
定义单轮职责:恰好消费一条输入;合法则更新统计,无效则记录或提示;任何路径都不能重复消费或停在原地。
写循环不变量:每轮开始时,统计变量准确描述已经处理过的全部有效数据,当前输入尚未计入。
以输入 12, 18, -1 为例:
表格迫使我们区分“当前输入”“已处理数据”和“结束协议”。如果某一行无法明确填写,代码里往往也存在状态含糊。
写一个循环,输出 3 到 36 的全部 3 的倍数,包含两端。要求控制变量本身就按 3 前进,不在循环体里额外用 if 过滤。
在整数数组中找到第一个能被 7 整除的元素,输出它的下标;没有则输出 -1。
使用 UTF-8 逐行读取 input.txt,统计非空行。要求使用 try-with-resources,并区分空行与文件末尾。
用种子 42 创建 Random,掷骰子 20 次。验证每个结果都在 1 到 6,并统计 6 出现的次数。再创建第二个相同种子的生成器,检查两条序列是否逐项相同。
真正可维护的循环,读者能在不运行程序的情况下说清第一轮、普通一轮、最后一轮和零轮输入。做到这一点,语法选择通常会自然变得简单。
最后写退出结论:输入已结束,若 count 为 0 就报告无数据,否则计算平均值并输出最大值。
每轮开始时,0 到 i - 1 都已经检查且不满足条件。找到后结果确定,break 结束搜索;若一直没找到,初值 -1 被保留。
空行是有效读取结果,只是 line.isBlank() 为真;null 才表示没有下一行。try-with-resources 保证读取结束或失败时关闭 reader。
测试断言放在确定事实上:区间、次数和同种子序列一致。没有要求 6 必须出现某个固定次数,因为短样本的频数本来就会波动。