BigDecimal精度丢失?
我们知道在计算金额的时候要用BigDecimal来处理,但是如果使用不当也会出现精度的问题,先来看一段代码:
@Test public void test1() { BigDecimal n1 = new BigDecimal("0.05"); BigDecimal n2 = new BigDecimal("0.01"); BigDecimal add = n1.add(n2); System.err.println(add.doubleValue()); BigDecimal n3 = new BigDecimal(0.05); BigDecimal n4 = new BigDecimal(0.01); BigDecimal add1 = n3.add(n4); System.err.println(add1.doubleValue()); }
根据大家的理解,先猜猜运行的结果会是什么样的呢?
0.06 0.060000000000000005
不知道与你预测的结果是不是一样呢?下面来说明一下为什么会出现这种情况。
一、产生精度丢失的原因
问题的原因就是第一个是采用String的构造方法来创建的BigDecimal对象,第二个是通过double的构造函数来创建的BigDecimal对象,那问题会在这里吗?我们看一下double的构造方法里面会不会有一些线索:
/** * Translates a {@code double} into a {@code BigDecimal} which * is the exact decimal representation of the {@code double}'s * binary floating-point value. The scale of the returned * {@code BigDecimal} is the smallest value such that * <tt>(10<sup>scale</sup> × val)</tt> is an integer. * <p> * <b>Notes:</b> * <ol> * <li> * The results of this constructor can be somewhat unpredictable. * One might assume that writing {@code new BigDecimal(0.1)} in * Java creates a {@code BigDecimal} which is exactly equal to * 0.1 (an unscaled value of 1, with a scale of 1), but it is * actually equal to * 0.1000000000000000055511151231257827021181583404541015625. * This is because 0.1 cannot be represented exactly as a * {@code double} (or, for that matter, as a binary fraction of * any finite length). Thus, the value that is being passed * <i>in</i> to the constructor is not exactly equal to 0.1, * appearances notwithstanding. * * <li> * The {@code String} constructor, on the other hand, is * perfectly predictable: writing {@code new BigDecimal("0.1")} * creates a {@code BigDecimal} which is <i>exactly</i> equal to * 0.1, as one would expect. Therefore, it is generally * recommended that the {@linkplain #BigDecimal(String) * <tt>String</tt> constructor} be used in preference to this one. * * <li> * When a {@code double} must be used as a source for a * {@code BigDecimal}, note that this constructor provides an * exact conversion; it does not give the same result as * converting the {@code double} to a {@code String} using the * {@link Double#toString(double)} method and then using the * {@link #BigDecimal(String)} constructor. To get that result, * use the {@code static} {@link #valueOf(double)} method. * </ol> * * @param val {@code double} value to be converted to * {@code BigDecimal}. * @throws NumberFormatException if {@code val} is infinite or NaN. */ public BigDecimal(double val) { this(val,MathContext.UNLIMITED); }
这里我们通过方法的注解可以看到,官方已经提示了如果直接通过double的这个构造函数来创建BigDecimal会出现不可预测的结果。再跟进看一下:
public BigDecimal(double val, MathContext mc) { if (Double.isInfinite(val) || Double.isNaN(val)) throw new NumberFormatException("Infinite or NaN"); // Translate the double into sign, exponent and significand, according // to the formulae in JLS, Section 20.10.22. long valBits = Double.doubleToLongBits(val); int sign = ((valBits >> 63) == 0 ? 1 : -1); int exponent = (int) ((valBits >> 52) & 0x7ffL); long significand = (exponent == 0 ? (valBits & ((1L << 52) - 1)) << 1 : (valBits & ((1L << 52) - 1)) | (1L << 52)); exponent -= 1075;
可以看到首先将double值转换成Long,而精度丢失也就是在这里发生的。因为我们知道任何数字在计算机中都是用二进制来存储的,但是我们通过0.1这样一个十进制的double的数值来创建对象,计算机会先将其转化为二进制的数据,但是在十进制转换成二进制的过程中,有一些十进制的数是无法使用一个有限的二进制数来表达的,然后这里只取了前64位的二进制,而后面没有取尽的二进制就被舍弃了,所以就出现了精度丢失的问题。
这也就是为什么我们通过0.1这个double值来创建一个BigDecimal对象,但是实际创建的却是一个 0.1000000000000000055511151231257827021181583404541015625 的值。所以说我们传进去的值就不是一个精确的值,那最终计算出来的结果也就一定不是我们期望的精度值了。
注释的最后建议我们如果要想使用double来创建BiDecimal的时候,也可以使用静态方法valueOf
BigDecimal n1 = new BigDecimal("0.05");
等价于
BigDecimal n2 = BigDecimal.valueOf(0.05);
这样都是不会产生精度问题的。
二、为什么十进制的double无法转成有限的二进制
通过上面的分析我们知道,产生精度丢失是因为十进制的double值无法转换成有限的二进制导致的,那double是怎么转换成二进制的呢?为什么会取不尽呢?下面我们来看一下double的小数怎么计算它的二进制:
这里用0.4来举例吧:
- 首先是 0.4 * 2 = 0.8 , 然后把0.8进行int强制类型转换: (int)0.8 = 0
- 用上一步的结果0.8减去强转int的值:(0.8 - 0)* 2 = 1.6 ,再次进行强转int : (int) 1.6 = 1
- 重复上一步的操作:(1.6 -1)* 2 = 1.2 , (int)1.2 = 1
- (1.2 -1) * 2 = 0.4 , (int)0.4 = 0
- (0.4 - 0) * 2 = 0.8 , (int)0.8 = 0
- (0.8 - 0) * 2 = 1.6 , (int)1.6 = 1
通过上面的循环可以看出永远也无法取尽 ,而最终0.4的二进制会是 : 01001.......
正因为这里无法取尽,但是在BigDecimal的时候只取了Long类型的前64位,所有采用double作为参数的构造方法创建的BigDecimal对象就这样产生了精度丢失的问题。
三、为什么String构造参数的对象没有精度丢失问题
首先我们先看一段代码,通过这段代码来进行说明:
@Test public void test4() { BigDecimal n1 = new BigDecimal("0.2"); BigDecimal n2 = new BigDecimal("123.4567"); BigDecimal add = n2.add(n1); System.err.println(add.toPlainString()); }
通过 Debug 来看一下创建之后的对象里面的这些成员变量都代表什么:
- Scale : 如果为零或正,表示小数点右边的位数。如果是负数,则该数的未缩放值乘以10的负号次方。例如,-3表示未缩放的值乘以1000。
- Precision : 精度是未缩放值中的位数。例如,对于数字123.45,返回的精度是5。
- stringCache : 就是原始的String类型的数值
- intCompact :就是去掉小数点之后的值
接下来我们看add方法的内存是怎么操作的:

这里我们看先是要计算出两个scale的差值,通过本例可以看到差值是3 ,但是计算出来这个3要做什么呢?我们继续看这个longMutiplyPowerTen的方法:

这里可以看到差值raise是3,对应在 LONG_TEN_POWERS_TABLE 这个常量数组中的是1000 ,最后返回的值是 2 * 1000 = 2000,将2扩大了1000倍

接下来就比较清晰了,实际上是用1234567来加上2000来进行计算,然后取高的scale进行最后精度的展示。
这里回答一下本小节的问题:为什么说用String构造创建的对象在进行计算的时候没有出现精度的问题,就是因为实际上在计算的时候是把数值先转化成long类型,然后再把两个long类型的数据进行计算,这里有的同学可能就会问了,那如果两个long进行乘法或者是加法的运算之后超过了long的取值范围了呢?好问题!
这里我的示例没有覆盖到全部的场景,只是为了说明基本原理,大家有兴趣的可以看一下BigDecimal的源码,在处理可能超过long范围的数值的时候,是采用BigInteger来进行处理。而我们知道BigInteger类可以处理包含任意长度数字序列的数值可以进行任意精度的整数运算。
四、浮点数在计算机中是怎么存储的呢?
在Java语言中浮点数包括float和double两种,float是单精度的,在内存中占用4个字节,double是双精度的,在内存中占用8个字节。但是小数点怎么来存呢?IEEE给了出相关规范,在内存中是采用一种科学记数法来存储浮点数的:用符号、指数和尾数来表示一个浮点数。先看一个表格,在下面都会用到:
| 类型 | 符号位 | 指数 | 尾数 | 长度 | 偏移附加值 |
| float | 1 | 8 | 23 | 32 | 127 |
| double | 1 | 11 | 52 | 64 | 1023 |
- 符号位:因为只有1位,所以0-表示整数 ,1-表示负数
- 指数:用指数部分的值减去 偏移附加值 得到该数实际的指数
- 尾数:有效位数
- 偏移附加值:IEEE754标准规定的附加值
因为double和float的存储原理是一样的,只不过double的位数比较多,所以相应的精度也更大一些,所以这里拿float举例来说明:
比如我们取13.75来计算一下在内存中的存储是什么样的,首先把浮点数换算成二进制:
整数部分:13 ,通过模2取余法 ,得到:1101
小数部分:0.75 ,得到:11
所以13.75最终的二进制表示为:1101.11
这里我们将1101.11向左移动,直到小数点前只剩下一位,1.10111 * 23 , 向左移动了3位
这样就能够清晰的看到:
底数:因为小数点前面只保留一位(那最前面一位只能是1),所以IEEE规定只记录小数点后面的就好,那么这里底数也就是 :1 0111
指数:实际是3(因为移动了3位),但是因为float的偏移附加值是127 ,在转换成十进制的时候需要减去,所以这里我们要加上127(double就加上1023),所以实际指数为130,对应的二进制为:1000 0010
符号位:因为13.75是正数,所以这里符号位为0
那么最终可以知道,13.75的float类型的浮点数,在内存中的存储格式为:0 1000 0010 1 0111 0000 0000 0000 0000 00
五、既然精度会丢失,那BigDecimal最终会打印出来多少位呢?
这里我们首先看一段代码:
BigDecimal n3 = new BigDecimal(0.05); System.err.println(n3.toPlainString()); BigDecimal n4 = new BigDecimal(0.01); System.err.println(n4.toPlainString());
运行之后会在控制台看到如下的结果:
0.05000000000000000277555756156289135105907917022705078125 0.01000000000000000020816681711721685132943093776702880859375
这里思考一个问题,既然我们通过double的构造参数来创建BigDecimal对象的时候,会出现精度丢失,那这里为什么输出的结果位数也是不同的呢?
为了说明这个问题我们首先说清楚一个问题,就是上面提到的偏移量是怎么算出来的,为什么要有偏移量?
首先我们知道偏移量是出现在指数部分的,这里我们用float来举例说明,在float中是用8bit来表示指数,但是我们知道指数是分正负的,那么就需要这8bit中有符号位置。所以8bit的取值范围就应该是 1111 1111 ~ 0111 1111 ,换算成十进制就是 -127~+127,但是在这里里面有一个数比较特殊,就是1111 1111 ,这个值被用来表示正负无穷大和NaN无效数,所以就需要在原来的取值范围内将这个数去掉,那么原来的8bit的取值范围就变成了 1111 1110 ~ 0111 1111 ,如果第一位我们算做符号位的话,那就是 -126 ~ +127。这里还有一个特殊的边界值就是 0000 0000 ,这里的第一位是我们人为用于区分正负的,但是实际上如果不用来区分正负8bit的取值范围应该是: 0000 0000 ~ 1111 1111 ,换算成十进制就是 0~255,那这里同样去掉两个边界值就是1~254 。那么还是考虑到指数是要区分正负的,而把第一位看作符号位的时候右边界的值127在254的范围之内,那就需要将254的右边界向左移动127位,所以最终的偏移量就是127。这也就是float偏移量的缘由了,同样double的也是这么计算出来的,就不再推理了。
知道了浮点数存储的偏移量之后,我们来看一下BigDecimal中的源码,来找一下为什么0.05 和 0.01最终输出的精度是不同的。

这里面我们知道exponent表示的是当前double数值的指数,前面我们也提到了double的偏移量是1023,那么这里想要还原出原始的指数,就应该用现在的1018 - 1023 ,但是这里却是1075 ?
这又是为什么呢?
前面我们举例子来算过指数,应该还记得我们在最后计算指数的时候,最前面是保留了一位1,也就是小数点前面的一位数字其实是没有存储在significand中的,那么这里要还原的时候,就需要把这个数字还原出来。所以我们看这段代码:
long significand = (exponent == 0 ? (valBits & ((1L << 52) - 1)) << 1 : (valBits & ((1L << 52) - 1)) | (1L << 52));
这里exponent(指数)如果是0 ,那么就在右边补0,否则就在左边补1,那么实际的尾数部分就应该是下面这种情况:
1 1111 11111111 11111111 11111111 11111111 11111111 11111110
或者
1 1111 11111111 11111111 11111111 11111111 11111111 11111111
这里在最左边一位的后面我们知道应该是有一个小数点的,但是我们希望位数保存的是一个整数(理论上我们需要在目前的基础上再左移动 252 才行),我们只需在科学计数法中将指数部分再减去52,significand存储的数据就可以看做是整数了。
所以说源码中是一共减去了(1023 + 52) = 1075。
继续看代码,当前是计算的0.05的情况:

这里我们可以看到scale精度字段是在这里进行赋值为56,最后进行数据展示的时候我们看到小数点后面的精度就是56位的。
那么我们再看一下0.01的情况:

这里可以看到是59,所以这就能看出来为什么同样是通过double构造函数来创建的BigDecimal对象,但是最终展示的精度是不同的。

浙公网安备 33010602011771号