1. 程式人生 > >記一次產線的JS精度丟失

記一次產線的JS精度丟失

最近生產上出現了一次JS精度丟失的現象,這個問題解決起來不是很難, 但是對於為什麼會出現這種情況進行了思考,得到了一些體會,Mark 一下。

這裡寫圖片描述

首先:這個請求是Ajax請求,如果正常的post或者get 請求是不會出現這種問題,原因後續會講。

首先:先來說下JS精度丟失是什麼,總體來說,就是JS表示浮點數的時候會出現誤差。包含兩類問題:

1、使用JS進行浮點數計算,得到錯誤的結果。

2、Ajax請求後端返回的數值型別的值大於JS的表示範圍(本次產線的場景。)

浮點數計算。

經典的 0.1+0.2!=0.3

產生這個問題的原因是及計算機是二進位制的方式儲存浮點數,同時又有位數的限制,導致了浮點數精度的缺失。

幾乎所有的程式語言都採用IEEE-745標準表示浮點數,這是一種對於實數的近似值數值表現,並不能精確的表示類似0.1這樣 的簡單的數字。為什麼呢?

浮點數:簡單來講浮點數就是小數點邏輯上不固定,跟它對應的就是定點數,小數點位置固定。比如oracle中的Number(5,2),意思就是總長度5位,其中整數3位,小數兩位。小數點固定。這種表達方式的缺點就是表達範圍和精度比較侷限。不利於同時表達特大或者特小的數字。而浮點數卻能很好的規避這個缺點,浮點數使用一個有效數字,一個基數,一個指數,以及一個表示正負的符號來表達實數,通過指數達到浮動小數點的效果,例如1.234x10^1和 1.234x10^2 。

IEEE-745:計算機的儲存方式是二進位制,因此使用計算機表示浮點數的時候基數就變成了2,可以理解為2進位制的科學計數法,(尾數用原碼;階碼用“移碼”;基為2)。

IEEE-745標準定義的浮點資料格式為:(其實有32bit 的單精度和64 bit的雙精度,本例只說雙精度)

值(尾數)Fraction 指數(階碼)Exponent 符號 Sign
52bits(0-51) 11bits(52-62) 1bit(63)
有效數字 表示小數點在資料中的位置 1–負數;0–正數

按照上面的指數表示方法,一個二進位制的浮點數會有不同的表示:

0.00101(2) = 1.01×2−3 = 10.1×2−4

為了提高資料的表示精度同時保證資料表示的唯一性,需要對浮點數做規格化處理。在計算機內,對非0值的浮點數,要求尾數域的最高有效位應為1,稱滿足這種表示要求的浮點數為規格化表示:把不滿足這一表示要求的尾數,變成滿足這一要求的尾數的操作過程,叫作浮點數的規格化處理,可以通過尾數移位和修改階碼實現。

即:浮點數的格式變為:

x=(1)s(1.M)2e e=E1023

尾數域值是1.M。因為規格化的浮點數的尾數域最左位總是1,故這一位不予儲存,而認為隱藏在小數點的左邊。

指數e本身一共11bit,可以表示的有符號數範圍為-1023-+1023。為了更好的進行浮點數的計算和比較,需要對指數進行處理,將他們全部轉化為正數(因為硬體工程師只想設計加法電路),也就是加上一個偏移量之後再存起來。也就是e+偏移量 = E。

