《Java 编程入门》4.3 运算符、溢出与数值边界

从 library 的罚金计算出发,讲清整数除法、取模符号、类型提升、溢出与 Math.addExact 系列方法;再解释 0.1 + 0.2 为什么不等于 0.3、金额为什么用分而不是 double,最后用位运算表示借阅状态标志,并给出可复现的本机输出与边界检查清单。

本节目标:能解释 7 / 2、-7 % 2、0.1 + 0.2 和 Integer.MAX_VALUE + 1 的结果,知道什么时候该用 Math.*Exact,并能用位运算表达一组布尔状态。
适用版本:JDK 25 LTS(Temurin 25.0.5)。

罚金计算为什么容易错

library 的借阅规则里有一条:逾期按天计罚金,每天 50 分,封顶 50 元。规则本身很简单,但实现时会连续遇到四个数值问题:

  1. 逾期天数怎么算?两个日期相减,得到的是整数天还是带小数的天?
  2. 每天 50 分,逾期 3 天是 150 分,怎么格式化成 1.50 元?
  3. 如果逾期 100 万天,50 * 1_000_000 会不会溢出?
  4. 罚金封顶 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:把流程写成可读的句子 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发