1. 程式人生 > >BigDecimal 的那些坑事兒

BigDecimal 的那些坑事兒

最近檢視rebate資料時,發現一個bug,主要現象是,當扣款支付寶的賬號款項時,返回的是數字的金額為元,而資料庫把金額儲存為分,這中間要做元與分的轉化,這個轉化規則很簡單,就是*100的,所以一開始程式碼很簡單,如下。

Float f =  Float.valueOf(s);
f =f*100;
Long result = f.longValue();
s=”9.86”時,杯具出現了,result的結果為985而不是986float的精度損失導致float(985.99994)轉化為整形時,丟掉小數部分成為985,簡單的方法,我們可以提高精度使用雙精度的double型別,提高精度,比如
Double d =  Double.valueOf(s);
d = d*100;
Long result = d.longValue();
當s=”9.86”時,確實能夠得到正確結果,但是當s=”1219.86”時,這時候由於精度問題導致最終的result為121985為不是121986。當時以為使用double解決的問題,其實隱藏更隱蔽的bug。
針對這樣的問題,如果使用C/C++語言,那麼通用解決方案可以這樣。
Double d =  Double.valueOf(s);
d = d*100+0.5;// 注意這裡,我們使用的是+0.5的形式。
Long result = d.longValue();
但是,我們使用的java語言,java應該有更優雅的解決方案,那就是BigDecimal。

使用BigDecimal的解決方案成這個樣子

Double dd= Double.valueOf(s);
BigDecimal bigD = new BigDecimal(dd);
bigD = bigD.multiply(new BigDecimal(100));
Long result = bigD.longValue();
狂暈,輸出結果是985為不是986,列印bigD
System.out.println(bigD.toString());
輸出如下
985.9999999999999431565811391919851303100585937500
不會再加上一個BigDecimal(0.5)吧。我相信在使用過BigDecimal過程中,肯定有那裡不對的地方,multiply方法中可以傳入精度,那就構造MathContext物件,修改如下
Double dd= Double.valueOf(s);
BigDecimal bigD = new BigDecimal(dd);
MathContextmc = new MathContext(4,RoundingMode.HALF_UP);
//4表示取四位有效數字,RoundingMode.HALF_UP表示四捨五入
bigD= bigD.multiply(new BigDecimal(100),mc);
Long result = bigD.longValue();
最後結果輸出為986,貌似已經找到完成解決方案,其實不然,注意到MathContext中的4了嘛?這是因為我們保留4位有效數字,假如我們輸入的數字是大於4的,比如1219.86,最終輸出結果是122000,這是因為1219.86保留4位有效數字時,第四位的9四捨五入,除去精確位補零,所以最終結果成了122000。問題就成了,我們必須知道元變分後的最終有效位數,”9.86”,有效位數是4,”19.86”有效位數是5,把字串s的長度傳過去就可以了,那麼程式碼如下
Double dd =Double.valueOf(s);
BigDecimalbigD = new BigDecimal(dd);
MathContextmc = new MathContext(s.length(),RoundingMode.HALF_UP);
//4表示取四位有效數字,RoundingMode.HALF_UP表示四捨五入
bigD= bigD.multiply(new BigDecimal(100),mc);
Long result = bigD.longValue();
至此,已經可以得到一個正確的元轉分的程式碼,但是這裡的s.length()終歸不讓人感覺舒服,接下來,我們探索BigDecimal原理,嘗試用更優雅的方法解決這個問題。
BigDecimal,不可變的、任意精度的有符號十進位制數。BigDecimal 由任意精度的整數非標度值 和 32 位的整數標度(scale) 組成。如果為零或正數,則標度是小數點後的位數。如果為負數,則將該數的非標度值乘以 10 的負 scale 次冪。因此,BigDecimal 表示的數值是 (unscaledValue × 10-scale)。我們知道BigDecimal有三個主要的建構函式

1

public BigDecimal(double val)

將double表示形式轉換為BigDecimal

2

public BigDecimal(int val)

將int表示形式轉換為BigDecimal

