深入理解计算机系统(2)

本文围绕计算机信息的表示和处理展开,介绍了信息存储,包括十六进制表示、字节顺序等;阐述整数表示,如无符号数、补码编码及转换;说明了整数运算的优化与溢出问题;还讲解了浮点数,涵盖二进制小数、IEEE标准、舍入及运算规则等内容。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

第二章 信息的表示和处理



对于二进制所表示的数字来说,主要有三种,即无符号、补码以及浮点数。计算机的表示法是用有限数量的位来对一个数字编码,因此,当结果太大以至不能表示时,某些运算就会溢出。溢出会导致某些令人吃惊的后果。例如,int类型使用四个字节(32位二进制)来表示,计算表达式200300400*500会得出-884901888,这违背了数学运算特性。





2.1 信息存储


大多数计算机使用8位的字节,作为最小的可寻址的内存单位,而不是访问内存中单独的位。换句话说,我们在访问内存的内容时,最小的访问单位是字节。机器级程序将内存抽象为一个非常大的字节数组,称为虚拟内存。内存的每个字节都由一个唯一的数字来标识,称为它的地址,数组下标是地址的增量,所有可能地址的集合就称为虚拟地址空间。虚拟地址空间是为了给机器级的程序一个概念上的映像,实际的实现是将动态随机访问存储器(DRAM)、闪存、磁盘存储器、特殊硬件和操作系统软件结合起来,为程序提供一个看上去统一的字节数组。



2.1.1 十六进制表示法


对于机器来说,可能比较喜欢0和1,但是对于人类这种高级生物来说,1和0就有点不够看了。因此通常情况下,为了便于阅读,我们会使用十六进制去表示二进制。1位十六进制的数字可以表示4位二进制数字,因此一个字节就可以表示为0x00—0xFF


  • 十六进制和二进制之间的相互转换

在这里插入图片描述

  • 十六进制和十进制之间的相互转换

1)十进制转十六进制:用十进制数不断的除以16,得到一个商和一个余数,余数用十六进制数表示作为最低位数。以此类推。例如:十进制数314156.
314156 ÷ 16 = 19634・・・・12 (C)
19634 ÷ 16 = 1227 ・・・・ 2 (2)
1227 ÷ 16 = 76 ・・・・11 (B)
76 ÷ 16 = 4 ・・・・12 (C)
4 ÷ 16 = 0 ・・・・ 4 (4)
所以十六进制数为0x4CB2C。

2)十六进制转十进制:用16的幂乘以每个十六进制数。例如:0x7AF.
7 x 16^2 + 10 x 16^1 + 15 x 16^0 = 1792 + 160 + 15 = 1976.
所以十进制数为1976



2.1.2 寻址和字节顺序


对于跨越多字节的程序对象,我们必须建立两个规则才能唯一确定一个程序对象的值:
1)这个对象的起始虚拟内存地址是多少?
2)以及在内存中此对象字节的排列顺序是什么(比如int类型4个字节的排列顺序是什么)?
对于第一个问题,由于大部分计算机都采用连续的内存地址去存储一个程序对象,因此我们称内存地址中最小的那个就是该程序对象的起始地址,也是该程序对象的地址。例如,假设一个类型为int的变量x的地址为0x100,也就是说,地址表达式&x的值为0x100,那么,(假设数据类型int为32位表示)x的4个字节将被存储在内存的0x100、0x101、0x102和0x103位置。
对于第二个问题,一般有两种方式,即大端法和小端法。

  • 小端存储:按照从最低有效字节到最高有效字节的顺序存储
  • 大端存储:按照从最高有效字节到最低有效字节的顺序存储
    例如:
    在这里插入图片描述

那么Java中如何确定当前环境所用的CPU是何种类型的字节顺序呢?
在java.nio.Bits中是这样实现的:

static {
        long a = unsafe.allocateMemory(8);
        try {
            unsafe.putLong(a, 0x0102030405060708L);
            byte b = unsafe.getByte(a);
            switch (b) {
            case 0x01: byteOrder = ByteOrder.BIG_ENDIAN;     break;
            case 0x08: byteOrder = ByteOrder.LITTLE_ENDIAN;  break;
            default:
                assert false;
                byteOrder = null;
            }
        } finally {
            unsafe.freeMemory(a);
        }
    }

