最近生产上出现了一次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,称满足这种表示要求的浮点数为规格化表示:把不满足这一表示要求的尾数,变成满足这一要求的尾数的操作过程,叫作浮点数的规格化处理,可以通过尾数移位和修改阶码实现。
即:浮点数的格式变为:
尾数域值是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解析之后页面展示错了的现象。
参考:https://fed.renren.com/2017/05/13/float-number/
本文记录了一次生产线上遇到的JS浮点数精度丢失问题,深入探讨了浮点数计算的原理,包括JS中浮点数表示的误差、IEEE-745标准、浮点数规格化处理以及非规范浮点数。解释了为何0.1+0.2不等于0.3,并提出了解决方案:避免在JS中直接进行浮点数计算,或转换为整数计算后再转回浮点数。还分析了Ajax请求与正常HTTP请求的区别,指出Ajax请求中可能出现的精度问题源于JSON解析时数值类型处理。
5031

被折叠的 条评论
为什么被折叠?