3

public BigDecimal(String val)

將字串表示形式轉換為BigDecimal

通過這三個建構函式,可以把double型別,int型別,String型別構造為BigDecimal物件,在BigDecimal物件內通過BigIntegerintVal儲存傳遞物件數字部分,通過int scale;記錄小數點位數,通過int precision;記錄有效位數(預設為0)。
BigDecimal的加減乘除就成了BigInteger與BigInteger之間的加減乘除,浮點數的計算也轉化為整形的計算,可以大大提供效能,並且通過BigInteger可以儲存大數字,從而實現真正大十進位制的計算,在整個計算過程中,還涉及scale的判斷和precision判斷從而確定最終輸出結果。
我們先看一個例子

BigDecimal d1 = new BigDecimal(0.6);
BigDecimal d2 = new BigDecimal(0.4);
BigDecimal d3 = d1.divide(d2);
System.out.println(d3);
大家猜一下,以上輸出結果是?再接著看下面的程式碼
BigDecimal d1 = new BigDecimal(“0.6”);
BigDecimal d2 = new BigDecimal(“0.4”);
BigDecimal d3 = d1.divide(d2);
System.out.println(d3);
看似相似的程式碼,其結果完全不同,第一個例子中,丟擲異常。第二個例子中,輸出列印結果為1.5。造成這種差異的主要原因是第一個例子中的建立BigDecimal時,0.60.4是浮動型別的,浮點型放入BigDecimal內,其儲存值為
0.59999999999999997779553950749686919152736663818359375
0.40000000000000002220446049250313080847263336181640625
這兩個浮點數相除時,由於除不盡,而又沒有設定精度和保留小數點位數,導致丟擲異常。而第二個例子中0.60.4是字串型別,由於BigDecimal儲存特性,通過BigInteger記錄BigDecimal的值,所以,0.6和0.4可以非常正確的記錄為
0.6
0.4
兩者相除得出1.5來。
對於第一個例子,如果我們想得到正確結果,可以這樣來
BigDecimal d1 = new BigDecimal(0.6);
BigDecimal d2 = new BigDecimal(0.4);
BigDecimal d3 = d1.divide(d2, 1, BigDecimal.ROUND_HALF_UP);
現在看我們留下的那個問題,使用更優雅的方式解決元轉化為分的方式,上一個問題中,我們通過傳遞s.length()從而獲得精度,如果之前的s是double型別的,那邊這樣的方式就會有問題,通過上面的例子,我們可以調整為一下的通用方式
Double dd= Double.valueOf(s);
BigDecimal bigD = new BigDecimal(dd);
bigD = bigD.multiply(newBigDecimal(100)). divide(1, 1, BigDecimal.ROUND_HALF_UP);
Long result = bigD.longValue();
我們通過/1,然後設定保留小數點方式,以及設定數字保留模式,從而得到兩個數乘積的小數部分。還有以下模式

列舉常量摘要 
ROUND_CEILING  
          向正無限大方向舍入的舍入模式。
ROUND_DOWN  
          向零方向舍入的舍入模式。
ROUND_FLOOR  
          向負無限大方向舍入的舍入模式。
ROUND_HALF_DOWN  
          向最接近數字方向舍入的舍入模式,如果與兩個相鄰數字的距離相等,則向下舍入。
ROUND_HALF_EVEN  
          向最接近數字方向舍入的舍入模式,如果與兩個相鄰數字的距離相等,則向相鄰的偶數舍入。
ROUND_HALF_UP  
          向最接近數字方向舍入的舍入模式,如果與兩個相鄰數字的距離相等,則向上舍入。
ROUND_UNNECESSARY  
          用於斷言請求的操作具有精確結果的舍入模式,因此不需要舍入。(預設模式
ROUND_UP  
          遠離零方向舍入的舍入模式。

總結:
1:儘量避免傳遞double型別,有可能話,儘量使用intString型別。
2:做乘除計算時,一定要設定精度和保留小數點位數。
3BigDecimal計算時,單獨放到try catch內。