原理很简单,就是先分配8个字节Long类型的内存,然后放内存中放入16进制0x0102030405060708L的数据,判断第一个字节是0x01还是0x08,如果是0x01,则说明大端字节顺序,如果是最大的0x08,则是小端字节顺序。



2.1.3 强制类型转转换


对于一个特定的数据类型来讲,计算机在解释这类数据的值的时候,是根据起始位置以及数据类型的位数来确定的。比如对于无符号int类型的数据来说,倘若我们知道它的起始位置为0x1,而当前的操作系统采取的是大端法规则,假设0x1-0x4的内存地址中存储的字节依次为0xFF,0xFF,0xFF,0xFF,由此计算机将会帮我们计算出这个无符号int类型的值为232-1,也就是无符号int类型的最大值


这其中计算机是根据0x1-0x4这四个字节上的值,以及无符号int类型的解释方式,最终得到的这个无符号int类型的值。


由此可见,计算机在解释一个数据类型的值时主要有四个因素:

  1. 位排列规则(大端或者小端)
  2. 起始位置
  3. 数据类型的字节数
  4. 数据类型的解释方式

对于特定的系统来说,前两种因素都是特定的,而对于后两种因素的改变,则可以改变一个数据类型的值的最终计算结果,这就是强制类型转换。对于大部分高级程序设计语言来讲,都提供了强制类型转换。


比如C语言,我们可以将一个无符号int类型的值强制转换为其它类型,在转换之后,对于上面四个因素之中,改变的是最后两个。

#include <stdio.h>

int main(){
    unsigned int x = 0xFFFFFF61;
    int *p = &x;
    char *cp = (char *)p;
    printf("%c\n",*cp);
}

这是一个简单的强制类型转换示例,可以看到我们将一个无符号int类型的值,先赋给了一个int类型的指针,又强制转换成了char类型的指针,最终我们输出这个char类型指针所代表的字符。
结果的输出是一个a


输出a的原因就是由上面的四个因素决定的,我们看这个具体程序上的四个因素。


  1. cp指针的值与x变量的起始内存地址相等。(起始位置)
  2. 使用的linux系统是小端表示法,也就是说假设x变量的起始内存地址为0x1,那么0x1-0x4的值分别为0x61、0xFF、0xFF、0xFF。(位排列规则)
  3. char只占一个字节,因此会只读取0x61这个值。(数据类型的字节数,或者说大小)
  4. 0x61为十进制的97,对应ascii表的话,代表的是字符a,因此最终输出了a。(数据类型的解释方式)

可以看出,强制类型转换有时候会让结果变的让人难以预料,因此这种技巧一般不太推荐使用,但是这种手段也确实是程序设计语言所必需的。



2.1.4 表示字符串


这一点其实上面我们已经提到了,我们知道97其实代表的是字符’a’,而这个的由来就是根据ascii表来的,我们在linux系统上可以输入man ascii命令来查看。



2.1.5 布尔代数


  • 布尔运算~ 对应逻辑运算NOT,在命题逻辑中用﹁表示
  • 布尔运算& 对应逻辑运算AND,在命题逻辑中用∧表示
  • 布尔运算 | 对应逻辑运算OR,在命题逻辑中用∨表示
  • 布尔运算^ 对应逻辑运算异或,在命题逻辑中用⊕表示

布尔代数定义了四种二进制的运算,这四种运算分别是或、与、非和异或。下图展示了在布尔代数的知识体系中,对这四种运算的定义。


在这里插入图片描述

从左至右依次是与、或、非、以及异或。这个图阐述的是针对一位二进制的运算结果,我们可以将其扩大到N位二进制。比如两个二进制[aw,aw-1…a1]和[bw,bw-1…b1],它们的四种运算则是对两者每一个相对应的位上做相应的运算。



