qinghuan
← 回到知识探索

后端现场 / 动手实验

取了绝对值,分桶下标为什么还会是负数?

Math.abs 不是“所有 int 都变成非负”的保证。用最小整数、负哈希和三种桶数量,找出偶发下标错误背后的边界。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先看那个无法放回 int 的正数
  4. Java 的余数,不保证落在非负区间
  5. 两个都合法的下标,也可能指向不同桶
  6. 需要绝对值本身,与需要下标是两件事
  7. 保存完整程序,再核对输出
  8. 范围正确,不等于分布均匀或分片完整
  9. 记住这些要点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
最小整数
int 能保存的最小值 -2147483648。
向下取模
与向下取整除法配套的余数;除数为正时结果非负且小于除数。
打开完整术语小抄 ↗

你把哈希值映射到三个桶,写下 Math.abs(hash) % 3。普通正数和负数都通过了测试,某次却出现 -2,下标访问失败。绝对值怎么还能是负数?

问题藏在 int 的最小值:它的负数一侧,比正数一侧多容纳一个数。把这个唯一的边界样例放进测试,就能看到普通输入始终没有触发的路径。

把例子带到自己的电脑上

下载完整练习包

需要 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • Main.java:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
javac -version
javac -encoding UTF-8 --release 17 Main.java
java -cp . Main

解压后,在含 Main.java 的目录打开终端。命令分别回车执行。固定输入的独立 Java 17 程序,双 JDK 实测;互动区只回放记录。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

同一个哈希值,四种写法会落进哪个桶

正在准备实验控件…

先预测下标,再查看记录。有效范围为 0 到 2;合法下标不代表不同表达式给出相同分桶结果。

从本页 Java 程序实际运行的 6 × 3 组结果中选取记录,不在浏览器模拟 Java int 溢出。桶数仅提供已实测的三个正值。

先看那个无法放回 int 的正数

Integer.MIN_VALUE 是 -2147483648,int 的最大值却是 2147483647。最小值对应的数学绝对值是 2147483648,已经超过 int 能保存的正数范围。Math.abs(int) 对这个参数返回原来的最小值,因此结果仍然为负。

在本次程序里,Math.abs(Integer.MIN_VALUE) % 3 得到 -2。改成 4 个桶时却得到 0,看上去正常;改成 10 个桶又得到 -8。只测一种恰好整除的桶数量,可能把问题藏起来。

这不是随机数或并发问题,也不要求真的找到某个特殊字符串。直接传入最小整数,就可以独立验证下标计算函数的边界。

Java 的余数,不保证落在非负区间

Java 整数除法朝零截断,% 与这种除法配套。用 -7 和 3 看:-7 / 3 是 -2,-7 % 3 是 -1,二者满足 -2 × 3 + (-1) = -7。负被除数的余数可能仍然为负。

分桶希望得到的通常是 0 到 bucketCount - 1。对于正桶数,Math.floorMod(hash, bucketCount) 满足这个范围;在 -7、3 的例子里,它给出 2,对应向下取整的商 -3。

本页比较同一个 hash、同一个桶数下的四种表达式:原始 %、先 abs 后 %、先 % 后 abs,以及 floorMod。不要只问有没有异常,还要观察不同规则把同一个输入分到了哪里。

两个都合法的下标,也可能指向不同桶

Math.abs(hash % buckets) 在正 int 桶数条件下可以得到范围内结果,因为余数的绝对值小于桶数,已经避开 int 最小值的绝对值溢出。但它与 floorMod 不是同一个映射规则。

例如 -7、3:先取余再取绝对值得到 1,floorMod 得到 2。两个数字都是合法下标,指向的桶却不同。因此不能把“都不会越界”说成“替换前后业务行为不变”。

如果数据已经按旧算法写入分区或缓存槽位,更改规则可能让查询去另一个位置寻找同一个键。上线前需要考虑已有数据的定位与迁移;本文只演示本地整数映射,没有执行真实数据库分片或迁移。

需要绝对值本身,与需要下标是两件事

如果业务真正需要这个 int 的数学绝对值,可以先拓宽到 long,再计算 Math.abs((long) hash)。本例得到 2147483648。顺序很重要:先用 int 做完 abs,再转换为 long,已经来不及修复那次溢出。