經過處理過之後的E是無符號的正數,11位的表示範圍為0-2047(00000000000-11111111111)但是在浮點數的階碼中,全0和全1是保留值,用來表示特殊情況。那麼E的取值範圍是1-2046. 偏移量是1023,那麼e的範圍就是(-1022-+1023),至於為啥不是1024,參考知乎大神的解答吧(https://www.zhihu.com/question/24784136/answer/144601879)。

特殊規定

E為全0:當M=0時,表示機器數0,根據符號位表示+0,−0 ,不過數值比較的時候是一樣的;

E為全1:當M=0 時,根據符號位表示+∞和−∞;

E全0:M≠0 ,則表示這個值不是一個真正的值NAN,NAN有兩類:QNAN一般表示未定義的算術運算結果,如0/0 ,∞×0, sqrt(−1) ,SNAN一般被用於標記未初始化的值,以此來捕獲異常。

上述說的的都是規範浮點數,當兩個絕對值極小的浮點數相減後,其差值的指數可能超出允許範圍,最終只能近似為0。為了解決此類問題,IEEE標準中引入了非規範(Denormalized)浮點數,規定當浮點數的指數為允許的最小指數值時,尾數不必是規範化(Normalized)的。有了非規範浮點數,去掉了隱含的尾數位的制約,可以儲存絕對值更小的浮點數。而且,由於不再受到隱含尾數域的制約,上述關於極小差值的問題也不存在了,因為所有可以儲存的浮點數之間的差值同樣可以儲存。

64位雙精度值的範圍

二進位制 範圍 十進位制近似範圍
規範浮點數 2^-1022至 (2-2^-52)*2^1023 2.2x10-308至1.8*10^308
非規範浮點數 2^-52x2^-1022 至 (1-2^-52)*2^-1022 4.9x10^-324至2.2x10^-308

64位浮點數的精度問題:

M一共有52bit ,二進位制的精度為52bit,十進位制的精度為2^52-1 = 4503599627370496,共16位,這就是64 bit 10進位制的最大精度,但是,64bit 能精確保證的精度是15位,16就可能會引入誤差,當然如果位數超過了16位,那麼肯定會帶來誤差。

OK ,說了那麼多,現在來說說為啥0.1+0.2 != 0.3。

0.1,0.2,0.3的二進位制格式為:

0.1二進位制格式:0.00011001100110011001100110011001100110011001100110011001100(無限迴圈)

0.2二進位制格式:0.0011001100110011001100110011001100110011001100110011001100(無限迴圈)

0.3二進位制格式:0.01001100110011001100110011001100110011001100110011001100(無限迴圈)

按照浮點數的規範格式0.1,0.2儲存格式為

0.1 浮點格式值:0|01111111011|1001100110011001100110011001100110011001100110011010

0.2 浮點格式值:0|01111111100|1001100110011001100110011001100110011001100110011010

0.3 浮點格式值:0|01111111101|0011001100110011001100110011001100110011001100110011

由於尾數最長52bit ,那麼也就是說,對於這些無限迴圈的,到了52bit之後,會按照0舍1入的方式進行處理,0.1和0.2 由於舍掉的都是1,因此都有進位,那麼其實記憶體中的他們已經比實際的大了。

然後再看機器是如何把他們相加的。

浮點數相加,首先會比較階碼是否一致,如果一致則尾數相加,如果不一致,需要先對階,小階向大階看起,即將小階指數調成和大階一樣大,然後尾數右移相應的位數。

0.1階數為:1019, 0.2階數為1020,所以需要處理的是0.1,

原0.1 = 1.1001100110011001100110011001100110011001100110011010x2^(1019-1023)

新0.1 = 0.1100110011001100110011001100110011001100110011001101**0**x2^(1020-1023)

階數加了1,那麼右移1位,位數一共52bit ,最後一位按照0舍1入進行截斷,所以新的0,1 為

新0.1 = 0|01111111100|1100110011001100110011001100110011001100110011001101

0.2 = 0|01111111100|1001100110011001100110011001100110011001100110011010

階數相等之後,浮點數的加減就是尾數的加減。

1100110011001100110011001100110011001100110011001101
+1001100110011001100110011001100110011001100110011010
10110011001100110011001100110011001100110011001100111

發現尾數發生了進位,變為53bit,那麼需要化簡,階數+1,右移1位,則機器計算的結果為:

新0.1+0.2 = 0|01111111101|1011001100110011001100110011001100110011001100110100

0.3實際浮點格式:0|01111111101|0011001100110011001100110011001100110011001100110011

可以看到兩者是不一樣的,這就解釋了為什麼0.1+0.2!=0.3。

OK,迴歸到主題,使用JS進行浮點數計算,得到錯誤的結果,這個應該無需再說;JS的包含字串(String)、數字(Number)、布林(Boolean)、陣列(Array)、物件(Object)、空(Null)、未定義(Undefined)供七種資料型別,其中Number用來表示資料,JS所有數字都是用浮點型表示。知道了問題原因,怎麼解決呢。

最好的方案當然是避免在JS中進行小數浮點數的計算,交給後端去處理。如果無法避免,可以嘗試先將小數轉成整數,計算之後再轉換成小數。當然這個要注意資料的範圍,不要超過JS能表示的最大整數(15bit)。

回到這次的產線問題:Ajax請求後端返回的數值型別的值大於JS的表示範圍。通過上面的解釋很容易理解這個問題,Ajax返回的值超過了JS Number的最大表示範圍,導致無法儲存, 那麼得到錯誤的結論也就理所應當了。

上面說這個請求是Ajax請求,如果正常的post或者get 請求是不會出現這種問題,這是為什麼呢?

這裡就要說一說JSP(Java Server Pages)了,JSP 的執行過程如下。

這裡寫圖片描述

從上圖可以看出,首先html 檔案是由容器執行servlet例項產生。到tomcat 的路徑tomcat_home\work\Catalina 下可以找到jsp 被編譯成的java 檔案,開啟檔案會發現JSP都被編譯為xxx_jsp.java檔案了,在生成XXX_jsp.java 檔案的時候,對於變數我們採用的是String方式獲取,所以,如果我們的請求是非ajax的請求,是請求伺服器返回一個新的頁面,那麼web容器載入執行servlet例項的時候會統一將值作為字串處理,然後返回一個生成好html檔案, 那麼對於超過JS精度的數字,比如比較大的long值,由於在執行java的過程中已經轉換成String了,生成的HTML檔案中也是正確的,瀏覽器只是負責展示,這種情況就不會出現問題。
這裡寫圖片描述

那麼ajax 請求為什麼不可以。原因是ajax不是去伺服器請求html, 請求一般都是用來請求後端資料的,得到資料之後,動態的更新部分頁面內容,這是ajax的主要用途,大部分情況下ajax採用JSON和後臺互動,主要用來傳遞引數和獲取結果值,本例中就是通過ajax請求,去請求相關資料列表,服務返回一個JSON格式的字串給前端。伺服器一般採用物件進行資料傳遞,所以最後肯定會經過一次物件像JSON轉換。物件轉JSON的時候用反射找到物件類的所有Get方法,然後把”get”去掉,小寫化,作為JSON的每個key值,如 getA 對應的key值為 a,而與真實的類成員名無關。在伺服器上,java程式在轉JSON的時候,如果是數值型的,轉成的JSON字串中對應的Key的值是不會有雙引號的。這個JSON字串返回給瀏覽器之後,瀏覽器解析的時候,發現沒有雙引號的值,識別為數值型,使用Number來接收和解析,這個時候如果超過了Number的範圍,那麼就會造成精度丟失。所以就可以看到本例圖中,返回的字串ID是正常的,但是經過JS解析之後頁面展示錯了的現象。