qinghuan
← Java SE / 全部笔记

单元 23 / Java SE

P0 · 必会主线L1 · 基础

BigDecimal 与金额:构造、舍入和比较都要有规则

从输入方式开始保证金额精度,再约定币种、单位、比较和舍入规则,避免只换类型却留下计算问题。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能按明确规则计算金额,并解释表示差异、舍入时机和比较方式。

学习理由、准备要求与知识点

为什么学它

金额是常见后端数据,精度、舍入和比较规则必须在接口与存储间一致。

先读这些内容

知识点清单

  • 二进制浮点近似
  • 字符串构造 BigDecimal
  • 不可变运算结果
  • scale 与精度
  • 除法与舍入模式
  • 逐项和最终舍入
  • equals 与 compareTo
  • 币种单位和存储契约

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

算金额之前,先说清是什么货币、单位是什么,以及最终允许保留到哪一位。BigDecimal 能按十进制表示和计算数值,内部用整数和 scale 记录这个值,适合需要明确舍入规则的场景。但它不会替你管理币种;不同货币不能随意相加,也不是所有业务都固定保留两位小数。

想准确表示 0.1,优先从明确的十进制字符串创建,例如 new BigDecimal("0.1")。若写 new BigDecimal(0.1),传入的 double 已经是二进制近似值,转换只会忠实保留这个近似。BigDecimal.valueOf(double) 使用不同的转换方式,但也找不回上游丢掉的精度。精确金额最好从接口输入开始就用十进制文本或整数最小单位保存。

scale 和 precision 要分开看。常见的非负 scale 表示小数点后有几位,precision 表示用于记录数值的有效数字位数;例如 12.30 的 scale 是 2,precision 是 4。

scale 也可以为负,这时表示整数还要乘相应的十的幂,不能一律理解为“小数位数”。setScale 调整 scale,必要时要舍入;MathContext 约束运算精度。BigDecimal 不可变,这些操作返回新值。

1.0 与 1.00 数值比较可相等,但 equals 还比较 scale,作为键时要先确定相等规则。

文中这几个词,先认一认

scale
它告诉程序怎样放置内部整数的小数点:内部整数 123 配 scale=2 表示 1.23,配 scale=-2 则表示 12300。规则仍是“内部整数×10 的负 scale 次方”,所以 scale 不能一律当成小数位数。
precision
它数的是 BigDecimal 内部整数有几位,而不是小数点后有几位。比如从字符串“12.30”创建的数,precision 是 4、scale 是 2;末尾这个 0 也属于当前表示保留的数字。
舍入模式
数值无法按目标精度完整保留时,约定怎样取近似结果,例如是否四舍五入。
守恒分摊
把金额分成多份时,保证各份相加仍等于原金额,并明确处理舍入产生的差额。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

除法是最容易暴露规则缺失的地方。一除以三没有有限十进制展开,无指定精度或舍入的精确 divide 会失败;这不是库坏了,而是数学结果无法按所要求形式精确表示。调用者应明确保留几位、采用哪种舍入,以及舍入发生在单项还是总额层面,不能随意捕获异常后返回零。

舍入名称最好配数字理解。把 2.5 舍入到整数时,HALF_UP 得到 3;HALF_EVEN 在正好居中时选偶数,因此得到 2。再看负数:把 -1.1 舍入到整数,CEILING 得到 -1,FLOOR 得到 -2,分别朝数轴的正方向和负方向取整,不能只说“进一位”或“去掉小数”。业务规则应同时列出正负例子。若输入本来就应精确满足目标要求,可用 UNNECESSARY:一旦需要舍入就报错。

多人分摊不能简单把总额除以人数、每份独立四舍五入,再假设总和不变。更稳妥的方法是先转为整数最小单位,求整数商和余数,把余数对应的单位按稳定顺序分给前若干参与者。图中每份最多相差一个最小单位,且所有份数之和由商余恒等式保证等于原额。

正在加载流程图…

