在进行大数运算时,遇到了运算精度不够的问题,稍作了一下整理
0.8-0.7 != 0.7-0.6原因 二进制表示十进制数造成的精度缺失,从而导致计算出现误差;至于精度缺失的原因就要从二进制如何表示小数说起:
十进制小数转化二进制
乘2至无小数
举个例子
| 初始值(取整) | 变换后(x2) | 二进制部分 |
|---|---|---|
| 0.8 | 1.6 | 0.1 |
| 0.6 | 1.2 | 0.11 |
| 0.2 | 0.4 | 0.110 |
| 0.4 | 0.8 | 0.110 |
| 死循环 |
找到了原因,接下来该解决问题:
- 首先想到的就是放大数据,用整数来表示小数,就不会有精度缺失了,
- 实际上Java本身就提供了
BigDecimal类来处理大数运算,我们可以将数值转换为BigDecimal对象,调用它的方法进行高精度运算
BigDecimal源码
- 首先看一下它存储了哪些数据,从中可以猜测该类也是通过,缩放数值来实现精确运算的;
private final BigInteger intVal;// 未缩放的值(即真实值)
private final int scale;// 缩放比例 真实值 = 整数化的值 / (10^scale)
private transient int precision;//数值的位数
private final transient long intCompact;//数值(如果数值大于Long.MAX_VALUE,值为Long.MIN_VALUE)
- 新建一个
BigDecimal对象,构造方法可以传入多种类型的数据,一般建议传入string类型的值来防止数据精度的损失,尤其是public BigDecimal(double val)源码中有一段注释it is generally recommended that the {@linkplain #BigDecimal(String)<tt>String</tt> constructor} be used in preference to this one大致意思是建议使用string类型参数代替double类型参数,原因显然是double类型带来的精度丢失 - 接下来研究下构造函数,
BigDecimal(String val)最终调用的方法是BigDecimal(str.toCharArray(),0,val.length(),MathContext.UNLIMITED),以下是我对改构造函数的简化
public void BigDecimal(char[] in, int offset, int len, MathContext mc) {
int prec = 0;//数值位数(不计数值前面的0)
int scl = 0;//放大比例 真实值 = 记录的数值
long rs = 0;//数值
BigInteger rb = null;//传入数值长度超过18,会使用改对象存储数据
// 取符号位
boolean isneg = false; // assume positive
if (in[offset] == '-') {
isneg = true; // leading minus means negative
offset++;
len--;
} else if (in[offset] == '+') { // leading + allowed
offset++;
len--;
}
// 取数值
boolean dot = false;//是否到小数部分
long exp = 0;//指数
char c;
boolean isCompact = len <= 18;
int idx = 0;
if (isCompact) {// 有足够的空间计算
for (; len > 0; offset++, len--) {
c = in[offset];
if ((c == '0')) {
if (prec == 0) prec = 1;
else if (rs != 0) {
rs *= 10;
++prec;
}
if (dot) ++scl;//记录小数位数
} else if (c >= '1' && c <= '9') {
int digit = c - '0';
if (prec != 1 || rs != 0) ++prec;
rs = rs * 10 + digit;
if (dot) ++scl;
} else if (c == '.') {//之后是小数位
if (dot) throw new NumberFormatException();//存在两个及以上的小数点
dot = true;
} else if (Character.isDigit(c)) {//判断char是否为数值
//尝试转换字符为数字
} else if ((c == 'e') || (c == 'E')) {
exp = parseExp(in, offset, len);//指数计算
if ((int) exp != exp) // overflow
throw new NumberFormatException();
break;//指数计算完成,跳出循环
} else {
throw new NumberFormatException();
}
}
if (prec == 0)//无数值
throw new NumberFormatException();
if (exp != 0) scl = adjustScale(scl, exp);// scl和exp抵消
rs = isneg ? -rs : rs;
int mcp = mc.getPrecision();//获取精度位数
int drop = prec - mcp;
if (mcp > 0 && drop > 0) {
while (drop > 0) {
// 检查缩放位数是否超出限制
scl = checkScaleNonZero((long) scl - drop);
// 三个参数一次为:数值,10^drop(表示精确到哪一位) , 舍入模式对应的常量,返回结果为舍入后的值
rs = divideAndRound(rs, LONG_TEN_POWERS_TABLE[drop], mc.roundingMode.oldMode);
// 计算舍入后数值的位数
prec = longDigitLength(rs);
//再次计算需要丢弃的位数,直至符合精度要求
drop = prec - mcp;
}
}else {
// 省略,逻辑相似,但考虑到数值超int上限,使用char[]存储数值,
// 完成后将char[]转为BigInteget类型
}
}
// 赋值
this.scale = scl;
this.precision = prec;
this.intCompact = rs;
this.intVal = rb;
}
-
刚才的构造函数中有一个参数
MathContext没有解释,字面上看是指数学上下文,应该是存储些运算相关的环境变量,在BigDecimal中我们用到了两个,一个是精度precision(即参与运算的最大位数),一个是舍入模式roundingMode,BigDecimal默认的上下文是MathContext.UNLIMITED:银行家舍入,无限精度;简单介绍下Java中的舍入模式- ROUND_UP
向远离零的方向舍入。舍弃非零部分,并将非零舍弃部分相邻的一位数字加一。
- ROUND_DOWN
向接近零的方向舍入。舍弃非零部分,同时不会非零舍弃部分相邻的一位数字加一,采取截取行为。
- ROUND_CEILING
向正无穷的方向舍入。如果为正数,舍入结果同ROUND_UP一致;如果为负数,舍入结果同ROUND_DOWN一致。注意:此模式不会减少数值大小。
- ROUND_FLOOR
向负无穷的方向舍入。如果为正数,舍入结果同ROUND_DOWN一致;如果为负数,舍入结果同ROUND_UP一致。注意:此模式不会增加数值大小。
- ROUND_HALF_UP
向“最接近”的数字舍入,如果与两个相邻数字的距离相等,则为向上舍入的舍入模式。如果舍弃部分>= 0.5,则舍入行为与ROUND_UP相同;否则舍入行为与ROUND_DOWN相同。这种模式也就是我们常说的我们的“四舍五入”。
- ROUND_HALF_DOWN
向“最接近”的数字舍入,如果与两个相邻数字的距离相等,则为向下舍入的舍入模式。如果舍弃部分> 0.5,则舍入行为与ROUND_UP相同;否则舍入行为与ROUND_DOWN相同。这种模式也就是我们常说的我们的“五舍六入”。
- ROUND_HALF_EVEN
向“最接近”的数字舍入,如果与两个相邻数字的距离相等,则相邻的偶数舍入。如果舍弃部分左边的数字奇数,则舍入行为与 ROUND_HALF_UP 相同;如果为偶数,则舍入行为与 ROUND_HALF_DOWN 相同。注意:在重复进行一系列计算时,此舍入模式可以将累加错误减到最小。此舍入模式也称为“银行家舍入法”,主要在美国使用。四舍六入,五分两种情况,如果前一位为奇数,则入位,否则舍去。
- ROUND_UNNECESSARY
断言请求的操作具有精确的结果,因此不需要舍入。如果对获得精确结果的操作指定此舍入模式,则抛出ArithmeticException
PS:
Java默认的Math.round()实现的是四舍五入,而.net,和python3中的运算包的round()方法默认实现的银行家舍入,使用新语言的时候感觉需要注意下他的舍入函数的舍入模式;避免采坑. -
了解了
BigDecimal的数据构造过程,如何运算就显而易见了, 详情自己看源码吧;- 加减法,将两个数的
scale放大至相同进行加减运算 - 乘除法,数值部分乘除运算,
scale部分加减运算
- 加减法,将两个数的