BigDecimal精度问题
在近来项目展示价格的时候,遇到了一个问题,一个价格为99999.999的商品在购物车中的展示却是100000.00。
原因归结于在原项目代码中,是采用BigDecimal的格式,然而前端需要展示的时候,采用的却是String类型。
在类型转换的过程中,出现了精度损失。于是潜下心来对BigDecimal高精度的展示做了一番研究。
总结来看,BigDecimal转换成String主要有两种方式,且对于价格999999.999,两种方法的输出并不一致。具体代码如下:
BigDecimal price = new BigDecimal("999999.999"); //价格信息
//方法一
String format1 = String.format("%.2f", price);
System.out.println("stringFormat: "+format1); // 1000000.00
//方法二
String format2 = price.setScale(2, RoundingMode.DOWN).toPlainString();
System.out.println("scale: "+ format2); // 999999.99
String.format方式
方法一,即String.format方式,是项目中最初使用的代码。为了一探究竟,追进format源码查看,可以看到代码如下:
public Formatter format(Locale l, String format, Object ... args) {
ensureOpen();
int last = -1;
int lasto = -1;
FormatString[] fsa = parse(format); // 首先解析字符串中特殊的标识符号
for (int i = 0; i < fsa.length; i++) {
FormatString fs = fsa[i];
int index = fs.index(); // 获取到对应标识符号的下标
try {
switch (index) {
case -2:
fs.print(null, l);
break;
case -1:
if (last < 0 || (args != null && last > args.length - 1))
throw new MissingFormatArgumentException(fs.toString());
fs.print((args == null ? null : args[last]), l);
break;
case 0:
lasto++;
last = lasto;
if (args != null && lasto > args.length - 1)
throw new MissingFormatArgumentException(fs.toString());
fs.print((args == null ? null : args[lasto]), l); // 关键代码
break;
default:
last = index - 1;
if (args != null && last > args.length - 1)
throw new MissingFormatArgumentException(fs.toString());
fs.print((args == null ? null : args[last]), l);
break;
}
} catch (IOException x) {
lastException = x;
}
}
return this;
}
可以看到关键方法其实是fs.print(),这里追进去查看print的源码。可以看到print源码首先会根据当前的类型做个判断,这里我们采用的是BigDecimal,因此会走到printFloat方法中。
if (dt) {
printDateTime(arg, l);
return;
}
switch(c) {
case Conversion.DECIMAL_INTEGER:
case Conversion.OCTAL_INTEGER:
case Conversion.HEXADECIMAL_INTEGER:
printInteger(arg, l);
break;
case Conversion.SCIENTIFIC:
case Conversion.GENERAL:
case Conversion.DECIMAL_FLOAT:
case Conversion.HEXADECIMAL_FLOAT:
printFloat(arg, l); // 方法会走到这里.
break;
case Conversion.CHARACTER:
case Conversion.CHARACTER_UPPER:
printCharacter(arg);
break;
case Conversion.BOOLEAN:
printBoolean(arg);
break;
case Conversion.STRING:
printString(arg, l);
break;
case Conversion.HASHCODE:
printHashCode(arg);
break;
case Conversion.LINE_SEPARATOR:
a.append(System.lineSeparator());
break;
case Conversion.PERCENT_SIGN:
a.append('%');
break;
default:
assert false;
}
}
追入printFloat方法查看,可以看到,进一步的,printFloat方法又会对当前需要处理的对象进行类型的判断,并选取合适的方法进行处理。
private void printFloat(Object arg, Locale l) throws IOException {
if (arg == null)
print("null");
else if (arg instanceof Float)
print(((Float)arg).floatValue(), l);
else if (arg instanceof Double)
print(((Double)arg).doubleValue(), l);
else if (arg instanceof BigDecimal)
print(((BigDecimal)arg), l); // 关键方法
else
failConversion(c, arg);
}
追进BigDecimal对应的方法中,查看相应的print方法,可以看到关键方法其实是print(),因此我们继续追入查看相应代码。
private void print(BigDecimal value, Locale l) throws IOException {
if (c == Conversion.HEXADECIMAL_FLOAT)
failConversion(c, value);
StringBuilder sb = new StringBuilder();
boolean neg = value.signum() == -1; // 判断当前的数字符号是正数还是负数
BigDecimal v = value.abs(); // 取其绝对值
leadingSign(sb, neg); // 设置首个符号,'+' 或 ' ' 或 '(' 或 '-'
print(sb, v, l, f, c, precision, neg); // 关键方法
trailingSign(sb, neg); // 去除多余的符号
a.append(justify(sb.toString()));
}
private void print(StringBuilder sb, BigDecimal value, Locale l,Flags f, char c, int precision, boolean neg)throws IOException{
if(c == Conversion.SCIENTIFIC){
......
} else if (c == Conversion.DECIMAL_FLOAT) {
int prec = (precision == -1 ? 6 : precision); // 指的是我们当前设置的需要保留的精度,例子中为保留2位小数。
int scale = value.scale(); // scale指的是当前数字的精度范围,例子中为3位小数因此是3。
if (scale > prec) { // 如果当前位数大于要保留的位数
int compPrec = value.precision();//这里comPrec指的是整个数的位数,例子中为9.
if (compPrec <= scale) {
value = value.setScale(prec, RoundingMode.HALF_UP);
} else {
compPrec -= (scale - prec);
// 重新计算减少后位数的值,随后调用BigDecimal方法重新整理位数
// 而BigDecimal方法本身采用的RoundMode.HalfUp,因此在构造后会发生进位,导致结果出现差异。
value = new BigDecimal(value.unscaledValue(),
scale,
new MathContext(compPrec));
}
}
BigDecimalLayout bdl = new BigDecimalLayout(
value.unscaledValue(),
value.scale(),
BigDecimalLayoutForm.DECIMAL_FLOAT);
char mant[] = bdl.mantissa();
int nzeros = (bdl.scale() < prec ? prec - bdl.scale() : 0);
if (bdl.scale() == 0 && (f.contains(Flags.ALTERNATE) || nzeros > 0))
mant = addDot(bdl.mantissa());
mant = trailingZeros(mant, nzeros);
localizedMagnitude(sb, mant, f, adjustWidth(width, f, neg), l);
}
}
至此,我们明白了第一种方法的弊端所在,即其是默认采用的RoundMode.halfUp方法,会自动进位导致数据出现偏差。
price.setScale方式
第二种方式,是修复后的方式,该方式的好处在于,可以自定义设置进位、舍入的策略,从而得到更符合逻辑的结果。具体源代码如下:
public BigDecimal setScale(int newScale, int roundingMode) {
if (roundingMode < ROUND_UP || roundingMode > ROUND_UNNECESSARY)
throw new IllegalArgumentException("Invalid rounding mode");
int oldScale = this.scale;
if (newScale == oldScale) // 新旧位数一致则直接返回
return this;
if (this.signum() == 0) // 0返回任意位数均可
return zeroValueOf(newScale);
if(this.intCompact!=INFLATED) { // 如果当前不超过-2^63(Long类型的上限),则进行计算
long rs = this.intCompact; //rs为当前不带小数点的数值,eg. 999.99 ==> 99999
if (newScale > oldScale) {
// 如果新保留位数大于原位数
int raise = checkScale((long) newScale - oldScale);
if ((rs = longMultiplyPowerTen(rs, raise)) != INFLATED) {
return valueOf(rs,newScale);
}
BigInteger rb = bigMultiplyPowerTen(raise);
return new BigDecimal(rb, INFLATED, newScale, (precision > 0) ? precision + raise : 0);
} else {
// 否则计算需要丢弃的位数,并对应的创建新的BigDecimal对象
int drop = checkScale((long) oldScale - newScale);
if (drop < LONG_TEN_POWERS_TABLE.length) {
// 关键方法
return divideAndRound(rs, LONG_TEN_POWERS_TABLE[drop], newScale, roundingMode, newScale);
} else {
return divideAndRound(this.inflated(), bigTenToThe(drop), newScale, roundingMode, newScale);
}
}
} else {
if (newScale > oldScale) {
int raise = checkScale((long) newScale - oldScale);
BigInteger rb = bigMultiplyPowerTen(this.intVal,raise);
return new BigDecimal(rb, INFLATED, newScale, (precision > 0) ? precision + raise : 0);
} else {
int drop = checkScale((long) oldScale - newScale);
if (drop < LONG_TEN_POWERS_TABLE.length)
return divideAndRound(this.intVal, LONG_TEN_POWERS_TABLE[drop], newScale, roundingMode,
newScale);
else
return divideAndRound(this.intVal, bigTenToThe(drop), newScale, roundingMode, newScale);
}
}
}
紧接着我们追入divideAndRound方法中,
private static BigDecimal divideAndRound(long ldividend, long ldivisor, int scale, int roundingMode,
int preferredScale) {
int qsign; // 符号
long q = ldividend / ldivisor; // 约到相应的位数,eg. 999999999/10 == > 99999999
if (roundingMode == ROUND_DOWN && scale == preferredScale)
//如果是舍去的方案,则直接返回对应的值即可。
return valueOf(q, scale); // 该方法会用对应的值创造一个BigDecimal的对象。
long r = ldividend % ldivisor;
qsign = ((ldividend < 0) == (ldivisor < 0)) ? 1 : -1;
if (r != 0) {
boolean increment = needIncrement(ldivisor, roundingMode, qsign, q, r);
return valueOf((increment ? q + qsign : q), scale);
} else {
if (preferredScale != scale)
return createAndStripZerosToMatchScale(q, scale, preferredScale);
else
return valueOf(q, scale);
}
}
总结
总结来看,String.format方式默认采用的是Round_HalfUp的方式创建对应的对象,导致了数字出现进位的情况。而setScale方法,可以自由的选择对应的舍入方式,总体上看更为灵活。配合toPlainString方法可以很好的实现所有需要的功能。