本篇分摊约定最小单位为分,余数按输入顺序分配;实际币种规则需另行确认。
查看流程图源码(Mermaid)
flowchart TD
 A["校验币种与两位小数契约"] --> B["转成整数分"]
 B --> C["总分除以人数得到商 q 与余数 r"]
 C --> D["前 r 人分 q+1 分"]
 C --> E["其余人分 q 分"]
 D --> F["合计严格等于原总额"]
 E --> F

按顺序看,每一步做了什么

  1. 确定币种、单位与最小粒度。
  2. 从精确十进制来源构造值。
  3. 明确尺度、精度与舍入位置。
  4. 用整数最小单位设计守恒分摊。
  5. 测试中点、零、负数和超大数值。

03对照实现代码

商余分解如何把结果与原值联系起来 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/math/BigDecimal.java · java.math.BigDecimal#divideAndRemainder(BigDecimal)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
import java.math.BigDecimal;
public final class SourceModel {
    public record Parts(BigDecimal quotient, BigDecimal remainder) {}
    public static Parts parts(BigDecimal value, BigDecimal divisor) {
        // 先求向零截取的整数商,再用恒等式计算剩余值。
        BigDecimal quotient = value.divideToIntegralValue(divisor);
        BigDecimal consumed = divisor.multiply(quotient);
        return new Parts(quotient, value.subtract(consumed));
    }
}

真实 divideAndRemainder 先用 divideToIntegralValue 求整数商,再用原值减去“商乘除数”得到余数。模型改用 record 返回这两部分,数学关系不变,返回形式不同。整数商按相应 API 规则向零取整,因此这里的余数不保证总是非负,不能直接当成数学中某些定义下的模。

这个分解不需要把完整除法结果展开成无限小数。我们只要知道能分出多少整份和还剩多少,因此一除以三也能得到整数商零与余数一。它与要求某个小数精度的 divide 服务于不同任务,不能把所有名字里有 divide 的方法混成同一种舍入行为。

负数输入时余数可能为负,这与后面分摊算法要求非负总额不同。算法通过前置条件缩小数学范围,使余数自然落在零到人数减一之间;源码 API 本身的适用范围更广。阅读实现时应区分库的通用语义与业务算法额外建立的约束。

任意精度不代表无限资源或固定时间。数位越多,乘除运算和对象分配成本越高,输入仍需要合理长度与规模限制。教学模型展示调用链与数学关系,不模拟 BigDecimal 的紧凑整数表示、BigInteger 路径和各种舍入优化;性能结论不能从这几行调用数直接得出。

代码先调用谁,再调用谁

  1. divideAndRemainder 请求整数商
  2. divideToIntegralValue 计算向零整数部分
  3. 商乘除数得到已分配部分
  4. 原值减去已分配部分返回余数

04动手写一个例子

把两位小数总额守恒分给 n 人

输入非负十进制字符串和一到一万的参与人数,金额必须精确到分,允许多余尾零。返回每人两位小数金额,余数分配给靠前位置;支持超过 long 的总额。

输入 1
10.00,3人
输出 1
[3.34,3.33,3.33]
输入 2
0.01,3人
输出 2
[0.01,0.00,0.00]
输入 3
1.001,2人
输出 3
拒绝无法精确到分的金额

先把金额规范到两位小数,并使用 UNNECESSARY 拒绝需要舍入的输入。随后小数点右移两位并转为精确 BigInteger,得到整数分。这样后续分摊完全在整数领域进行,避免每份小数除法产生不同舍入误差,也避免固定 long 上界限制总额。

令总分为 q*n+r,其中零不大于 r 且 r 小于 n。给前 r 人 q+1 分,剩余人 q 分,合计就是 q*n+r,任意两份最多相差一分。稳定的先后顺序使同一输入得到同一结果,但这并不自动代表业务公平;长期重复分摊可能需要轮换余数起点。

人数限制控制输出内存,超大金额的数位成本仍要单独考虑。测试不仅逐份比较,还应检查合计守恒和尺度。负总额在本题直接拒绝,退款分摊需要定义负数排序和余数方向,不能简单放开校验后沿用正数证明。

