在Java中处理需要保留固定小数位的数值时,RoundingMode.HALF_EVEN是一个经常被选用的策略。它也叫银行家舍入法,与常见的四舍五入(HALF_UP)不同,HALF_EVEN在舍弃位正好是5且5之后没有非零数字时,会把前一位处理成偶数。比如2.675保留两位小数,理论上应该向2.68舍入,因为7是奇数,进位后变成偶数8。但如果直接用double类型去构造BigDecimal,结果可能大不相同。这正是很多精度问题的起点:规则本身是确定的,但参与运算的数值在进入舍入逻辑之前已经带上了二进制表示误差。

一、HALF_EVEN的规则和在BigDecimal中的表现
HALF_EVEN的完整判断逻辑可以拆成两步。第一步看舍弃部分的最高位,如果小于5,直接舍弃;如果大于5,直接进位。第二步只在舍弃部分的最高位恰好等于5时生效,此时继续观察5之后是否还有非零位。如果有,说明数值实际上超过一半,仍然进位;如果5之后全为零,说明正好卡在中间,这时才执行向偶数靠拢的策略。所谓向偶数靠拢,就是看保留位最后一位是奇数还是偶数,奇数则进位,偶数则舍弃。
这个规则在处理十进制字符串构造的BigDecimal时非常稳定。下面这段代码可以直观看到相同尾数5在不同前一位奇偶性下的表现。
import java.math.BigDecimal;
import java.math.RoundingMode;
BigDecimal value1 = new BigDecimal("2.675");
BigDecimal r1 = value1.setScale(2, RoundingMode.HALF_EVEN);
System.out.println(r1); // 2.68
BigDecimal value2 = new BigDecimal("2.685");
BigDecimal r2 = value2.setScale(2, RoundingMode.HALF_EVEN);
System.out.println(r2); // 2.68
BigDecimal value3 = new BigDecimal("2.6751");
BigDecimal r3 = value3.setScale(2, RoundingMode.HALF_EVEN);
System.out.println(r3); // 2.68
2.675保留两位时,舍弃位是5,保留位最后一位是7,7是奇数,进位后变成8,于是结果2.68。2.685保留两位时,舍弃位同样是5,但保留位最后一位是8,8已经是偶数,因此不再进位,结果为2.68。2.6751虽然在第三位小数上看还是5,但5后面还有1,实际数值大于2.675,所以直接进位到2.68。这几组例子说明,HALF_EVEN并不是简单的“见5就进”,它对刚好处于中间位置的数做了更精细的处理。
与HALF_UP相比,HALF_EVEN的统计优势在于减少系统性向上偏差。假设有大量数值需要舍入,如果全部都采用HALF_UP,碰到尾数为5的中间值时一律进位,累计结果会偏大。而HALF_EVEN将中间值交替分配给奇数和偶数,整体误差更接近零。这也是银行、金融统计等场景更愿意采用它的原因。但前提是参与舍入的数值必须是精确的十进制数,一旦使用二进制浮点类型作为数据源,规则优势就可能被表示误差覆盖。
二、double带来的二进制误差为什么无法靠HALF_EVEN纠正
Java中的double类型遵循IEEE 754标准,使用二进制科学计数法表示数值。十进制里的很多有限小数,在二进制中会变成无限循环小数。例如0.1在二进制中无法精确表示,只能保存一个接近0.1的值。2.675这个字面量在编译期被转换成最接近的double值,但这个真实存储值并不等于2.675,而是略小一点。换句话说,当我们把2.675赋给一个double变量时,精度的丢失已经发生了。
如果使用new BigDecimal(double)构造BigDecimal,会把这个不精确的二进制值完整展开为十进制,误差就会全部暴露出来。
double d = 2.675; BigDecimal fromDouble = new BigDecimal(d); System.out.println(fromDouble.toPlainString()); // 2.67499999999999982236431605997495353221893310546875 BigDecimal scaled = fromDouble.setScale(2, RoundingMode.HALF_EVEN); System.out.println(scaled); // 2.67
从输出可以看到,double中的2.675实际上是一个很长的小数,它比2.675小了不到一丁点。当调用setScale(2, RoundingMode.HALF_EVEN)时,保留到两位小数,第三位小数的位置上是4而不是5,因此舍入逻辑根本不会触发中间值规则,直接向下舍弃得到2.67。可是业务人员看到的数据源头明明是2.675,自然期望得到2.68。这种误差并不是HALF_EVEN算错了,而是输入值本身已经不是当初的十进制数。
需要特别留意的是BigDecimal.valueOf(double)。它内部使用Double.toString生成字符串,再交给BigDecimal构造器。对于2.675,Double.toString(2.675)会输出最短的能唯一标识该double的字符串“2.675”,因此用BigDecimal.valueOf(2.675)构造出的对象恰好是精确的2.675,再做HALF_EVEN舍入就能得到2.68。但这并不是一个可以依赖的通用安全方案。例如0.1加0.2的结果是0.30000000000000004,BigDecimal.valueOf会忠实地展现这个污染后的数值,已经无法回到0.3。所以valueOf只是缓解展示层问题,不能恢复上游已经发生的精度错误。
三、DecimalFormat与格式化输出中的同类问题
DecimalFormat在进行数字格式化时,默认使用的舍入模式就是HALF_EVEN。如果直接传入double类型进行格式化,同样会受到二进制表示误差的影响。下面这个例子看起来和BigDecimal的案例类似。
import java.text.DecimalFormat;
import java.math.RoundingMode;
DecimalFormat df = new DecimalFormat("#.##");
df.setRoundingMode(RoundingMode.HALF_EVEN);
System.out.println(df.format(2.675)); // 2.67
这里的结果同样是2.67,而不是2.68。原因和前面分析的一样:传入format方法的double已经比2.675略小,格式化器看到的不是十进制意义上的2.675。因此,即便我们把RoundingMode显式设置为HALF_EVEN,也无法改变数据入口处已经发生的偏差。
如果格式化目标本身是BigDecimal,情况会好很多。使用字符串构造出精确的BigDecimal,再调用DecimalFormat.format(Object),或者直接使用BigDecimal.setScale后转字符串,都能避免二进制展开问题。关键在于数据在进入格式化器之前就应当保持十进制精确表示。DecimalFormat只是一个输出工具,它不会替你修正数据源。
四、实用的规避方案与最佳实践
涉及金额、利率、统计数据等对精度敏感的场景,应该从一开始就避免使用double或float作为业务数据载体。推荐的做法是在接收外部输入、解析接口数据、读取数据库字段时,直接使用字符串构造BigDecimal。例如new BigDecimal("2.675")就能精确表示用户输入的十进制值,之后再执行setScale和HALF_EVEN,行为完全可预期。
// 推荐:从字符串构建
BigDecimal amount = new BigDecimal("2.675");
BigDecimal rounded = amount.setScale(2, RoundingMode.HALF_EVEN);
System.out.println(rounded); // 2.68
// 如果必须接收 double,使用 valueOf 可以避免二进制精确展开
double price = 2.675;
BigDecimal fromValueOf = BigDecimal.valueOf(price);
System.out.println(fromValueOf.setScale(2, RoundingMode.HALF_EVEN)); // 2.68
// 但遇到已污染的 double 依然无解
double polluted = 0.1 + 0.2;
System.out.println(BigDecimal.valueOf(polluted).toPlainString()); // 0.30000000000000004
如果某个系统已经使用了double存储金额,最根本的修复方式不是继续追加舍入逻辑,而是在数据入库或接口定义层改为以最小货币单位存储整数。比如金额单位为分,2.68元就存268分。整数在long或int范围内可以精确表示,后续格式化和计算都不需要反复依赖浮点舍入。只有在展示层才把整数换算回带小数的金额。
对于统计汇总场景,如果数据量很大,单条记录已经丢失精度,最终合计误差可能被放大。此时需要区分是业务允许的精度范围,还是必须逐条记录都以十进制精确值保存。使用BigDecimal配合MathContext可以控制运算精度,但MathContext中的精度位数和setScale中的小数位数是两个不同的概念,混用容易造成新的误解。比如new MathContext(3)表示总共保留3位有效数字,而不是保留3位小数,这和常见的业务需求并不一致。
最后还要注意,HALF_EVEN的行为只有在十进制值恰好处于两个候选值的正中间时才显得特殊。实际开发中,很多隐藏精度问题并不是HALF_EVEN本身引起的,而是浮点类型让本来不是中间值的数变成了中间值,或者让本来应该是中间值的数偏离了中间值。排查这类问题时,先打印出舍入前的实际数值,比反复检查舍入模式更有效。
Java浮点数HALF_EVEN舍入精度陷阱修改时间:2026-09-28 15:24:08