本节目标:能解释
7 / 2、-7 % 2、0.1 + 0.2和Integer.MAX_VALUE + 1的结果,知道什么时候该用Math.*Exact,并能用位运算表达一组布尔状态。
适用版本:JDK 25 LTS(Temurin 25.0.5)。
罚金计算为什么容易错
library 的借阅规则里有一条:逾期按天计罚金,每天 50 分,封顶 50 元。规则本身很简单,但实现时会连续遇到四个数值问题:
- 逾期天数怎么算?两个日期相减,得到的是整数天还是带小数的天?
- 每天 50 分,逾期 3 天是 150 分,怎么格式化成
1.50元? - 如果逾期 100 万天,
50 * 1_000_000会不会溢出? - 罚金封顶 5000 分,用
if还是Math.min?
这四个问题分别对应整数除法、取模、溢出和边界处理。它们不是「语法细节」,而是业务规则能不能算对的核心。本节从这些具体问题出发,把运算符的行为讲清楚。
算术运算符与类型提升
Java 的算术运算符和大多数语言一样:+、-、*、/、%。区别在于类型提升规则:
byte、short、char参与算术时,先提升为int。- 两个操作数类型不同时,向范围更大的类型提升:
int+long→long,int+float→float。 - 如果两个操作数都是整数,结果一定是整数,不会自动变成小数。
int a = 7;
int b = 2;
System.out.println(a / b); // 3
System.out.println(a / 2.0); // 3.5
System.out.println((double) a / b); // 3.5
a / b 是整数除法,结果是 3,小数部分被截断。想要 3.5,至少有一个操作数要是浮点类型。(double) a / b 先把它转成 double,再做浮点除法。
char 参与算术时用的是它的 Unicode 值:
char letter = 'A';
System.out.println(letter + 1); // 66
System.out.println((char) (letter + 1)); // B
letter + 1 的结果是 int,不是 char。要得到字符,必须显式转回 char。这条规则在字符串拼接时容易混淆:"A" + 1 是字符串 "A1",因为 + 在这里是字符串连接,不是算术加法。
整数除法与取模
整数除法的规则是「向零截断」:
System.out.println(7 / 2); // 3
System.out.println(-7 / 2); // -3
System.out.println(7 / -2); // -3
-7 / 2 不是 -4,也不是 -3.5,而是 -3。Java 不做「向下取整」,它截掉小数部分。需要向下取整时用 Math.floorDiv:
System.out.println(Math.floorDiv(-7, 2)); // -4
System.out.println(Math.floorMod(-7, 2)); // 1
取模的符号跟随被除数:
System.out.println(7 % 2); // 1
System.out.println(-7 % 2); // -1
System.out.println(7 % -2); // 1
这条规则在「把日期差转成天数」时要特别注意。如果结束时间早于开始时间,差值是负数,% 的结果也可能是负数。罚金计算里先判断 daysLate <= 0 直接返回 0,就是为了避开负数的边界。
除以零会抛异常:
int zero = 0;
System.out.println(10 / zero);
Exception in thread "main" java.lang.ArithmeticException: / by zero
浮点数除以零不会抛异常,结果是 Infinity 或 NaN。这个差异说明:整数和浮点的边界行为不同,不能混着推理。
溢出:int 的 32 位边界
int 是 32 位有符号整数,范围是 -2,147,483,648 到 2,147,483,647。最大值加 1 不会变成更大的数,而是回绕到最小值:
int max = Integer.MAX_VALUE;
System.out.println(max + 1); // -2147483648
System.out.println(max + 1 == Integer.MIN_VALUE); // true
这不是 bug,是二进制补码的溢出行为。JVM 规范明确规定了整数运算按 32 位回绕,所以结果可预测,但通常不是业务想要的。
long 也会溢出,只是范围大得多(约 ±9.22e18)。超过 long 的范围,就需要 BigInteger。
要检测溢出,用 Math.addExact、Math.subtractExact、Math.multiplyExact、Math.negateExact:
try {
System.out.println(Math.addExact(Integer.MAX_VALUE, 1));
} catch (ArithmeticException e) {
System.out.println("addExact: " + e.getMessage());
}
本机输出:
addExact: integer overflow
Math.multiplyExact 同理。它会检查结果是否超出 int 范围,超出就抛 ArithmeticException,而不是静默回绕。对金额、库存、计数这类「错了比崩了更危险」的字段,优先用 *Exact。
还有 Math.toIntExact(long),把 long 收窄成 int 时检查范围:
long wide = 3_000_000_000L;
System.out.println((int) wide); // -1294967296
System.out.println(Math.toIntExact(wide)); // ArithmeticException
强制转换 (int) 会截断高位,结果是错的;toIntExact 会拒绝。两种写法在源码上只差一个方法调用,行为差一个数量级。
浮点数:0.1 + 0.2 为什么不等于 0.3
double 用二进制浮点表示十进制小数,很多十进制小数无法精确表示。0.1 在二进制里是无限循环小数,存储时已经被舍入。两个舍入值相加,结果自然不是精确的 0.3:
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(0.1 + 0.2 == 0.3); // false
这不是 Java 独有的问题,IEEE 754 浮点都有。比较浮点数时,不能直接用 ==,要么比较差值是否小于一个容差:
double diff = Math.abs((0.1 + 0.2) - 0.3);
System.out.println(diff < 1e-9); // true
要么改用「整数最小单位」或 BigDecimal。金额场景推荐整数分:89.00 元存成 8900 分,加减乘都是整数运算,结果是精确的。展示时再格式化成两位小数。
BigDecimal 能精确表示十进制小数,但它的运算比 int/long 慢,而且必须用字符串构造器:
var price = new BigDecimal("89.00");
var total = price.multiply(new BigDecimal("3"));
System.out.println(total); // 267.00
上面的片段需要 import java.math.BigDecimal;。new BigDecimal(89.00) 会把 double 的误差带进来,结果不是精确的 89。金额从字符串或整数构造,不要从 double 构造。
位运算与状态标志
位运算在业务代码里不常用,但表达「一组互斥或可组合的布尔状态」时很合适。library 的书可能有多个状态:可借、已预留、损坏。用三个 boolean 字段会占用三个字段,用位标志可以塞进一个 int:
public static final int AVAILABLE = 1 << 0; // 0001
public static final int RESERVED = 1 << 1; // 0010
public static final int DAMAGED = 1 << 2; // 0100
1 << n 表示把 1 左移 n 位,得到第 n 位为 1 的掩码。组合状态用按位或:
int flags = AVAILABLE | DAMAGED; // 0101
判断某一位是否设置:
boolean damaged = (flags & DAMAGED) != 0;
清除某一位:
flags &= ~DAMAGED;
~ 是按位取反,~DAMAGED 得到一个除了第 2 位以外全是 1 的掩码,再与 flags 做与运算,就能把第 2 位清零。
移位运算有三个:
| 运算符 | 名称 | 行为 |
|---|---|---|
<< | 左移 | 低位补 0,相当于乘以 2 的幂 |
>> | 算术右移 | 高位补符号位,负数保持负数 |
>>> | 逻辑右移 | 高位补 0,负数会变成正数 |
int negative = -8;
System.out.println(negative >> 2); // -2
System.out.println(negative >>> 2); // 1073741822
-8 >> 2 是 -2,相当于除以 4 并向负无穷取整;-8 >>> 2 把符号位也当成数据,结果是一个很大的正数。处理位掩码时用 >>>,处理有符号数值时用 >>,不要混。
位运算的优先级比算术运算符低,写的时候加括号:
int mask = (1 << 2) | (1 << 4);
boolean hasBit3 = (mask & (1 << 3)) != 0;
不加括号时,1 << 2 | 1 << 4 也能算对,但读起来费劲。位运算的表达式宁可多写几个括号。
在 library 里落地
把罚金和总价的计算放进 LoanPolicy:
package com.plume.library.domain;
public final class LoanPolicy {
public static final int LOAN_DAYS = 14;
public static final int LATE_FEE_CENTS_PER_DAY = 50;
public static final int MAX_LATE_FEE_CENTS = 5_000;
private LoanPolicy() {
}
public static int lateFeeCents(int daysLate) {
if (daysLate <= 0) {
return 0;
}
long raw = (long) daysLate * LATE_FEE_CENTS_PER_DAY;
return (int) Math.min(raw, MAX_LATE_FEE_CENTS);
}
public static int totalPriceCents(int unitPriceCents, int copies) {
return Math.multiplyExact(unitPriceCents, copies);
}
}
lateFeeCents 先用 long 计算,避免 daysLate * 50 在 int 里溢出;再用 Math.min 封顶,最后转回 int。因为封顶值是 5000,转回 int 一定安全。
totalPriceCents 用 Math.multiplyExact,库存或册数异常大时直接抛异常,而不是返回一个负数。调用方可以选择捕获并提示「数量过大」,而不是把错误金额写进订单。
写一个探针验证边界:
package com.plume.library.domain;
public final class FineProbe {
public static void main(String[] args) {
System.out.println("3 days late = " + LoanPolicy.lateFeeCents(3));
System.out.println("0 days late = " + LoanPolicy.lateFeeCents(0));
System.out.println("-2 days late = " + LoanPolicy.lateFeeCents(-2));
System.out.println("200 days late = " + LoanPolicy.lateFeeCents(200));
System.out.println("price = " + LoanPolicy.totalPriceCents(8900, 3));
try {
System.out.println(LoanPolicy.totalPriceCents(8900, 300_000));
} catch (ArithmeticException e) {
System.out.println("multiplyExact: " + e.getMessage());
}
}
}
编译并运行:
javac -d out src/main/java/com/plume/library/domain/LoanPolicy.java \
src/main/java/com/plume/library/domain/FineProbe.java
java -cp out com.plume.library.domain.FineProbe
本机 JDK 25 输出:
3 days late = 150
0 days late = 0
-2 days late = 0
200 days late = 5000
price = 26700
multiplyExact: integer overflow
200 天按每天 50 分算应该是 10000 分,但封顶 5000,所以输出 5000。8900 * 300_000 是 26.7 亿,超过 int 上限,multiplyExact 抛出 integer overflow。如果换成普通乘法,结果是 -1624967296,一个负数金额。
再用 JShell 对照普通乘法和 long 乘法:
int price = 8900;
int copies = 300_000;
price * copies; // -1624967296
(long) price * copies; // 2670000000
这两个结果说明:溢出不是「算不出来」,而是「算出一个看起来像数字的错值」。 对金额和计数,错值比异常更危险,因为它会继续参与后续计算,最后才在某个奇怪的地方暴露。
常见错误与边界
「用 int 算金额乘法。」 unitPrice * quantity 两个 int 相乘,结果先按 int 算,再赋给 long 也已经晚了。要写成 (long) unitPrice * quantity,或者直接用 Math.multiplyExact 让溢出显式失败。
「用 == 比较 double。」 0.1 + 0.2 == 0.3 是 false,double 的舍入误差会让相等判断随机失败。金额用整数分或 BigDecimal;科学计算用容差比较。
「% 的结果一定是正数。」 -7 % 2 是 -1,不是 1。需要非负余数时用 Math.floorMod,需要「向零截断」时用 % 并显式处理负数。
「>> 和 >>> 混用。」 负数右移时,>> 保留符号位,>>> 补 0。位掩码用 >>>,有符号除法用 >>。不确定时加括号,并用小例子验证。
小结
- 整数除法向零截断,
-7 / 2是-3;向下取整用Math.floorDiv,非负余数用Math.floorMod。 int溢出会回绕,Integer.MAX_VALUE + 1等于Integer.MIN_VALUE;对金额和计数用Math.*Exact。- 强制转换
(int)会静默截断,Math.toIntExact会检查范围并抛异常。 0.1 + 0.2不等于0.3,金额用整数分或BigDecimal,不要用double。- 位运算适合表达可组合的状态标志;
<<建掩码,&判断,|组合,& ~清除。 - 溢出最危险的地方是「不报错」,所以关键计算要主动检查边界。
第 4 章把 Book 的字段、类型和数值边界定下来了。下一章开始写行为:把 add、list、borrow、return、search、quit 六个命令接进入口,用 if、for、while 和 switch 把流程写成可读的句子。
阅读导航:上一节:4.2 var、final 与类型推断 · 下一节:5.1 if、for、while:把流程写成可读的句子 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。