因浮点精度损失了1分钱
一、问题描述
产品反馈与商家对账时,有单运费金额对不上:本该是10块2,可商家侧收到的是10块1毛9,莫名少了一分钱。然后紧急根据单号查询日志信息。
本场景是消费一个MQ,把消息通过数据转换文件转换为商家侧数据格式,然后推送给商家。其中出问题的字段,仅做了一个逻辑,金额单位由元转分,类似如下:
1 | <set var="fenVar" expr="${yuanVar * 100}" class="long"/> |
通过日志观察此单的yuanVar为10.2,而数据转换结果就变为了1019。第一时间反应是精度损失导致的问题,于是直接用我们的EL表达式工具进行测试,结果如下。el表达式计算${10.2 * 100}后结果为1019.9999999999999。

二、解决方案
自定义一个函数如下:
1 |
|
然后数据转换文件中通过该函数对金额进行转换。
1 | <set var="fenVar" expr="${fn:yuanToFen(yuanVar)}" class="long"/> |
三、溯因
1. 探究EL表达式相关逻辑
假设当前数据转换定义如下:
1 | <set var="test" expr="${10.2 * 100}" class="long"/> |
则数据转换引擎最终会调用如下等效代码进行计算:
1 | public <T> T evalExpr(String expr, Class<T> clazz) { |
通过断点调试关注以下两个逻辑:
ø 浮点计算的过程及结果
ø 最终类型转换的过程及结果
1)浮点计算过程
EL表达式子首先调用AstBinary的MUL进行乘法运算。
1 | public class AstBinary extends AstRightValue { |
接下来看NumberOperations#mul方法:
1 | public static final Number mul(TypeConverter converter, Object o1, Object o2) { |
2)浮点计算结果
此方法返回值类型为Double,值为1019.9999999999999。
3)类型转换过程
由于数据转换指定结果类型为long,故其会调用的TypeConverterImpl#coerceToLong方法。
1 | protected Long coerceToLong(Object value) { |
由于当前value为Double类型,执行Long.valueOf(((Number)value).longValue());。
1 | public final class Double extends Number implements Comparable<Double> { |
4)类型转换结果
Double#longValue会根据Narrowing Primitive Conversions(原生类型窄化约束)把1019.9999999999999转为1019。可见精度损失是发生在el表达式计算的结果类型转换。
NOTE:
AstBinary,顾名思义是抽象语法树二目运算类,其包含了加减乘除等二目运算操作,如下图。

2. 类型窄化—浮点转Long
以下摘自:https://docs.oracle.com/javase/specs/jls/se7/html/jls-5.html#jls-5.1.3
A narrowing primitive conversion may lose information about the overall magnitude of a numeric value and may also lose precision and range.
A narrowing conversion of a floating-point number to an integral type T takes two steps:
- In the first step, the floating-point number is converted either to a
long, if T islong, or to anint, if T isbyte,short,char, orint, as follows:
- If the floating-point number is NaN (§4.2.3), the result of the first step of the conversion is an
intorlong0.- Otherwise, if the floating-point number is not an infinity, the floating-point value is rounded to an integer value
V, rounding toward zero using IEEE 754 round-toward-zero mode (§4.2.3). Then there are two cases:
- If T is
long, and this integer value can be represented as along, then the result of the first step is thelongvalueV.- Otherwise, if this integer value can be represented as an
int, then the result of the first step is theintvalueV.- Otherwise, one of the following two cases must be true:
- The value must be too small (a negative value of large magnitude or negative infinity), and the result of the first step is the smallest representable value of type
intorlong.- The value must be too large (a positive value of large magnitude or positive infinity), and the result of the first step is the largest representable value of type
intorlong.- In the second step:
- If T is
intorlong, the result of the conversion is the result of the first step.- If T is
byte,char, orshort, the result of the conversion is the result of a narrowing conversion to type T (§5.1.3) of the result of the first step.
由以上可知,当浮点转long时会执行以下步骤:
1)如果浮点时NaN,则转为0;
1 | Fload.NaN ==》 0 |
2)如果浮点是无限类型,则进行如下转换:
1 | Float.NEGATIVE_INFINITY ==》 Long.MIN_VALUE |
3)其他情况,使用 IEEE 754 向零舍入模式把浮点数舍入为Long。
3. IEEE 754及向零舍入模式
1)IEEE 754浮点表示

由于计算机是机遇二进制的所以制定了IEEE 754这种浮点存储格式。使用IEEE 754的二进制表示的数必定是离散的,其无法与十进制一一对应,有时只能近似表达一个10进制数,这之间的差距称为精度损失。
例如:十进制0.2转换为二进制,执行以下操作
0.2 * 2 = 0.4,取整数0
0.4 * 2 = 0.8,取整数0
0.8 * 2 = 1.6,取整数1
0.6 * 2 = 1.2,取整数1
0.2 * 2 = 0.4,取整数0
以下会无穷重复上述步骤。
10进制0.2的2进制表示为:0.0011 0011 0011 0011 ...。在IEEE 754中尾数长度是有限的,则必然造成精度损失。
也就意味着十进制的0.2经过IEEE 754存储后,再转回十进制就会变为:0.199999999...
2)IEEE 754向零舍入
所谓的向零舍入就是简单的截断小数后面值。1019.9999999就被截断为1019。
四、使用BigDecimal
1. 浮点精度损失案例
1 | public static void main(String[] args) { |
1 | 执行结果: |
2. BigDecimal及其精度
1)八种舍入模式
ø BigDecimal.ROUND_CEILING

ø BigDecimal.ROUND_FLOOR

ø BigDecimal.ROUND_DOWN

ø BigDecimal.ROUND_UP

ø BigDecimal.ROUND_HALF_UP
BigDecimal.ROUND_UP的限制版,当丢弃的分数>= 0.5时,进行UP,否则DOWN;即十进制的四舍五入。
ø BigDecimal.ROUND_HALF_DOWN
BigDecimal.ROUND_UP的限制版,当丢弃的分数>0.5时,进行UP,否则DOWN;
ø BigDecimal.ROUND_HALF_EVEN
this is the rounding mode that statistically minimizes cumulative error when applied repeatedly over a sequence of calculations. It is sometimes known as “Banker’s rounding,” and is chiefly used in the USA。
当在一系列计算中重复应用时,该舍入模式可以在统计上最小化累积误差。 它有时被称为“银行家的四舍五入”,主要用于美国。
BigDecimal.ROUND_UP的限制版,当丢弃的分数的左侧是奇数时,表现同BigDecimal.ROUND_HALF_UP;否则,表现同BigDecimal.ROUND_HALF_DOWN。
简而言之,主要是对舍弃的分数是0.5时,舍入结果需要是一个偶数。示例如下。
1 | 5.5 -> 6 |
ø BigDecimal.ROUND_UNNECESSARY
不需要舍入,发生舍入时,会抛出异常throw new ArithmeticException("Rounding necessary");
2)BigDecimal使用
ø 优先使用字符串入参构造函数
1 | BigDecimal d = new BigDecimal("1.2"); |
ø 对运算结果设置合适精度
1 | public long mul(float a, float b, int scale) { |
五、结论
1)在进行浮点计算时,要评估结果的精度。尤其是金额场景计算时,一定要有精度敏感。
2)在EDI里使用BigDecimal增加一个内置全局函数,提升开发效率;
3)在EDI的开发工具里考虑增加提示,当运算涉及浮点时提示是否需要关注精度问题;