Java 程序由类型、方法、语句和表达式组成;编译器先检查名称、类型、作用域与控制流,JVM 再执行生成的字节码。
表达式的中间类型、短路规则与循环边界都属于程序语义;代码能够编译,不代表它处理了溢出、null 或边界输入。
缩小变量作用域,用卫语句处理无效输入,让分支覆盖完整,并针对边界值与副作用验证实际执行路径。
是什么,为什么存在
Java 语言基础是组织一个可执行程序的最小规则集:名称怎样绑定到声明,值怎样经过表达式计算,以及语句怎样决定下一步执行位置。Java 是静态类型语言,大部分名称与类型错误会在编译期暴露。编译成功之后,相同规则还决定运行时的求值顺序、分支选择与循环终止。
源文件通常声明一个或多个类、接口、枚举或记录。方法把操作封装在具名边界中,语句改变局部状态或控制执行顺序,表达式产生值。初学代码常从 public static void main(String[] args) 进入,但语言基础同样适用于 Web 控制器、测试方法、批处理任务和库代码。
变量不是无类型的容器。每个变量都有编译期类型、作用域和生命周期;局部变量还必须在读取前完成明确赋值。表达式把变量、字面量、方法调用与运算符组合起来,语句再决定是否使用结果、重复计算或提前离开当前控制结构。
这组规则存在的目的,是让编译器与读者对程序含义达成一致。缩进可以帮助人阅读,却不定义 Java 的块;花括号才定义块边界。类似地,方法名可以暗示意图,却不能改变 && 的短路语义或整数除法的规则。
本主题聚焦程序结构、变量、表达式与控制流。基本类型、引用类型和数值转换的完整规则属于 java/data-types;数组、集合、字符串、异常与面向对象设计分别由对应主题展开。这里会在理解控制流所需的位置提到它们,但不会重复各自的 API 清单。
工作原理
从源文件到入口方法
javac 读取源文件,完成词法、语法、名称和类型检查,再生成类文件。java 启动 JVM,加载入口类并调用符合启动协议的 main 方法。类文件名和运行时类名都区分大小写,公开顶层类型的名称还必须与源文件名一致。
main 中的 public 允许启动器访问方法,static 表示调用不需要先创建类的实例,void 表示方法不返回结果给调用者。String[] args 接收命令行参数;参数可能是长度为零的数组,但不是由启动器传入的 null。
一个方法体是块,块中可以包含局部变量声明与语句。每对花括号都会建立嵌套边界,但不一定建立新对象或新线程。代码离开块后,其中声明的局部名称不再可见。
方法调用与按值传递
Java 的参数传递始终按值进行。传入基本类型时,形参得到基本值的副本;传入引用类型时,形参得到引用值的副本。被调用方法无法通过重新给形参赋值,让调用方变量改为指向另一个对象。
引用副本仍然指向同一个对象,因此方法可以通过它修改可变对象的状态。调用方是否观察到变化,取决于共享对象的契约,而不是 Java 突然变成了按引用传递。把“引用按值传递”说清楚,可以同时解释重新赋值无效与对象修改可见这两种现象。
实参先从左到右求值,再把结果交给匹配的方法。重载目标由编译期类型和可用转换决定;实例方法真正执行哪个重写实现,还会使用接收者的运行时类型。重载与重写的完整规则属于 java/oop,基础审查至少要区分这两个阶段。
非 void 方法的每条正常完成路径都必须返回兼容的值。return 可以提前结束方法,void 方法也能使用不带值的 return。如果退出代表失败而不是正常结果,应使用明确的异常或结果模型,而不是返回一个未记录的魔法值。
从语义选择语句形式
语法形式应与控制意图对应。下表不是风格排名,而是从需求到语言结构的最短映射。
| 需求 | 通常采用 | 关键检查 |
|---|---|---|
| 按顺序执行步骤 | 表达式语句与方法调用 | 副作用与异常顺序 |
| 在两个动作间选择 | if / else | 条件是否真的是 boolean |
| 从有限类别产生值 | switch 表达式 | 完整性与每条路径的结果 |
| 按条件重复 | while 或普通 for | 初值、退出条件与更新 |
| 遍历全部元素 | 增强 for | 是否需要索引或结构修改 |
表达同一结果的语法可能不止一种。选择时应让合法输入、退出点和结果类型容易看见;为了少写几行而嵌套条件运算符,通常会隐藏这些信息。
声明、作用域与明确赋值
声明把名称、类型和可选的初始化表达式联系起来。局部变量的作用域从声明位置开始,持续到所在块结束;内层块不能重新声明仍在作用域内的同名局部变量。字段遵循不同的成员规则,不应拿字段遮蔽与局部变量规则互相推导。
Java 编译器对局部变量执行 明确赋值(definite assignment) 分析。只有编译器能证明每条到达读取点的路径都已经赋值,读取才合法。这是基于控制流的编译期判断,不会在运行时自动为局部变量补零或补 null。
final 局部变量只能赋值一次,但它引用的对象不因此变成不可变对象。final List<String> names 禁止让 names 指向另一个列表,却不禁止调用 names.add(...)。需要不可变语义时,类型和创建方式也必须提供相应契约。
var 只适用于带初始化器的局部变量等受支持位置。它让编译器推断一个静态类型,不会把 Java 变成动态类型,也不能只写 var result; 留待以后推断。初始化表达式如果让类型意图含糊,应显式写出类型。
表达式与求值顺序
Java 按从左到右的顺序求值运算符操作数,方法调用的实参也从左到右求值。这个保证在表达式包含方法调用、递增或异常时尤其重要。不过,把多次状态修改塞进一个表达式仍会增加审查成本;有副作用的步骤通常适合拆成具名语句。
算术运算取决于操作数类型。两个 int 相除执行整数除法,两个 int 相乘也先产生 int,即使结果随后赋给 long。更完整的数值提升、缩小转换与溢出规则见 java/data-types;基础代码至少要检查中间表达式,而不只检查接收结果的变量。
&& 与 || 实现 短路求值(short-circuit evaluation) 。left && right 只在左侧为 true 时求值右侧,left || right 只在左侧为 false 时求值右侧。单个 & 与 | 也能用于布尔操作数,但两侧都会求值,因此不能替代用于防护 null 或零除的短路运算符。
赋值也是表达式,但把它嵌入条件通常容易看错。比较相等使用 ==;赋值使用 =。对引用使用 == 比较的是是否为同一个引用,业务值相等通常要调用该类型定义的 equals 方法。
条件分支
if 根据一个 boolean 表达式选择路径。Java 不把数字、空字符串或对象隐式当成真假值,因此条件必须直接产生 boolean。花括号即使包住单条语句也值得保留,因为后续加入日志或校验时,不会意外把新语句放到分支外。
else 总是与语法上最近且尚未配对的 if 配对。清楚的花括号和较浅的嵌套能让这种归属直接可见。无效输入适合用 卫语句(guard clause) 提前返回,让主要路径留在较浅层级。
现代 switch 可以是语句,也可以是产生值的表达式。箭头规则不会落入下一分支,多个标签可以写成 case 1, 2 ->。表达式的每条正常完成路径必须产生兼容的值;单表达式规则直接给值,块规则则使用 yield。
switch 表达式必须覆盖所有可能的选择值。对开放取值范围的类型,通常需要 default;对枚举或密封层次,编译器可以从已知常量或允许的子类型证明完整性。完整性是编译器保证,不代表未来二进制演化不需要测试。
布尔条件的边界
条件最好直接表达领域判断,例如 hasStock && paymentAccepted。把赋值、递增或远程调用埋在条件里,会让真假结果与副作用纠缠。需要保留诊断信息时,先计算具名结果,再组合最终条件。
Java 不支持数学式的链式比较。范围检查要写成 0 <= index && index < length,不能写成 0 <= index < length。短路顺序还应先放置安全且便宜的防护,再放置依赖该防护的访问。
否定复杂条件时,应应用布尔代数并保留清楚的括号。与其把 !(active && authorized) 留给读者展开,不如在领域允许时写出具名谓词。改写后仍要测试两侧边界,因为可读性改善不会自动证明逻辑等价。
循环与提前结束
for 适合初始化、条件和更新能并排表达的循环。增强 for 适合依次读取数组或 Iterable 中的元素;需要索引、倒序或原地替换时,普通 for 通常更明确。while 在每轮开始前检查条件,do-while 则保证主体至少执行一次。
循环能否终止取决于状态是否朝退出条件推进。审查时要同时检查初始值、条件、更新和整数边界。对数组索引,常见范围是 0 <= index && index < array.length,上界不能写成 <= array.length。
continue 跳到下一轮,break 离开当前循环或 switch,return 结束当前方法。带标签的 break 或 continue 可以作用于外层语句,但它们常提示嵌套逻辑值得提取成方法。异常也会导致控制流突然结束,详细传播规则见 java/exceptions。
作用域不等于对象生命周期
局部名称的作用域是编译期可见范围,对象生命周期则由运行时可达性决定。方法返回后,局部名称消失,但它指向的对象如果被返回、存入字段或交给其他长期对象,仍然可以存活。
反过来,一个仍在作用域内的局部变量也不表示对象必然保留到块结束;实现可以在语义允许时回收已经不再使用的对象。业务代码不应依赖具体回收时刻,资源释放则应交给 try-with-resources 等明确机制。
缩小作用域的主要收益是减少可修改状态与可用名称,而不是直接承诺内存性能。变量在第一次需要时声明,读者就能更快判断哪些语句可能影响它,也更容易发现循环间意外复用的状态。
示例
下面四个程序从顺序执行开始,逐步加入分支、循环和短路求值。每个源文件都可以独立编译和运行,输出来自本地 JDK 的实际执行结果。
变量、表达式与输出
第一个程序声明订单数据,计算小计,并用条件运算符选择配送标签。变量只放在使用它们的方法中,名称直接表达单位。
public class OrderSummary {
public static void main(String[] args) {
String customer = "Amina";
int unitPriceCents = 240;
int quantity = 3;
int subtotalCents = Math.multiplyExact(unitPriceCents, quantity);
boolean freeShipping = subtotalCents >= 500;
String delivery = freeShipping ? "standard-free" : "standard-paid";
System.out.printf("customer=%s subtotal=%d%n", customer, subtotalCents);
System.out.println("delivery=" + delivery);
}
}customer=Amina subtotal=720
delivery=standard-freeMath.multiplyExact 表明订单小计不允许静默溢出。条件运算符适合这种只在两个值之间选择的情况;如果每个分支还要修改多个状态,普通 if 会更清楚。
卫语句与 switch 表达式
第二个程序先拒绝负数,再处理空订单,最后按商品数分类。提前返回把无效与特殊情况移出主要决策路径。
public class ShippingDecision {
static String classify(int itemCount, boolean expedited) {
if (itemCount < 0) {
return "invalid";
}
if (itemCount == 0) {
return "empty";
}
String band = switch (itemCount) {
case 1, 2 -> "small";
case 3, 4, 5 -> "medium";
default -> "large";
};
return expedited && itemCount <= 5
? band + "-express"
: band + "-standard";
}
public static void main(String[] args) {
for (int itemCount : new int[] {-1, 0, 2, 7}) {
System.out.println(itemCount + " -> " + classify(itemCount, true));
}
}
}-1 -> invalid
0 -> empty
2 -> small-express
7 -> large-standard箭头形式不会从 small 继续执行到 medium。default 覆盖所有大于 5 的整数,而前面的卫语句已经处理负数与零,因此每条路径的含义都可见。
循环中的跳过与终止
第三个程序扫描库存变动。零变动没有信息,使用 continue 跳过;库存低于阈值后,后续数据对本次告警已经无关,因此使用 break 结束扫描。
public class StockScan {
public static void main(String[] args) {
int[] changes = {3, 0, -2, 5, -8, 4};
int balance = 10;
for (int change : changes) {
if (change == 0) {
continue;
}
balance = Math.addExact(balance, change);
System.out.println("change=" + change + " balance=" + balance);
if (balance < 9) {
System.out.println("reorder");
break;
}
}
}
}change=3 balance=13
change=-2 balance=11
change=5 balance=16
change=-8 balance=8
reorder增强 for 不暴露索引,因为这里的决策只依赖元素值。执行到 break 后,最后一个 4 不会被处理;测试预期必须包含这一事实。
可见的短路路径
最后一个程序让每次布尔检查记录自己的名称。第二项失败后,&& 不再执行第三项;同一规则也保护后面的除法。
import java.util.ArrayList;
import java.util.List;
public class EvaluationOrder {
static boolean check(List<String> events, String name, boolean result) {
events.add(name);
return result;
}
public static void main(String[] args) {
List<String> events = new ArrayList<>();
boolean accepted = check(events, "stock", true)
&& check(events, "credit", false)
&& check(events, "fraud", true);
int denominator = 0;
boolean safe = denominator != 0 && 100 / denominator > 2;
System.out.println("events=" + events);
System.out.println("accepted=" + accepted + " safe=" + safe);
}
}events=[stock, credit]
accepted=false safe=false列表保留了实际求值顺序:fraud 没有出现。若把第二个表达式中的 && 改成 &,右侧仍会执行并抛出 ArithmeticException,因此这两个运算符不是风格上的替代写法。
陷阱
修复方法: 在声明点给出有业务含义的初值,或确保每条分支都赋值。不要只为通过编译而塞入假的零值;如果“尚未计算”是合法状态,应明确建模该状态。
修复方法: 按类型契约使用 equals;可能含 null 时使用 Objects.equals。只有在问题确实是“是否为同一个对象”时才使用引用 ==。
修复方法: 在运算前把操作数提升到所需类型,或使用 Math.addExact、multiplyExact 等精确方法拒绝溢出。数值边界的完整选择依据见 java/data-types。
修复方法: 使用半开区间 0 <= index && index < values.length,并让更新集中在循环头。只需要元素值时改用增强 for,同时测试空、单元素和终止边界。
修复方法: 新代码优先使用箭头规则;需要从分支产生值时使用 switch 表达式。维护传统写法时,把有意的贯穿明确记录并用测试覆盖,否则每个分支显式 break。
明确赋值与可达性
明确赋值分析跟踪的是变量在每个程序点的状态,不是运行一次测试时观察到的值。if 的两个分支都给局部变量赋值后,后续读取可以合法;只有其中一个分支赋值时,编译器会拒绝读取,除非另一条路径在到达读取点前已经 return 或抛出异常。
这项分析故意保守。编译器依据语言规定的表达式与语句结构证明状态,不会执行任意业务方法来猜测它总是返回什么。把关键条件藏进一个普通布尔方法,可能让人知道路径安全,却不足以改变局部变量的明确赋值状态。
空白 final 局部变量把规则再收紧一步:读取前必须明确赋值,而且赋值前必须明确未赋值。两个互斥分支各赋值一次可以合法,因为任一实际路径只发生一次赋值。循环内给同一个空白 final 局部变量赋值通常无法证明只执行一次。
可达性分析解决另一个问题:一条语句是否可能被执行。无条件 return 后紧接的普通语句不可达,会导致编译错误。这个检查能发现部分死代码,但不证明业务条件有意义,也不会替代覆盖率与边界测试。
模式变量也使用控制流作用域。value instanceof String text && !text.isBlank() 的右侧只会在匹配成功时执行,因此 text 在那里可用。随意把 && 改成 & 不仅改变求值行为,也可能破坏编译器据以判断模式变量可用的流程结构。
表达式语义隐藏在语法下面
从左到右求值意味着前一个操作数的副作用或异常发生在后一个操作数之前。方法调用先求值目标引用,再按顺序求值参数,最后进入方法体。如果某一步突然完成,后续操作数与方法体都不会执行。
短路运算符在这个通用顺序上增加了条件跳过。a != null && a.isReady() 先读取 a 并完成空值比较,只有结果为 true 才调用方法。交换两侧或改用 & 都会改变契约,而不仅是排版。
复合赋值也比表面更具体。target += value 会对左侧求值一次,完成运算,并包含回到左侧类型的隐式转换。它不总能机械替换为 target = target + value,因为后者可能需要显式转换,而且复杂左侧还可能被求值两次。
条件运算符 condition ? left : right 只会求值被选中的一个结果表达式。它适合选择一个值,不适合塞入多个状态修改。结果类型还受两个分支及目标上下文影响,因此两个分支看似相近,也可能触发装箱或数值转换。
递增与递减表达式同时产生值并修改变量。后缀 index++ 产生修改前的值,前缀 ++index 产生修改后的值。单独作为循环更新时差异通常不影响下一轮,但嵌入数组访问或方法实参后,求值时机会直接影响结果。
循环不变量与边界
循环不变量是在每轮开始或结束时都应成立的条件。例如,扫描 values[0..index) 时,可以约定这段半开区间已经处理,而 index..length 尚未处理。把这个条件写进测试或注释,比笼统地写“遍历数组”更能帮助审查。
终止还需要一个朝边界单调推进的量。索引循环通常让 length - index 每轮减小;重试循环则需要最大次数、截止时间或外部取消。仅凭“成功时会 break”不能证明失败路径终止。
半开区间 [start, end) 让长度自然等于 end - start,空区间也能用 start == end 表示。它与数组合法上界配合,减少了最后一个元素附近的特殊算术。反向循环则要重新推导边界,不能机械替换递增符号。
| 输入形状 | 要证明的行为 |
|---|---|
| 空输入 | 主体执行零次,结果具有定义 |
| 单元素 | 唯一元素只处理一次 |
| 正好到阈值 | 比较符号符合包含或排除契约 |
| 提前命中 | break 后没有额外副作用 |
| 从不命中 | 循环仍能终止并返回规定结果 |
增强 for 中的循环变量接收本轮元素值。对基本类型数组,给循环变量重新赋值不会修改数组元素;对引用元素,通过引用修改对象仍可能可见。需要替换数组槽位时,应使用显式索引,让写入位置清楚可查。
do-while 的首次无条件执行适合先显示一次菜单或先尝试一次操作的契约。若零次执行也可能合法,使用 while 往往更直接。选择循环形式之前先回答主体最少执行几次,可以避免为迁就语法而复制初始化逻辑。
switch 的完整性与突然完成
传统 switch 语句允许冒号标签之间贯穿,这是早期语法的一部分。箭头规则不允许贯穿,规则右侧可以是表达式、块或 throw 语句。两种形式解决不同兼容需求;新代码选择一种形式并保持一致,通常最容易审查。
switch 表达式的目标是产生一个值,因此必须是完整的。块形式不能用普通 break 携带结果,而要用 yield。如果规则抛出异常,它会突然完成,不需要为该路径产生值。
对枚举写出所有当前常量时,编译器可以接受没有源码 default 的表达式。这样新增枚举常量后,重新编译会指出遗漏分支。不过,运行新枚举类与未重新编译的旧调用代码仍是二进制演化场景,所以部署兼容性测试不能省略。
break、continue、return 与 throw 都会让当前语句或表达式突然完成,但目标不同。break 离开循环或相应语句,continue 进入循环的下一轮,return 离开方法,throw 沿调用栈寻找处理器。准确说出目标,比笼统地称它们为“跳转”更利于审查嵌套控制流。
复杂分支的维护成本通常来自路径组合,而不是关键字数量。先用卫语句排除无效状态,再让主要 switch 处理有效领域值,可以减少组合。若仍需要多层标签跳转或共享可变标志,提取具名方法往往能让输入、结果与提前退出变得可测试。
4个问题 · 1 道输出预测题 · 1 道找错题