Java 基础体系 · 第 36/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 25 词法、表达式与运算符:求值顺序、提升、溢出和短路
Java 程序从源代码到可执行行为,至少要经过三层规则约束:
- 词法规则决定字符如何组成标识符、关键字、字面量、运算符和分隔符。
- 表达式规则决定表达式的类型、可赋值性、装箱拆箱、隐式转换和结果类型。
- 求值规则决定哪些子表达式先执行、哪些子表达式可能不执行,以及异常和副作用何时发生。
这三层不能混为一谈。例如:
int x = 1 + 2 * 3;
词法规则先把它识别为整数 1、运算符 +、整数 2、运算符 *、整数 3;语法和优先级规则把它解释为 1 + (2 * 3);数值提升规则决定中间结果是 int;求值规则决定先计算 2 * 3,再与 1 相加。
下文以 Java SE 25 的语言规范为准,重点覆盖 JLS 第 3 章“词法结构”和第 15 章“表达式”。
一、从源代码到表达式:先区分词法、语法和求值
1. 词法分析处理“字符如何组成词”
词法分析把源文件字符流切分为若干词法单元(token),例如:
total += price * count;
可以看作:
标识符(total)
运算符(+=)
标识符(price)
运算符(*)
标识符(count)
分隔符(;)
空格、换行和注释通常用于分隔词法单元,但它们不是表达式的一部分。另一方面,词法阶段并不决定 * 的含义是乘法,还是某些语法结构中的其他符号;这要由后续语法规则判断。
2. 语法分析处理“词如何组合”
例如:
a + b * c
语法规则和运算符优先级把它解析为:
a + (b * c)
而不是:
(a + b) * c
括号会显式改变语法树:
(a + b) * c
3. 求值处理“语法树如何运行”
对于:
int result = f() + g() * h();
语法结构是:
f() + (g() * h())
求值顺序则是:
- 求值
f(); - 求值
g(); - 求值
h(); - 计算
g() * h(); - 计算
f() + (...); - 把结果赋给
result。
因此,优先级决定树结构,求值顺序决定树中子表达式何时执行。优先级不是求值顺序的替代品。
二、Java 25 的词法结构
1. Unicode 转义发生在普通词法分析之前
Java 源文件使用 Unicode 字符。源文件中的 Unicode 转义形式:
\u0061
会在后续词法分析前被转换成字符 a。
因此下面的代码并不一定像表面看起来那样:
// \u000a System.out.println("执行");
\u000a 会被转换为换行符。换行符可能结束注释,使后面的 System.out.println("执行"); 成为真正的代码。
Unicode 转义的处理顺序是一个真实的词法边界问题:
原始字符
↓
Unicode 转义处理
↓
行终止符识别、注释处理
↓
词法单元识别
↓
语法分析
工程上不应利用这种行为编写代码。它会使代码审查、格式化、静态分析和安全扫描看到的内容与编译器实际处理的字符产生差异。
2. 空白和注释
Java 的空白包括空格、制表符、换页符和行终止符等。普通注释有两种:
// 行注释
/*
* 块注释
*/
文档注释:
/**
* 文档注释。
*/
注释会被词法分析阶段移除,不能直接成为表达式的一部分。例如:
int x = 1 /* + 100 */ ;
等价于:
int x = 1;
但注释不能出现在一个词法单元内部来“拼接”它:
in/*comment*/t x = 1; // 非法
这里的 in 和 t 不会重新合成为 int。
3. 标识符、关键字和上下文关键字
普通标识符可以使用 Unicode 字符,并且可以包含数字,但不能以数字开头:
int count2;
int 计数;
int $internal;
$ 在语法上合法,但通常保留给编译器生成代码或框架内部使用。下划线 _ 自 Java 9 起是关键字,不能单独作为标识符:
int _; // 编译错误
Java 关键字不能作为普通标识符,例如:
int class; // 编译错误
true、false 和 null 是字面量,不是关键字。
Java 还存在上下文关键字。它们只有在特定语法位置才具有特殊含义,因此某些情况下仍可作为普通名称使用。例如:
int var = 10;
这里的 var 是变量名;而:
var number = 10;
这里的 var 表示局部变量类型推断语法。
var 并没有引入一种新的静态类型。编译器会根据初始化表达式推断出实际类型:
var number = 10; // 实际类型为 int
var text = "hello"; // 实际类型为 String
不能使用 var 声明没有初始化表达式的局部变量:
var value; // 编译错误
类似的上下文关键字还包括模块声明中的 module、requires,记录类相关的 record,密封类相关的 sealed、permits,以及模式匹配相关语法中的关键字。它们的含义由所在语法位置决定,而不是简单地对所有标识符位置一律禁用。
4. 分隔符和运算符
常见分隔符包括:
( ) { } [ ] ; , . ... @ ::
常见运算符包括:
+ - * / %
++ --
< <= > >=
== !=
&
^
|
&& ||
!
~
<< >> >>>
? :
= += -= *= /= %= &= ^= |= <<= >>= >>>=
> 的连续出现需要结合语法解释。例如泛型类型中的:
List<List<String>>
在现代 Java 中可以直接书写两个 >,语法分析器根据上下文识别它们;它们并不会被词法阶段简单地强制识别为一个 >> 运算符。
三、字面量:源代码中的直接值
1. 整数型字面量
Java 支持十进制、二进制、八进制和十六进制整数:
int decimal = 42;
int binary = 0b101010;
int octal = 052;
int hexadecimal = 0x2A;
整数可以使用下划线提高可读性:
int million = 1_000_000;
long mask = 0xFFFF_FFFFL;
下划线不能出现在字面量的开头、结尾、进制前缀后或类型后缀附近:
int a = _100; // 这是标识符,不是合法的数字写法
int b = 100_; // 编译错误
int c = 0x_FF; // 编译错误
没有后缀的整数十进制字面量通常具有 int 类型;超出 int 范围时,如果不能表示为合法的 long 字面量,编译失败:
int a = 2_147_483_647; // 合法
long b = 2_147_483_648L; // 需要 L
下面的代码不会因为目标变量是 long 就自动把字面量当作 long:
long value = 2_147_483_648; // 编译错误:该字面量先按 int 检查
应写成:
long value = 2_147_483_648L;
2. 浮点型字面量
浮点字面量可以使用小数或十六进制形式:
double a = 3.14;
double b = 6.02e23;
double c = 0x1.0p3; // 1.0 × 2^3,即 8.0
float d = 3.14F;
十六进制浮点字面量必须带二进制指数 p 或 P,因为十六进制小数部分本身不能唯一确定二进制浮点指数。
默认浮点字面量是 double:
float x = 3.14; // 编译错误
float y = 3.14F; // 正确
3. 字符串和字符字面量
字符字面量表示一个 char:
char letter = 'A';
char newline = '\n';
char unicode = '\u4F60';
字符串字面量表示 String:
String text = "Java";
String escaped = "第一行\n第二行";
字符串字面量不能直接跨越普通换行。多行文本应使用文本块:
String json = """
{
"name": "Java"
}
""";
文本块仍然是 String,缩进和行终止符会按照文本块规则处理。它不是一种新的运行时类型。
4. null、布尔字面量和类型
boolean enabled = true;
boolean disabled = false;
String value = null;
null 没有普通的类类型,它可以转换到引用类型,但不能转换到基本类型:
String text = null; // 合法
int number = null; // 编译错误
如果发生拆箱:
Integer boxed = null;
int primitive = boxed; // 运行时 NullPointerException
这里不是在编译阶段报错,因为 Integer 可以拆箱为 int;但实际值为 null,运行时无法取得基本类型值。
四、表达式的基本模型:类型、值和副作用
表达式通常会产生一个值和一个类型,也可能产生副作用:
int x = 1;
int y = x++;
x++ 的值是旧值 1,副作用是把 x 修改为 2,因此:
y == 1
x == 2
前缀自增则先修改,再产生新值:
int x = 1;
int y = ++x;
结果为:
y == 2
x == 2
表达式的“值”与“副作用”必须分别考虑。很多求值顺序问题正是因为一个表达式既读取状态又修改状态。
1. 变量表达式和值表达式
变量名可以出现在读取位置:
int a = 10;
int b = a + 1;
也可以出现在赋值位置:
a = 20;
但并非每个表达式都可作为赋值目标:
(a + 1) = 3; // 编译错误
数组访问表达式也可以作为变量:
int[] values = {10, 20};
values[0] = 99;
字段访问是否可赋值,还取决于字段是否为 final。
2. 方法调用表达式
方法调用既可能返回值,也可能产生副作用:
int length = text.length();
如果方法返回 void,它仍然是表达式形式,但不能被当作有值表达式使用:
int x = System.out.println("hello"); // 编译错误
3. 赋值表达式本身有值
Java 中赋值表达式的结果是赋值后的值:
int a;
int b = (a = 10);
执行后:
a == 10
b == 10
这也是链式赋值成立的原因:
int a, b, c;
a = b = c = 0;
赋值从右向左结合:
a = (b = (c = 0))
但“从右向左结合”描述的是语法分组;真正的赋值求值还必须遵循赋值表达式的操作数求值规则。
五、运算符优先级与结合性
常见优先级从高到低大致如下:
| 优先级 | 运算符或结构 |
|---|---|
| 高 | 后缀 x++、x-- |
一元 ++x、--x、+、-、~、!、强制类型转换 |
|
乘除模 *、/、% |
|
加减 +、- |
|
移位 <<、>>、>>> |
|
关系 <、<=、>、>=、instanceof |
|
相等 ==、!= |
|
按位与 & |
|
按位异或 ^ |
|
| 按位或 ` | |
条件与 && |
|
| 条件或 ` | |
条件运算符 ?: |
|
| 低 | 赋值 =、+= 等 |
例如:
int result = 2 + 3 * 4;
等价于:
int result = 2 + (3 * 4);
括号应当用于表达意图,而不是依赖读者记忆全部优先级:
boolean valid = (age >= 18) && active;
结合性不是求值顺序
减法通常左结合:
int result = 10 - 3 - 2;
语法上等价于:
int result = (10 - 3) - 2;
结果是 5,不是 9。
赋值通常右结合:
a = b = 1;
等价于:
a = (b = 1);
结合性决定表达式树如何分组;而子表达式是否先执行,仍然由求值规则决定。
六、Java 的求值顺序:同一表达式中从左到右
Java 对大多数表达式采用严格的从左到右求值顺序。一个操作数求值完成后,才开始求值下一个操作数。
static int mark(String name, int value) {
System.out.println(name);
return value;
}
public static void main(String[] args) {
int result = mark("A", 1)
+ mark("B", 2)
* mark("C", 3);
System.out.println(result);
}
输出顺序是:
A
B
C
7
虽然乘法优先级高于加法,但并不意味着 B * C 的两个操作数会在 A 之前求值。实际过程是:
- 求值左操作数
mark("A", 1),得到1; - 求值右侧加法操作数
mark("B", 2) * mark("C", 3); - 在乘法中先求值
B,再求值C,得到2 * 3 = 6; - 计算
1 + 6 = 7。
1. 方法参数从左到右求值
static int mark(String name, int value) {
System.out.println(name);
return value;
}
static void call(int a, int b, int c) {
}
public static void main(String[] args) {
call(
mark("first", 1),
mark("second", 2),
mark("third", 3)
);
}
输出必然是:
first
second
third
不是因为方法参数“并发计算”,而是因为 Java 规范规定方法调用参数表达式按从左到右求值。
2. 数组访问中的顺序
数组访问同时涉及数组引用表达式和下标表达式:
int[] values = {10, 20};
int index = 0;
int result = values[index++];
求值过程是:
- 求值
values,取得数组引用; - 求值
index++,得到旧值0; - 把
index更新为1; - 访问
values[0],结果为10。
如果数组引用表达式或下标表达式抛出异常,后续部分不会继续执行。
3. 异常会截断后续求值
int result = first() + second() + third();
如果 second() 抛出异常:
first()已经执行;second()执行并抛出异常;third()不执行;- 加法结果不会产生。
因此,左到右求值并不保证所有子表达式都执行,只保证已经开始的求值遵循规定顺序。
4. 左到右不等于线程安全
counter = counter + 1;
在一个线程内部,读取 counter、计算加一、写回的顺序是确定的;但这不是原子操作。多个线程同时执行时,仍可能发生丢失更新:
线程 A 读取 0
线程 B 读取 0
线程 A 写入 1
线程 B 写入 1
最终结果为 1,而不是 2。
语言层面的求值顺序不提供跨线程的互斥、原子性或可见性。需要这些性质时,应使用锁、原子类或其他符合 Java 内存模型的同步机制。
七、基本类型、装箱拆箱和数值提升
运算符行为经常取决于操作数类型。Java 不能把所有数值都当作同一种类型处理。
1. 基本数值类型
Java 的整数基本类型为:
byte 8 位有符号整数
short 16 位有符号整数
int 32 位有符号整数
long 64 位有符号整数
字符类型:
char 16 位无符号 UTF-16 代码单元
浮点类型:
float 32 位 IEEE 754 浮点数
double 64 位 IEEE 754 浮点数
此外还有:
boolean
boolean 不参与数值运算,也不能与整数互相转换:
int x = true; // 编译错误
2. 装箱和拆箱
基本类型可以装箱为包装类型:
Integer boxed = 10;
引用类型在适当上下文中可以拆箱:
Integer value = 10;
int primitive = value;
但拆箱 null 会抛出 NullPointerException:
Integer value = null;
int result = value + 1; // 拆箱时抛出 NullPointerException
这类问题经常隐藏在重载调用、比较和复合赋值中。
3. 一元数值提升
对 byte、short、char 使用一元 +、一元 - 或按位取反 ~ 时,通常会先提升为 int:
byte b = 1;
var x = +b;
x 的类型是 int,不是 byte。
char c = 'A';
int code = ~c;
c 先转换为 int 的数值 65,再按位取反。
4. 二元数值提升
对于大多数二元数值运算,Java 使用二元数值提升。简化规则如下:
- 如果任一操作数是
double,另一操作数转换为double; - 否则如果任一操作数是
float,转换为float; - 否则如果任一操作数是
long,转换为long; - 否则两者都转换为
int。
例如:
byte a = 10;
byte b = 20;
var result = a + b;
result 的类型是 int。因此下面代码无法直接编译:
byte result = a + b; // 编译错误
需要显式转换:
byte result = (byte) (a + b);
转换具有风险,因为 a + b 的 int 结果可能超出 byte 范围。
5. char 参与数值运算
char c = 'A';
int next = c + 1;
'A' 的数值为 65,结果是 66。表达式结果类型是 int:
char next = c + 1; // 编译错误
可以显式转换:
char next = (char) (c + 1);
但如果结果超过 char 的范围,转换会丢失高位。
6. 移位运算的特殊提升规则
移位运算符为:
<< 左移
>> 带符号右移
>>> 无符号右移
左操作数经过一元数值提升;右操作数也会提升为 int 或保持为 long,但它的全部数值并不会作为位移距离直接使用:
int左操作数的位移距离只使用右操作数的低 5 位,即对32取模;long左操作数的位移距离只使用右操作数的低 6 位,即对64取模。
int a = 1;
System.out.println(a << 32); // 1
System.out.println(a << 33); // 2
因为:
32 & 0x1F = 0
33 & 0x1F = 1
对 long:
long value = 1L;
System.out.println(value << 64); // 1
System.out.println(value << 65); // 2
>> 会复制符号位,适合带符号数的算术右移;>>> 左侧补零,适合需要观察无符号位模式的场景:
int negative = -1;
System.out.println(negative >> 1); // -1
System.out.println(negative >>> 1); // 2147483647
-1 的二进制补码全是 1。算术右移继续补 1,无符号右移则补 0。
八、整数溢出:Java 不自动抛出异常
1. 有符号整数采用补码回绕
对于 int,可表示范围是:
-2^31 到 2^31 - 1
也就是:
-2,147,483,648 到 2,147,483,647
当结果超出范围时,Java 保留低 32 位:
int max = Integer.MAX_VALUE;
int result = max + 1;
System.out.println(result);
输出:
-2147483648
推导过程:
0x7FFFFFFF + 1 = 0x80000000
0x80000000 按 int 的有符号补码解释为 Integer.MIN_VALUE。
同理:
int min = Integer.MIN_VALUE;
int result = min - 1;
System.out.println(result); // 2147483647
整数加法、减法、乘法和一元负号通常不会因为溢出抛出异常。
2. 一元负号也可能溢出
int value = Integer.MIN_VALUE;
int result = -value;
System.out.println(result); // -2147483648
数学上,-(-2^31) = 2^31,但 int 无法表示 2^31。因此结果再次回到 Integer.MIN_VALUE。
3. 先扩大类型再运算
下面的代码已经在 int 中溢出,之后再赋给 long 已经无法恢复:
long wrong = Integer.MAX_VALUE + 1;
System.out.println(wrong); // -2147483648
必须让至少一个操作数在运算前就是 long:
long correct = (long) Integer.MAX_VALUE + 1;
System.out.println(correct); // 2147483648
转换的位置决定是否能避免溢出:
long a = (long) (Integer.MAX_VALUE + 1); // 先 int 溢出,再转 long
long b = (long) Integer.MAX_VALUE + 1; // 先转 long,再计算
结果分别为:
a == -2147483648
b == 2147483648
4. 常量表达式中的溢出仍然按类型计算
int x = 2_000_000_000 + 2_000_000_000;
这段代码可以通过编译,但结果是 int 溢出后的值,而不是编译器因为“数学结果超范围”而拒绝它。
如果目标是得到 long:
long x = 2_000_000_000L + 2_000_000_000L;
不能依赖左侧变量的类型来改变右侧表达式的计算类型。
5. 需要检测溢出时使用精确算术方法
Math.addExact、Math.subtractExact、Math.multiplyExact 等方法在溢出时抛出 ArithmeticException:
int value = Integer.MAX_VALUE;
int result = Math.addExact(value, 1);
运行时会抛出:
java.lang.ArithmeticException: integer overflow
这与普通 + 的行为不同:
int result = value + 1; // 不抛异常,结果回绕
如果业务要求“超范围即失败”,必须显式选择检测型运算;如果要求模 2^32 运算,例如哈希或位级算法,则普通整数回绕可能正是需要的行为。
九. 除法、余数和特殊边界
1. 整数除零抛出异常
int result = 10 / 0;
运行时抛出:
ArithmeticException: / by zero
变量除法也是如此:
int divisor = 0;
int result = 10 / divisor; // ArithmeticException
2. 浮点除零不抛出同样的异常
double a = 10.0 / 0.0;
double b = -10.0 / 0.0;
double c = 0.0 / 0.0;
结果分别为:
a == Infinity
b == -Infinity
c == NaN
可以用:
Double.isInfinite(a);
Double.isNaN(c);
进行判断。浮点运算遵循 IEEE 754 语义,因此不能用整数除法的直觉推断浮点除法。
3. MIN_VALUE / -1 是整数除法的特殊点
int result = Integer.MIN_VALUE / -1;
System.out.println(result); // -2147483648
数学结果是 2147483648,超出 int 范围。Java 对这个特殊情况保留最小值,不抛出异常。
4. 余数的符号
整数余数满足:
(a / b) * b + (a % b) == a
例如:
System.out.println(7 % 3); // 1
System.out.println(-7 % 3); // -1
System.out.println(7 % -3); // 1
余数的符号与被除数相关,而不是简单地“总是非负”。如果需要数学意义上的非负模,应使用:
Math.floorMod(-7, 3); // 2
% 和 Math.floorMod 不是同一个规则。
十、浮点数:舍入、无穷大和 NaN
浮点运算不能直接等同于实数运算:
double x = 0.1 + 0.2;
System.out.println(x == 0.3); // false
0.1、0.2 和 0.3 通常都不能被二进制浮点格式精确表示。加法会在可表示值之间舍入,因此:
0.1 + 0.2
得到的近似值不一定与字面量 0.3 的近似编码完全相同。
此外:
double nan = Double.NaN;
System.out.println(nan == nan); // false
System.out.println(nan != nan); // true
任何与 NaN 的数值比较,除了 != 的特殊结果外,都不会得到通常意义上的“相等”。判断 NaN 应使用:
Double.isNaN(nan)
而不要写:
nan == Double.NaN // 永远为 false
浮点值的比较还要考虑正负零:
System.out.println(0.0 == -0.0); // true
如果业务需要严格区分正零和负零,应使用适合该目的的位级检查,而不能只依赖 ==。
十一、字符串连接:+ 不总是数值加法
当二元 + 的任一操作数是字符串时,它可能成为字符串连接:
String result = "count=" + 3 + 4;
System.out.println(result); // count=34
求值和连接过程是:
("count=" + 3) + 4
"count=3" + 4
"count=34"
不是先计算 3 + 4。
如果希望先做数值运算,必须加括号:
String result = "count=" + (3 + 4);
System.out.println(result); // count=7
连接过程中,非字符串操作数会转换为字符串;引用为 null 时通常表现为字符串 "null":
String value = null;
System.out.println("value=" + value); // value=null
字符串连接是左结合的,因此下列表达式的分组很重要:
System.out.println(1 + 2 + "x"); // 3x
System.out.println("x" + 1 + 2); // x12
在循环中大量使用 + 拼接字符串可能产生不必要的中间对象。这里的性能取舍属于实现和编译器优化问题,不改变语言层面的连接语义;需要明确构造过程时可以使用 StringBuilder。
十二、关系、相等和引用比较
1. 数值比较会进行数值提升
byte a = 1;
int b = 1;
System.out.println(a == b); // true
比较前,byte 会提升为 int。
包装类型参与比较时可能发生拆箱:
Integer a = 1000;
Integer b = 1000;
System.out.println(a == b); // 通常为 false
System.out.println(a.equals(b)); // true
== 在这里比较两个引用是否指向同一对象;equals 比较 Integer 的数值内容。不能根据小整数缓存现象推断所有 Integer 都能用 == 比较值:
Integer x = 100;
Integer y = 100;
System.out.println(x == y); // 可能为 true,来自规范允许的缓存范围
Integer p = 1000;
Integer q = 1000;
System.out.println(p == q); // 不应依赖,通常为 false
即使某些实现或缓存策略使结果为 true,也不构成值比较的正确依据。
2. null 比较和拆箱风险
Integer value = null;
System.out.println(value == null); // true
但:
System.out.println(value == 0);
会尝试将 value 拆箱为 int,因此抛出 NullPointerException。
value == null 是引用比较,不需要拆箱;value == 0 需要数值比较,必须先拆箱。
3. 浮点比较受 NaN 影响
double x = Double.NaN;
System.out.println(x < 1.0); // false
System.out.println(x > 1.0); // false
System.out.println(x == 1.0); // false
System.out.println(x != 1.0); // true
判断无序或非法计算结果时,应显式检查 NaN。
十三、按位运算与逻辑运算不是一回事
1. 整数按位运算
整数支持:
& 按位与
| 按位或
^ 按位异或
~ 按位取反
例如:
int flags = 0b1010;
int mask = 0b0110;
System.out.println(flags & mask); // 2,二进制 0010
System.out.println(flags | mask); // 14,二进制 1110
System.out.println(flags ^ mask); // 12,二进制 1100
~flags 会对整数的全部位取反,并且结果仍受整数类型宽度约束:
System.out.println(~0); // -1
因为 0 的 32 位表示全为 0,取反后为全 1,即 int 的 -1。
2. 布尔 &、|、^
布尔操作数也可以使用 &、|、^:
boolean a = true;
boolean b = false;
System.out.println(a & b); // false
System.out.println(a | b); // true
System.out.println(a ^ b); // true
但这些运算符对布尔值不会短路,左右两侧都必须求值:
static boolean check(String name, boolean value) {
System.out.println(name);
return value;
}
boolean result = check("left", false) & check("right", true);
输出:
left
right
即使左侧已经是 false,右侧仍会执行。
十四、短路运算:&&、|| 和 ?:
短路的含义是:根据前一个条件的结果,后续表达式可能完全不求值。
1. 条件与 &&
boolean result = left && right;
求值规则:
- 先求值
left; - 如果
left为false,整个结果为false,不求值right; - 如果
left为true,再求值right; - 最终结果等于
right。
String text = null;
if (text != null && !text.isEmpty()) {
System.out.println("有内容");
}
执行过程:
text != null为false;&&确定整个条件为false;!text.isEmpty()不执行;- 因此不会因为调用
null引用而抛出异常。
如果写成:
if (text != null & !text.isEmpty()) {
// ...
}
右侧仍会执行,text.isEmpty() 会抛出 NullPointerException。
2. 条件或 ||
boolean result = left || right;
求值规则:
- 先求值
left; - 如果
left为true,整个结果为true,不求值right; - 如果
left为false,再求值right。
例如:
if (cache.containsKey(key) || loadFromDatabase(key)) {
// 找到或加载成功
}
当缓存命中时,数据库加载方法不会执行。
3. 短路操作数中的副作用可能不发生
int x = 0;
boolean result = false && (++x > 0);
System.out.println(x); // 0
++x 没有执行。不能假设短路表达式两边的所有更新都会发生。
再看:
int x = 0;
boolean result = true || (++x > 0);
System.out.println(x); // 0
同样,右侧没有执行。
4. 条件运算符 ?: 只求值一个分支
int value = condition ? first() : second();
求值过程:
- 求值
condition; - 如果为
true,求值first(),不求值second(); - 如果为
false,求值second(),不求值first()。
它不同于先把两个值都算好再选择:
int value = condition ? first() : second();
因此可以安全地表达条件性访问:
String name = user == null ? "anonymous" : user.name();
只有被选择的分支会执行。
5. 条件表达式的类型不能只看运行时分支
条件表达式还需要在编译阶段确定类型:
var value = condition ? 1 : 2L;
两个分支经过数值提升后,value 的类型是 long。
引用分支也有类似的类型推断和条件表达式类型规则:
var value = condition ? "text" : new StringBuilder();
编译器会根据条件表达式的类型规则确定一个共同类型;var 只是接收这个编译期类型,并不会让运行时类型决定变量声明类型。
十五、条件表达式、逻辑表达式和位运算的选择
下面三种写法具有不同语义:
a && b
a & b
a ? b : c
| 写法 | 操作数类型 | 是否短路 | 结果 |
|---|---|---|---|
a && b |
boolean |
是 | 布尔值 |
a & b |
整数或 boolean |
否 | 按位结果或布尔结果 |
a ? b : c |
条件为 boolean |
只求值一个分支 | 分支值 |
如果右侧包含方法调用、数组访问、拆箱或状态修改,短路差异会直接改变程序行为:
boolean valid = input != null && input.length() > 0;
这是安全的,因为先确认引用非空。
boolean valid = input != null & input.length() > 0;
这不是等价替换,因为右侧仍然会求值。
十六、复合赋值:隐含一次转换,但只求值左侧一次
复合赋值:
a op= b;
大致相当于:
a = (T) (a op b);
其中 T 是左侧变量的类型,并且左侧表达式只求值一次。
1. 普通赋值和复合赋值的差异
byte x = 1;
x = x + 1; // 编译错误:x + 1 的类型是 int
x += 1; // 合法:结果转换回 byte
x += 1 并不意味着没有溢出:
byte x = 127;
x += 1;
System.out.println(x); // -128
计算过程可以理解为:
x 的值 127
提升为 int
127 + 1 = 128
转换回 byte
128 超出 byte 范围
保留低 8 位
结果为 -128
2. 左侧表达式只求值一次
考虑数组:
int[] values = {10};
int index = 0;
values[index++] += 5;
过程是:
- 求值数组引用
values; - 求值下标
index++,取得旧值0; index变为1;- 读取
values[0],得到10; - 计算
10 + 5; - 写回
values[0],结果为15。
如果机械地把它改写为:
values[index++] = values[index++] + 5;
左侧下标会求值两次,访问位置和副作用都变了。这说明复合赋值的“一次求值”不是格式上的缩写,而是语言语义的一部分。
3. 复合赋值中的类型转换可能掩盖错误
int total = 1_000_000_000;
total += 2_000_000_000;
这仍然会发生 int 溢出。复合赋值自动转换回左侧类型,不等于自动检查结果是否合理。
十七、前缀和后缀 ++、--
1. 后缀形式先取值再修改
int x = 5;
int y = x++;
System.out.println(x); // 6
System.out.println(y); // 5
2. 前缀形式先修改再取值
int x = 5;
int y = ++x;
System.out.println(x); // 6
System.out.println(y); // 6
3. ++ 受变量类型限制
++ 和 -- 的操作数必须是可修改变量,并且类型适合自增自减。它们可以作用于数值变量,也可以作用于 char:
char c = 'A';
c++;
System.out.println(c); // B
对于包装类型:
Integer value = 1;
value++;
过程包含拆箱、数值运算和重新装箱。如果:
Integer value = null;
value++;
则会在拆箱时抛出 NullPointerException。
4. 不要把复杂副作用堆在同一表达式中
虽然 Java 对求值顺序有明确规定,但下面的代码难以审查:
int result = array[i++] + array[i++];
它具有确定的左到右行为,但可读性和维护性较差。拆成多个语句可以显式展示状态变化:
int firstIndex = i++;
int secondIndex = i++;
int result = array[firstIndex] + array[secondIndex];
这里不是因为原表达式“不确定”,而是因为确定的副作用顺序不应被隐藏在复杂表达式中。
十八、强制类型转换:编译期允许不等于运行时安全
1. 数值窄化转换会丢失信息
int value = 130;
byte result = (byte) value;
System.out.println(result); // -126
byte 只保留低 8 位:
130 的低 8 位为 10000010
按 byte 的补码解释为 -126。
2. 浮点转整数会截断并可能饱和到边界
int a = (int) 3.9; // 3
int b = (int) -3.9; // -3
转换不是四舍五入,而是向零截断。对于超出目标整数类型可表示范围的浮点值,转换遵循 Java 规定的边界行为,而不是简单按照整数溢出的低位回绕规则处理。
需要四舍五入时,应明确使用:
Math.round(3.9); // 4
3. 引用类型转换可能在运行时失败
Object value = "text";
Integer number = (Integer) value;
编译器允许这种转换,因为 Object 可能引用 Integer;但实际对象是 String,运行时抛出:
ClassCastException
可以先检查:
if (value instanceof Integer number) {
System.out.println(number + 1);
}
模式变量 number 只在编译器能证明匹配成功的范围内可用。
十九、常见误解与对应诊断
误解一:乘法优先级高,所以乘法一定先执行
错误理解:
f() + g() * h()
会先执行 g() 和 h(),再执行 f()。
实际顺序:
f()
g()
h()
g() * h()
f() + (...)
优先级只决定结构,不改变 Java 的左到右操作数求值规则。
误解二:赋给 long 就能避免 int 溢出
long value = Integer.MAX_VALUE + 1;
右侧先按 int 运算并溢出。应在运算前扩大操作数:
long value = (long) Integer.MAX_VALUE + 1;
误解三:Integer 的 == 比较数值
Integer a = 1000;
Integer b = 1000;
a == b
比较的是引用身份,不是数值内容。应使用:
a.equals(b)
但如果允许 null,应使用:
Objects.equals(a, b)
以避免调用空引用方法。
误解四:& 和 && 只是写法不同
a && b
可能不求值 b;而:
a & b
对布尔操作数仍会求值两边。涉及空值检查、昂贵调用或副作用时,结果可能完全不同。
误解五:整数溢出会自动抛异常
int x = Integer.MAX_VALUE + 1;
不会抛异常,只会回绕。需要失败即报告时,应使用:
Math.addExact(Integer.MAX_VALUE, 1);
误解六:0.1 + 0.2 应该严格等于 0.3
浮点数是有限精度的二进制近似。涉及金额、精确小数或十进制定点规则时,不应直接使用二进制浮点比较业务相等性;应根据需求选择 BigDecimal 或定义允许误差的比较策略。
二十、一个可运行的综合示例
下面的程序集中展示求值顺序、短路、提升、溢出和复合赋值:
public class ExpressionRulesDemo {
static int mark(String name, int value) {
System.out.println("evaluate " + name);
return value;
}
static boolean check(String name, boolean value) {
System.out.println("check " + name);
return value;
}
public static void main(String[] args) {
System.out.println("== 求值顺序 ==");
int result = mark("A", 1)
+ mark("B", 2)
* mark("C", 3);
System.out.println("result = " + result);
System.out.println("\n== 短路 ==");
boolean andResult =
check("left-false", false)
&& check("right-not-evaluated", true);
System.out.println("andResult = " + andResult);
boolean orResult =
check("left-true", true)
|| check("right-not-evaluated", false);
System.out.println("orResult = " + orResult);
System.out.println("\n== 提升 ==");
byte a = 100;
byte b = 27;
int sum = a + b;
System.out.println("sum = " + sum);
System.out.println("\n== 溢出 ==");
int overflow = Integer.MAX_VALUE + 1;
System.out.println("overflow = " + overflow);
long noOverflow = (long) Integer.MAX_VALUE + 1;
System.out.println("noOverflow = " + noOverflow);
System.out.println("\n== 复合赋值 ==");
byte small = 127;
small += 1;
System.out.println("small = " + small);
}
}
使用 Java 25 编译和运行:
javac ExpressionRulesDemo.java
java ExpressionRulesDemo
关键输出应为:
== 求值顺序 ==
evaluate A
evaluate B
evaluate C
result = 7
== 短路 ==
check left-false
andResult = false
check left-true
orResult = true
== 提升 ==
sum = 127
== 溢出 ==
overflow = -2147483648
noOverflow = 2147483648
== 复合赋值 ==
small = -128
其中:
A在整个右侧乘法之前执行,体现操作数从左到右求值;&&左侧为false,右侧没有输出;||左侧为true,右侧没有输出;byte + byte的结果是int;int溢出回绕;- 先转换为
long才能避免int运算阶段的溢出; byte += 1会自动转换回byte,因此127 + 1变为-128。
二十一、阅读复杂表达式时的可靠方法
面对包含副作用、装箱或多种数值类型的表达式,可以按以下顺序展开:
第一步:先按优先级和括号建立语法树
例如:
a + b * c == d && check()
应先理解为:
((a + (b * c)) == d) && check()
第二步:标记每个子表达式的类型
特别检查:
byte、short、char是否会提升为int;- 是否发生拆箱;
+是数值加法还是字符串连接;- 比较两侧是否需要统一数值类型;
- 条件表达式两个分支的共同类型是什么。
第三步:按从左到右排列可能产生副作用的部分
标记:
- 方法调用;
++、--;- 赋值;
- 数组访问;
- 字段访问;
- 拆箱;
- 可能抛异常的运算。
第四步:单独处理短路和条件分支
对:
a && b
a || b
condition ? x : y
明确哪些分支可能完全不执行。
第五步:最后检查数值范围和异常路径
重点检查:
int、long是否溢出;- 除数是否为零;
- 浮点结果是否可能为
NaN或无穷大; - 包装类型是否可能为
null; - 引用强制转换是否可能抛出
ClassCastException; - 数组下标是否可能越界。
例如:
result = list != null
&& index >= 0
&& index < list.size()
&& list.get(index) > limit;
这里的条件顺序不是任意的:
- 先检查
list != null,避免空引用; - 再检查
index >= 0; - 再检查
index < list.size(); - 最后才访问
list.get(index)。
依靠 && 的短路规则,后面的表达式建立在前面的条件已经成立之上。若改用 &,这些安全前提就不再成立。
Java 25 中,词法规则负责确定代码由什么组成;优先级和结合性负责确定表达式如何分组;类型规则负责确定提升、装箱、拆箱和转换;求值规则负责确定副作用和异常的发生顺序;短路规则则允许某些子表达式根本不执行。整数溢出、浮点特殊值和引用比较又在这些规则之上形成了最容易被忽略的边界。
真正准确地理解一个表达式,不能只看运算符表。应当同时回答四个问题:
它被解析成什么结构?
每个部分的编译期类型是什么?
哪些部分会按什么顺序求值?
哪些转换、溢出或异常可能改变最终结果?
这四个问题共同决定了 Java 表达式的实际语义。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 生产交付:容器、JVM 参数、探针、灰度、容量和回滚
- 下一篇:Java 25 字符串:不可变、String Pool、文本块、格式化和性能
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论