
1. 这一章到底在讲什么——运算和语句在Java里的位置写这个笔记的时候我一直在想一个问题为什么很多人学到集合、IO、框架的时候总感觉代码写得“飘”逻辑一复杂就兜不住。后来我复盘自己带过的那些新人发现问题几乎都出在第2章。运算和语句这一章表面上是Java的语法入门实际上是你写任何一段逻辑的底层骨架。运算符决定了你对数据的处理能力语句决定了你对程序流程的控制能力这两个能力不过关后面所有的集合、多线程、JVM知识都是空中楼阁。这一章解决的核心问题就两个第一表达式是怎么求值的也就是一个式子摆在面前你能否准确判断它最终算出什么结果第二程序流程是怎么被控制的也就是分支、循环、跳转这些语句在什么场景下该用哪种。无论你是准备Java面试、参加蓝桥杯这类算法竞赛还是做Web后端开发这两件事都是每天要碰的。我见过很多人在实际编码里写出莫名其妙的bug最后定位下来要么是运算符优先级搞错要么是浮点数比较出了偏差要么是break和continue用混了。基础知识不过硬运行期就是拿事故换教训。所以这篇笔记我不打算按教科书的方式把符号排列一遍而是把所有知识点拆开揉碎结合我在真实项目和面试辅导里遇到的场景来讲。每个运算符为什么这么设计、每个语句应该怎么选我都会把背后的逻辑说清楚再把容易踩的坑单独拉出来保证你看完能直接上手用。2. 运算符逐个拆解——从算术到位运算每个符号背后的设计意图2.1 算术运算符与浮点数的“不精确”算术运算符总共就那几个加、减、乘、除、取余外加自增和自减。加法、减法、乘法大家都熟真正需要留神的是整数除法。两个整数相除结果直接截断小数部分不会四舍五入也不会自动转成浮点数。比如7除以2你如果以为结果是3.5那程序会诚实地给你一个3。这里有一个很经典的补充场景求平均值的时候如果拿两个整数直接除小数部分悄悄丢了等你发现平均值不对代码已经上线跑了很久。int a 7; int b 2; System.out.println(a / b); // 输出3小数部分被截断 System.out.println(a % b); // 输出1取余得到的是剩余部分遇到需要精确小数结果的场景我建议先把其中一个操作数转成double也就是写成a * 1.0 / b或者直接用double类型的变量参与运算。取余运算符%也是很多人用不熟的地方它的结果符号和被取余的数左边那个数保持一致这个在判断奇偶、循环取模的时候非常常用例如i % 2 0就是经典偶数判断比位运算的写法更容易读懂适合业务代码。浮点数最大的坑在于精度。0.1加0.2在Java里并不等于0.3这个梗大家应该听过但真正理解的人不多。原因很简单计算机用二进制表示小数而0.1这样的十进制小数在二进制里是无限循环的根本没法精确存储。这就像你用有限的位数去写三分之一怎么写都只能写个近似值。所以在涉及金额、订单、价格计算的代码里我从来不用double要么用BigDecimal要么用整数存“分”为单位的值。这不是小题大做是生产环境里血泪换来的经验。double x 0.1 0.2; System.out.println(x); // 0.30000000000000004 System.out.println(x 0.3); // false自增和自减也值得单独说说。i和i的区别很多人能背出“先用后加”和“先加后用”但真到复合表达式里就容易卡壳。我教新人的时候总让他们确认一个原则如果你不是单独写一条i语句而是把它塞在表达式里多半是在给自己制造阅读障碍。写代码的时候自增自减最好是独立成行不要在长表达式里混用否则代码可读性和可维护性都会被打折。2.2 复合赋值运算符的隐式陷阱复合赋值运算符看起来只是简写比如a 1等于a a 1。但这里藏着一个我见过很多人都不知道的坑复合赋值运算符自带隐式类型转换。看一个例子就明白了short类型的变量s执行s s 1会编译报错因为short和int运算后结果是int直接赋值给short会丢失精度但执行s 1却能编译通过因为Java对复合赋值运算符做了特殊处理它等价于s (short)(s 1)自动帮你强转了。这个设计初看挺贴心实际上很容易埋雷。我有一次在代码里用byte累计一个计数器因为觉得数字不会超过一百就放心地用b 1累加结果循环跑到128byte直接溢出变成负数排查了好一会儿才意识到是复合赋值运算符做了强转而不是自动防止溢出。这类问题不会在编译阶段暴露只在运行期以诡异的结果出现非常具有迷惑性。以后看到、-、*这类运算符心里要多问一句这里会不会有溢出风险2.3 短路逻辑——比较与逻辑运算符的核心机制逻辑运算符里和||的短路机制是Java里非常值得一提的设计。短路的意思是如果能通过左侧的表达式的值确定整个表达式的结果右侧表达式就不再执行了。这个特性和性能关系不大真正的价值在于避免运行时异常。写过代码的人肯定见过这样的模式if (list ! null list.size() 0) { // 安全地操作列表 }这里就是利用了的短路机制。当list为null时list ! null这一半是false整个表达式的值必定是false后面list.size()压根不会执行所以不会抛出空指针异常。如果你写成if (list ! null list.size() 0)那个单独的逻辑与运算符不会短路list.size()照样执行空指针异常就来了。类似地用||做默认值回退也很实用比如String name (userInput ! null !userInput.isEmpty()) ? userInput : defaultValue。顺带说一句逻辑与、逻辑或和位运算符之间的区别是面试里特别爱问的一个点。和||是逻辑短路运算符操作的是布尔值、|、^这些是位运算符既能操作布尔值也可以操作整数位。当它们操作布尔值时区别就在于是否短路。面试官问这些不是为了考记忆而是看你对底层执行机制理解得透不透。2.4 位运算从底层原理到面试高频题位运算对于很多Java程序员来说是“听过但没用过”的知识点但它在状态压缩、权限控制、算法优化、网络协议解析这些场景里是真正的大杀器。Java的位运算符包括按位与、按位或|、按位异或^、按位取反~以及左移、右移、无符号右移。这些东西看起来是给底层C语言程序员准备的但Java里也保留了全套原因很简单在某些计算场景位运算比普通运算快而且在表达二进制层面的逻辑时更自然。左移一位相当于乘以2右移一位相当于除以2。这一条在面试题里反复出现比如判断一个数是不是2的整数次幂可以写成(n (n - 1)) 0这个式子背后的原理是2的整数次幂的二进制形式只有一个位是1减去1之后这一位变成0后面的位全部变成1两者相与就得到0。用异或运算交换两个数也是经典操作a a ^ b; b a ^ b; a a ^ b;虽然在实际项目里没人会为了交换变量用异或但面试官通过这个题能看出你对位运算的理解深度。在实际项目中位运算最常见的应用是权限标志位的组合。比如一个int变量用低16位表示16种权限每个权限占一个二进制位那判断用户是否有某个权限只需要做一次按位与运算添加权限就做按位或运算取消权限就和取反后的掩码相与。我在做一个后台管理系统时就用这种方式设计了一套用户权限模型数据库只需要存一个int字段内存里的判断也是一条指令级操作比拼字符串或者维护一张关联表轻量太多。int PERM_READ 1 0; // 0001 int PERM_WRITE 1 1; // 0010 int PERM_EXEC 1 2; // 0100 int userPerm PERM_READ | PERM_WRITE; // 添加读、写权限 boolean canRead (userPerm PERM_READ) ! 0; // 判断是否有读权限输出true说到移位运算还要提醒一句Java的位移是对int和long操作的如果操作数是byte、short、char会先提升为int再移位。移位距离会做模运算比如int类型左移33位实际上等价于左移1位因为33对32取模等于1。这个细节很冷门但我在一次代码评审里真遇到过有人写了1 40以为是在做超大数的乘方实际上结果和我预期完全不同。3. 表达式求解规则——优先级、类型提升与边界情况3.1 运算符优先级别靠背靠推导运算符优先级这张表几乎所有教程都会给但几乎没有人真正去背它。原因很简单真实项目里凡是优先级容易模糊的地方老手都会直接加括号。括号是最便宜、最清晰的表达方式它能把你脑子里的优先级意图直接告诉读代码的人不用让他们去翻表核对。我见过一些新手写这种代码if (a b c)这行代码的执行方式和你想象的可能完全不同因为的优先级比要高。这种问题排查起来非常恶心因为它不报错就是结果不对。我不是说你完全不需要知道优先级恰恰相反你需要知道那些最常见的规则并且理解它们的规律。乘除取余优先于加减比较优先于逻辑与、逻辑或赋值运算符优先级最低。另外还有一个容易被忽略的规律一元运算符的优先级高于二元运算符两元运算符里算术高于移位移位高于比较比较高于位运算与逻辑运算逻辑运算高于赋值。记不住的时候就在关键位置加括号这不是示弱是职业素养。还有一个字符串拼接的优先级问题值得专门讲。Java里既可以做加法也可以做字符串拼接。当表达式是1 2 3时运算顺序从左到右先算出12等于3再和3拼接成33而1 2 3则是先拼接12再拼接成123。同一个加号两种完全不同的结果。这个在日志输出、拼SQL语句、拼提示信息的时候太容易踩到了我建议在拼接字符串时至少让一个操作数显式是String或者干脆用StringBuilder。3.2 整数除法、类型提升和字符串拼接类型提升也是表达式求值里一个高频考点。Java在运算时如果操作数类型不一致会把小范围的类型自动提升到大范围比如int和long运算int先转成long再算int和double运算int先转成double。这个机制的本意是好的保证结果不丢精度但副作用是结果类型也跟着变。很多人写平均值除以一个数组长度函数返回int结果小数部分被截断还找不到原因就是因为没有意识到除法的结果是int还是double取决于操作数类型而不是你变量声明成什么类型。byte、short、char之间做运算的时候有个特别反直觉的规则它们会先提升为int再运算哪怕两个short相加结果也是int。这就导致short a 1; short b 2; short c a b;这行代码直接编译报错。我在带新人做数据协议解析的时候经常看到他们用byte数组拼来拼去一不留神就出现编译错误其实就是类型提升这个基础规则在起作用。遇到这种问题显式强转或者换用int中间变量都是很直接的解决办法。把这条扩展一下说表达式的类型由操作数中“最大”的那个类型决定。如果你想得到一个double结果那么至少一个操作数必须是double。这是为什么我以前写比例计算时总要确保有一方是小数或直接除以0.0。否则分母和分子都是整数算出来永远是小整数错误发生在细节中极难察觉。3.3 溢出、NaN和无穷——边界情况怎么处理表达式运算还有一个绕不开的话题就是边界值。int的最大值是2147483647如果你对这个数加1不会报错而是变成-2147483648。这就是整数溢出它的本质是二进制的补码表示到头了再进位就翻了符号位。这种问题在数学里是不可想象的但在计算机里就是日常。我做定时任务的时候计算下一次执行时间戳就吃过亏毫秒级时间戳一乘就溢出了结果定时任务在某个时刻瞬间重置到很早以前排查起来极其诡异。处理溢出没有太取巧的招最实用的就是提前预判如果两个大数相加或相乘先用long甚至BigDecimal来算然后再判断是否超出目标范围。Java 8以后提供了Math.addExact、Math.multiplyExact这类方法溢出时直接抛ArithmeticException而不是静默给出错误结果这在数值敏感的金融领域值得优先考虑。浮点数的边界情况更特殊double可以表示NaN不是一个数和正负无穷它们都是合法值。0.0除以0.0得到NaN1.0除以0.0得到正无穷。特别要注意的是NaN不等于任何值包括它自己也就是说NaN NaN是false。你在代码里判断一个数是否为NaN要用Double.isNaN()方法而不能直接比较。这些边界情况平时不常遇到但一旦遇到而你不知道那就是一段很长的排错时间。4. 控制语句——分支、循环和跳转4.1 分支if-else 和 switch 怎么选分支语句是程序的岔路口让代码在满足不同条件时走不同路径。最基本的if-else大家都会写我想强调的是两个容易被忽略的点。第一个是条件判断里的边界顺序。如果你的代码里只在某个区间内做正确的事区间外的场景也要考虑别让异常情况悄悄地落到某个完全无关的分支里。第二个是else if的匹配规则Java里的else if会从上往下依次匹配一旦某个条件成立后面的所有条件都不再判断。如果你把“通用”条件写在“特定”条件前面特定分支永远走不到这种看似逻辑正确实则完全错误的问题调试时非常烧脑。switch语句是另一套分支结构它适合做多值相等判断尤其是判断一个整数或枚举变量到底属于哪个离散值。老版本的switch只能匹配整数、字符、枚举和字符串从Java 14起引入了箭头语法switch表达式写法更简洁而且不需要break因为每个分支默认就是独立作用域。switch (status) { case 0 - System.out.println(新建); case 1 - System.out.println(进行中); case 2 - System.out.println(已完成); default - System.out.println(未知状态); }如果是需要区间判断或者复杂的组合条件if-else更合适如果是单一变量的精确相等匹配switch的可读性明显更好。我在实际项目中总结的经验是能不用多层嵌套if就不嵌套因为嵌套每深一层阅读理解成本就指数上升。如果逻辑确实复杂优先用卫语句guard clause把异常分支提前返回让主流程保持平坦。4.2 循环for、while、do-while 各自的主场循环语句是程序重复劳动的执行者。for循环适合“我知道要循环多少次”的场景比如遍历数组、遍历一个固定次数的过程while循环适合“我不知道要循环几次但知道什么时候停下来”的场景典型的是读取文件直到EOF或者轮询某个条件变为true。do-while和while唯一的区别是do-while即使条件一开始就是false也会先执行一次循环体这在某些需要先执行再判断的场景里很有用比如菜单必须先显示一次再根据用户输入决定是否继续。for循环里一个常见的误用是给循环变量做复杂的运算。很多人在力扣刷题和蓝桥杯的时候喜欢把多层for循环加各种条件判断塞在括号里比如for (int i 0, j n - 1; i j; i, j--)。这种写法虽然合法但可读性很差。我的建议是循环里只保留循环变量的变化逻辑复杂的计算放到循环体里去做虽然多两行代码但排查问题时思路会清晰得多。还要记住for循环括号里可以声明多个类型相同的变量用逗号分隔但作用域仅限于这个for循环内部。循环体和循环条件里能访问到哪些变量这个作用域规则一定要清楚否则很容易出现“变量找不到”的编译错误。我记得在一次代码评审里有人把循环之后还要用的变量写在了for的初始化里结果循环一结束变量就消失了只能回炉重构。4.3 跳转break、continue 到底什么区别break和continue这两个关键词是我面试时最喜欢问的基础题之一。原因很简单能把它们准确说清楚的人基础一定扎实。一句话版本break是跳出整个循环continue是跳过本次循环剩余部分直接进入下一次循环。听上去很简单但真实场景里用错的情况非常多因为嵌套循环里break只跳出它所在的那一层循环而不是跳出所有循环。想在嵌套循环里一次性跳出外层通常要借助一个标志位或者给外层循环加标签Java的标签跳转语法其实存在但实际项目里极少人用因为它会破坏结构化程序设计的思路。for (int i 0; i 10; i) { if (i 3) continue; if (i 7) break; System.out.println(i); } // 输出0 1 2 4 5 6continue在实际业务里最常见的用途是过滤遍历集合的时候跳过不符合条件的数据只处理剩下的。这和“收集后统一过滤”是两种思路前者在数据量大时能省一次遍历后者在逻辑清晰度上更胜一筹。到底用哪种取决于具体场景。至于break除了跳出循环还在switch的老语法里作为阻断分支穿透的关键词这个我们在后面踩坑部分细说。还有一个容易被忽略的点是return。return不只是从方法返回一个值它还承担着终止整个方法执行的作用。如果你在一个方法里写了循环循环里遇到某种必须提前结束方法的场景用return比用break再在外面包一个if判断要清爽得多。很多人把return、break、continue混为一谈实际上它们的层级完全不一样return跳出的是方法break跳出的是循环或switchcontinue跳过的是本次循环迭代。5. 实战排错——这一章最经典的五类问题5.1 浮点数比较为什么0.1加0.2不等于0.3这个问题在我排过的bug里出场率很高。某次我维护一个统计报表模块页面显示的比例总和偶尔是99.9%或者100.1%用户反映数据不准确。最后定位下来就是double累加导致的精度误差修正的方式有两种要么所有百分比计算都用BigDecimal并且在除法时指定精度和舍入模式要么把百分比放大成整数再计算比如用万分比而不是百分比存储最后展示时再格式化。我倾向于后者因为整数运算在性能上和心智负担上都更轻。判断两个浮点数是否相等也有讲究。你不能写if (a b)因为两个“看起来一样”的浮点数可能在最后一位上差了极小值。正确做法是判断绝对误差是否小于一个阈值比如Math.abs(a - b) 1e-9。如果要更严格用BigDecimal的compareTo方法比较它会忽略精度差异只看数值大小。这里踩过坑的朋友应该都明白compareTo和equals对BigDecimal来说结果可能不一致equals会连精度一起比较0.1和0.10用equals比较就是false。5.2 switch-case穿透到底是bug还是特性老版switch的最大特点是穿透。意思是如果case分支里没有break执行完这个case后会继续往下执行下一个case的语句直到遇到break或者switch结束。我见过最离谱的一段代码是一个模版方法里写了一个巨大的switch十几个case大部分都漏写了break导致不管用户选哪个选项代码都会把后面所有case执行一遍。这个bug隐藏了很久因为测试时前端页面恰好只访问了最后一个case对应的功能前面的语法错误被掩盖了。不过穿透也不完全是坏事在某些场景下它是刻意为之的。比如多个case要执行同一段逻辑可以写成case 1: case 2: case 3: 共享下面的代码块。JDK 14之后的箭头语法直接从语言层面消灭了穿透问题每个分支自动单独作用域不再需要break。如果你还在用传统switch我的建议是每个case结尾都明确写上break或return即使写完之后不会执行任何代码也要让读代码的人知道你是故意终止的。防御性编程就是这样宁可多写两行也不要给别人留下模糊的想象空间。5.3 死循环、空语句和括号问题——那些悄无声息的坑死循环的经典形态是while(true)很多人的第一反应是这个代码肯定有问题但什么时候该用呢某些服务器监听程序、事件轮询处理确实是需要无限循环的问题出在你是不是给循环留了退出条件。比如在while(true)里读消息队列读到特定信号就break这种写法本身没有错但如果break条件被某个复杂逻辑漏掉了就会造成永不退出。我的经验是用while(true)之前先在心里画一下退出路径图如果自己都说不出什么时候退出那就别这么写。空语句的坑也很有意思。因为Java允许一个分号就是一个合法语句所以你在for循环后面不小心多写了一个分号循环体就变成空操作大括号里的代码移出循环只执行一次。for (int i 0; i 10; i);这一行的结尾分号会让人头痛因为程序不会报错只是循环里什么都没干。排查这种问题时缩进和代码审查是最后一道防线我的建议是从一开始就用IDE的格式化工具让代码结构一眼就能看出哪些是循环体。还有一个和运算强相关的坑是if条件里误用赋值运算符。if (a true)在C语言里是合法的在Java里会编译报错因为Java要求if条件必须是布尔值这是Java比C安全的一个体现。但你以为这就安全了还有一种写法是if (a true)这里又黑又长不如直接写if (a)。我在评审时看到这种代码会顺手把 true改成直接布尔变量不是因为强迫症而是因为省略这些冗余判断后逻辑意图更清晰。结尾关于这一章我最后想说的话根据我个人这几年写Java和带新人的经验运算和语句这一章最值得投入精力的不是背语法而是把“为什么”想透。为什么要用位运算而不是普通运算为什么要用短路逻辑为什么浮点数不能直接比较为什么break和continue要分得那么清楚——这些问题的答案会在你后面所有编码中反复用到。你可以暂时不理解但一定要保持这种追问的习惯因为编程语言的每个设计背后都是某种取舍理解取舍才算是真正掌握。我自己的做法是在学完这章以后立刻把以前写过的简单程序翻出来用新的眼光重新审视一遍。把那些能简化的逻辑简化掉把隐蔽的边界条件补齐把浮点数比较修正成误差比较你会发现代码质量明显提升一个档次。基础知识的价值不在于考试能得多少分而在于你写出的每一行代码都比别人更稳排查问题时能比别人更快定位到根因。最后再说一个很多人问我的问题刷题对这个章节有用吗我的回答是刷算法题确实能帮你巩固运算符和控制语句但刷题不是目的真正的目的是让你在面对复杂业务逻辑时能够自然而然地把正确的运算符和语句组合出来。多动手写、多调试、多复盘你的基本功就会在这些细节中一点点扎实起来。这一章的内容看上去简单但它是你通往所有高级Java知识的最短路径。