2.1.6 java中的位级运算


  • &(AND):
    运算规则:参与运算的数字,低位对齐,高位不足的补零,对应的二进制位都为1,则运算结果为1,否则为0
  • | (OR):
    运算规则:参与运算的数字,低位对齐,高位不足的补零,对应的二进制位有一个为1则为1,否则为0
  • ^(XOR):
    运算规则:参与运算的数字,低位对齐,高位不足的补零,对应的二进制位相同为零,不相同为1
  • ~(NOT):
    运算规则:只操作一个数字,将该数字中为1的位变成0,为0的位变成1

注:&和|在java中即可以表示位运算,又可以表示逻辑运算. 在不同场景表示的意思编译器会自动识别



2.1.7 java中的逻辑运算


&,| ,!(非), &&(双与), ||(双或)

&&运算的特点:和&运算的结果是一样的,但运算的过程有点不一样。
&:无论左边运算的结果是什么,右边都参与运算。
&&:当左边的运算结果为false,右边不参与运算。

||运算的特点:和|运算的结果是一样的,但运算的过程有点不一样。
|:无论左边运算的结果是什么,右边都参与运算。
||:当左边的运算结果为true,右边不参与运算。



2.1.8 java中的移位运算


  • " < <" 左移:右边空出的位上补0,左边的位将从字头去掉

右移分为算术右移和逻辑右移。


  • ">>"算术右移:右边的位被挤掉。对于左边移出的空位,补上最高有效位的值。几乎所有的编译器/机器组合都对有符号数使用算术右移
  • ">>>"逻辑右移:右边的位被挤掉,对于左边移出的空位一概补上0。对于无符号数,右移必须是逻辑的。




2.2 整数表示


在这里插入图片描述



2.2.1 无符号数编码


在这里插入图片描述

无符号编码属于相对较简单的格式,因为它符合我们的惯性思维,上述定义其实就是对二进制转化为十进制的公式而已,只不过在一向严格的数学领域来说,是要给予明确的含义的。



2.2.2 补码编码


最常见的有符号数的计算机表示方式就是补码形式。在这个定义中,将字的最高有效位解释为负权,我们用函数B2T来表示。java中使用的就是补码。


在这里插入图片描述

我们观察这个公式,不难看出,补码格式下,对于一个w位的二进制序列来说,当最高位为1,其余位全为0时,得到的就是补码格式的最小值,即
在这里插入图片描述
而当最高位为0,其余位全为1时,得到的就是补码格式的最大值,根据等比数列的求和公式,即
在这里插入图片描述



2.2.3 有符号数和无符号数之间的转换


在C语言当中,我们经常会使用强制类型转换,而在之前的章节中,也提到过强制类型转换。强制类型转换不会改变二进制序列,但是会改变数据类型的大小以及解释方式,那么考虑相同整数类型的无符号编码和补码编码,数据类型的大小是没有任何变化的,变化的就是它们的解释方式。比如1000这个二进制序列,如果用无符号编码解释的话就是表示8,而若采用补码编码解释的话,则是表示-8。


一、补码转换为无符号数:


在这里插入图片描述


二、无符号数转换为补码:


在这里插入图片描述



2.2.4 C语言中的有符号数和无符号数


有符号数和无符号数的本质区别其实就是采用的编码不同,前者采用补码编码,后者采用无符号编码。


在C语言中,有符号数和无符号数是可以隐式转换的,不需要手动实施强制类型转换。不过也正是因为如此,可能你不小心将一个无符号数赋给了有符号数,就会造成出乎意料的结果,就像下面这样。

#include <stdio.h>

int main(){
    short i = -12345;
    unsigned short u = i;
    printf("%d %d\n",i,u);
}

输出结果为-12345,53191。一个不小心,一个负数就变成正数了。


再看下面这个程序,它展示了在进行关系运算时,由于有符号数和无符号数的隐式转换所导致的违背常规的结果。

#include <stdio.h>

int main(){
    printf("%d\n",-1 < 0U);
    printf("%d\n",-12345 < 12345U);
}

两个结果都为0,也就是false,这与我们直观的理解是违背的,由于C语言对同时包含有符号和无符号数表达式的这种处理方式,出现了一些奇特的行为。当执行一个运算时,如果它的一个运算数是有符号的而另一个是无符号的,那么C语言会隐式地将有符号参数强制类型转换为无符号数,并假设这两个数都是非负的,来执行这个运算。就像我们将要看到的,这种方法对于标准的算术运算来说并无多大差异,但是对于像<和>这样的关系运算符来说,它会导致非直观的结果。