为什么这样写是对的前 i 份合计等于 i*q 加上前 i 个位置已分配的余数单位,余数单位总数最终恰好为 r。

时间 O(n) 次结果构建,加上与金额数位有关的大整数除法成本。额外空间 O(n*b) 上界,b 为每份结果的数位规模。

只做一次商余运算,随后创建 n 个金额对象;任意精度算术按数位收费,不能把每次操作视为与输入长度无关的常数。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 46 行

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

Main.java · 完整可运行程序 · Java
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
public class Main {
    static List<BigDecimal> split(String text, int people) {
        if (text == null || people < 1 || people > 10_000)
            throw new IllegalArgumentException("input");
        BigDecimal total = new BigDecimal(text).setScale(2,RoundingMode.UNNECESSARY);
        if (total.signum() < 0) throw new IllegalArgumentException("negative");
        // 精确转成分,金额再大也不先挤进 long。
        BigInteger cents = total.movePointRight(2).toBigIntegerExact();
        BigInteger[] parts = cents.divideAndRemainder(BigInteger.valueOf(people));
        int extra = parts[1].intValueExact();
        List<BigDecimal> output = new ArrayList<>(people);
        for (int i = 0; i < people; i++) {
            // 只有前余数个位置多一分,保证合计守恒。
            BigInteger value = parts[0].add(i < extra ? BigInteger.ONE : BigInteger.ZERO);
            output.add(new BigDecimal(value,2));
        }
        return output;
    }
    static void check(String text, int n, String... expected) {
        List<BigDecimal> actual = split(text,n);
        List<String> rendered = actual.stream().map(BigDecimal::toPlainString).toList();
        if (!rendered.equals(List.of(expected))) throw new AssertionError(rendered);
        BigDecimal sum = actual.stream().reduce(BigDecimal.ZERO,BigDecimal::add);
        if (sum.compareTo(new BigDecimal(text)) != 0) throw new AssertionError("sum");
    }
    public static void main(String[] args) {
        // 普通余数、零、少于人数的分数与超 long 金额。
        check("10.00",3,"3.34","3.33","3.33");
        check("0",2,"0.00","0.00");
        check("0.01",3,"0.01","0.00","0.00");
        check("100000000000000000000.00",2,
                "50000000000000000000.00","50000000000000000000.00");
        boolean precisionRejected = false;
        try { split("1.001",2); } catch (ArithmeticException e) { precisionRejected = true; }
        if (!precisionRejected) throw new AssertionError("scale");
        boolean negativeRejected = false;
        try { split("-1",2); } catch (IllegalArgumentException e) { negativeRejected = true; }
        if (!negativeRejected) throw new AssertionError("negative");
        System.out.println("PASS 6");
    }
}

这些特殊情况也要试一试

  • 零金额仍返回指定人数的零份额。
  • 总分小于人数时部分人得到零。
  • 多余非零小数位拒绝,尾零允许。
  • 大额使用 BigInteger,不发生 long 截断。
进阶选读 · 可选深入:按权重做最大余数分摊

场景参与者权重不同,但总额仍必须守恒。

做法先按精确比例计算每人的整数下取份额,再按余数大小和稳定平价规则分配剩余单位,保留分配明细。

复杂度通常 O(n log n) 用于余数排序,额外成本取决于权重数位。

好处与代价规则能守恒但公平性定义需业务确认,不能把数学方法替代合同或会计规则。

05项目里怎么用

金额跨数据库、Java 和客户端传递时,币种、单位和写法要统一。检查数据库 DECIMAL 的精度与小数尺度、Java BigDecimal 和页面格式是否对应,防止中间被截断或变成二进制浮点。接口要说明用字符串还是数值,并用大数、带尾零金额做来回转换检查。

舍入位置比舍入模式同样重要。逐行计算税费再合计,与先合计再计算可能得到不同末位;两者都可能符合某种业务规则,但必须选定并保持一致。保存原始金额、比例、舍入策略与分摊差额,有助于解释账单,不能只留下最终数字让后续无法复核。

