線上專案千萬不要隨便用BigDecimal,坑的我差點被開除!
文章來源: http://juejin.cn/post/7121852516228136996
目錄
-
背景
-
BigDecimal概述
-
BigDecimal的4個坑
-
小結
背景
一直從事金融相關專案,所以對BigDecimal再熟悉不過了,也曾看到很多同學因為不知道、不瞭解或使用不當導致資損事件發生。
所以,如果你從事金融相關專案,或者你的專案中涉及到金額的計算,那麼你一定要花時間看看這篇文章,全面學習一下BigDecimal。
BigDecimal概述
Java在java.math包中提供的API類BigDecimal,用來對超過16位有效位的數進行精確的運算。 雙精度浮點型變數double可以處理16位有效數,但在實際應用中,可能需要對更大或者更小的數進行運算和處理。
一般情況下,對於不需要準確計算精度的數字,可以直接使用Float和Double處理,但是Double.valueOf(String) 和Float.valueOf(String)會丟失精度。所以如果需要精確計算的結果,則必須使用BigDecimal類來操作。
BigDecimal物件提供了傳統的+、-、*、/等算術運算子對應的方法,通過這些方法進行相應的操作。BigDecimal都是不可變的(immutable)的, 在進行每一次四則運算時,都會產生一個新的物件 ,所以在做加減乘除運算時要記得要儲存操作後的值。
BigDecimal的4個坑
在使用BigDecimal時,有4種使用場景下的坑,你一定要了解一下,如果使用不當,必定很慘。 掌握這些案例,當別人寫出有坑的程式碼,你也能夠一眼識別出來,大牛就是這麼練成的。
第一:浮點型別的坑
在學習瞭解BigDecimal的坑之前,先來說一個老生常談的問題: 如果使用Float、Double等浮點型別進行計算時,有可能得到的是一個近似值,而不是精確的值。
比如下面的程式碼:
@Test
public void test0(){
float a = 1;
float b = 0.9f;
System.out.println(a - b);
}
結果是多少?0.1嗎?不是,執行上面程式碼執行的結果是0.100000024。之所以產生這樣的結果,是因為0.1的二進位制表示是無限迴圈的。
由於計算機的資源是有限的,所以是沒辦法用二進位制精確的表示 0.1,只能用「近似值」來表示,就是在有限的精度情況下,最大化接近 0.1 的二進位制數,於是就會造成精度缺失的情況。
關於上述的現象大家都知道,不再詳細展開。同時,還會得出結論在科學計數法時可考慮使用浮點型別,但如果是涉及到金額計算要使用BigDecimal來計算。
那麼,BigDecimal就一定能避免上述的浮點問題嗎?來看下面的示例:
@Test
public void test1(){
BigDecimal a = new BigDecimal(0.01);
BigDecimal b = BigDecimal.valueOf(0.01);
System.out.println("a = " + a);
System.out.println("b = " + b);
}
上述單元測試中的程式碼,a和b結果分別是什麼?
a = 0.01000000000000000020816681711721685132943093776702880859375
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) 建立一個具有引數所指定以字串表示的數值的物件。
其中涉及到引數型別為double的構造方法,會出現上述的問題,使用時需特別留意。
第二:浮點精度的坑
如果比較兩個BigDecimal的值是否相等,你會如何比較? 使用equals方法還是compareTo方法呢?
先來看一個示例:
@Test
public void test2(){
BigDecimal a = new BigDecimal("0.01");
BigDecimal b = new BigDecimal("0.010");
System.out.println(a.equals(b));
System.out.println(a.compareTo(b));
}
乍一看感覺可能相等,但實際上它們的本質並不相同。
equals方法是基於BigDecimal實現的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
public void test3(){
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("3.0");
a.divide(b);
}
執行上述程式碼的結果是什麼? ArithmeticException異常 !
java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
at java.math.BigDecimal.divide(BigDecimal.java:1690)
...
這個異常的發生在官方文件中也有說明:
If the quotient has a nonterminating decimal expansion and the operation is specified to return an exact result, an ArithmeticException is thrown. Otherwise, the exact result of the division is returned, as done for other operations.
總結一下就是,如果在除法(divide)運算過程中,如果商是一個無限小數(0.333…),而操作的結果預期是一個精確的數字,那麼將會丟擲 ArithmeticException
異常。
此時,只需在使用divide方法時指定結果的精度即可:
@Test
public 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
public 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位有效數字的貨幣值,百分值,以及一般數值進行格式化控制。
使用示例如下:
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。 而必須使用時,一定要規避上述的坑。
歡迎掃碼加入儒猿技術交流群,每天晚上20:00都有Java面試、Redis、MySQL、RocketMQ、SpringCloudAlibaba、Java架構等技術答疑分享,更能跟小夥伴們一起交流技術
另外推薦儒猿課堂的1元系列課程給您,歡迎加入一起學習~
網際網路Java工程師面試突擊課
(1元專享)
SpringCloudAlibaba零基礎入門到專案實戰
(1元專享)
億級流量下的電商詳情頁系統實戰專案
(1元專享)
Kafka訊息中介軟體核心原始碼精講
(1元專享)
12個實戰案例帶你玩轉Java併發程式設計
(1元專享)
Elasticsearch零基礎入門到精通
(1元專享)
基於Java手寫分散式中介軟體系統實戰
(1元專享)
基於ShardingSphere的分庫分表實戰課
(1元專享)
- 為了面試阿里、美團和位元組,我用12張圖就能講清楚分庫分表怎麼做!
- 線上專案千萬不要隨便用BigDecimal,坑的我差點被開除!
- 如果面試官讓你設計美團外賣的分庫分表架構,就該這麼說!
- 專案上線一年,整理了一份SpringBoot效能優化方案!
- 一次資料庫SQL注入生產事故讓我們公司損失了上千萬!
- 我找到了一個快速定位SpringBoot介面超時問題的神器!
- 從美團挖來的架構師居然這麼設計DB 快取,真的長見識了!
- 唉,一次堆外記憶體洩露讓整個團隊通宵處理到爆肝!
- 小公司裡用SpringBoot做MySQL分庫分表,踩了一些坑!
- 30k招了一個工程師,一來就設計了牛逼的許可權系統!
- SpringBoot @Async註解一起用,速度提升100倍!
- 面試官說入職以後要參與重構Kafka核心,我嚇的不敢接offer!
- 萬能通用!許可權系統就該這麼設計
- Controller層程式碼這麼寫,簡潔又優雅!
- 同事把RabbitMQ講透了,佩服!
- 這是我見過寫得最爛的Controller層程式碼...
- BigDecimal,切記別再用錯了!
- 替代SpringCloud,Istio好用到爆!
- 吐血整理:一份不可多得的架構師圖譜!
- Java8 Stream,過分絲滑!