BigDecimal概述
Java在java.math包中提供的API类BigDecimal,用来对超过16位有效位的数进行精确的运算。双精度浮点型变量double可以处理16位有效数,但在实际应用中,可能需要对更大或者更小的数进行运算和处理。
一般情况下,对于不需要精确计算精度的数字,可以直接使用Float和Double处理,但是Double.valueOf(String)和Float.valueOf(String)会丢失精度。所以如果需要精确计算的结果,则必须使用BigDecimal类来操作。
BigDecimal对象提供了传统的+、-、*、/等算术运算符对应的方法,通过这些方法进行相应的操作。BigDecimal都是不可变的(immutable)的,在进行每一次四则运算时,都会产生一个新的对象,所以在做加减乘除运算时要记得要保存操作后的值。
BigDecimal不可以随便用
在使用BigDecimal时,有四种使用场景下的坑,你一定要了解一下,如果使用不当,必定很惨。
一、浮点类型的坑
在学习了解BigDecimal的坑之前,先来说一个老生常谈的问题:如果使用Float、Double等浮点类型进行计算时,有可能得到的是一个近似值,而不是精确的值。
@Test
void test0() {
float a = 1;
float b = 0.9F;
System.out.println(a - b); // 0.100000024
}
结果是多少?0.1吗?不是,执行上面代码的结果是0.100000024。之所以产生这样的结果,是因为0.1的二进制表示是无限循环的。由于计算机的资源是有限的,所以是没办法用二进制精确的表示0.1,只能用【近似值】来表示,就是在有限的精度情况下,最大化接近0.1的二进制数,于是就会造成精度缺失的情况。
关于上述的现象大家都知道,不再详细展开。同时,还会得出结论在科学计数法时可考虑使用浮点类型,但如果是涉及到金额计算要使用BigDecimal来计算。
那么,BigDecimal就一定能避免上述的浮点问题吗?来看下面的示例:
@Test
void test1() {
BigDecimal a = new BigDecimal(0.01);
BigDecimal b = BigDecimal.valueOf(0.01);
System.out.println("a = " + a);// a = 0.01000000000000000020816681711721685132943093776702880859375
System.out.println("b = " + b);// b = 0.01
}
即便是使用BigDecimal,结果依旧会出现精度问题。这就涉及到创建BigDecimal对象时,如果有初始值,是采用new BigDecimal()的形式,还是通过BigDecimal.valueOf()方法了
之所以会出现上述现象,是因为new BigDecimal时,传入的0.1已经是浮点类型了,鉴于上面说的这个值只是近似值,鉴于上面说的这个值只是近似值,在使用new BigDecimal时就把这个近似值完整的保留下来
BigDecimal.valueOf()
public static BigDecimal valueOf(double val) {
// Reminder: a zero double returns '0.0', so we cannot fastpath
// to use the constant ZERO. This might be important enough to
// justify a factory approach, a cache, or a few private
// constants, later.
return new BigDecimal(Double.toString(val));
}
在valueOf内部,使用Double.toString()方法,将浮点类型的值转换成了字符串,因此就不存在精度丢失问题了
在此得出一个基本的结论:
- 在使用BigDecimal构造函数时,尽量传递字符串而非浮点类型
- 如果无法满足第一条,则可采用BigDecimal.valueOf()方法来构造初始化值
这里延伸一下,BigDecimal常见的构造有如下几种:
- BigDecimal(int): 创建一个具有参数所指定整数值的对象;
- BigDecimal(double): 创建一个具有参数所指定双精度值得对象;
- BigDecimal(long): 创建一个具有参数所指定长整数值得对象;
- BigDecimal(String): 创建一个具有参数所指定以字符串表示得数值得对象;
二、浮点精度的坑
如果比较两个BigDecimal的值是否相等,你会如何比较?使用equals()方法还是compareTo()方法呢?
@Test
void test2() {
BigDecimal a = new BigDecimal("0.01");
BigDecimal b = new BigDecimal("0.010");
System.out.println(a.equals(b));// false
System.out.println(a.compareTo(b));// 0
}
equals()方法源码:
@Override
public boolean equals(Object x) {
if (!(x instanceof BigDecimal))
return false;
BigDecimal xDec = (BigDecimal) x;
if (x == this)
return true;
if (scale != xDec.scale)
return false;
long s = this.intCompact;
long xs = xDec.intCompact;
if (s != INFLATED) {
if (xs == INFLATED)
xs = compactValFor(xDec.intVal);
return xs == s;
} else if (xs != INFLATED)
return xs == compactValFor(this.intVal);
return this.inflated().equals(xDec.inflated());
}
仔细阅读代码可以看出,equals()方法不仅比较了值是否相等,还比较了精度是否相同。上述示例中,由于两者的精度不同,所以equals()方法的结果当然是false了。而compareTo()方法实现了Comparable接口,真正比较的是值得大小,返回的值为-1(小于),0(等于),1(大于)
基本结论:通常情况,如果比较两个BigDecimal值的大小,采用其实现的compareTo()方法;如果严格限制精度的比较,那么则可考虑使用equals()方法。
另外,这种场景在比较0值的时候比较常见,比如比较BigDecimal(“0”)、BigDecimal(“0.0”)、BigDecimal(“0.00”),此时一定要使用compareTo()方法进行比较
三、设置精度的坑
在项目中看到好多同学通过BigDecimal进行计算时不设置计算结果的精度和舍入模式,真是着急人,虽然大多数情况下不会出现什么问题。但下面的场景就不一定了:
@Test
void test3() {
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("3.0");
a.divide(b);
}
执行结果:
java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
在除法(divide)运算过程中,如果商是一个无限小数(0.333…),而操作的结果预期是一个精确的数字,那么将会抛出 ArithmeticException异常
此时,只需在使用divide方法时指定结果的精度即可:
@Test
void test3() {
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("3.0");
BigDecimal c = a.divide(b, 2, RoundingMode.HALF_UP);
System.out.println(c);// 0.33
}
基本结论:在使用BigDecimal进行(所有)运算时,一定要明确指定精度和舍入模式
拓展一下,舍入模式定义在RoundingMode枚举类中,共有8种:
- RoundingMode.UP: 舍入远离零的舍入模式。在丢弃非零部分之前始终增加数字(始终对非零舍弃部分前面的数字加1)。注意:此舍入模式始终不会减少计算值的大小。
- RoundingMode.DOWN: 接近零的舍入模式。在丢弃某部分之前始终不增加数字(从不对舍弃部分前面的数字+1,即截短)。注意,此舍入模式始终不会增加计算值的大小。
- RoundingMode.CEILING: 接近正无穷大的舍入模式。如果BigDecimal为正,则舍入行为与ROUNDUP相同;如果为负,则舍入行为与ROUNDDOWN相同。注意,此舍入模式始终不会减少计算值。
- RoundingMode.FLOOR: 接近负无穷大的舍入模式。如果BigDecimal为正,则舍入行为与ROUNDDOWN相同;如果为负,则舍入行为与ROUNDUP相同。注意,此舍入模式始终不会增加计算值。
- RoundingMode.HALF_UP: 向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则为向上舍入的舍入模式。如果舍弃部分 >= 0.5,则舍入行为与ROUND_UP相同;否则舍入行为与ROUND_DOWN相同。注意,这是我们在小学时学过的舍入模式(四舍五入)。
- RoundingMode.HALF_DOWN: 向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则为上舍入的舍入模式。如果舍弃部分 > 0.5,则舍入行为与ROUND_UP相同;否则舍入行为与ROUND_DOWN相同(五舍六入)。
- RoundingMode.HALF_EVEN: 向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则向相邻的偶数舍入。如果舍弃部分左边的数字为奇数,则舍入行为与ROUNDHALFUP相同;如果为偶数,则舍入行为与ROUNDHALF_DOWN相同。注意,在重复进行一系列计算时,此舍入模式可以将累加错误减到最小。此舍入模式也称为“银行家舍入法”,主要在美国使用。四舍五入,五分两种情况。如果前一位为奇数,则入位,否则舍去。一下例子为保留小数点1位,那么这种舍入方式下的结果。1.15 ==> 1.2,1.25 ==> 1.2
- RoundingMode.UNNECESSARY: 断言请求的操作具有精确的结果,因此不需要舍入。如果对获得精确结果的操作指定此舍入模式,则抛出ArithmeticException。
通常我们使用的四舍五入即RoundingMode.HALF_UP。
四、三种字符串输出的坑
当使用BigDecimal之后,需要转换为String类型,你是如何操作的?直接toString()?
@Test
void test4() {
BigDecimal a = BigDecimal.valueOf(35634535255456719.22345634534124578902);
System.out.println(a.toString()); // 3.563453525545672E+16
}
也就是说,本来想打印字符串的,结果打印出来的是科学计数法的值。
在这里我们需要了解BigDecimal转换字符串的三个方法
- toPlainString(): 不使用任何科学计数法
- toString(): 在必要的时候使用科学计数法
- toEngineeringString(): 在必要的时候使用工程计数法。类似于科学计数法,只不过指数的幂都是3的倍数,这样方便工程上的应用,因为在很多单位转换的时候都是10^3
基本结论:根据数据结果展示格式不同,采用不同的字符串输出方法,通常使用比较多的方法为toPlainString()。
另外,NumberFormat类的format()方法可以使用BigDecimal对象作为其参数,可以利用BigDecimal对超出16位有效数字的货币值,百分值,以及一般数值进行格式化控制。
使用示例如下:
@Test
void test5() {
NumberFormat currency = NumberFormat.getCurrencyInstance(); // 建立货币格式化引用
NumberFormat percent = NumberFormat.getPercentInstance(); // 建立百分比格式化引用
percent.setMaximumFractionDigits(3); // 百分比小数点最多3位
BigDecimal loanAmount = new BigDecimal("15000.48"); // 金额
BigDecimal interestRate = new BigDecimal("0.008"); // 利率
BigDecimal interest = loanAmount.multiply(interestRate); // 相乘
System.out.println("金额:\t" + currency.format(loanAmount));
System.out.println("利率:\t" + percent.format(interestRate));
System.out.println("利息:\t" + currency.format(interest));
}
输出结果如下:
金额: ¥15,000.48
利率: 0.8%
利息: ¥120.00
总结
本篇文章介绍了BigDecimal使用中场景的坑,以及基于这些坑我们得出的“最佳实践”。虽然某些场景下推荐使用BigDecimal,它能够达到更好的精度,但性能相较于double和float,还是有一定的损失的,特别在处理庞大,复杂的运算时尤为明显。故一般精度的计算没必要使用BigDecimal。而必须使用时,一定要规避上述的坑。