聊一聊Java中double精度去哪了
前段時間, 因為要測試一個剛出爐的高頻策略, 放實盤去跑吧, 怕出岔, 所以寫了個簡單的回測系統, 跑一遍歷史資料. 其中有一部分是關於撮合系統, 簡陋了點, 還算能跑得起來, 幾個用例下來, 也沒什麼問題, 接著增加歷史資料量, 居然出現了負數, 簡直不可能發生的事情居然出現了, 雖然都是小金額的偏差, 但是畢竟跟錢打交道, 必須謹慎, 況且現在比特幣那麼貴, 絲毫偏差都是不允許的!
當然, 後面就是苦逼的找bug, 邏輯沒問題, 發狠的, 把所有的資料都打印出來, 日誌一頁一頁沒有盡頭, 心裡發麻, 硬著頭皮一條條排查, 人品不錯, 開頭就發現一條異常資料, 0.05+0.01=0.060000000000000005, 瞬間明白, google it, 才發現Java的double原來精度那麼蛋疼. 網上推薦BigDecimal代替double, 果然不錯, 那就用BigDecimal替換. 等所有的double都換之後, 狗血的事情發生了, BigDecimal是如此的慢, 以至於跑一個用例花了之前N倍的時間,
怎麼辦, 只能用一個折中的辦法, 數值表示仍然用double, 數值計算用BigDecimal, 於是乎, 有了如下的一個四則運算工具類
當然, 這裡我想做的, 不僅僅只是找到暫時的解決方案, 更多的事想明白為什麼double會出現這樣的問題, 如果是其他語言, 例如C, 會不會也出現這樣的問題, 我試著用同一組資料, 發現C對於這組資料是沒有出現異常的, 那麼, Java為什麼會這麼與眾不同呢? 網上說其他語言也有類似的情況, 那麼我們該如何避免這些地雷呢? 既然Java的double問題那麼多, 我當前系統用double表示數值, 會不會出現偏差? 如果Java中採用BigDecimal效率這麼低, 那些大型交易所, 效能要求極高, 如何控制延遲呢? 或者還有其他更好的技術?
繼續先前的話題, double奇葩的精度. 試完了C, 為何不看看其他的語言, 如python, 畢竟基本上現在的程式語言基本採用IEEE754標準, 儲存方式相同, 計算由CPU完成, 結果為什麼會不同? 果然使用python得出的結論也是0.060000000000000005, 不免懷疑之前C的結果. C的語句是這樣的:
printf("%lf", 0.01 + 0.05); #輸出結果為0.060000.
是精度, 保留的精度不對, 於是設定保留小數點後18位, 因為java的輸出小數點後有18位:
printf("%.18lf", 0.01 + 0.05); #輸出結果為0.060000000000000005.
這才是真相, 線索越來越多, 0.01+0.05這道題, 跟語言無關, 而是跟IEEE754標準有關?
這個實驗當中, 結果比預期的多, 那麼有沒有比預期的少?
System.out.println(0.09 + 0.01); #輸出結果為0.09999999999999999.
但是, 如果取小數點後10位以內, 結果還是好的, 而且一般現實也不需要那麼高的精度, 但是, 倘若每次計算都做一次round, 勢必效能大打折扣!然而不做round, 的確是不嚴謹的作法, 如果是支付場景, 0.09+0.01<0.1, 那麼這次交易就完成不了, 這是絕對不能容忍的錯誤!
談了現象, 再談談原因, 其實很簡單, 10進位制的世界, 對於0, 1的世界的計算機來說, 有點懸.
這裡我們回顧一下小數轉二進位制的計算規則:乘2取整法, 以0.05為例:
- 0.05 * 2 % 1 = 0.1(0)
- 0.1 * 2 % 1 = 0.2(0)
- 0.2 * 2 % 1 = 0.4(0)
- 0.4 * 2 % 1 = 0.8(0)
- 0.8 * 2 % 1 = 0.6(1)
- 0.6 * 2 % 1 = 0.2(1) // 到達這裡的時候, 又回到先前第2步狀態
如果一直算下去, 結果會是:0.00(0011*n), 0011*n表示n次重複, 用科學技術法表示:1.100(1100*n) * 2−52−5, 用IEEE754表示,exp=1023+(-5)=1018, fraction部分, 整數1去掉, 二進位制小數第53位做0舍1入操作, 則:
s=0(正數) exp==01111111010 frac=1001100110011001100110011001100110011001100110011010 binary=0 01111111010 1001100110011001100110011001100110011001100110011010 hex=3fa999999999999a
用相同的方式對0.01做轉換, 可以發現也只能用近似的值表示, 上一篇提到的既然Java的double問題那麼多,
我當前系統用double表示數值, 會不會出現偏差?
, 很明顯, 偏差肯定有, 具體情況具體分析!
尾數整數能表示最大值是254−1254−1, 即當你的整數部分大於或接近254−1254−1時, 如果有小數, 則小數精度丟失非常嚴重. 這裡可以得出一個初步結論:
在整數部分不太大的情況下, double可以保證精度丟失微乎其微;而當整數部分過大時, 小數部分會做非常粗暴省略.
接下來聊一聊精度取捨的原則, 然後判斷是否適合自身專案的需求.
double之所以會產生精度的丟失, 最根本的因素是用於表示小數的二進位制位數不夠, 然後做round, 造成丟失.
這裡我們假設能表示小數的二進位制位長度為x, 那麼, 在儲存的時候, 如果x位後面還有內容, 將做round處理, 那麼到底損失或者增加了多少? 這裡驗證下:
-
如果x+1二進位制位為1, 進位, 則比原數要放大一些, 假設增量t, 那麼t<1/2xt<1/2x, 以下是證明: tmax=1/2x+1+1/2x+2+1/2x+3+…+1/2x+ntmax=1/2x+1+1/2x+2+1/2x+3+…+1/2x+n 1/2x−tmax=1/2x(1−1/2−1/4−1/8...)>0,因:1/2+1/4+1/8+...+1/2n<11/2x−tmax=1/2x(1−1/2−1/4−1/8...)>0,因:1/2+1/4+1/8+...+1/2n<1
-
如果x+1二進位制位為0, 不進位, 則比原數字小一些, 假設減量d, 那麼d<1/2x+1d<1/2x+1, 證明同上.
其實, 在計算過程中, 只要考慮的1/2x1/2x影響有多大即可!因為1/2x1/2x都不會影響結果, 1/2x+11/2x+1更不會了.
假設儲存的浮點數小數的最長10進位制位長度為L, 我們可以通過改變數, 來判斷是否產生精度影響. 這裡舉個例子, 令L = 4, A = ?.0004, 假設最壞的情況, A在轉化為二進位制後, 儲存時發生了截斷, 做了round, 那麼:
如果是增加, 最多增加t=1/2xt=1/2x. x = 14時, t=1/2xt=1/2x = 0.000061035, A + t = ?.000461035, 如果從記憶體裡讀取, 然後保留4位10進位制小數, 變成了?.0005, 誤差產生了;x = 15時, t=1/2xt=1/2x = 0.000030518, 變成了?.0004, 與實際相符.
如果是減少, 最多減少d=1/2x+1d=1/2x+1. x = 14時, d=1/2x+1d=1/2x+1 = 0.000030518, A - d = ?.000360482, 如果從記憶體裡讀取, 然後保留4位10進位制小數, 變成了?.0004, .
那麼, 可認為, L=4的情況下, 需要二進位制小數位至少x=15, 才能保證符合要求.
從上面的例子可以看到, 當十進位制小數位L+1上的加減值大於5時, 會是結果產生偏差. 這裡, 可以推出一般的規律:
如果1/2x<5∗10−L−11/2x<5∗10−L−1, 則能保證精度滿足要求.
這裡舉一個比特幣交易所的例子, 關於balance, price, amount的問題, price整數最大長度a, 小數精確到x位, amount整數最大長度b, 小數精確到y位, 那麼理論上price * amount, 整數最多a+b位, 小數最多位x+y位, 令x=2,y=4,看是否滿足實際生產,
amount<200, 000個, 則200, 000(10)=30D40(16), 二進位制長度為20位, 小數部分34位, 1/234=5.82077∗10−111/234=5.82077∗10−11, 則小數點後9位是最大極限, 滿足要求.
一般price<100,000, 則10,0000(10)=186A0(16), 二進位制長度20位, 小數部分34位, 同上, 小數點後9位是最大極限, 滿足要求.
那麼, 對於balance, a+b=40, 則小數部分14位二進位制, 1/214=6.1035∗