2.2.5 扩展一个数字的位表示


当我们将一个短整型的变量转换为整型变量时,就涉及到了位的扩展,此时由两个字节扩充为四个字节。


在进行位的扩展时,最容易想到的就是在高位全部补0,也就是将原来的二进制序列前面加入若干个0,也称为零扩展。还有一种方式比较特别,是符号扩展,也就是针对有符号数的方式,它是直接扩展符号位,也就是将二进制序列的前面加入若干个最高位。


  • 无符号数的零扩展:要将一个无符号数转换为一个更大的数据类型,我们只要简单地在表示的开头添加0。这种运算被称为零扩展。
  • 补码数的符号扩展:要将一个补码数字转换为一个更大的数据类型,可以执行一个符号扩展,在表示中添加最高有效位的值。

对于零扩展来说,很明显扩展之后的值与原来的值是相等的,而对于符号扩展来说,也是一样,只不过没有零扩展来的直观。我们在计算补码时有一个比较简单的办法,就是符号位若为0,则与无符号是类似的。若符号位为1,也就是负数时,可以将其余位取反最终再加1即可。因此当我们对一个有符号的负数进行符号扩展时,前面加入若干个1,在取反之后都为0,因此依旧会保持原有的数值。


总之,在对位进行扩展时,是不会改变原有数值的。



2.2.6 截断数字


截断与扩展相反,它是将一个多位二进制序列截断至较少的位数,也就是与扩展是相反的过程。截断可能会导致数据的失真。


一、对于无符号编码来说,截断后就是剩余位数的无符号编码数值


在这里插入图片描述

二、 对于补码编码来说,截断后的二进制序列与无符号编码是一样的,因此我们只需要多加一步,将无符号编码转换为补码编码就可以了。


在这里插入图片描述


不难看出,具有有符号和无符号数的语言,可能会因此引起一些不必要的麻烦,而且无符号数除了能表示的最大值更大以外,似乎并没有太大的好处。因此有很多语言是不支持无符号数的。如Java语言,就只有有符号数,这样省去了很多不必要的麻烦。无符号数很多时候只是为了表示一些无数值意义的标识,比如我们的内存地址,此时的无符号数就有点类似于数据库主键或者说键值对中的键值的概念,仅仅是一个标识而已。





2.3 整数运算


平时的编程过程中,当进行整数运算时,经常会遇到一些奇怪的结果,比如两个正数加出负数,表达式x<y和表达式x-y<0会产生不同的结果,这些都是由于数值表示的有限性导致的



2.3.1 无符号数加法


考虑一个4位数字表示,x=9和y=12的位表示分别为[1001]和[1100]。它们的和是21,5位的表示为[10101]。由于位数都是有限制的。因此在我们计算过后,可能需要对结果进行截断操作。如果丢弃最高位,我们就得到[0101],也就是说,十进制值的5,这就和值21 mod 16=5一致。


我们可以假设是进行w位的二进制运算,那么在运算之后的实际结果也一定是w位的。这里有两种情况,一种是结果依然是w位的,也就是w+1位为0。第二种则是达到了w+1位,这个时候我们需要将结果截断到w位。第一种情况则属于正常的加法运算,对于第二种来说,假设完整的结果为sum,则实际的结果最终为sum mod 2w。书中给出了一个公式。


在这里插入图片描述

对于第一种情况,x+y < 2w,则sum mod 2w是与sum一致的。对于第二种来说,当2w =< x+y < 2w+1,对 x + y 进行2w的取模运算,与 x + y - 2w是等价的。



2.3.2 无符号的非


无符号的加法会形成一个阿贝尔群,这意味着无符号加法满足一些特性。比如可交换,可结合等等。在这个群中,单位元为0,那么每一个群中的元素,也就是每一个无符号数u,都会拥有一个逆元u-1 ,满足 u+ u-1 = 0。