输入资源限制与错误反馈应清晰。人数上限控制输出数量,金额字符串长度控制大整数成本,币种决定最小结算单位。生产退款、汇率和税务规则需要独立确认;本篇只是整数单位守恒分摊的技术模型,不宣称一套两位小数策略覆盖所有财务业务。

容易踩的坑

  • 从已经近似的 double 期待恢复原始十进制精度。
  • 混淆 scale 与 precision。
  • 逐份四舍五入后忽略合计差额。
  • 认为 BigDecimal 任意精度就无需输入规模限制。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
整数最小单位守恒和范围推理直接需要固定单位并处理溢出或大整数结算与确定粒度分摊
BigDecimal十进制表达与舍入规则明确需要维护尺度、精度和币种契约价格、比例与金额计算
二进制浮点适合近似数值计算不能精确表示所有十进制小数允许误差的测量或科学计算

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为什么一除以三的精确 divide 会失败?

展开参考答案与推导

其十进制展开无限,无法以有限 BigDecimal 精确表示。应根据需求指定尺度和舍入或 MathContext,不能把这种失败当作所有除法都应自动补零的理由。

02equals 与 compareTo 为什么可能给不同相等结果?

展开参考答案与推导

BigDecimal.equals 还考虑 scale,而 compareTo 比较数值大小。1.0 与 1.00 因此可在排序上等价而不 equals;作为键前应明确需要表示相等还是数值相等。

03分摊为什么总和一定守恒?

展开参考答案与推导

商余恒等式为总分等于人数乘商再加余数。算法给每人商,并恰好给余数人数各加一分,因此最终合计严格恢复总分,不依赖近似小数舍入。

换个情况,还会处理吗

01三个人各算 10/3 得到 3.33,少的一分怎么处理?

展开参考答案与推导

先定义差额分配规则,例如按稳定参与顺序给首位多一分。不能悄悄丢弃差额或把总额改成9.99;分摊结果与原额守恒应成为明确检查项。

02金额从接口进入后末位变了,先检查哪条链?

展开参考答案与推导

核对输入是否经过 double、数据库字段是否截断、序列化是否改变格式,以及何处执行 setScale。精度损失可能发生在 BigDecimal 创建之前,后续换类型无法自动恢复。

03同一分摊每次首位获额外一分是否公平?

展开参考答案与推导

它是确定的排序规则,不自动保证长期公平。业务可以轮换起点或采用其它明确策略,但应保持可复现和合计守恒,并记录参与者顺序与规则版本。

写代码试一试

01允许精确尾零

输入 / 条件
1.000,2人
输出 / 预期
[0.50,0.50]

提示 UNNECESSARY 只拒绝需要舍入。

展开参考答案与推导

加入对应输出测试。减少尾零不丢失数值,因此可以规范到两位;1.001 则必须改变数值才能落到分,应继续拒绝,区分这两个边界。

02检验任意两份最多差一分

输入 / 条件
10.00,6人
输出 / 预期
最大值减最小值为0.01

提示 根据商与商加一构造。

展开参考答案与推导

对结果求最小与最大,再比较差额不大于0.01,同时核对合计。仅检查差额不足以证明总额正确,两项性质需要同时保持。

03支持零参与人数吗?

输入 / 条件
金额10.00,人数0
输出 / 预期
拒绝

提示 不能进行零除法或返回貌似成功结果。

展开参考答案与推导

保留人数至少一的前置条件并新增异常断言。返回空列表会使分摊总和变成零,破坏接口承诺;若业务允许无人参与,应由上层单独表示未分配状态。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能按明确规则计算金额,并解释表示差异、舍入时机和比较方式。

本篇重点加练

