美文网首页
JS位运算异常(位运算精度丢失)的原因探究

JS位运算异常(位运算精度丢失)的原因探究

作者: 囍冯总囍 | 来源:发表于2020-08-11 11:21 被阅读0次

    参考资料

    《【转+补充】深入研究js中的位运算及用法》
    《【JS时间戳】获取时间戳的最快方式探究》

    由来

    日常开发中一直没遇到过位运算导致精度丢失的问题,直到这天,搞10位时间戳取整的时候,终于被我撞上了。具体是个什么场景呢,我们来还原下案发现场:

    • 首先我们获取一下10位的时间戳
    const {
        performance
    } = require('perf_hooks')
    // 通过performance获取当前时间
    const t = performance.timeOrigin + performance.now()
    console.log(t)
    console.log(t / 1000)
    console.log(t / 1000 << 0)
    

    可以看到输出的结果为:

    1597113682985.075
    1597113682.958075
    1597113682
    

    得到的t是一个精确到微秒的时间戳。但是请求接口的时候需要的是一个10位(精确到秒)的时间戳,所以这里需要将它转换为10位,自然就是➗1000即可,然后通过位运算来实现类似Math.trunc的取证效果,得到了我们要的10位时间戳。至此完美解决!那问题又是如何发生的呢?

    • 然后获取一下13位时间戳

    按照上面的运算规律,如果我们要获取13位时间戳,是不是直接对t>>0就可以了呢?我们来看一下:

    const {
        performance
    } = require('perf_hooks')
    // 通过performance获取当前时间
    const t = performance.timeOrigin + performance.now()
    console.log(t>>0)
    

    输出结果如下:

    -614151127
    

    WTF!!!看到了咩!!!居然输出了一个负数!!!我们想要的结果应该是1597113682985才对啊!为什么会出现了负数呢!!!

    由此,怪物出现啦!我们今天就来解读(xiang fu)一下它!

    原因分析+知识恶补

    想到这里,我们一定就会怪是位运算的锅!那这个锅该怎么让位运算背起来呢!我们来研究一下!

    首先我们知道,JS中没有真正的整型,数据都是以double(64bit)的标准格式存储的,这里就不再赘述了,要想搞透其中的原理,请打开【传送门】

    • 什么是位运算?

    位运算是在数字底层(即表示数字的 32 个数位)进行运算的。由于位运算是低级的运算操作,所以速度往往也是最快的(相对其它运算如加减乘除来说),并且借助位运算有时我们还能实现更简单的程序逻辑,缺点是很不直观,许多场合不能够使用。

    位运算只对整数起作用,如果一个运算子不是整数,会自动转为整数后再运行。虽然在 JavaScript 内部,数值都是以64位浮点数的形式储存,但是做位运算的时候,是以32位带符号的整数进行运算的,并且返回值也是一个32位带符号的整数。而32位整型的取值范围是:-2147483648 到 2147483647,最大值转换为时间后是:2038-01-19 11:14:07,所以我们在这里做个预测,2038年1月19号11:14:07后,如果地球还在,可能会有一批程序出现BUG。显然10位时间戳目前还在这个范围内,而超过10位后就已经超出了界限,这也就是为什么会因为位运算导致精度丢失的原因所在了。

    • 关于二进制

    • ECMAScript中的所有数值都以IEEE-754 64位格式存储,但位操作符并不直接操作64位的值,而是以32位带符号的整数进行运算的,并且返回值也是一个32位带符号的整数
    • 这种位数转换使得在对特殊的NaN和Infinity值应用位操作时,这两个值都会被当成0来处理
    • 如果对非数值应用位操作符,会先使用Number()将该值转换成数值再应用位操作,得到的结果是一个数值
    //'|'表示按位或,一个整数与0按位或运算可以得到它本身,一个小数与0按位或运算可以得到取整效果
    console.log( 1.3 | 0);//1
    console.log( 1.8 | 0);//1
    console.log( Infinity | 0);//0
    console.log( -Infinity | 0);//0
    console.log( NaN | 0);//0
    console.log('12px' | 0);//0
    console.log('12' | 0);//12
    

    以下来源于w3shool:
    ECMAScript 整数有两种类型,即有符号整数(允许用正数和负数)和无符号整数(只允许用正数)。在 ECMAScript 中,所有整数字面量默认都是有符号整数,这意味着什么呢?

    有符号整数使用 31 位表示整数的数值,用第 32 位表示整数的符号,0 表示正数,1 表示负数。数值范围从 -2147483648 到 2147483647

    可以以两种不同的方式存储二进制形式的有符号整数,一种用于存储正数,一种用于存储负数。正数是以真二进制形式存储的,前 31 位中的每一位都表示 2 的幂,从第 1 位(位 0)开始,表示 20,第 2 位(位 1)表示 21。没用到的位用 0 填充,即忽略不计。例如,下图展示的是数 18 的表示法。

    那在js中二进制和十进制如何转换呢?如下

    console.log((18).toString(2));//"10010"
    console.log(0b00000000000000000000000000010010);//18
    
    // 十进制 => 二进制
    let num = 10;
    console.log(num.toString(2));
    // 二进制 => 十进制
    let num1 = 1001;
    console.log(parseInt(num1, 2)); 
    

    负数同样以二进制存储,但使用的格式是二进制补码。计算一个数值的二进制补码,需要经过下列3个步骤:

    1. 求这个数值绝对值的二进制码
    2. 求二进制反码,即将0替换成1,将1替换成0
    3. 得到的二进制反码加1

    例如,要确定-18的二进制表示,首先必须得到18的二进制表示,如下所示:
    0000 0000 0000 0000 0000 0000 0001 0010

    接下来,计算二进制反码,如下所示:
    1111 1111 1111 1111 1111 1111 1110 1101

    最后,在二进制反码上加 1,如下所示:
    1111 1111 1111 1111 1111 1111 1110 1101 +
    0000000000000000000000000000 0001 =
    1111 1111 1111 1111 1111 1111 1110 1110

    因此,-18 的二进制就是 1111 1111 1111 1111 1111 1111 1110 1110
    而其相反数18的二进制为0000 0000 0000 0000 0000 0000 0001 0010

    ECMAScript会尽力向我们隐藏所有这些信息,在以二进制字符串形式输出一个负数时,我们看到的只是这个负数绝对值的二进制码前面加上了一个负号

    var num = -18;
    console.log(num.toString(2));//'-10010'
    
    • 浮点数的二进制

    JavaScript 只有一种数字类型 ( Number )

    JavaScript采用 IEEE 754 标准双精度浮点(double64),64位中有1位符号位,11位存储指数,52位存储浮点数的有效数字
    有时候小数在二进制中表示是无限的,所以从53位开始就会舍入(舍入规则是0舍1入),这样就造成了“浮点精度问题”(由于舍入规则有时大点,有时小点)

    IEEE标准中float的存储规则
    IEEE标准中double的存储规则
    更多详细介绍,请参看传送门

    我们将1596596596.3742654.toString(2)转为二进制字符串表示如下:
    1011111001010100010000101110100.0101111111001111110111
    但实际在内存中的存储如下:

    1. 首先将整数部分1596596596转为二进制:1011111001010100010000101110100
    2. 将小数部分转为二进制:0.010111111100111111011011011101010000011000111100010111
    3. 所以其二进制拼接后为:1011111001010100010000101110100.010111111100111111011011011101010000011000111100010111,但显然位数超出了64位的限制,而且小数点也不可能存储的为小数点(只有0和1啊)
    4. 所以将小数点左移30位后转为科学计数法:1.011111001010100010000101110100010111111100111111011011011101010000011000111100010111 * 2^30
    5. 正数,符号位为0,我们在最高位符号位中填0
    6. 指数部分,通过左移得到的,指数为正,因此62位填1,然后将指数30-1=29,二进制为101001,在左边添0,所以61~52位凑够了10位,因此指数部分为100 0010 1001
    7. 至于尾数部分,直接将科学计数法后小数点后面的数扔进去即可(因为超出52位长度,所以更多的位数会舍去,最后一位会0舍1入),所以尾数部分为:0111110010101000100001011101000101111111001111110111
    8. 至此,这个浮点数的二进制就存储为:0100 0010 1001 0111 1100 1010 1000 1000 0101 1101 0001 0111 1111 0011 1111 0111,转为16进制为:0x4297CA885D17F3F7
    • JS中的精度丢失

    说到这里就不得不简单提一下数字精度丢失的问题。上面也知道,JS中所有的数字都是用double方式进行存储的,所以必然会存在精度丢失问题。

    以下转自文章:JavaScript数字精度丢失问题总结

    此时只能模仿十进制进行四舍五入了,但是二进制只有 0 和 1 两个,于是变为 0 舍 1 入。这即是计算机中部分浮点数运算时出现误差,丢失精度的根本原因。

    大整数的精度丢失和浮点数本质上是一样的,尾数位最大是 52 位,因此 JS 中能精准表示的最大整数是 Math.pow(2, 53),十进制即 9007199254740992

    大于9007199254740992的可能会丢失精度:
    9007199254740992 >> 10000000000000...000 ``// 共计 53 个 0
    9007199254740992 + 1 >> 10000000000000...001 ``// 中间 52 个 0
    9007199254740992 + 2 >> 10000000000000...010 ``// 中间 51 个 0

    实际上
    9007199254740992 + 1 ``// 丢失
    9007199254740992 + 2 ``// 未丢失
    9007199254740992 + 3 ``// 丢失
    9007199254740992 + 4 ``// 未丢失

    以上,可以知道看似有穷的数字, 在计算机的二进制表示里却是无穷的,由于存储位数限制因此存在“舍去”,精度丢失就发生了。

    想了解更深入的分析可以看这篇论文(你品!你细品!):What Every Computer Scientist Should Know About Floating-Point Arithmetic
    关于精度和范围的内容可查看【JS的数值精度和数值范围】


    位运算导致数据异常的过程分析

    通过前面的知识补充,我们已经知道:

    位运算只对整数起作用,如果一个运算子不是整数,会自动转为整数后再运行。虽然在 JavaScript 内部,数值都是以64位浮点数的形式储存,但是做位运算的时候,是以32位带符号的整数进行运算的,并且返回值也是一个32位带符号的整数。

    ECMAScript 中,所有整数字面量默认都是有符号整数,这意味着什么呢?有符号整数使用 31 位表示整数的数值,用第 32 位表示整数的符号,0 表示正数,1 表示负数。数值范围从-2147483648 到 2147483647

    这也就是为什么对于整数部位为10位的时间戳,通过位运算可以进行取整(因为目前时间戳159xxxxxxx<2147483647),不存在时间戳超过范围的问题。但是对于13位时间戳,如1596615447123>2147483647,此时再通过位运算操作的时候就会导致异常,如:

    let t = 1596615447015.007
    console.log(Math.trunc(t), Math.trunc(t / 1000)) // 1596615447015 1596615447
    console.log(t / 1000 | 0) // 1596615447
    console.log(t | 0) // -1112387097
    

    这主要是因为在进行位运算之前,JS会先将64bit的浮点数1596615447015.01转为32bit的有符号整型后进行运算的,这个转换过程如下:

    32bit整型存储结构
    1. 首先1596615447015.333的二进制表示为10111001110111101101100100101000111100111.0101010101,其在内存中的存储结构如下:
      1. 正数,最高位符号位0
      2. 科学计数法小数点左移,指数位最高位为1
      3. 小数点左移40位,则剩余指数部分为40-1=39的10位二进制00 0010 0111
      4. 所以前12位为0100 0010 0111
    2. 剩余52位从小数点后开始取52位(不足52位在最后补0,超过则最后一位0舍1入)为0111001110111101101100100101000111100111010101010100
    3. 所以1596615447015.333的二进制存储表示为:0100 0010 0111 0111 0011 1011 1101 1011 0010 0101 0001 1110 0111 0101 0101 0100,转为16进制表示为:0x42773BDB251E7554
    4. 开始将其转为32bit的int类型,首先根据指数位100 0010 0111可知,小数点右移39+1=40位,剩余小数位数舍掉,则52位尾数部分得到的是73BDB251E7,即二进制表示为0111 0011 1011 1101 1011 0010 0101 0001 1110 0111
    5. 截取上面二进制的后32位得到:1011 1101 1011 0010 0101 0001 1110 0111,系统会将这32位数当作转换后的int类型,由于最高位为1,即这是一个负数
    6. 对于系统来说,如果是负数,则用这个负数的补码表示,即这个负数绝对值的二进制按位取反,然后最后一位执行不进位+1的来的,所以对于上面这个二进制,将其转为10进制的过程如下:
      1. 最高位符号位为1,表示负数
      2. 既然是负数,最后一位不退位-1,得到:011 1101 1011 0010 0101 0001 1110 0110
      3. 取补码:100 0010 0100 1101 1010 1110 0001 1001
      4. 表示为十进制:-1112387097
    7. 至此,就可以解释为什么1596615447015.333 | 0 = -1112387097了。

    为了验证上述过程,我们再举一个例子:1590015447015.123 >> 0 = 877547495

    1. 1590015447015.123的二进制表示为:10111001000110100010011100100111111100111.000111111
    2. 舍去其小数部分后,从后往前取32位为:00110100010011100100111111100111
    3. 最高位为0,正数,直接转为10进制为:877547495

    将将将将!没错的吧!所以JS的这个坑还真是。。。 让人Orz

    相关文章

      网友评论

          本文标题:JS位运算异常(位运算精度丢失)的原因探究

          本文链接:https://www.haomeiwen.com/subject/xkafdktx.html