在Java开发中,只要涉及到小数计算,几乎都会遇到一个头疼的问题——精度丢失。比如简单的0.1+0.2,结果竟然不是0.3而是0.30000000000000004;再比如电商场景的金额计算,一点点精度偏差都可能导致重大损失。这时候,大家都会想到用BigDecimal来解决。今天我们就从“什么是精度丢失”开始,一步步扒开BigDecimal的源码,把这个问题讲透。
在讲BigDecimal之前,我们得先搞清楚:什么是精度丢失?为什么用float、double会出现这个问题?
先看几个直观的案例,大家可以自己在IDE里跑一下:
double a = 0.1;double b = 0.2;System.out.println(a + b); double c = 1.001;double d = 1000;System.out.println(c * d); double price = 99.99;double quantity = 3;System.out.println(price * quantity);
是不是很诡异?明明是很简单的计算,结果却差了一点点。这不是代码写错了,而是float、double的“天生缺陷”——它们是二进制浮点数,无法精确表示所有的十进制小数。
举个通俗的例子:我们用十进制无法精确表示1/3(0.3333...),同理,二进制也无法精确表示0.1(十进制)。float、double在存储这些无法精确表示的小数时,会采用“近似值”存储,这就为后续的计算精度丢失埋下了隐患。
而BigDecimal的核心作用,就是解决这个问题——它能精确表示十进制小数,并支持精确的算术运算。那它是怎么做到的?我们从源码入手分析。
要理解BigDecimal的精度保障原理,核心看三个关键点:存储结构、运算逻辑、舍入模式。我们逐一拆解源码(基于JDK 8)。
1. 存储结构:用“整数+缩放因子”精准表示十进制数
BigDecimal之所以能精确表示小数,核心是它的存储方式和double完全不同。它没有用二进制浮点数存储,而是用“无符号整数 + 缩放因子”的组合来表示十进制数。
先看BigDecimal的核心成员变量(源码片段):
public class BigDecimal extends Number implements Comparable<BigDecimal> { private final BigInteger intVal; private final int scale;
}
这里的关键逻辑是:BigDecimal把所有的十进制数,都转化为“整数”来存储,再通过scale来标记小数点的位置。比如:
表示0.1:intVal = 1(整数部分),scale = 1(小数点后1位),即 1 / 10^1 = 0.1
表示0.2:intVal = 2,scale = 1,即 2 / 10^1 = 0.2
表示99.99:intVal = 9999,scale = 2,即 9999 / 10^2 = 99.99
这种存储方式的优势很明显:无论是什么十进制小数,只要能被“整数+缩放因子”表示,就能精确存储。因为它本质上是用整数来存储所有有效数字,而整数的二进制存储是精确的,不会有近似值的问题。
补充说明:BigInteger是Java中用于表示任意精度整数的类,所以intVal可以存储极大的整数,这也让BigDecimal支持超大精度的小数表示。
2. 运算逻辑:基于整数运算,避免精度丢失
既然存储是精确的,运算逻辑自然也要保证精确。BigDecimal的所有算术运算(加、减、乘、除),本质上都是基于intVal的整数运算,再通过调整scale来保证结果的精度。
我们以最常见的“加法”为例,拆解源码逻辑(简化后):
public BigDecimal add(BigDecimal augend) { int thisScale = this.scale; int augendScale = augend.scale; int scale = Math.max(thisScale, augendScale);
BigInteger thisInt = this.intVal.movePointRight(scale - thisScale); BigInteger augendInt = augend.intVal.movePointRight(scale - augendScale);
BigInteger sumInt = thisInt.add(augendInt);
return new BigDecimal(sumInt, scale);}
用之前的0.1+0.2来验证这个逻辑:
0.1的intVal=1,scale=1;0.2的intVal=2,scale=1
统一scale为1(两者最大值)
转化后的数据:0.1→1(1*10^(1-1)),0.2→2(2*10^(1-1))
整数相加:1+2=3
新BigDecimal:intVal=3,scale=1 → 3/10^1=0.3(精确结果)
这就是为什么0.1+0.2用BigDecimal计算能得到精确的0.3!因为它本质上是整数运算,完全避免了二进制浮点数的近似存储问题。
再看“乘法”(简化源码逻辑):
public BigDecimal multiply(BigDecimal multiplicand) { BigInteger productInt = this.intVal.multiply(multiplicand.intVal); int productScale = this.scale + multiplicand.scale; return new BigDecimal(productInt, productScale);}
比如99.99*3:
完美解决了之前double计算出现的299.96999999999996的问题!
3. 舍入模式:处理除法等可能产生无限小数的场景
前面说的加、乘运算,结果的scale是确定的,不会产生无限小数。但除法不一样,比如1÷3=0.3333...,这时候即使是十进制,也无法精确表示。
BigDecimal在处理这种场景时,不会像double那样直接用近似值,而是通过舍入模式来明确如何处理无限小数,保证精度可控。
看除法的源码(关键部分):
public BigDecimal divide(BigDecimal divisor, int scale, RoundingMode roundingMode) { if (scale < 0) { throw new ArithmeticException("Negative scale"); } if (roundingMode == null) { throw new NullPointerException("RoundingMode must not be null"); } }
举个例子:1÷3,要求保留2位小数,四舍五入:
BigDecimal a = new BigDecimal("1");BigDecimal b = new BigDecimal("3");BigDecimal result = a.divide(b, 2, RoundingMode.HALF_UP);System.out.println(result);
如果不指定舍入模式,直接调用divide(BigDecimal divisor),当结果是无限小数时,会抛出ArithmeticException异常。这也提醒我们:使用BigDecimal做除法时,必须指定scale和舍入模式,避免异常。
常见的舍入模式(RoundingMode):
HALF_UP:四舍五入(最常用,比如保留2位小数,0.333→0.33,0.335→0.34)
ROUND_DOWN:截断(直接舍弃多余小数位,0.339→0.33)
ROUND_UP:进一(无论小数位是多少,都进一位,0.331→0.34)
HALF_EVEN:银行家舍入法(四舍六入五考虑,五后非零则进一,五后为零看前一位,偶数则舍,奇数则进,减少累计误差,适合金融场景)
虽然BigDecimal能保证精度,但如果使用不当,依然会出现精度问题。最常见的坑就是:用double构造BigDecimal。
看案例:
BigDecimal wrong = new BigDecimal(0.1);System.out.println(wrong); BigDecimal right = new BigDecimal("0.1");System.out.println(right);
为什么会这样?因为0.1作为double本身就是近似值,用它构造BigDecimal时,会把这个近似值直接存储进去,自然就出现了精度问题。
所以,核心建议:
构造BigDecimal时,优先用String参数(比如new BigDecimal("0.1")),或者用BigDecimal.valueOf(double d)(该方法会先把double转化为String,再构造)。
做除法时,必须指定scale和舍入模式。
阅读原文:原文链接
该文章在 2025/12/26 11:59:37 编辑过