1. 运算符与表达式:先搞懂它们在Java里到底扮演什么角色
Java的运算符和表达式,说穿了就三件事:怎么算数、怎么比较、怎么赋值。但你要是只背住那张优先级表,遇到真正项目里的坑,一样会摔得鼻青脸肿。我见过不少工作一两年的开发,写a++和++a倒背如流,但一碰到i += 1和i = i + 1在类型转换上的差别就懵了;还有人在公司的代码评审会上,因为一个==比较两个Integer对象判断相等,被老同事当众指出来,场面相当尴尬。
这个主题适合所有人:准备Java面试的应届生,刚入门编程的在校生,还有那些功力不深但天天跟业务代码打交道的后端开发。运算符和表达式不是你背完就完事的知识点,它是一套“求值规则”,换句话说,你写的每一行赋值、每一次判断、每一个三元表达式,背后都是一次完整的表达式求值过程。搞懂它,你调试代码的速度能快一倍,写出来的代码也不容易埋雷。
这篇东西不打算按教科书的方式给你念一遍优先级表,那玩意儿网上到处都是。我换个思路:从实际开发会碰到的真实问题切入,带你一步步拆解运算符的底层逻辑、表达式的求值顺序、类型转换的坑,以及面试高频题背后的原理。看完之后,你可以直接拿这些结论去检查自己项目的代码,大概率能发现几个潜在问题。
2. 一套划分体系,把14类运算符安排得明明白白
2.1 按“目数”理解一元、二元、三元运算符
Java里的运算符数量不少,官方文档能给你列出几十个。但你别被数量吓到,先按“需要几个操作数”来分,立马清晰:
- 一元运算符:只操作一个表达式,比如
-5、!flag、++count、(int) x。 - 二元运算符:操作两个表达式,绝大部分运算符都属于这一类,比如
a + b、x > y、c && d。 - 三元运算符:Java里只有一个,就是
条件 ? 值1 : 值2,三个操作数参与。
按目数分类是理解表达式求值的第一步。为什么?因为一元运算符的优先级天然高于大部分二元运算符,比如-a * b,实际是先对a取负再做乘法,而不是-(a * b)的另一种写法。这类问题你写几年代码可能都遇不到,但一旦遇到,就知道为什么加括号永远比记优先级可靠了——后面我会专门说括号的事。
2.2 按功能划分:算术、赋值、关系、逻辑、位运算等
按功能分,是日常开发中最常用的划分方式。这里先给大家一张全景表,后面每个类别我会单独展开讲关键细节:
| 类别 | 代表运算符 | 典型使用场景 | 最容易踩的坑 |
|---|---|---|---|
| 算术运算符 | + - * / % ++ -- | 数值计算、计数、取模 | 整数除法直接舍去小数、%的结果符号 |
| 赋值运算符 | = += -= *= /= %= | 变量更新、累加统计 | 复合赋值隐含强制类型转换 |
| 关系运算符 | > < >= <= == != | 条件判断、循环控制 | 引用类型用==比较地址而非内容 |
| 逻辑运算符 | && || ! | 多条件组合判断 | 短路求值导致部分代码没执行 |
| 位运算符 | & | ~ ^ << >> >>> | 权限控制、状态压缩、性能优化 | 优先级低于==,必须加括号 |
| 三元运算符 | ? : | 简化if-else赋值 | 两侧类型不一致引发自动类型提升 |
| 类型比较 | instanceof | 运行时类型检查 | 编译期就能确定不可能的类型会直接报错 |
光看这张表还不够。很多人的误区是“我记住了运算符,就会写表达式了”,实际上运算符只是零件,表达式才是成品。表达式可以由运算符、操作数、方法调用、字面量等组合而成,求值的结果可能是值、变量,甚至什么都不是(比如只执行副作用)。下一节我重点讲表达式求值这件事,这才是我认为真正的核心。
3. 表达式求值:优先级、结合性和求值顺序,到底谁说了算
3.1 你对“优先级”的直觉,可能在第一层
不少人对优先级的印象停留在“先乘除后加减”。到了Java里,很多运算符的优先级其实有更细致的划分。举个真实的例子:
boolean result = (flags & 0xFF) == 0xFF;如果你不主动加括号直接写flags & 0xFF == 0xFF,编译都不会报错,但结果完全不对,因为==的优先级高于&,实际会被解析成flags & (0xFF == 0xFF),也就是把flags和一个boolean做按位与,逻辑直接崩了。这种问题在C、C++、Java里只要写位运算相关的代码就很容易发生。我的经验是,位运算符和逻辑运算符混用的时候,一律加括号,不要相信自己的记忆。
Java的优先级表网上到处都有,我不完整抄一遍,但把很容易被忽略的几层拎出来:
- 最高档位:后缀运算符,也就是
array[i]、obj.method()、a++这类,其次是++a这种前缀一元运算符。 - 强制类型转换
(type)也属于一元运算符那一档,它比算术运算符高。 - 移位运算符
<< >> >>>的优先级比加减法低,比关系运算符高。很多人写size << 2 + 1会误以为是(size << 2) + 1,实际是先计算2 + 1,再左移3位。 - 逻辑与
&&高于逻辑或||,但两者都低于关系运算符。
所以a || b && c实际是a || (b && c),而不是(a || b) && c。你以为短路顺序没问题,但如果b计算会抛异常,这个细节就关键了。
3.2 结合性:从左到右还是从右到左,不是拍脑袋定的
结合性解决的是“优先级相同的时候,先算谁”的问题。大部分二元运算符是左结合,也就是从左往右算:
int result = 100 / 10 * 5; // 结果是50,不是2因为/和*优先级相同,左结合就是(100 / 10) * 5。然而有一类运算符是右结合:一元运算符、赋值运算符、三元运算符。
赋值运算符的右结合非常好理解:
int a, b, c; a = b = c = 10;这个表达式能编译能运行,就是因为右结合使得求值顺序是a = (b = (c = 10))。再比如三元运算符右结合,意味着条件1 ? 值A : 条件2 ? 值B : 值C会先解析成条件1 ? 值A : (条件2 ? 值B : 值C)。这种写法能链式判断,但我个人还是建议:就算理解了右结合,嵌套三元也最好拆开写,或者用括号明确。因为代码是写给人看的,不是写给编译器看的。
3.3 求值顺序:左到右逐个算,但短路运算符不讲武德
优先级和结合性决定的是“谁先和谁结合”,但同一表达式中,各个操作数的求值顺序一般是从左到右。比如:
int result = methodA() + methodB() * methodC();这里虽然*优先级高,会先决定methodB() * methodC()是一组,但methodA()依然会先于methodB()执行。这是Java和C/C++不一样的地方之一,C++里函数参数的求值顺序在不同编译器下是未定义的,Java则明确规定从左到右,这也让代码行为更好预测。
但有两个例外,也就是传说中的短路运算符&&和||:
if (list != null && list.size() > 0) { // ... }如果list是null,list != null为false,短路生效,右侧的list.size()根本不会被调用,也就不会抛NullPointerException。这个特性如果你不理解,可能觉得“为什么有时候if里面代码没执行就跳过了”;如果你理解,就知道这是Java故意的设计,也是防空的惯用招数。
但短路也能变成坑。比如:
boolean result = (a++ > 3) && (b++ > 5);如果第一个条件已经为false,第二个b++不会执行,b的值就不会变。这在调试时很容易让人摸不着头脑。所以写含副作用的短路表达式时,务必搞清楚“右侧不执行”的可能性。
3.4 有了优先级表,为什么我仍然劝你多写括号
这一个建议,我真心觉得价值超过前面所有知识点:在复杂的表达式里,用括号显式表达你的意图,哪怕某些括号是多余的。原因有三个:
- 节省你的认知负担。读代码的时候,你每次都要去回忆“这个运算符优先级高不高”,时间久了人会累,就会出错。
- 规避不同语言之间的差异。你可能会从Java切到C#、Go、PHP,它们的优先级表有细微差异,依赖记忆容易踩坑。
- 代码是协作产品。你写的表达式,同事也要维护。能10秒看懂的表达式,就不要让他花3分钟去查优先级。
而且很多静态检查工具对复杂表达式会直接给出警告,要求加括号。不要等到公司CI挂了再补,现在就养成习惯。
4. 算术运算符与赋值运算符:最常用,也最容易忽略细节
4.1 整数除法、取余和溢出,三个高频兄弟
算术运算符很简单,但总有三个地方埋伏着让人摸不着头脑的行为。
第一个是整数除法:
int result = 7 / 2; // 结果是3Java里整数之间做除法,结果还是整数,小数点部分直接丢掉,不是四舍五入,就是截断。如果你想要浮点数结果,至少一个操作数要换成浮点类型:
double result = 7 / 2.0; // 3.5很多初学者在写平均值、比例、百分比时栽在这里。比如计算一个数组的平均分,写成sum / count,得到的永远是整数;你需要(double) sum / count,或者sum / (double) count。
第二个是取余运算符%。
取余运算大多数情况下很简单,17 % 5 得 2。但有个坑:如果操作数里有负数呢?答案是Java里%的结果符号和被除数(左操作数)保持一致:
-17 % 5 == -2 17 % -5 == 2这个规则可以理解为:先用绝对值的思路算出余数,再把被除数的符号附上去。不要指望它和数学里的“模”一样永远是正数。
第三个是溢出。int类型的最大值是21亿多,两个int相乘很容易超过这个范围:
int a = 1_000_000; int b = 2_000_000; long c = a * b; // 先按int相乘,已经溢出了,再赋给long也救不回来正确的写法是先让一个操作数转成long:long c = (long) a * b;。这个问题在计算金额、文件大小、时间戳毫秒数时特别常见,一定要养成“运算前评估可能的最大值”的习惯。
4.2++和--的前后缀区别,不只是执行顺序
i++先用后加,是很多初学者第一个接触到的“看起来有陷阱”的语法。实际上它的本质是:
- 前缀版本(
++i):先自增,再返回自增后的值。 - 后缀版本(
i++):先返回原值,再执行自增。
看起来很简单,但到了复杂表达式里就考验人了。来个经典面试题:
int i = 0; i = i++; System.out.println(i); // 输出多少?答案是0。这个题很多工作两年的人也答不对。原因是右边i++是先取i的当前值0作为表达式的值,然后i自增变成1,最后赋值又把0赋回给i,所以最后是0。这个细节用一句话记:自增发生在赋值之前还是之后,取决于你在同一表达式里的位置。
我的实际建议是:尽量不要在复杂表达式里使用++或--,尤其是赋值表达式里。i++单独成行没问题,但在array[i++] = value这种写法里,虽然能正常工作,但可读性一般,出错时也不好排查。代码风格越直白越安全。
4.3 复合赋值运算符里藏着的强制类型转换
+=、-=、*=这类复合赋值运算符,写起来省事,但很多人的认知空白在于:复合赋值运算符自带一次强制类型转换。看这个例子:
short s = 10; s = s + 1; // 编译报错:int无法赋值给short s += 1; // 编译通过,神奇不神奇原因是s + 1的结果是int,直接赋给short会报错;而s += 1背后的等价写法是s = (short)(s + 1),有一个隐藏的类型转换。这在实际项目里有个隐患:如果你用int类型的变量加来加去,然后赋值给一个范围更小的类型,复合赋值运算符会静默截断,可能丢精度。所以不要以为+=和=就是简写的关系,它们在类型语义上有细微差别。
4.4 浮点数运算:为什么会得到0.30000000000000004
部分开发者在写金融计算时踩过浮点精度的坑。二进制浮点数无法精确表示所有十进制小数,比如:
double result = 0.1 + 0.2; System.out.println(result); // 0.30000000000000004这不是Java的问题,IEEE 754标准下的C、JavaScript、Python都一样。如果你要精确表示金额,直接用BigDecimal,构造参数传字符串而不是double,比如new BigDecimal("19.9"),千万别写new BigDecimal(19.9),那样精度损失在构造时就发生了。
5. 关系运算符与逻辑运算符:条件判断里的短路与类型之谜
5.1==比较基本类型和引用类型,两套游戏规则
关系运算符里最坑的永远是==。它用在基本类型上,比较的是值;用在引用类型上,比较的是地址是否相同。意思是两个不同对象,即使内容完全一样,==也返回false。
典型场景就是String:
String a = "hello"; String b = "hello"; System.out.println(a == b); // true,但它俩指向常量池中的同一个对象 String c = new String("hello"); String d = new String("hello"); System.out.println(c == d); // false,两个不同对象很多初学者不理解为什么第一个是true。关键在于字符串常量的驻留机制。你写String a = "hello"时,如果常量池里已经有这个字符串,就直接复用。这是JVM的优化,不是==的语义。实际开发中判断字符串相等,永远用equals,或者更安全的Objects.equals(a, b)(前者可以避免a为null时报空指针)。
再聊一个面试高频题,Integer缓存:
Integer x = 127; Integer y = 127; System.out.println(x == y); // true Integer m = 128; Integer n = 128; System.out.println(m == n); // false这是因为Integer在-128到127之间有一个缓存池,自动装箱时直接复用缓存的对象,超出范围就每次都new新对象。结论很简单:包装类型比较数值用equals,或者拆箱成基本类型再比较。
5.2&&和||的短路,以及一个不太聊的&和|
逻辑运算符里,&&和||是短路版本,&和|是非短路版本。这在按位运算里也用到,但在逻辑判断场景里:
if (a > 0 & b++ > 5) { // ... }如果a > 0是false,b++仍然会被执行,因为&不会短路。你本意可能只是想判断两个条件,却无意中多了一次副作用。大部分人不会在日常代码里这么写,但如果你在做位掩码判断时,使用了&而不是&&,一定要清楚它俩行为不同。
还有一个逻辑运算符的宝藏技巧:用逻辑表达式直接赋值,替代繁琐的if-else。比如:
boolean isValid = name != null && name.length() > 3;这种写法既清晰又安全。但再往前一步,用三元表达式判断并赋值:
String status = user.isActive() ? "正常" : "禁用";这就是很典型的优雅写法,可读性也不错。但记住我前面说的,不要嵌套太深。
5.3instanceof:类型检查的优雅姿势与注意点
instanceof运算符用来判断一个对象是否是某个类的实例或其子类的实例:
if (obj instanceof String str) { System.out.println(str.length()); }Java 16开始支持这种“模式匹配”写法,不用先判断再强转,省了一行代码。在重写equals方法时,这是标准套路:
public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof User user)) return false; return userId == user.userId; }注意一个细节:JDK 14之前,instanceof右侧必须是一个类型名,不能是变量;JDK 16之后模式匹配就很好用了。另外如果用instanceof判断null,结果永远是false,所以不需要额外的null判断。
6. 位运算符和移位运算符:被低估的编码利器,掌握后代码性能与优雅度双提升
6.1 按位与、或、异或、取反:权限系统的底层实现
位运算在日常业务代码里出场率不高,但真到用的时候,基本都是核心模块:权限、开关、状态机、压缩。先说最常用的按位操作:
&按位与:两个位都是1才为1,常用于掩码操作。|按位或:任一个位为1即为1,常用于开启标志。^按位异或:两个位不同才为1,常用于切换标志。同一值异或两次能恢复原值,这个特性在简易加密和交换变量时很有用。~按位取反:每位取反,负数在Java中是通过补码表示的,所以~5等于-6,这个初看很反直觉,但记住公式~x = -x - 1就可以。
经典的权限控制模式如下:
// 权限常量,用不同二进制位表示 public static final int READ = 1 << 0; // 0001 public static final int WRITE = 1 << 1; // 0010 public static final int EXECUTE = 1 << 2; // 0100 // 用一个int存所有权限 int permission = 0; permission |= READ; // 开启读权限 permission |= WRITE; // 开启写权限 boolean canWrite = (permission & WRITE) != 0; // 判断是否有写权限 permission &= ~WRITE; // 关闭写权限这种写法的优势是用一个int就能搞定一组开关状态,数据库存一个字段就行,查询时直接用SQL位运算筛选,性能很好。
6.2 移位运算符:<<、>>和>>>三兄弟的语义差异
Java提供三种移位运算符:
<<左移:低位补0,相当于乘2的n次方(前提是不溢出)。>>右移:带符号右移,左边补符号位。正数补0,负数补1,结果接近除以2的n次方并向下取整。>>>无符号右移:无论正负,左边一律补0。这个在Java里独有,C和C++没有。
看个例子:
int x = -8; int y = x >> 1; // -4,符号位补1 int z = x >>> 1; // 2147483644,最高位变成0无符号右移在处理无符号数值、哈希运算、颜色分量提取时非常有用。比如从一个int里取出RGB各通道值,就靠移位加掩码:
int color = 0x66CCFF; int red = (color >> 16) & 0xFF; int green = (color >> 8) & 0xFF; int blue = color & 0xFF;这个套路在图像处理、前端颜色转换、游戏开发里很常见。
6.3 位运算的技巧:奇偶判断、交换变量、2的幂检测
面试题里常有位运算技巧,实际生产中偶尔也有奇效:
- 奇偶判断:
(n & 1) == 0为偶数,否则为奇数。比取模更快一些。 - 交换两个变量,不用第三个变量:
a ^= b; b ^= a; a ^= b;这个技巧看起来很酷,但强调一次:实际开发中不要用这种写法炫技,可读性差,编译器也未必比你聪明多少。了解原理即可。
- 判断一个整数是不是2的幂:
boolean isPowerOfTwo = (n > 0) && ((n & (n - 1)) == 0);因为这个逻辑本质上是一个很漂亮的数学性质:2的幂次方的二进制表示只有一个1,减1之后高位全部翻转,与操作后必为0。
6.4 为什么位运算在哈希和状态压缩中不可替代
HashMap的扩容和数组索引定位,处处都有位运算的影子:hash ^ (hash >>> 16)、(n - 1) & hash。如果你去读一下JDK源码,会发现位运算在追求极致性能的地方无处不在。状态压缩也是,像摆棋类游戏、八皇后问题,用一个int的各位表示一行棋格的状态,判断冲突只需一次与运算。
理解位运算不是让你业务代码里到处炫技,而是让你读源码、处理底层性能问题时,能跟上作者的思路。
7. 三元运算符和类型转换:优雅与陷阱并存的两兄弟
7.1 不要用三元运算符做复杂嵌套,但必要的场景非常顺手
三元运算符条件 ? 值A : 值B是Java里唯一的三目运算符。最典型的使用场景就是简单条件赋值,我前面已经给过一个例子。它比if-else更紧凑,但有两个注意点:
- 嵌套三元可读性极差。
a > 0 ? 1 : b > 0 ? 2 : 3已经不容易看了,再往上叠基本是灾难。 - 三元运算符的两个结果分支,会触发类型统一。比如
int x = flag ? 1 : 2.0;,结果是2.0对应的int强转,可能丢精度,编译器还会警告。更经典的坑是:
Object result = flag ? 1 : "字符串";1会被自动装箱成Integer,然后两个分支统一类型时可能提升为Object,这个过程如果你不理解,调试时只能看到result的类型不太对,但不知道机制。简单说,三元运算符的两个分支类型必须能够统一,如果类型差异大,别硬塞进同一个表达式,拆成if-else更清晰。
7.2 赋值运算符里的隐式类型转换:int到short、char到int
Java是强类型语言,不同类型之间的赋值和运算有一套自动类型转换规则:
- 自动转换:小范围类型可以自动转到大范围类型。
byte -> short -> int -> long -> float -> double,以及char -> int。为什么float排在long后面?因为float虽然精度不总是足够,但取值范围比long大,转换规则按范围走。 - 强制转换:大范围转到小范围,需要显式
(type)。比如int转byte,如果数值超出范围,高位截断,结果就不可预期。
举一个实际开发中容易翻车的例子:
char c = 'A'; int code = c; // 自动转成65,没问题 char lower = (char) (code + 32); // 转成'a',但必须强转还有数值计算中的类型提升:
byte a = 10; byte b = 20; byte c = (byte) (a + b); // a + b 结果已经是int,必须强转这个规则很多新手不理解,为什么两个byte相加就会编译报错。原因是Java语言规范规定,对于byte、short、char之间的算术运算,结果一律提升为int。这是为了减少实际生产中8位、16位计算的溢出问题,宁可提升计算再截断,也不让中间过程丢精度。
7.3 字符串拼接运算符+:到底算算术还是拼接
Java里+被重载了两层含义:数值相加和字符串拼接。当+两侧出现String时,它就把其他操作数转成字符串拼接起来。这个行为本身不难,但有一个顺序问题:
System.out.println(1 + 2 + "=" + 3 + 4); // 输出"3=34"而不是"3=7"因为表达式从左到右求值,先算1 + 2得3,再和字符串拼成"3=",后面3 + 4因为左侧已经是字符串了,就只能拼接字符串,"3=34"。如果你希望输出"3=7",需要加括号:1 + 2 + "=" + (3 + 4)。
涉及大量字符串拼接的时候,还要考虑性能。在循环里用+拼接字符串,会产生大量中间String对象,长期运行的内存压力不小。正确做法是用StringBuilder:
StringBuilder sb = new StringBuilder(); for (String item : list) { sb.append(item); }Java编译器在某些简单场景会自动把+优化成StringBuilder,但循环体内的拼接不一定总能优化到位,养成显式使用StringBuilder的习惯更稳妥。
8. 运算符优先级总览与“括号优先”的实战守则
8.1 一张表读懂Java核心运算符优先级
为了照顾用表格学习的人,我整理一个简化版优先级表,按从高到低排列:
| 优先级 | 运算符 | 说明 |
|---|---|---|
| 最高 | () [] . | 括号、数组下标、成员访问 |
| 高 | ++ -- ~ ! (type) | 后缀自增、前缀自增、取反、转型 |
| 中高 | * / % | 乘除取余 |
| 中 | + - | 加减,字符串拼接也算 |
| 中低 | << >> >>> | 移位 |
| 低 | < > <= >= instanceof | 关系比较 |
| 较低 | == != | 相等判断 |
| 更低 | & | 按位与 |
| 低 | ^ | 按位异或 |
| 较低 | | | 按位或 |
| 很低 | && | 逻辑与 |
| 最低 | || | 逻辑或 |
| 右结合 | ? : | 三元 |
| 右结合 | `= += -= *= /= %= <<= >>= >>>= &= ^= | =` |
注意几个反直觉的点:instanceof和关系运算符同一档,==比&高,&比^高,^比|高。这意味着a & b == c会先算b == c,所以我之前在(flags & 0xFF) == 0xFF那个例子里反复强调加括号,不是小题大做。
8.2 真实的表达式求值顺序案例:从一行代码里读出全部规则
来个综合案例,把所有规则串一遍:
int a = 1; int b = 2; int c = 3; boolean flag = false; int result = flag ? a++ : b-- + c;分析这段代码:
- 三元的条件
flag先求值,false,所以走b-- + c分支。 b--是先取b当前值2,再自减成1,但表达式的值是2。- 2 + 3得5,赋给result。
- 最终
a还是1,b变成1,c是3,result是5。
如果你一开始没算对,问题多半出在b--上。再来看一个更多运算符的版本:
int x = 2; double y = x++/2 + x * 1.5;逐步拆:
x++取2,x变3,2/2在整数除法里等于1。- 此时
x已经是3,所以3 * 1.5得4.5。 - 1 + 4.5 = 5.5。
关键点在于,同一个表达式里,x经过了自增后的值和原始值混在一起,很容易让人错算成1 + 3.0 = 4.0,实际上x++ / 2里的x用的是自增前的2。所以这种代码我强烈不建议在生产环境里出现,因为可读性差、易出错,价值也高不了多少。
8.3 为什么说“不要用运算符玩小聪明”
有一句话我想写在所有篇幅的最后,也是我带团队时最常说的一句话:代码的首要读者是维护它的同事,而不是编译器。
运算符和表达式是Java的底层基础,掌握它的原理和改进不了代码质量的话,就该站在“可读性”的高度做出取舍:
- 能加括号就加括号,哪怕多写两个字符。
- 不要为了炫技写
a ^= b; b ^= a; a ^= b;交换变量。 - 不要在复杂表达式里混用
++和多个算术运算。 - 判断相等,两件事分开:基本类型用
==,对象用equals。 - 浮点数比较大小,不要直接比较相等,除非你知道自己在做什么。
9. 常见问题对照速查表与面试高频题实录
9.1 日常开发中高频问题的排查速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 整数相除结果少了一位小数 | 整数除法直接截断 | 至少一个操作数转成double |
float/double计算出现小数误差 | IEEE 754精度限制 | 精确计算用BigDecimal,构造参数传字符串 |
两个字符串内容一样但==为false | ==比较的是引用地址 | 改用equals或Objects.equals |
两个超过127的Integer用==不相等 | Integer缓存池范围是-128到127 | 包装类型比较用equals |
if (a & b == c)结果不对 | ==优先级高于& | 加括号,写成(a & b) == c |
| 循环里大量字符串拼接很慢 | 产生大量中间String对象 | 改用StringBuilder |
| 三元表达式里两个分支类型差异大 | 自动类型提升导致结果类型出乎意料 | 拆成if-else或显式强转 |
| 复合赋值运算符丢了精度 | 自带强制类型转换 | 明确评估范围,必要时先强转再运算 |
| 取余结果带负数 | Java符号跟随被除数 | 需要正余数时手动调整:((a % b) + b) % b |
9.2 面试官最爱考的几个表达式求值题
除了前面提到的int i = 0; i = i++;,再补充几个典型的:
int y = 10; int z = y-- - --y;这个题建议你自己算一遍再运行验证,这里不给答案,算完就会对前后缀自增有更深理解。int[] arr = {1,2,3}; int i = 1; arr[i++] = arr[i] + 10;问执行后数组是什么?关键是确定arr[i]里i用的是哪个值。boolean a = false; boolean b = true; boolean c = b || (a && false);问c是多少?这个考短路还有优先级,&&优先算,然后||短路,其实b是true时直接走完了。switch表达式里能不能用case 1 + 2?答案是可以,条件是常量表达式才行。Java要求case值必须是编译期常量,1 + 2满足,但普通变量 + 2不满足。final int x = 5; int y = x + 3;这个没问题,因为final修饰的常量在编译期就可确定。
这些题的价值不在于“考倒你”,而是逼着你把“表达式求值顺序”这层逻辑想清楚。死记答案没意义,拿着IDE跑一遍,再对着我们前面讲的规则做推导,收获大得多。
9.3 几个我亲历的项目事故:都是运算符惹的祸
说三个我真实遇到过的线上事故,帮助你理解这个知识点到底有多重要。
第一个是金额计算。同事用一个int保存以“分”为单位的金额,两个金额相加超过int上限,溢出后变成负数,导致对账失败。排查很久才发现是溢出。从那以后,凡是金额相关字段,一律用long保存分,或者直接用BigDecimal。
第二个是Integer比较。同事在缓存里拿到两个对象,用==判断是否等于某个常量,结果小值时正常,大值时偶发异常。查了半天才发现因为Integer缓存池的问题。从那以后团队要求,包装类型相等判断一律用Objects.equals。
第三个是短路导致的漏更新。一个日志统计功能里,代码写着count > 0 && count++ > 0,因为逻辑写得太隐晦,导致在某些条件下计数不更新。没人注意到这个细节。最后把这种隐晦逻辑全部改成独立判断加自增,问题才消失。
这些事故都不是多高深的技术,恰恰是因为基础运算符的细节没掌握,才在复杂的业务场景里爆雷。基础不牢,后面真是每天都得给代码还债。
10. 实操总结:把运算符与表达式落到日常编码里的5个习惯
写到这里,其实光讨论理论已经到头了,我更想送你几句能直接用起来的习惯:
- 复杂表达式优先加括号,不要考验同事的优先级记忆能力。
- 类型转换能显式就显式,不要依赖隐式转换,尤其是在有窄化风险时。
- 判断相等分两种:基本类型看值,引用类型看内容,别混用。
- 复合赋值运算符要警惕隐式转型,赋值前先想类型的最大范围。
- 前后缀自增别塞进复杂表达式,单独写一行,读起来不累,逻辑也不容易错。
保持这几个习惯之后,你会发现代码里与运算符相关的bug会明显变少。不是运算符本身把你坑了,而是你以为自己懂了,结果在某个细节上判断失误。
如果你正处在学习Java的阶段,这篇文章里的代码建议你全部手敲一遍,一个都不要复制粘贴。手敲代码这个动作,看着笨拙,但调错的过程会逼着你把每个表达式求值的细节都过一遍脑子,效果比读十篇文章都好。等你哪天能不看资料,自己推导出i = i++的输出是0,并且能解释清楚每个步骤,这个知识点基本就彻底是你的了。