读完这篇,你能做什么
能按明确规则计算金额,并解释表示差异、舍入时机和比较方式。
学习理由、准备要求与知识点
为什么学它
金额是常见后端数据,精度、舍入和比较规则必须在接口与存储间一致。
先读这些内容
知识点清单
- 二进制浮点近似
- 字符串构造
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按顺序看,每一步做了什么
- 确定币种、单位与最小粒度。
- 从精确十进制来源构造值。
- 明确尺度、精度与舍入位置。
- 用整数最小单位设计守恒分摊。
- 测试中点、零、负数和超大数值。
03对照实现代码
Microsoft OpenJDK 17.0.20.1;java.base/java/math/BigDecimal.java · java.math.BigDecimal#divideAndRemainder(BigDecimal)
以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。
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 路径和各种舍入优化;性能结论不能从这几行调用数直接得出。
代码先调用谁,再调用谁
divideAndRemainder请求整数商divideToIntegralValue计算向零整数部分- 商乘除数得到已分配部分
- 原值减去已分配部分返回余数
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。
只做一次商余运算,随后创建 n 个金额对象;任意精度算术按数位收费,不能把每次操作视为与输入长度无关的常数。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
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 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能按明确规则计算金额,并解释表示差异、舍入时机和比较方式。
本篇重点加练
重点加练舍入规则与金额单位,让计算、传输和存储使用同一业务合同。
- 除法与舍入模式
- 币种单位和存储契约
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01排错实验
分别从字符串与
double构造金额 0.1,执行加法并比较保存与忽略返回值两种调用;记录精确字符串表示,不以格式化后显示相同结束分析。- 提交内容
- Java 实验、自写结果记录与解释。
- 怎样算完成
- 构造来源明确
- 不可变返回结果的使用可检验
练到的知识点二进制浮点近似 · 字符串构造 BigDecimal · 不可变运算结果
- 作业 02原理问答
金额输入 12.3400、0.005 和 1
/3,分别讨论 scale、precision 与需要舍入的时点;定义支付接口允许的单位、位数和拒绝条件。- 提交内容
- 数值属性表与金额合同。
- 怎样算完成
- 存储精度与显示位数分开
- 非终止除法政策明确
练到的知识点scale 与精度 · 除法与舍入模式
综合应用
- 作业 03代码推演
订单含三项相同的 0.335 元服务费。分别为逐项计费和总额计费写出运算流程,再比较 equals 与
compareTo在金额对账中承担的判定任务。- 提交内容
- 流程、舍入合同与比较测试。
- 怎样算完成
- 先给业务政策再计算
- 数值等价与表示等价分开
练到的知识点逐项和最终舍入 · equals 与 compareTo
- 作业 04编程实践
实现只支持 CNY 的账单合计器,输入十进制字符串单价与整数数量,输出最小货币单位的整数;明确负数、超过两位小数和溢出政策。
- 提交内容
- Java 实现、自写空订单/边界/非法金额测试及运行证据。
- 怎样算完成
- 单位在接口中可见
- 转换与舍入可审计
- 不经
double中转
练到的知识点字符串构造 BigDecimal · 不可变运算结果 · 除法与舍入模式 · 币种单位和存储契约
- 作业 05面试场景
支付服务与数据库金额看似一致,但去重集合把两条对账记录分开。设计同时检查币种、数值、scale 和键合同的排查过程。
- 提交内容
- 证据表、样本构造和修复验收条件。
- 怎样算完成
- 不把金额相等简化为显示相同
- 跨系统数值合同明确
练到的知识点逐项和最终舍入 · equals 与 compareTo · 币种单位和存储契约
- 作业 06编程实践
实现汇率换算练习器,输入原币金额、汇率和目标币最小单位位数;汇率仅用固定样本,不联网,明确舍入模式、非法汇率与金额上限。
- 提交内容
- Java 实现、自写整除/舍入边界/非法输入测试与运行证据。
- 怎样算完成
- 币种单位和位数显式
- 结果政策先于实现声明
练到的知识点除法与舍入模式 · 币种单位和存储契约
- 作业 07设计评审
接口金额字段从“元字符串”改为“分整数”,客户端存在多个版本。设计请求响应与数据库转换的版本边界,列出避免百倍误差的验收。
- 提交内容
- 单位流图、版本合同与样本。
- 怎样算完成
- 每次换单位都有可追踪位置
- 历史数据解释不被静默改变
练到的知识点币种单位和存储契约
- 作业 08代码推演
合同规定税额在最终步骤舍入,但实现中某个中间商先保留两位小数。构造三组小数输入,逐步写出精确运算与舍入时点进行比较。
- 提交内容
- 运算链及独立核对记录。
- 怎样算完成
- 比较只改变舍入时点
- 结果符合哪份合同可明确判定
练到的知识点除法与舍入模式
进阶挑战 · 掌握前置后选做
- 作业 09编程实践
完成集合后:把总折扣分配到多条订单行,要求分配之和符合总额合同且每行不产生负实付;自行制定平价规则,输入含零额行与极小总折扣。
- 提交内容
- Java 实现、自写守恒/边界/顺序测试及运行证据。
- 怎样算完成
- 舍入余量有业务规则
- 总量与逐行约束均验证
- 明确算法成本
练到的知识点除法与舍入模式 · 逐项和最终舍入 · 币种单位和存储契约
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 条件变化
账单新增按重量计价和支持不同最小货币单位的币种。审查固定保留两位小数的接口设计,提出所需的类型信息和通用验收规则。
- 提交内容
- 计价模型、币种合同和测试矩阵。
- 怎样算完成
- 数量精度与货币精度分开
- 规则不能只从显示格式推断
练到的知识点除法与舍入模式 · 币种单位和存储契约
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
Java 17 API:BigDecimal (新标签页)↗
重点阅读字符串构造、scale、divide 舍入及 equals
/compareTo差别。 - 实践参考文档英文
Java 17 API:RoundingMode (新标签页)↗
比较
HALF_UP、HALF_EVEN等舍入方向,用案例明确业务规则。 - 深入规范英文
Java 17 语言规范:转换与上下文 (新标签页)↗
查阅数值提升、强制转换、装箱拆箱以及方法调用时的转换规则。
- 深入参考文档英文
OpenJDK 17u java.math.BigDecimal (新标签页)↗
定位
divideAndRemainder使用整数商与乘减恒等式生成余数的实现。 - 深入参考文档英文
Java17 BigInteger API (新标签页)↗
查阅商余运算与精确窄化转换的异常边界。