无符号数的逆元满足以下公式(公式中的左边就是u-1 ,由于图中的符号不好表示,所以以u-1替代)。


在这里插入图片描述



2.3.3 补码加法


对于补码的加法来讲,是建立在无符号加法的基础上来进行的,这么做的一个重要前提是,它们的位表示都是一样的。其实补码加法就是先按照无符号加法进行运算,然后在进行无符号和有符号的转换。因此,对于两个补码编码的有符号数来说,他们进行加法运算的最终结果为:U2TwU2T_{w}U2Tw(sum mod 2w)(假设实际的无符号加法运算结果为sum)。


与无符号加法不同的是,这里会出现三种结果,一种是正常的结果,一种是正溢出,一种是负溢出。


在这里插入图片描述



2.3.4 补码的非


对于补码来说,它同样的与无符号有一样的特性,也就是对于任意一个w位的补码数t来说,它都有唯一的逆元t-1 ,使得t + t-1 = 0。


一个w位的补码数的范围在-2w-1(包含)到2w-1之间,直观的可以看出,对于不等于-2w-1的补码数x来说,它的逆元就是-x。而对于-2w-1来说,它的二进制位表示为1后面跟着w-1个0,我们需要找到一个数与其相加之后结果为0。这种时候我们需要考虑的是,如果是-x,也就是2w-1,则它的位表示需要w+1位,是不存在的。因此我们需要考虑溢出的情况,对照上面的补码加法的公式,负溢出的时候需要加上2w,因此-2w-1的逆元就是-2w + 2w-1 = -2w-1,也就是它本身。所以,补码的逆元满足以下公式(这里与上面一样,公式左边就是t-1)。


在这里插入图片描述



2.3.5 无符号数乘法


无符号的乘法与加法类似,它的运算方式是比较简单的,只是也可能产生溢出。对于两个w位的无符号数来说,它们的乘积范围在0到(2w-1)2之间,因此可能需要2w位二进制才能表示。因此由于位数的限制,假设两个w位的无符号数的真实乘积为pro,根据截断的规则,则实际得到的乘积为pro mod 2w


C语言中的无符号乘法被定义为产生w位的值,就是2w位的整数乘积的低位表示的值。


在这里插入图片描述



2.3.6 补码乘法


与加法运算类似,补码乘法也是建立在无符号的基础之上的,因此我们可以很容易的得到,对于两个w位的补码数来说,假设它们的真实乘积为pro,则实际得到的乘积为 U2TwU2T_{w}U2Tw(pro mod 2w)。C语言中的有符号乘法是通过将2w位的乘积截断为w位来实现的。将一个补码数截断为w位相当于先计算该值模2w,再把无符号数转换为补码。得到公式如下:


在这里插入图片描述



2.3.7 乘法的优化


乘法运算的时间周期是很长的。因此计算机界的高手们想出了一种方式可以优化乘法运算的效率,就是使用移位和加法来替代乘法。优化的前提是对于一个w位的二进制数来说,它与2k的乘积,等同于这个二进制数左移k位,在低位补k个0。


在这里插入图片描述

举个例子,对于x * 17,我们可以计算x * 16 + x = (x << 4) + x ,这样算下来的话,我们只需要一次移位一次加法就可以搞定这个乘法运算。而对于x * 14,则可以计算 x * 8 + x * 4 + x * 2 = (x << 3) + (x << 2) + (x << 1) ,更快的方式我们可以这么计算,x * 16 - x * 2 = (x << 4) - (x << 1) 。


这里最后需要提一下的是,加法、减法和移位的速度并不会总快于乘法运算,因此是否要进行上面的优化就取决于二者的速度了


注意,无论是无符号运算还是补码运算,乘以2的幂都可能会导致溢出。结果表明,即使溢出的时候,我们通过移位得到的结果也是一样的。



2.3.8 整数除法


