☕【Java實戰系列】「技術盲區」Double與Float的坑與解決辦法以及BigDecimal的取而代之!

浩宇天尚發表於2021-12-23

探究背景

涉及諸如float或者double這兩種浮點型資料的處理時,偶爾總會有一些怪怪的現象,不知道大家注意過沒,舉幾個常見的栗子:

條件判斷超預期

System.out.println( 1f == 0.9999999f );   // 列印:false
System.out.println( 1f == 0.99999999f );  // 列印:true

資料轉換超預期

float f = 1.1f;
double d = (double) f;
System.out.println(f);  // 列印:1.1
System.out.println(d);  // 列印:1.100000023841858 

基本運算超預期

System.out.println( 0.2 + 0.7 );
// 列印:0.8999999999999999   納尼?

資料自增超預期

float f1 = 8455263f;
for (int i = 0; i < 10; i++) {
    System.out.println(f1);
    f1++;
}
// 列印:8455263.0
// 列印:8455264.0
// 列印:8455265.0
// 列印:8455266.0
// 列印:8455267.0
// 列印:8455268.0
// 列印:8455269.0
// 列印:8455270.0
// 列印:8455271.0
// 列印:8455272.0

float f2 = 84552631f;
for (int i = 0; i < 10; i++) {
    System.out.println(f2);
    f2++;
}
//    列印:8.4552632E7   納尼?不是 +1了嗎?
//    列印:8.4552632E7   納尼?不是 +1了嗎?
//    列印:8.4552632E7   納尼?不是 +1了嗎?
//    列印:8.4552632E7   納尼?不是 +1了嗎?
//    列印:8.4552632E7   納尼?不是 +1了嗎?
//    列印:8.4552632E7   納尼?不是 +1了嗎?
//    列印:8.4552632E7   納尼?不是 +1了嗎?
//    列印:8.4552632E7   納尼?不是 +1了嗎?
//    列印:8.4552632E7   納尼?不是 +1了嗎?
//    列印:8.4552632E7   納尼?不是 +1了嗎?

所以說用浮點數(包括double和float)處理問題有非常多隱晦的坑在等著我們們!

分析原因出處

我們就以第一個典型現象為例來分析一下:

System.out.println( 1f == 0.99999999f );

直接用程式碼去比較1和0.99999999,居然列印出true!這說明了什麼?這說明了計算機壓根區分不出來這兩個數。這是為什麼呢?

深入分析

輸入的這兩個浮點數只是我們人類肉眼所看到的具體數值,是我們通常所理解的十進位制數,但是計算機底層在計算時可不是按照十進位制來計算的,學過計算機組成原理的人都知道,計算機底層最終都是基於像010100100100110011011這種0、1二進位制來完成的。

將這兩個十進位制浮點數轉化到二進位制,直接給出結果(把它轉換到IEEE 754 Single precision 32-bit,也就float型別對應的精度)

1.0(十進位制)
    ↓
00111111 10000000 00000000 00000000(二進位制)
    ↓
0x3F800000(十六進位制)
0.99999999(十進位制)
    ↓
00111111 10000000 00000000 00000000(二進位制)
    ↓
0x3F800000(十六進位制)

這兩個十進位制浮點數的底層二進位制表示是一樣的,怪不得==的判斷結果返回true!

但是1f == 0.9999999f返回的結果是符合預期的,列印false,我們也把它們轉換到二進位制模式下看看情況:

1.0(十進位制)
    ↓
00111111 10000000 00000000 00000000(二進位制)
    ↓
0x3F800000(十六進位制)

0.9999999(十進位制)
    ↓
00111111 01111111 11111111 11111110(二進位制)
    ↓
0x3F7FFFFE(十六進位制)

它倆的二進位制數字表示確實不一樣,這是理所應當的結果。

那麼為什麼0.99999999的底層二進位制表示竟然是:00111111 10000000 00000000 00000000呢?

這不明明是浮點數1.0的二進位制表示嗎?主要要分一下浮點數的精度問題了。

浮點數的精度問題!
學過 《計算機組成原理》 這門課的小夥伴應該都知道,浮點數在計算機中的儲存方式遵循IEEE 754 浮點數計數標準,可以用科學計數法表示為:

只要給出:符號(S)、階碼部分(E)、尾數部分(M) 這三個維度的資訊,一個浮點數的表示就完全確定下來了,所以float和double這兩種浮點數在記憶體中的儲存結構如下所示:

符號部分(S)

0-正 1-負

階碼部分(E)(指數部分):

對於float型浮點數,指數部分8位,考慮可正可負,因此可以表示的指數範圍為-127 ~ 128
對於double型浮點數,指數部分11位,考慮可正可負,因此可以表示的指數範圍為-1023 ~ 1024

尾數部分(M):