如果业务要求结果必须能装进 int,并希望溢出时明确失败,Java 17 中可以使用 Math.absExact。它对最小 int 抛出 ArithmeticException。本例捕获异常只为展示结果,不把异常吞掉当成成功值。

如果目标只是正桶数下的非负下标,则直接表达这个目的更清楚。先确保桶数大于 0,再使用约定的映射函数;桶数为 0 无法分桶,负桶数也不满足这里讨论的索引条件。

保存完整程序,再核对输出

程序打印一行最小整数的边界观察,再打印 6 个哈希值 × 3 个正桶数,共 18 组下标对照。内部断言检查 floorMod 和先取余再 abs 的范围,同时保留“两个合法映射不同”的反例。

2026-10-26,分别在 Microsoft OpenJDK 17.0.20.1 与 21.0.12 上使用 --release 17 编译并运行,两个版本输出一致。互动区回放这些固定记录;浏览器没有启动 JVM。程序没有联网、连接数据库或依赖第三方库。

展开收起代码 · 小实验java · 42 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
public class Main {
    static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }

    public static void main(String[] args) {
        int minimum = Integer.MIN_VALUE;
        String exactFailure = "none";
        try {
            Math.absExact(minimum);
        } catch (ArithmeticException error) {
            exactFailure = error.getClass().getSimpleName();
        }
        check(Math.abs(minimum) == minimum, "int absolute value edge changed");
        check(Math.abs((long) minimum) == 2147483648L, "widen before abs");
        check(exactFailure.equals("ArithmeticException"), "absExact must reject overflow");
        System.out.printf("{\"type\":\"edge\",\"value\":%d,\"absolute\":%d,"
                + "\"absoluteLong\":%d,\"absExactException\":\"%s\"}%n",
                minimum, Math.abs(minimum), Math.abs((long) minimum), exactFailure);
        int[] hashes = {minimum, -7, -1, 0, 1, 7};
        int[] bucketCounts = {3, 4, 10};
        for (int hash : hashes) {
            for (int buckets : bucketCounts) {
                int remainder = hash % buckets;
                int absFirst = Math.abs(hash) % buckets;
                int absAfter = Math.abs(hash % buckets);
                int floor = Math.floorMod(hash, buckets);
                check(floor >= 0 && floor < buckets, "floorMod index must be in range");
                check(absAfter >= 0 && absAfter < buckets, "abs of bounded remainder");
                System.out.printf("{\"type\":\"index\",\"hash\":%d,\"buckets\":%d,"
                        + "\"remainder\":%d,\"absFirst\":%d,\"absAfter\":%d,"
                        + "\"floorMod\":%d,\"absFirstInRange\":%s}%n",
                        hash, buckets, remainder, absFirst, absAfter, floor,
                        absFirst >= 0 && absFirst < buckets);
            }
        }
        check(Math.abs(minimum) % 3 == -2 && Math.floorMod(minimum, 3) == 1,
              "expected counterexample");
        check(Math.abs(-7 % 3) == 1 && Math.floorMod(-7, 3) == 2,
              "two valid mappings need not agree");
    }
}

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-26 使用 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12 复跑这份源码并核对完整输出。固定输入的独立 Java 17 程序,双 JDK 实测;互动区只回放记录。