重点加练舍入规则与金额单位,让计算、传输和存储使用同一业务合同。

  • 除法与舍入模式
  • 币种单位和存储契约

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01排错实验

    分别从字符串与 double 构造金额 0.1,执行加法并比较保存与忽略返回值两种调用;记录精确字符串表示,不以格式化后显示相同结束分析。

    提交内容
    Java 实验、自写结果记录与解释。
    怎样算完成
    • 构造来源明确
    • 不可变返回结果的使用可检验

    练到的知识点二进制浮点近似 · 字符串构造 BigDecimal · 不可变运算结果

  2. 作业 02原理问答

    金额输入 12.3400、0.005 和 1/3,分别讨论 scale、precision 与需要舍入的时点;定义支付接口允许的单位、位数和拒绝条件。

    提交内容
    数值属性表与金额合同。
    怎样算完成
    • 存储精度与显示位数分开
    • 非终止除法政策明确

    练到的知识点scale 与精度 · 除法与舍入模式

综合应用

  1. 作业 03代码推演

    订单含三项相同的 0.335 元服务费。分别为逐项计费和总额计费写出运算流程,再比较 equals 与 compareTo 在金额对账中承担的判定任务。

    提交内容
    流程、舍入合同与比较测试。
    怎样算完成
    • 先给业务政策再计算
    • 数值等价与表示等价分开

    练到的知识点逐项和最终舍入 · equals 与 compareTo

  2. 作业 04编程实践

    实现只支持 CNY 的账单合计器,输入十进制字符串单价与整数数量,输出最小货币单位的整数;明确负数、超过两位小数和溢出政策。

    提交内容
    Java 实现、自写空订单/边界/非法金额测试及运行证据。
    怎样算完成
    • 单位在接口中可见
    • 转换与舍入可审计
    • 不经 double 中转

    练到的知识点字符串构造 BigDecimal · 不可变运算结果 · 除法与舍入模式 · 币种单位和存储契约

  3. 作业 05面试场景

    支付服务与数据库金额看似一致,但去重集合把两条对账记录分开。设计同时检查币种、数值、scale 和键合同的排查过程。

    提交内容
    证据表、样本构造和修复验收条件。
    怎样算完成
    • 不把金额相等简化为显示相同
    • 跨系统数值合同明确

    练到的知识点逐项和最终舍入 · equals 与 compareTo · 币种单位和存储契约

  4. 作业 06编程实践

    实现汇率换算练习器,输入原币金额、汇率和目标币最小单位位数;汇率仅用固定样本,不联网,明确舍入模式、非法汇率与金额上限。

    提交内容
    Java 实现、自写整除/舍入边界/非法输入测试与运行证据。
    怎样算完成
    • 币种单位和位数显式
    • 结果政策先于实现声明

    练到的知识点除法与舍入模式 · 币种单位和存储契约

  5. 作业 07设计评审

    接口金额字段从“元字符串”改为“分整数”,客户端存在多个版本。设计请求响应与数据库转换的版本边界,列出避免百倍误差的验收。

    提交内容
    单位流图、版本合同与样本。
    怎样算完成
    • 每次换单位都有可追踪位置
    • 历史数据解释不被静默改变

    练到的知识点币种单位和存储契约

  6. 作业 08代码推演

    合同规定税额在最终步骤舍入,但实现中某个中间商先保留两位小数。构造三组小数输入,逐步写出精确运算与舍入时点进行比较。

    提交内容
    运算链及独立核对记录。
    怎样算完成
    • 比较只改变舍入时点
    • 结果符合哪份合同可明确判定

    练到的知识点除法与舍入模式

进阶挑战 · 掌握前置后选做

  1. 作业 09编程实践

    完成集合后:把总折扣分配到多条订单行,要求分配之和符合总额合同且每行不产生负实付;自行制定平价规则,输入含零额行与极小总折扣。

    提交内容
    Java 实现、自写守恒/边界/顺序测试及运行证据。
    怎样算完成
    • 舍入余量有业务规则
    • 总量与逐行约束均验证
    • 明确算法成本

    练到的知识点除法与舍入模式 · 逐项和最终舍入 · 币种单位和存储契约

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    账单新增按重量计价和支持不同最小货币单位的币种。审查固定保留两位小数的接口设计,提出所需的类型信息和通用验收规则。

    提交内容
    计价模型、币种合同和测试矩阵。
    怎样算完成
    • 数量精度与货币精度分开
    • 规则不能只从显示格式推断

    练到的知识点除法与舍入模式 · 币种单位和存储契约

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