除法与乘法不同,它不满足加法的分配律,而且它有时候会比乘法运算更慢,但是我们只能针对除数可表示为2k的除法运算进行优化,只不过我们用的是右移,而不是左移。无符号和补码数分别使用逻辑移位和算术移位来达到目的。为了准确进行定义,我们要引人一些符号。对于任何实数a,定义└ a ┘为唯一的整数a’,使得a’<a<a’+1,例如,└ 3.14 ┘ = 3,└ -3.14 ┘ = -4而└ 3 ┘= 3,同样,定义[a]为唯一的整数a’,使得a’-1<a<a’。例如,[3.14] = 4,[-3.14] = -3,而[3] = 3。对于x>= 0和y>0,结果会是└ x/y┘,而对于x<0和y>0,结果会是[x/y]。也就是说,它将向下舍入一个正值,而向上舍入一个负值。


  • 无符号除法(逻辑右移):无符号数除以2k等价于右移k位
    在这里插入图片描述

  • 补码除法(算术右移),x>=0,效果与无符号除法(逻辑右移)是一样的。在这里插入图片描述

  • 补码除法(算术右移),x<0:
    在这里插入图片描述





2.4 浮点数


对于浮点数的表示以及运算规则,在以前是各个计算机制造商各自有一套自己的标准,这给程序的可移植性造成了很大的困扰。最终在1985年左右,浮点数标准IEEE754就应运而生统一了浮点数的标准,浮点数不仅仅是为了让数值的表示更加精确,也是为了表示一些整数无法达到的数字,比如一些接近于0的数字,或者一些非常大的数值。因此浮点数对于计算机的意义,可以说是相当之大。



2.4.1 二进制小数


我们来看看二进制是如何表示小数的,这有助于我们理解浮点数的表示。如果是一个十进制小数,我们都再熟悉不过了,对于12345.6789来说,它的值是由下列式子得到的。

12345.6789 = 1 * 104 + 2 * 103 + 3 * 102+ 4 * 101 + 5 * 100 + 6 * 10-1 + 7 * 10-2 + 8 * 10-3 + 9 * 10-4

这对我们来说应该是常识,那么对于二进制小数也是类似的,考虑这样一个小数10010.1110,它的值则可以由以下式子得到。

10010.1110 = 1 * 24 + 0 * 23 + 0 * 22 + 1 * 21 + 0 * 20 + 1 * 2-1 + 1 * 2-2 + 1 * 2-3 + 0 * 2-4 = 16 + 2 + 1/2 + 1/4 + 1/8 = 18.875

从这个角度来看,二进制小数其实与十进制小数是一样的计算方式,只是这里是2的整数次幂而已。


在书中给出了二进制小数的公式,对于一个形式为bmb_{m}bmb0b_{0}b0.b−1b_{-1}b1b−nb_{-n}bn的二进制小数b来说,它的值为以下计算方式。
在这里插入图片描述
这里需要提醒的是,二进制小数不像整数一样,只要位数足够,它就可以表示所有整数。二进制小数无法精确的表示任意小数,比如最简单的,十进制小数0.3这样一个小数,二进制是无法精确的表示它的。



2.4.2 IEEE标准


IEEE标准采用类似于科学计数法的方式表示浮点小数,即我们将每一个浮点数表示为 V = (-1)S* M * 2E


  • S为符号位,为0时为正,为1时为负。
  • M为尾数,是一个二进制小数,它的范围是0至1-ε,或者1至2-ε(ε的值一般是2-k次方,其中设k > 0)
  • E为阶码,是一个二进制整数,可正可负,为了给尾数加权

浮点格式分为两种,一种是单精度,一种是双精度。单双精度分别对应于编程语言当中的float和double类型。其中float是单精度的,采用32位二进制表示,其中1位符号位,8位阶码以及23位尾数。double是双精度的,采用64位二进制表示,其中1位符号位,11位阶码以及52位尾数。


从上面的位数上就能看出,双精度浮点数所表示的范围将远远大于单精度浮点数。针对阶码E的值,浮点数的值可以分为三种不同的情况,分别是规格化的,非规格化的以及特殊值。


下图是书中对于单精度的三种情况的图示描述,分别是1、2、3,其中3也就是特殊值又分了两种情况3a和3b