运行后对照结果
{"type":"edge","value":-2147483648,"absolute":-2147483648,"absoluteLong":2147483648,"absExactException":"ArithmeticException"}
{"type":"index","hash":-2147483648,"buckets":3,"remainder":-2,"absFirst":-2,"absAfter":2,"floorMod":1,"absFirstInRange":false}
{"type":"index","hash":-2147483648,"buckets":4,"remainder":0,"absFirst":0,"absAfter":0,"floorMod":0,"absFirstInRange":true}
{"type":"index","hash":-2147483648,"buckets":10,"remainder":-8,"absFirst":-8,"absAfter":8,"floorMod":2,"absFirstInRange":false}
{"type":"index","hash":-7,"buckets":3,"remainder":-1,"absFirst":1,"absAfter":1,"floorMod":2,"absFirstInRange":true}
{"type":"index","hash":-7,"buckets":4,"remainder":-3,"absFirst":3,"absAfter":3,"floorMod":1,"absFirstInRange":true}
{"type":"index","hash":-7,"buckets":10,"remainder":-7,"absFirst":7,"absAfter":7,"floorMod":3,"absFirstInRange":true}
{"type":"index","hash":-1,"buckets":3,"remainder":-1,"absFirst":1,"absAfter":1,"floorMod":2,"absFirstInRange":true}
{"type":"index","hash":-1,"buckets":4,"remainder":-1,"absFirst":1,"absAfter":1,"floorMod":3,"absFirstInRange":true}
{"type":"index","hash":-1,"buckets":10,"remainder":-1,"absFirst":1,"absAfter":1,"floorMod":9,"absFirstInRange":true}
{"type":"index","hash":0,"buckets":3,"remainder":0,"absFirst":0,"absAfter":0,"floorMod":0,"absFirstInRange":true}
{"type":"index","hash":0,"buckets":4,"remainder":0,"absFirst":0,"absAfter":0,"floorMod":0,"absFirstInRange":true}
{"type":"index","hash":0,"buckets":10,"remainder":0,"absFirst":0,"absAfter":0,"floorMod":0,"absFirstInRange":true}
{"type":"index","hash":1,"buckets":3,"remainder":1,"absFirst":1,"absAfter":1,"floorMod":1,"absFirstInRange":true}
{"type":"index","hash":1,"buckets":4,"remainder":1,"absFirst":1,"absAfter":1,"floorMod":1,"absFirstInRange":true}
{"type":"index","hash":1,"buckets":10,"remainder":1,"absFirst":1,"absAfter":1,"floorMod":1,"absFirstInRange":true}
{"type":"index","hash":7,"buckets":3,"remainder":1,"absFirst":1,"absAfter":1,"floorMod":1,"absFirstInRange":true}
{"type":"index","hash":7,"buckets":4,"remainder":3,"absFirst":3,"absAfter":3,"floorMod":3,"absFirstInRange":true}
{"type":"index","hash":7,"buckets":10,"remainder":7,"absFirst":7,"absAfter":7,"floorMod":7,"absFirstInRange":true}

范围正确,不等于分布均匀或分片完整

floorMod 解决的是本页指定条件下的索引范围。它不会自动让差的哈希均匀分布,也不会解决扩容后大量键迁移、热点键、跨语言算法一致性或分布式路由问题。

互动区展示的是固定 18 组 Java 输出。它不使用 JavaScript 数字运算冒充 Java int 的溢出规则,也没有启动后台服务。若你换成 long、自定义哈希或更大的桶数,需要按新的类型与约定补充验证。

记住这些要点

  • Math.abs(Integer.MIN_VALUE) 仍然为负,因为对应正数超出 int 范围。
  • 对于正桶数,floorMod 给出从 0 到桶数减一的下标。
  • 先取余再 abs 与 floorMod 可能都合法,却映射到不同桶。
  • 需要数学绝对值时先拓宽类型,或用 absExact 明确报告溢出。

别把例子用过头

  • 只测试普通正负数,漏掉最小整数。
  • 只测 4 个桶,看到最小整数恰好余 0 就认为算法普遍安全。
  • 把“不会越界”误认为“与旧映射完全相同”。
  • 先进行 int 的 abs,再转 long,并期待恢复丢失的范围。
  • 把索引范围正确当作哈希分布、扩容或分片迁移已经验证。

自己试一小步

现有系统使用 Math.abs(hash) % buckets,准备修复时需要记录哪些兼容性信息?

  1. 补充 Integer.MAX_VALUE、Integer.MIN_VALUE + 1 和桶数 1,先预测再运行。
  2. 将旧规则与候选规则的结果逐项对照,单独记录旧规则越界和两种合法结果不一致。
  3. 写明桶数为 0 或负数的处理方式,并说明已有数据的定位是否依赖原映射。

做到这些,就算完成

  • 每个有效下标都满足 0 <= index && index < buckets。
  • 不把“有界”与“相同”混为一个断言。
  • 把本地算术验证与数据库分片迁移验证分开,未执行的系统行为不作保证。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。