学习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结果是-1,5 % -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。第二个++i,i先变成5,再取5参与加法。第三个i--,先取当前值5参与加法,然后i变成4。所以j = 3 + 5 + 5 = 13,i最后为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"(例如a=10,b=5)而不是"15"。正确写法是"总计:" + (a + b),用括号先算加法,再拼接。我见过线上日志打出一长串数字错乱,排查到最后发现就是这种加号方向问题。
3. 位运算:从补码到实战
3.1 为什么负数要用补码存储
位运算在入门阶段容易被跳过,因为业务代码里直接使用位运算的频率确实不高。但它在网络协议、权限系统、状态机、底层框架里是核心基石,面试也爱考。要讲位运算,必须从二进制的存储机制说起。
Java的整数类型全部采用补码表示,int是32位,long是64位。正数的原码、反码、补码都一样;负数则是按“取反加一”计算补码。为什么要这么麻烦?我习惯用一个时钟来类比:表盘上有12个小时,3点钟往后拨9小时和往前拨3小时,都停在12点,等价于3 - 3 = 3 + 9(mod 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(十进制12),b = 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效率更高,而且完全避开负数的取模符号问题。我在做算法题时经常用这个技巧。
权限系统是位运算最经典的落地场景。给权限位设定常量:读=1(0001)、写=2(0010)、执行=4(0100)。要赋予“读+写”权限,用按位或:perm = 1 | 2得到3(0011)。判断是否拥有写权限,用按位与:(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乘2,n << 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() > 0,s为null时安全短路,不会崩溃;写成s != null & s.length() > 0反而引入隐患。另外要注意,三目运算符不短路,两个分支哪个会被执行取决于条件,但没被选中的分支不会产生副作用,这点和&、|的行为类似。
4.3 自动类型提升与强制转换
Java的类型转换分为两种:自动提升和强制转换。自动提升是顺着箭头方向走的:byte→short→int→long→float→double,以及char→int→……。只要把一个低精度类型和一个高精度类型放在同一表达式里,低精度的会自动变成高精度的再参与运算。所以int + long结果是long,float + 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只会跳出最内层那个循环,外层照样继续。解决办法是使用标签(label):
outer: 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 float | float f = 3.14; | 小数默认是double,float变量赋值需要加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极难排查。这一章值得你多花几天时间,把每个例子都亲手跑一遍,改成别的数值试试,慢慢你就有感觉了。