浮點數的精度是由尾數的位數來決定的:

  • 對於float型浮點數,尾數部分23位,換算成十進位制就是 2^23=8388608,所以十進位制精度只有6 ~ 7位;
  • 對於double型浮點數,尾數部分52位,換算成十進位制就是 2^52 = 4503599627370496,所以十進位制精度只有15 ~ 16位

對於上面的數值0.99999999f,很明顯已經超過了float型浮點資料的精度範圍,出問題也是在所難免的。

精度問題如何解決

涉及商品金額、交易值、貨幣計算等這種對精度要求很高的場景該怎麼辦呢?

方法一:用字串或者陣列解決多位數問題

方法二:Java的大數類是個好東西

JDK早已為我們考慮到了浮點數的計算精度問題,因此提供了專用於高精度數值計算的大數類來方便我們使用。Java的大數類位於java.math包下:可以看到,常用的BigInteger 和 BigDecimal就是處理高精度數值計算的利器。

BigDecimal num3 = new BigDecimal( Double.toString( 1.0f ) );
BigDecimal num4 = new BigDecimal( Double.toString( 0.99999999f ) );
System.out.println( num3 == num4 );  // 列印 false
BigDecimal num1 = new BigDecimal( Double.toString( 0.2 ) );
BigDecimal num2 = new BigDecimal( Double.toString( 0.7 ) );
// 加
System.out.println( num1.add( num2 ) );  // 列印:0.9
// 減
System.out.println( num2.subtract( num1 ) );  // 列印:0.5
// 乘
System.out.println( num1.multiply( num2 ) );  // 列印:0.14
// 除
System.out.println( num2.divide( num1 ) );  // 列印:3.5

當然了,像BigInteger 和 BigDecimal這種大數類的運算效率肯定是不如原生型別效率高,代價還是比較昂貴的,是否選用需要根據實際場景來評估。

實際案例場景

使用Double計算問題

如果需要記錄一個16位整數且保留兩位小數點的金額數值,於是使用Double型別來接收金額,但在最後進行金額總和統計後,得出的金額數值小數點後面多出了小數位,且多出的小數位不為0,簡直要瘋了,每一筆的金額都是兩位小數點,但最後統計的總金額數值卻是多位小數點的。

double和float型別主要用於科學計算與工程計算而設計的,用於二進位制浮點計算,但我們在程式中寫的時候往往都是寫的10進位制,而這個10進位制的小數,對於計算機內部而言,是無法用二進位制的小數來精確表達出來的,只能表示出一個“不精確性”或者說“近似性”的結果,而用這個近似性的結果進行計算得出的資料,也往往與我們心中想要的資料不一樣,所以如果是想進行金額或其他類似的浮點型數值計算,不要使用double或float,推薦大家使用BigDecimal來進行運算。

BigDecimal的工具使用

BigDecimal是Java在java.math包中提供的API類,它可以用來對超過16位有效位的數進行精確的運算和處理。

BigDecimal建立物件

BigDecimal提高了四個構造方法來建立物件:

  • 建立整數型別的物件:new BigDecimal(int);
  • 建立雙精度數值型別的物件:new BigDecimal(double);
  • 建立長整數型別的物件:new BigDecimal(long);
  • 建立以字串表示的數值的字串型別物件:new BigDecimal(String);

四個構造方法就是四種建立物件的方式,但推薦使用第1、3、4種方式,而不推薦使用第2種方式,因為前面說了double無法精確的表示10進位制的小數,只能近似性的表示,這就具有一定的不可預知性了,如需建立浮點型別的BigDecimal物件,可以使用new BigDecimal(String)來建立。

BigDecimal的運算

BigDecimal對於數值的運算,提供了專用的方法:

  • BigDecimal.add(BigDecimal)  BigDecimal物件的相加方法,返回BigDecimal物件
  • BigDecimal.subtract(BigDecimal)  BigDecimal物件的相減方法,返回BigDecimal物件
  • BigDecimal.multiply(BigDecimal)  BigDecimal物件的相乘方法,返回BigDecimal物件
  • BigDecimal.divide(BigDecimal)  BigDecimal物件的相除方法,返回BigDecimal物件

注意:BigDecimal的物件都是不可變的,它的每一次四則運算,都會產生並返回新的物件,所以在做加減乘除運算時要用新的物件來儲存操作後的值。

BigDecimal比較大小

BigDecimal提供了compareTo(BigDecimal)來進行數值的大小比較,compareTo返回值為int型別:-1,0,1;

例如:bigdemical_1.compareTo(bigdemical_2)

  • 返回-1:表示bigdemical_1小於bigdemical_2;
  • 返回0,表示bigdemical_1等於bigdemical_2;
  • 返回1,表示bigdemical_1大於bigdemical_2;

BigDecimal還有其他一些東西,例如,BigDecimal的格式化、BigDecimal的輸出型別轉換、BigDecimal的異常情況處理及注意事項等等。

相關文章