在这里插入图片描述

  1. 情况1:规格化的值
    规格化的浮点数是上述的第1种情况,对于单精度来说,也就是阶码位不为0且不为255的这种情况。
    在此范围内的浮点数,阶码会被转换成一个“偏置”后的有符号数。“偏置”的含义就是在原有的值的基础上加上一个偏移量,对于阶码位数为k的情况来说,偏移量Bias = 2k-1-1。假设e是阶码的无符号数值,那么真实的阶码E = e - Bias。举个例子,假设阶码位数为8,则Bias = 127。由于8位阶码下的规格化的浮点数的阶码范围是1至254,因此真实阶码的范围则为-126至127。
    对于尾数的解释,则是一个小于1的小数或者0。也就是假设尾数位表示为fn−1f_{n-1}fn1f0f_{0}f0,则f的值为0.fn−10.f_{n-1}0.fn1f0f_{0}f0。这只是尾数的值,当计算浮点数数值的时候,会在尾数值的基础上加1,也就是真实的尾数M = 1 + f。相当于我们省掉了1位二进制,形成了浮点数表示的约定,默认尾数的值还要带上一个最高位的1。
  2. 情况2:非规格化的值
    非规格化的浮点数对应图中的第2种情况,也就是阶码全为0的时候。
    按照上面规格化的阶码求值方式来说,非规格化的阶码值应该固定在-Bias这个值上面。不过这里有一个小技巧,我们设定阶码的值E = 1 - Bias。这样做是为了能够平滑的从非规格化的浮点数过渡到规格化的浮点数。
    对于尾数的解释,非规格化的方式与规格化不同,它不会对尾数进行加1的处理,也就是说,真实的尾数M = f。这是为了能够表示0这个数值,否则的话尾数总是大于1,那么无论如何都将得不到0这个数值。
    非规格化的浮点数除了可以表示0以外,它还有一个作用,就是可以表示接近于0的数值。另外,在浮点数当中,0的表示有两种,一种是位表示全部为0,则为+0.0。还有一种则是符号位为1,其余全为0,此时为-0.0。
  3. 情况3:特殊值
    特殊值则对应图中的3a和3b这两种情况,也就是阶码全为1的时候。
    在阶码全为1时,如果尾数位全为0,则表示无穷大。符号位为0则表示正无穷大,相反则表示负无穷大。倘若尾数位不全为0时,此时则表示NaN,表示不是一个数字。


2.4.3 舍入


之前我们已经提到过,有很多小数是二进制浮点数无法准确表示的,因此就难免会遇到舍入的问题。这一点其实在我们平时的计算当中会经常出现,就比如之前我们提到过的0.3,它就是无法用浮点小数准确表示的。使用Java语言打印出了0.3的二进制表示,是这样的一个数字,0 01111101 00110011001100110011010。我们来简单算一下,这个数值大约是多少。它的阶码在偏置之后的值为-2,它的尾数位在加1之后为1 + 1/8 + 1/16 + 1/128 + 1/256 = 1.19921875。后面还有有效位,不过我们只大概计算一下,就不算那么精确了,最终算出来的值为0.2998046875。可以看出,这个值离0.3已经非常接近了,而且我们还省略了一小部分有效小数位,但是不管怎么说,二进制无法像十进制小数一样,准确的表示0.3这个数值。因此舍入这一部分是浮点数无法逃脱的内容。


在我们平时日常使用的十进制当中,我们一般对一个无理数或者有位数限制的有理数进行舍入时,大部分时候会采取四舍五入的方式,这算是一种比较符合我们期望的舍入方式。


