解讀:大整數(shù)傳輸為何禁用Long類型?
最新發(fā)布的《Java開(kāi)發(fā)手冊(cè)(嵩山版)》增加了前后端規(guī)約,其中有一條:禁止服務(wù)端在超大整數(shù)下使用Long類型作為返回。這是為何?在實(shí)際開(kāi)發(fā)中可能出現(xiàn)什么問(wèn)題?本文從IEEE754浮點(diǎn)數(shù)標(biāo)準(zhǔn)講起,詳細(xì)解析背后的原理,幫助大家徹底理解這個(gè)問(wèn)題,提前避坑。
8月3日,這個(gè)在我等碼農(nóng)心中具有一定紀(jì)念意義的日子里,《Java開(kāi)發(fā)手冊(cè)》發(fā)布了嵩山版。每次發(fā)布我都特別期待,因?yàn)榭偰苷业揭恍┏绦騿T不得不重視的“血淋淋的巨坑”。比如這次,嵩山版中新增的模塊——前后端規(guī)約,其中一條禁止服務(wù)端在超大整數(shù)下使用Long類型作為返回。
這個(gè)問(wèn)題,我在實(shí)際開(kāi)發(fā)中遇到過(guò),所以印象也特別深。如果在業(yè)務(wù)初期沒(méi)有評(píng)估到這一點(diǎn),將訂單ID這類關(guān)鍵信息,按照Long類型返回給前端,可能會(huì)在業(yè)務(wù)中后期高速發(fā)展階段,突然暴雷,導(dǎo)致嚴(yán)重的業(yè)務(wù)故障。期望大家能夠重視。
這條規(guī)約給出了直接明確的避坑指導(dǎo),但要充分理解背后的原理,知其所以然,還有很多點(diǎn)要思考。首先,我們來(lái)看幾個(gè)問(wèn)題,如果能說(shuō)出所有問(wèn)題的細(xì)節(jié),就可直接跳過(guò)了,否則下文還是值得一看的:
- 一問(wèn):JS的Number類型能安全表達(dá)的最大整型數(shù)值是多少?為什么(注意要求更嚴(yán),是安全表達(dá))?
- 二問(wèn):在Long取值范圍內(nèi),2的指數(shù)次整數(shù)轉(zhuǎn)換為JS的Number類型,不會(huì)有精度丟失,但能放心使用么?
- 三問(wèn):我們一般都知道十進(jìn)制數(shù)轉(zhuǎn)二進(jìn)制浮點(diǎn)數(shù)有可能會(huì)出現(xiàn)精度丟失,但精度丟失具體怎么發(fā)生的?
- 四問(wèn):如果不幸中招,服務(wù)端正在使用Long類型作為大整數(shù)的返回,有哪些辦法解決?
基礎(chǔ)回顧
在解答上面這些問(wèn)題前,先介紹本文涉及到的重要基礎(chǔ):IEEE754浮點(diǎn)數(shù)標(biāo)準(zhǔn)。如果大家對(duì)IEEE754的細(xì)節(jié)爛熟于心的話,可以跳過(guò)本段內(nèi)容,直接看下一段,問(wèn)題解答部分。
當(dāng)前業(yè)界流行的浮點(diǎn)數(shù)標(biāo)準(zhǔn)是IEEE754,該標(biāo)準(zhǔn)規(guī)定了4種浮點(diǎn)數(shù)類型:單精度、雙精度、延伸單精度、延伸雙精度。前兩種類型是最常用的。我們單介紹一下雙精度,掌握雙精度,自然就了解了單精度(而且上述問(wèn)題場(chǎng)景也是涉及雙精度)。
雙精度分配了8個(gè)字節(jié),總共64位,從左至右劃分是1位符號(hào)、11位指數(shù)、52位有效數(shù)字。如下圖所示,以0.7為例,展示了雙精度浮點(diǎn)數(shù)的存儲(chǔ)方式。
存儲(chǔ)位分配
1)符號(hào)位:在最高二進(jìn)制位上分配1位表示浮點(diǎn)數(shù)的符號(hào),0表示正數(shù),1表示負(fù)數(shù)。
2)指數(shù):也叫階碼位。
在符號(hào)位右側(cè)分配11位用來(lái)存儲(chǔ)指數(shù),IEEE754標(biāo)準(zhǔn)規(guī)定階碼位存儲(chǔ)的是指數(shù)對(duì)應(yīng)的移碼,而不是指數(shù)的原碼或補(bǔ)碼。根據(jù)計(jì)算機(jī)組成原理中對(duì)移碼的定義可知,移碼是將一個(gè)真值在數(shù)軸上正向平移一個(gè)偏移量之后得到的,即[x]移=x+2^(n-1)(n為x的二進(jìn)制位數(shù),含符號(hào)位)。移碼的幾何意義是把真值映射到一個(gè)正數(shù)域,其特點(diǎn)是可以直觀地反映兩個(gè)真值的大小,即移碼大的真值也大。基于這個(gè)特點(diǎn),對(duì)計(jì)算機(jī)來(lái)說(shuō)用移碼比較兩個(gè)真值的大小非常簡(jiǎn)單,只要高位對(duì)齊后逐個(gè)比較即可,不用考慮負(fù)號(hào)的問(wèn)題,這也是階碼會(huì)采用移碼表示的原因所在。
由于階碼實(shí)際存儲(chǔ)的是指數(shù)的移碼,所以指數(shù)與階碼之間的換算關(guān)系就是指數(shù)與它的移碼之間的換算關(guān)系。假設(shè)指數(shù)的真值為e,階碼為E ,則有 E = e + (2 ^ (n-1) - 1),其中 2 ^ (n-1) - 1 是IEEE754 標(biāo)準(zhǔn)規(guī)定的偏移量。則雙精度下,偏移量為1023,11位二進(jìn)制取值范圍為[0,2047],因?yàn)槿?是機(jī)器零、全1是無(wú)窮大都被當(dāng)做特殊值處理,所以E的取值范圍為[1,2046],減去偏移量,可得e的取值范圍為[-1022,1023] 。
3)有效數(shù)字:也叫尾數(shù)位。最右側(cè)分配連續(xù)的52位用來(lái)存儲(chǔ)有效數(shù)字,IEEE754標(biāo)準(zhǔn)規(guī)定尾數(shù)以原碼表示。
浮點(diǎn)數(shù)和十進(jìn)制之間的轉(zhuǎn)換
在實(shí)際實(shí)現(xiàn)中,浮點(diǎn)數(shù)和十進(jìn)制之間的轉(zhuǎn)換規(guī)則有3種情況:
1 規(guī)格化
指數(shù)位不是全零,且不是全1時(shí),有效數(shù)字最高位前默認(rèn)增加1,不占用任何比特位。那么,轉(zhuǎn)十進(jìn)制計(jì)算公式為:
- (-1)^s*(1+m/2^52)*2^(E-1023)
其中s為符號(hào),m為尾數(shù),E為階碼。比如上圖中的0.7 :
1)符號(hào)位:是0,代表正數(shù)。
2)指數(shù)位:01111111110,轉(zhuǎn)換為十進(jìn)制,得階碼E為1022,則真值e=1022-1023=-1。
3)有效數(shù)字:
- 0110011001100110011001100110011001100110011001100110
轉(zhuǎn)換為十進(jìn)制,尾數(shù)m為:1801439850948198。
4)計(jì)算結(jié)果:
(1+1801439850948198/2^52)*(2^-1) =0.6999999999999999555910790149937383830547332763671875
經(jīng)過(guò)顯示優(yōu)化算法后(在后文中詳述),為0.7。
2 非規(guī)格化
指數(shù)位是全零時(shí),有效數(shù)字最高位前默認(rèn)為0。那么,轉(zhuǎn)十進(jìn)制計(jì)算公式:
- (-1)^s*(0+m/2^52)*2^(-1022)
注意,指數(shù)位是-1022,而不是-1023,這是為了平滑有效數(shù)字最高位前沒(méi)有1。比如非規(guī)格最小正值為:
- 0x0.0000000000001*2^-1022=2^-52 * 2^-1022 = 4.9*10^-324
3 特殊值
指數(shù)全為1,有效數(shù)字全為0時(shí),代表無(wú)窮大;有效數(shù)字不為0時(shí),代表NaN(不是數(shù)字)。
問(wèn)題解答
1 JS的Number類型能安全表達(dá)的最大整型數(shù)值是多少?為什么?
規(guī)約中已經(jīng)指出:
在Long類型能表示的最大值是2的63次方-1,在取值范圍之內(nèi),超過(guò)2的53次方(9007199254740992)的數(shù)值轉(zhuǎn)化為JS的Number時(shí),有些數(shù)值會(huì)有精度損失。
“2的53次方”這個(gè)限制是怎么來(lái)的呢?如果看懂上文IEEE754基礎(chǔ)回顧,不難得出:在浮點(diǎn)數(shù)規(guī)格化下,雙精度浮點(diǎn)數(shù)的有效數(shù)字有52位,加上有效數(shù)字最高位前默認(rèn)為1,共53位,所以JS的Number能保障無(wú)精度損失表達(dá)的最大整數(shù)是2的53次方。
而這里的題問(wèn)是:“能安全表達(dá)的最大整型”,安全表達(dá)的要求,除了能準(zhǔn)確表達(dá),還有正確比較。2^53=9007199254740992,實(shí)際上,
- 9007199254740992+1 == 9007199254740992
的比較結(jié)果為true。如下圖所示:
這個(gè)測(cè)試結(jié)果足以說(shuō)明2^53不是一個(gè)安全整數(shù),因?yàn)樗荒芪ㄒ淮_定一個(gè)自然整數(shù),實(shí)際上9007199254740992、9007199254740993,都對(duì)應(yīng)這個(gè)值。因此這個(gè)問(wèn)題的答案是:2^53-1。
2 在Long取值范圍內(nèi),2的指數(shù)次整數(shù)轉(zhuǎn)換為JS的Number類型,不會(huì)有精度丟失,但能放心使用么?
規(guī)約中指出:
在Long取值范圍內(nèi),任何2的指數(shù)次整數(shù)都是絕對(duì)不會(huì)存在精度損失的,所以說(shuō)精度損失是一個(gè)概率問(wèn)題。若浮點(diǎn)數(shù)尾數(shù)位與指數(shù)位空間不限,則可以精確表示任何整數(shù)。
后半句,我們就不說(shuō)了,因?yàn)榻^對(duì)沒(méi)毛病,空間不限,不僅是任何整數(shù)可以精確表示,無(wú)理數(shù)我們也可以挑戰(zhàn)一下。我們重點(diǎn)看前半句,根據(jù)本文前面所述基礎(chǔ)回顧,雙精度浮點(diǎn)數(shù)的指數(shù)取值范圍為[-1022,1023],而指數(shù)是以2為底數(shù)。另外,雙精度浮點(diǎn)數(shù)的取值范圍,比Long大,所以,理論上Long型變量中2的指數(shù)次整數(shù)一定可以準(zhǔn)確轉(zhuǎn)換為JS的umber類型。但在JS中,實(shí)際情況,卻是下面這樣:
2的55次方的準(zhǔn)確計(jì)算結(jié)果是:36028797018963968,而從上圖可看到,JS的計(jì)算結(jié)果是:36028797018963970。而且直接輸入36028797018963968,控制臺(tái)顯示結(jié)果是36028797018963970。
這個(gè)測(cè)試結(jié)果,已經(jīng)對(duì)本問(wèn)題給出答案。為了確保程序準(zhǔn)確,本文建議,在整數(shù)場(chǎng)景下,對(duì)于JS的Number類型使用,嚴(yán)格限制在2^53-1以內(nèi),最好還是信規(guī)約的,直接使用String類型。
為什么會(huì)出現(xiàn)上面的測(cè)試現(xiàn)象呢?
實(shí)際上,我們?cè)诔绦蛑休斎胍粋€(gè)浮點(diǎn)數(shù)a,在輸出得到a',會(huì)經(jīng)歷以下過(guò)程:
1)輸入時(shí):按照IEEE754規(guī)則,將a存儲(chǔ)。這個(gè)過(guò)程很有可能會(huì)發(fā)生精度損失。
2)輸出時(shí):按照IEEE754規(guī)則,計(jì)算a對(duì)應(yīng)的值。根據(jù)計(jì)算結(jié)果,尋找一個(gè)最短的十進(jìn)制數(shù)a',且要保障a'不會(huì)和a隔壁浮點(diǎn)數(shù)的范圍沖突。a隔壁浮點(diǎn)數(shù)是什么意思呢?由于存儲(chǔ)位數(shù)是限定的,浮點(diǎn)數(shù)其實(shí)是一個(gè)離散的集合,兩個(gè)緊鄰的浮點(diǎn)數(shù)之間,還存在著無(wú)數(shù)的自然數(shù)字,無(wú)法表達(dá)。假設(shè)有f1、f2、f3三個(gè)升序浮點(diǎn)數(shù),且它們之間的距離,不可能在拉近。則在這三個(gè)浮點(diǎn)數(shù)之間,按照范圍來(lái)劃分自然數(shù)。而浮點(diǎn)數(shù)輸出的過(guò)程,就是在自己范圍中找一個(gè)最適合的自然數(shù),作為輸出。如何找到最合適的自然數(shù),這是一個(gè)比較復(fù)雜的浮點(diǎn)數(shù)輸出算法,大家感興趣的,可參考相關(guān)論文[1]。
所以,36028797018963968和36028797018963970這兩個(gè)自然數(shù),對(duì)應(yīng)到計(jì)算機(jī)浮點(diǎn)數(shù)來(lái)說(shuō),其實(shí)是同一個(gè)存儲(chǔ)結(jié)果,雙精度浮點(diǎn)數(shù)無(wú)法區(qū)分它們,最終呈現(xiàn)哪一個(gè)十進(jìn)制數(shù),就看浮點(diǎn)數(shù)的輸出算法了。下圖這個(gè)例子可以說(shuō)明這兩個(gè)數(shù)字在浮點(diǎn)數(shù)中是相等的。另外,大家可以想想輸入0.7,輸出是0.7的問(wèn)題,浮點(diǎn)數(shù)是無(wú)法精確存儲(chǔ)0.7,輸出卻能夠精確,也是因?yàn)橛懈↑c(diǎn)數(shù)輸出算法控制(特別注意,這個(gè)輸出算法無(wú)法保證所有情況下,輸入等于輸出,它只是盡力確保輸出符合正常的認(rèn)知)。
擴(kuò)展
JS的Number類型既用來(lái)做整數(shù)計(jì)算、也用來(lái)做浮點(diǎn)數(shù)計(jì)算。其轉(zhuǎn)換為String輸出的規(guī)則也會(huì)影響我們使用,具體規(guī)則如下:
上面是一段典型的又臭又長(zhǎng)但邏輯很嚴(yán)謹(jǐn)?shù)拿枋觯铱偨Y(jié)了一個(gè)不是很嚴(yán)謹(jǐn),但好理解的說(shuō)法,大家可以參考一下:
除了小數(shù)點(diǎn)前的數(shù)字位數(shù)(不算開(kāi)始的0)少于22位,且絕對(duì)值大于等于1e-6的情況,其余都用科學(xué)計(jì)數(shù)法格式化輸出。舉例:
3 我們一般都知道十進(jìn)制數(shù)轉(zhuǎn)二進(jìn)制浮點(diǎn)數(shù)有可能會(huì)出現(xiàn)精度丟失,精度丟失怎么發(fā)生的?
通過(guò)前面IEEE754分析,我們知道十進(jìn)制數(shù)存儲(chǔ)到計(jì)算機(jī),需要轉(zhuǎn)換為二進(jìn)制。有兩種情況,會(huì)導(dǎo)致轉(zhuǎn)換后精度損失:
1)轉(zhuǎn)換結(jié)果是無(wú)限循環(huán)數(shù)或無(wú)理數(shù)
比如0.1轉(zhuǎn)換成二進(jìn)制為:
- 0.0001 10011001100110011001100110011...
其中0011在循環(huán)。將0.1轉(zhuǎn)換為雙精度浮點(diǎn)數(shù)二進(jìn)制存儲(chǔ)為:
- 0 01111111011 1001100110011001100110011001100110011001100110011001
按照本文前面所述基礎(chǔ)回顧中的計(jì)算公式 (-1)^s*(1+m/2^52)*2^(E-1023)計(jì)算,可得轉(zhuǎn)換回十進(jìn)制為:0.09999999999999999。這里可以看出,浮點(diǎn)數(shù)有時(shí)是無(wú)法精確表達(dá)一個(gè)自然數(shù),這個(gè)和十進(jìn)制中1/3 =0.333333333333333...是一個(gè)道理。
2)轉(zhuǎn)換結(jié)果長(zhǎng)度,超過(guò)有效數(shù)字位數(shù),超過(guò)部分會(huì)被舍棄
IEEE754默認(rèn)是舍入到最近的值,如果“舍”和“入”一樣接近,那么取結(jié)果為偶數(shù)的選擇。
另外,在浮點(diǎn)數(shù)計(jì)算過(guò)程中,也可能引起精度丟失。比如,浮點(diǎn)數(shù)加減運(yùn)算執(zhí)行步驟分為:
零值檢測(cè) -> 對(duì)階操作 -> 尾數(shù)求和 -> 結(jié)果規(guī)格化 -> 結(jié)果舍入
其中對(duì)階和規(guī)格化都有可能造成精度損失:
- 對(duì)階:是通過(guò)尾數(shù)右移(左移會(huì)導(dǎo)致高位被移出,誤差更大,所以只能是右移),將小指數(shù)改成大指數(shù),達(dá)到指數(shù)階碼對(duì)齊的效果,而右移出的位,會(huì)作為保護(hù)位暫存,在結(jié)果舍入中處理,這一步有可能導(dǎo)致精度丟失。
- 規(guī)格化:是為了保障計(jì)算結(jié)果的尾數(shù)最高位是1,視情況有可能會(huì)出現(xiàn)右規(guī),即將尾數(shù)右移,從而導(dǎo)致精度丟失。
4 如果不幸中招,服務(wù)端正在使用Long類型作為大整數(shù)的返回,有哪些辦法解決?
需要分情況。
1)通過(guò)Web的ajax異步接口,以Json串的形式返回給前端
方案一:如果,返回Long型所在的POJO對(duì)象在其他地方無(wú)使用,那么可以將后端的Long型直接修改成String型。
方案二:如果,返回給前端的Json串是將一個(gè)POJO對(duì)象Json序列化而來(lái),并且這個(gè)POJO對(duì)象還在其他地方使用,而無(wú)法直接將其中的Long型屬性直接改為String,那么可以采用以下方式:
- String orderDetailString = JSON.toJSONString(orderVO, SerializerFeature.BrowserCompatible);
SerializerFeature.BrowserCompatible 可以自動(dòng)將數(shù)值變成字符串返回,解決精度問(wèn)題。
方案三:如果,上述兩種方式都不適合,那么這種方式就需要后端返回一個(gè)新的String類型,前端使用新的,并后續(xù)上線后下掉老的Long型(推薦使用該方式,因?yàn)榭梢悦鞔_使用String型,防止后續(xù)誤用Long型)。
2)使用node的方式,直接通過(guò)調(diào)用后端接口的方式獲取
方案一:使用npm的js-2-java的 java.Long(orderId) 方法兼容一下。
方案二:后端接口返回一個(gè)新的String類型的訂單ID,前端使用新的屬性字段(推薦使用,防止后續(xù)踩坑)。
引用
[1]http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.52.2247&rank=2
[2]《碼出高效》