
学习Java到这章算是正式进入语法核心地带了。上一章写过class、main、变量声明之后你马上要面对的就是最常用的运算写法以及控制程序走向的语句结构。我见过不少新手被运算符优先级绕晕也在面试里问过几十次自增自减和浮点数比较的细节这一篇就把这些散落的知识点串起来讲透。这一章的内容不只是“会用”更关键的是“看得懂别人代码在干什么”。工作里改bug、review老代码经常碰见一段表达式摆在那结果没人敢动就是因为优先级和类型转换的规则没吃透。我写这篇的思路是按实际使用频率来拆先讲运算符家族的成员关系和基本用法再重点挖算术运算里的坑然后单独拎出位运算讲底层原理最后把所有内容归拢到控制流语句里配合真实代码演示。适合谁来读刚学完变量和类型的新手可以完整过一遍准备面试的人重点看第2、3、5章里的细节题就算你写Java两三年了翻一翻也能查漏补缺尤其是短路求值、位运算、switch新语法的部分很多人到现在都没用明白。1. 运算符家族一览1.1 算术运算符与赋值运算符Java里最基础的一组运算符全部列出来就是、-、*、/、%、、--再加赋值家族的、、-、*、/、%。大部分效果和数学一致但有几个关键差异初学很容易忽略。第一是整数除法不会保留小数。int a 5 / 2;结果不是2.5而是2因为两个操作数都是整数Java直接做整除小数部分被丢掉。想让结果是小数至少一个操作数要写成浮点类型double a 5 / 2.0;或者double a 5.0 / 2;。这个问题写代码时经常遇到比如计算平均值sum / count两个都是int一除就丢精度很多人排查半天才发现是这里的问题。第二是取模运算符%的正负号规则。5 % 2结果是1-5 % 2结果是-15 % -2结果还是1。规则很简单结果的符号跟被除数左边那个数保持一致。这个在循环取下标、判断回文数、蓝桥杯那种数字题目里特别常见只记正整数取模会吃大亏。第三是复合赋值运算符。a 1等价于a a 1但有个隐藏差异复合赋值会自动做一次类型转换。byte b 10; b 1;能编译通过而byte b 10; b b 1;编译报错。原因是b 1的结果是int直接赋给byte会丢精度而内部隐式强转不会报错。这个细节面试里经常拿来当陷阱题。1.2 关系运算符、逻辑运算符与三元运算符关系运算符包括、、、、、!结果永远是boolean类型。最容易踩坑的是它用于基本类型时比较值用于引用类型时比较地址。字符串比较写abc abc有时碰巧返回true那是常量池的机制在起作用一旦换成new String(abc)结果就变成false。判断字符串相等一律用equals()方法这个习惯从第一天就要养成。逻辑运算符有、|、^、!、、||。新手常困惑为什么和看起来都表示“与”。区别在于短路行为左侧为false时右侧根本不会执行会把左右两边都算完。同理||左侧为true时右侧不会执行。短路求值不是语法装饰而是一条保护逻辑。最常见的用法是判空if (s ! null s.length() 0)如果写成if (s ! null s.length() 0)s是null时右侧照样执行直接抛空指针异常。面试题里特别喜欢把这个包装成“问输出结果”的形式。三元运算符条件 ? 表达式1 : 表达式2本质上是一个表达式有返回值所以可以直接赋值int max a b ? a : b;。也可以嵌套但嵌套超过一层可读性就会变差实际项目里我更建议用if-else替代多层三元代码是写给人看的。另一个容易忽略的点是三元运算符的类型推导int x condition ? 1 : 2.0;编译会直接报错因为三元的两个分支类型不一致时结果会提升到共同的兼容类型这里是double再赋给int就变得不安全了。2. 算术运算的三大经典陷阱2.1 自增自减与赋值顺序和--放在变量前面叫前置版本先变后用放在后面叫后置版本先用后变。单行使用时没有区别混在表达式里就是灾难。看这段代码int i 3; int j i i i--; System.out.println(i i); System.out.println(j j);先一笔一笔拆。初始i 3。第一个i先取当前值3参与加法然后i变成4。第二个ii先变成5再取5参与加法。第三个i--先取当前值5参与加法然后i变成4。所以j 3 5 5 13i最后为4。这种题在Java面试题里出场率极高尤其是一些公司笔试题会把自增自减和赋值混在一起专门考你执行顺序。我的建议很直接工作代码里不要写这种一行表达式。自增自减只用来做循环变量更新或者独立计数绝不混进算术表达式。不是说考试不重要而是你要能推算出结果并且知道在生产环境里为什么要避免。可读性比炫技重要得多这个原则放之四海而皆准。2.2 浮点数精度问题浮点运算的坑用一句代码就能演示出来double a 0.1; double b 0.2; System.out.println(a b);很多人第一反应是输出0.3实际控制台打印的是0.30000000000000004。再试1.0 - 0.9结果是0.09999999999999998。这不是Java的问题是二进制表示小数的天然缺陷。十进制的0.1转成二进制是个无限循环小数而double的存储空间有限只能截断保存于是产生了误差。生活里类比一下十进制里1除以3等于0.33333……你永远写不完只能用有限位数表示。二进制里0.1类似同样写不完。计算机只能存有限位所以任何浮点数比较都存在精度风险。处理方式分场景。普通计算、图形学、科学计算误差容忍度高比较时用差值法Math.abs(a - b) 1e-9就认为相等。但涉及金额、账务这类精确计算必须用BigDecimal。注意创建方式用BigDecimal.valueOf(0.1)绝对不要new BigDecimal(0.1)后者仍然基于二进制double构造精度问题根本没解决。BigDecimal m BigDecimal.valueOf(0.1); BigDecimal n BigDecimal.valueOf(0.2); BigDecimal sum m.add(n);还有一点BigDecimal不能用比较也不能用equals()判断大小。equals()会比较精度位数1.0和1.00用equals比较都不相等。正确姿势是compareTo()方法返回-1、0、1分别表示小于、等于、大于。2.3 字符串拼接的方向性既能做数字加法又能拼接字符串那混在一起时到底听谁的规则很简单从左到右依次判断。只要当前操作数里出现字符串后续就全部按字符串拼接处理如果全是数字就先做算术加法。看这两个例子输出截然不同System.out.println(1 2 3); // 输出 33 System.out.println(1 2 3); // 输出 123第一个表达式先算1 2 3遇到字符串3后变成3 3按拼接处理得到33。第二个表达式一开始就遇到字符串1后面所有数字都转为字符串拼接得到123。这个坑在打印日志时经常犯。比如要输出订单号 id 金额 amount没问题但要输出总计 a b如果a和b是int期望的是金额相加后的拼接结果实际会输出105例如a10b5而不是15。正确写法是总计 (a b)用括号先算加法再拼接。我见过线上日志打出一长串数字错乱排查到最后发现就是这种加号方向问题。3. 位运算从补码到实战3.1 为什么负数要用补码存储位运算在入门阶段容易被跳过因为业务代码里直接使用位运算的频率确实不高。但它在网络协议、权限系统、状态机、底层框架里是核心基石面试也爱考。要讲位运算必须从二进制的存储机制说起。Java的整数类型全部采用补码表示int是32位long是64位。正数的原码、反码、补码都一样负数则是按“取反加一”计算补码。为什么要这么麻烦我习惯用一个时钟来类比表盘上有12个小时3点钟往后拨9小时和往前拨3小时都停在12点等价于3 - 3 3 9mod 12。计算机里的加减法本质也是这样利用固定位数溢出后取模把减法转化成加法就只需要设计加法电路硬件大幅简化。int范围是-2147483648到2147483647范围不对称的原因就是补码体系中最高位用来表示符号-1的补码是32个1也就是0xFFFFFFFF。理解补码后位运算里很多看似反直觉的结果都能解释清楚了。比如为什么~1等于-2因为1的补码是00000000 00000000 00000000 00000001按位取反得到11111111 11111111 11111111 11111110这就是-2的补码。3.2 六个位运算符实例演示Java提供了六个位运算符按位与、按位或|、按位异或^、按位取反~、左移、右移还有一个无符号右移。前四个作用在每一位上后三个是整体移动比特位。我用两个简单的二进制数演示a 0b1100十进制12b 0b1010十进制10。int a 0b1100; int b 0b1010; System.out.println(a b); // 8 即 1000 System.out.println(a | b); // 14 即 1110 System.out.println(a ^ b); // 6 即 0110 System.out.println(~a); // -13 即 1111...0011从左往右一对一对地算按位与是“两个都是1才为1”按位或是“只要有1就是1”异或是“两个不相同才为1”。取反最坑结果不是想象中的“去掉符号”而是把所有位包括符号位一起翻转所以正数取反变成负数。移位运算符中左移右边补0正数负数都一样右移左边补的是符号位正数补0、负数补1结果相当于带符号除以2的幂并向下取整无符号右移左边永远补0负数右移后会变成一个很大的正数。这三者的区别面试必问尤其-8 1和-8 1前者是-4后者是2147483644差异巨大。3.3 位运算的高频实战场景先说最简单的奇偶判断。n 1等于0说明是偶数等于1说明是奇数这比n % 2效率更高而且完全避开负数的取模符号问题。我在做算法题时经常用这个技巧。权限系统是位运算最经典的落地场景。给权限位设定常量读10001、写20010、执行40100。要赋予“读写”权限用按位或perm 1 | 2得到30011。判断是否拥有写权限用按位与(perm 2) ! 0。这种设计只用一个int就能组合32种权限而且在数据库里可以存成一个整数查询的时候直接WHERE perm 2 2效率很高。交换两个整数也是一段经典代码int x 5; int y 9; x x ^ y; y x ^ y; x x ^ y; // x 9, y 5原理利用异或的自反性a ^ b ^ b a。但有一个致命前提x和y不能是同一个变量否则结果清零。数组里交换元素时arr[i]和arr[j]如果i、j相等就会出这种事。所以我在项目里还是推荐用临时变量法大多数人看异或交换代码都需要多想几秒而临时变量法一眼就能看懂。位运算还有一个常见用途是快速乘除2的幂n 1等于n乘2n 2等于n乘4。HashMap扩容时翻倍容量底层就用这类移位思路不过那是后面章节的内容了这里先埋个伏笔。4. 表达式计算规则与类型转换4.1 运算符优先级速查表Java运算符的优先级从高到低没有必要全背下来但心里要有谱。我把日常写代码真正会遇到的整理成一张表优先级运算符结合性最高()[].左到右高--!~一元/-(类型)右到左中高*/%左到右中-左到右中左到右中低instanceof左到右低!左到右更低左到右更低^左到右更低低左到右低最低? :右到左最低-*/%右到左这张表的核心结论有两条。第一取反、自增自减、强制转换这类一元运算符的优先级远比乘除高。第二位运算的优先级低于这类关系运算低于算术运算所以(flags 0xFF) 0xFF必须加括号才能表达正确语义。我见过生产代码里有一行if (a b 0)实际执行顺序是先比较b 0再做位运算和期望的含义差了十万八千里。我现在的习惯是除了最简单的加减乘除其他任何表达式都主动加括号。不加括号虽然可能碰巧正确但阅读者必须查优先级表才能确认这种代码是对同事时间的浪费。4.2 短路求值与表达式副作用前面提过和||的短路特性这里展开讲它和表达式副作用的交互。所谓副作用就是表达式里除了返回结果还在执行过程中改变了变量状态典型例子是自增自减和赋值。int count 0; boolean flag false; if (flag (count 0)) { // 不会进入 } System.out.println(count); // 输出0右侧根本没有执行因为flag是false直接短路整个条件已经确定为false右侧的count不会执行变量count没有被改变。如果把换成count就会变成1。逻辑上短路机制是为了效率能提前判断就不再往后算但从副作用角度看它省掉了可能产生影响的代码段。这个特性在空指针防护上非常有用。写成s ! null s.length() 0s为null时安全短路不会崩溃写成s ! null s.length() 0反而引入隐患。另外要注意三目运算符不短路两个分支哪个会被执行取决于条件但没被选中的分支不会产生副作用这点和、|的行为类似。4.3 自动类型提升与强制转换Java的类型转换分为两种自动提升和强制转换。自动提升是顺着箭头方向走的byte→short→int→long→float→double以及char→int→……。只要把一个低精度类型和一个高精度类型放在同一表达式里低精度的会自动变成高精度的再参与运算。所以int long结果是longfloat double结果是double。提升规则里有个隐蔽点两个byte或short运算结果自动提升到int不会原地保持byte。看这段很多人第一次编译都会报错byte a 10; byte b 20; byte c a b; // 编译错误Cannot convert from int to bytea b的结果是int赋给byte需要强转。正确写法是byte c (byte) (a b);。强制转换是从大范围类型转到小范围类型可能丢失精度甚至改变正负号。int x 300; byte y (byte) x;结果是44因为300的二进制超出byte的8位范围高位被截断。这个机制有个经典面试场景char和int的转换。char在Java里是16位无符号整数可以存中文字符char c A; int code c;得到65。反过来char c (char) code;则把码点转回字符。5. 控制流语句分支、循环与跳转5.1 选择结构if-else与switch的取舍分支判断是程序决策的基本手段。if后面跟boolean条件条件结果只要是boolean类型就行if (1)在Java里直接编译错误和Python或JavaScript不同。多分支时else if按从上到下的顺序匹配命中一个分支就结束判断所以条件要按“最可能命中”或“范围从窄到宽”的顺序排列既提高效率也不容易漏逻辑。switch是新老Java差异最大的地方。老写法是switch (day) { case 1: System.out.println(周一); break; case 2: System.out.println(周二); break; default: System.out.println(其他); }这里最经典的坑是漏写break导致的贯穿fall-through。一旦某个case匹配成功没有break的话后面的case语句会依次执行直到遇到break或switch结束。很多人第一次写switch都会因为忘记break而输出一串结果。JDK 14之后有了新语法。case后面用箭头-自带break语义多个匹配值可以用逗号并列还能有返回值String type switch (day) { case 1, 2, 3, 4, 5 - 工作日; case 6, 7 - 周末; default - 非法输入; };这种写法清晰得多。switch支持的类型包括byte、short、int、char、枚举以及从JDK 7开始的String。我在实际工作中分支少于等于3个用if-else分支多且条件匹配值固定优先用switch新语法。switch的代码可读性在分支多时明显更好。5.2 三种循环结构对比for循环适合已知循环次数while适合循环次数不定的场景do-while保证循环体至少执行一次。后者的实际应用场景偏少典型如“用户输入直到合法为止”但每次至少要提示一次输入。基础写法就不重复了重点说几个容易被忽略的点。for循环的三个部分都可以省略但两个分号不能少。for (;;)是死循环的经典写法等价于while (true)。循环变量作用域只在循环体内跳出循环后就访问不到了。增强for循环for (int x : arr)在遍历集合时很方便但它的本质是迭代器在遍历过程中不能通过list.remove()直接删除元素会抛ConcurrentModificationException异常。要删元素要么用传统for倒序遍历要么用迭代器自己的remove()方法。做一个综合的排序示例把循环、条件、交换这三类语句串起来int[] arr {5, 1, 4, 2, 8}; for (int i 0; i arr.length - 1; i) { for (int j 0; j arr.length - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; } } }这是冒泡排序的最简实现。内层循环的上界每轮减少1因为每完成一轮外层循环最大的数就像气泡一样冒到最后面下一轮就不需要再比较它。理解这个边界条件比背代码更有价值。我见过不少人在for (int j 0; j arr.length - i - 1; j)里少写-1导致数组下标越界这类边界问题的排查经验就是循环边界有符号变量时先套最小数据量比如数组长度1或2手动走一遍流程瞬间就暴露问题。5.3 break与continue的完整使用指南break用在两个地方switch里跳出分支循环里跳出整个循环体。continue只能用在循环里跳过本次循环的剩余语句直接进入下一次迭代。它们是控制流里最容易出理解偏差的一对面试会考工作时更会用到。看最简单的场景在一组数里找第一个大于10的数找到就停。int[] nums {3, 8, 12, 5, 20}; for (int n : nums) { if (n 10) { System.out.println(找到第一个大于10的数: n); break; } }break让for循环立刻结束后面元素不看了。continue则不一样比如打印1到10之间所有奇数for (int i 1; i 10; i) { if (i % 2 0) { continue; } System.out.print(i ); }遇到偶数时continue跳过本次循环里打印语句直接进入i和条件判断。嵌套循环时有个经典需求跳出外层循环。直接写break只会跳出最内层那个循环外层照样继续。解决办法是使用标签labelouter: for (int i 0; i 3; i) { for (int j 0; j 3; j) { if (j 2) { break outer; } System.out.println(i , j); } }break outer;会直接跳出标记为outer的那层循环。同理continue outer;会让外层循环继续下一轮同时内层循环彻底终止。标签的命名规范是大写加下划线但这个语法生产环境尽量少用它会让控制流变得难以追踪。绝大多数嵌套循环都可以通过拆方法、加boolean标志位的方式改写逻辑更清晰。5.4 语句块作用域与运行时链接认知Java里用大括号包起来的就是语句块变量在哪个语句块里声明作用域就限制在哪个语句块中。常见情况for循环声明的变量循环结束就失效if块里声明的变量块外访问不到同一个方法里两个互不嵌套的语句块可以声明同名变量不会冲突。{ int x 10; System.out.println(x); // 正常 } // System.out.println(x); // 编译报错找不到符号清晰的作用域管理能减少不少bug。最典型的表现是两个循环里都写for (int i 0; ...)如果i的执行范围能穿透循环体这段代码就没法编译了。Java之所以保持这个规则是为了让内存回收更明确局部变量在退出作用域后就不再被引用。另外网上偶尔能看到“Java是静态链接的”这类表述这里顺手澄清一下。Java源文件编译成class字节码后类与类之间的引用来往是通过符号引用记录的等到运行时类加载器再把符号引用解析成具体的内存地址。这个过程中类可以动态加载、动态替换和C那种编译期就把库地址定死的静态链接有本质区别。这也是为什么一个Java应用可以做到“改一个类文件重新编译就热更新”而典型的静态链接程序做不到。如果你上手Spring Boot这类框架会发现大量依赖都是在运行期组装起来的底层靠的就是这套动态解析机制。6. 高频报错与排查心得6.1 新手最容易踩中的五类错误把初学者经常遇到的编译和运行错误整理成一张速查表方便你对着排查报错信息出现场景原因与解决Cannot find symbol变量、方法名字写错或作用域不对检查拼写、变量声明位置是否在当前语句块内incompatible types: possible lossy conversion from double to floatfloat f 3.14;小数默认是doublefloat变量赋值需要加f后缀或强转Exception in thread main java.lang.ArrayIndexOutOfBoundsException数组访问越界下标超出0到length-1区间重点检查循环边界bad operand types for binary operator if (a b)且a、b是数字两侧必须是boolean不能拿int当条件missing return statement有返回值方法但某条路径没有return编译器无法保证所有分支都有返回值检查if-else完整性这些错误本质上都不是语法背不熟的问题而是对类型系统和作用域的理解不到位。比如float f 3.14这个报错理解“浮点字面量的默认类型是double低精度变量装不下高精度值需要强转”这个原理后一眼就能明白该怎么改不需要记报错文案。6.2 入门级但有效的调试方法初学者第一步可以养成“分段打印”的调试习惯。不要盯着一个复杂的表达式猜输出而是在关键节点用System.out.println把变量当前值打出来int i 3; System.out.println(i i); int j i; System.out.println(i 后 i i , 参与运算的值 j);把执行过程拆成一步步的小变量逐行验证推导结果。这个习惯看着笨但它能逼迫你理解每一行语句到底做了什么。等熟练了再转到IDE的断点调试你会发现断点调试本质就是在干同样的事——逐步观察状态变化。我练习这类主题时还有一个笨办法自己出题自己验证。比如随手写一个表达式System.out.println(8 1 | 2 0b101);先手算出结果再让机器跑一遍不一致就说明某个优先级或运算规则理解错了。运算和语句这块没有捷径手算机器校验就是最扎实的学法。蓝桥杯、LeetCode这些平台上的基础题都可以拿来当练习题重点不在题量在于每次都能讲清楚“为什么是这个输出”。6.3 一段小项目的综合串联如果一个例子能把本章知识全串起来我推荐做“输入一组数字统计奇偶个数、最大值并排序”的小练习我常拿它考实习生。需求拆开后涉及的语句结构包括读取数据需要循环判断奇偶需要位运算配合条件分支找最大值需要循环比较排序需要双层循环嵌套。写完之后对照检查一遍位运算n 1判断奇偶对不对边界条件i length - 1够不够所有分支都有执行路径吗整段代码里有没有出现一次短路保护空指针的机会这个小项目没有复杂的框架依赖核心就是把运算和语句这两件事用得滚瓜烂熟。做一遍、跑通、再手写一遍推导过程这一章就算真正过关了。我个人在实际教学里发现一个规律运算和语句这章学得扎实的人后面学面向对象、集合、IO时都特别顺因为所有的进阶语法都建立在“按条件走分支、按次数走循环、正确计算结果”这三件事上。反过来有些人后面写代码总感觉逻辑绕不明白回过头看往往就是这章的基础没夯实表达式优先级靠猜循环边界靠试这样写出来的bug极难排查。这一章值得你多花几天时间把每个例子都亲手跑一遍改成别的数值试试慢慢你就有感觉了。