不过针对浮点数来说,我们的舍入方式会更丰富一些。一共有四种方式,分别是向偶数舍入、向零舍入、向上舍入以及向下舍入。


  1. 向偶数舍入(IEEE默认的舍入方式):向偶数舍入有两个原则,一是向最接近的值舍入,再一个是当处在"中间值"时看有效数值是否是偶数,如果是偶数则直接舍去不进位,如果是奇数则进位。比如以下几个十进制都要求舍入到个位:1.4和1.6,所谓的"中间值"就是,比如1.4,因为是舍入到个位,所以它处在1和2之间,那中间值就是(1+2)/2=1.5。那么1.4就不是中间值,根据向最接近的值舍入的原则1.4跟1更近,所以舍入得到1。同理,1.6舍入得到2。再看1.5和2.5:根据上述可知,1.5和2.5都是中间值,那么就要看有效数字是否是偶数,1.5的有效数字是1,是奇数,所以要进位得到2;而2.5的有效数字是2,所以直接舍去不进位得到2。
    中间值是这么算的:看有效数字后面的数值有多少位,假设有n位,则半值为(基数)n/2(基数为几进制),比如1.4,有效数字后面是4,1位数字,十进制的基数是10,那么半值为5,二进制也是同样的道理。在二进制中,偶数我们认为是末尾为0的数,​二进制10.010,舍入到小数点下一位,那么看有效数字后面的数字是10,半值22/2 = 2 = 二进制的10,正好是中间值,那么看有效数字是10.0,是偶数,所以直接舍去得10.0。再看10.011,中间值同样是10,而有效数字后面的11比10大则直接进位得10.1。再看10.110,半值同样是10,有效数字后面的10正好是中间值,看有效数字10.1是奇数,那么要进1位,得11.0。
  2. 向零舍入:是向靠近零的值舍入,比如将1.5舍入为1,将0.1舍入为0。、
  3. 向上舍入,是往大了(也就是向正无穷大)舍入的意思,比如将1.5舍入为2,将-1.5舍入为-1。
  4. 向下舍入:与向上舍入相反,是向较小的值(也就是向负无穷大)舍入的意思。

这里需要提一下的是,除了向偶数舍入以外,其它三种方式都会有明确的边界。这里的含义是指这三种方式舍入后的值x’与舍入之前的值x会有一个明确的大小关系,比如对于向上舍入来说,则一定有x <= x’。对于向零舍入来说,则一定有|x| >= |x’|。


对于向偶数舍入来讲,它最大的作用是在统计时使用。向偶数舍入可以让我们在统计时,将舍入产生的误差平均,从而尽可能的抵消。而其它三种方式在这方面都是有一定缺陷的,向上和向下舍入很明显,会造成值的偏大或偏小。而对于向零舍入来讲,如果全是正数的时候则会造成结果偏小,全是负数的时候则会造成结果偏大。



2.4.4 浮点运算


在IEEE标准中,制定了关于浮点数的运算规则,就是我们将把两个浮点数运算后的精确结果的舍入值,作为我们最终的运算结果。(也有一些小技巧来避免执行这种精确的计算,当参数中有一个是特殊值(如-0、-00或NaN)时,IEEE标准定义了一些使之更合理的规则。例如,定义1/-0将产生一00,而定义1/+0会产生+00)正是因为有了这一个特殊点,就会造成浮点数当中,很多运算不满足我们平时熟知的一些运算特性。


浮点加法不具有加法的结合律,也就是a + b + c != a + (b + c),例如:

public static void main(String[] args){
        System.out.println(1f + 10000000000f - 10000000000f);
        System.out.println(1f + (10000000000f - 10000000000f));
    }

这一段程序会依次输出0.0和1.0,正是因为舍入而造成的这一误差。在第一个输出语句中,计算1f+10000000000f时,会将1这个有效数值舍入掉,而导致最终结果为0.0。而在第二个输出语句中10000000000f-10000000000f将先得到结果0.0,因此最终的结果为1.0。


浮点乘法也不具有结合律和分配律,也就是 a * b * c != a * (b * c); a * (b + c) != a * b + a * c。


浮点数失去了很多运算方面的特性,因此也导致很多优化手段无法进行,比如我们试图优化下面这样一段程序。

		//   优化前       
        float x = a + b + c;
        float y = b + c + d;
        // 编译器可能试图通过产生下列代码来省去一个浮点加法 
        //   优化后       
        float t = b + c;
        float x = a + t;
        float y = t + d;

然而,对于x来说,这个计算可能会产生与原始值不同的值,因为它使用了加法运算的不同的结合方式。在大多数应用中,这种差异小得无关紧要。不幸的是,编译器无法知道在效率和忠实于原始程序的确切行为之间,使用者愿意做出什么样的选择。结果是,编译器倾向于保守,避免任何对功能产生影响的优化,即使是很轻微的影响。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值