学习笔记:原码,反码和补码

一道面试题的思考

最近找工作,面试了几家公司,其中有一道面试题印象深刻:

1
2
3
4
5
6
7
8
Integer a = 12;
Integer b = 12;

Integer c = 225;
Integer d = 225;

System.out.println(a == b);
System.out.println(c == d);

正确答案是第一个true,第二个为false

面试官又问原因,我回答:因为Integer中有一个常量池,在-128 ~ 127这个范围内的数会直接从常亮池中获取(具体在Integer源码中有实现)。

面试官接着又问:为什么常亮池规定范围是-128 ~ 127,而不是其他?emmm……这个还真没仔细研究过。

为了彻底弄明白,事后我又查了很多资料,发现这货原来就是我们大学时候计算机基础里面的原码,反码和补码那一块内容。毕业这么多年早忘了,这是以前只有考试时候才会临时抱佛脚死记硬背一遍的知识,整理这篇文章,就算是重新复习一下吧。

机器数和真值

在了解原码,反码和补码之前,我们首先来了解一下机器数和真值的概念。

机器数

一个数在计算机中的二进制表示形式,叫做这个数的机器数。机器数是带符号的,在计算机中用一个数的二进制最高位存放符号,正数为0,负数为1。例如:

1
2
3
十进制		二进制
+1 0000 0001
-1 1000 0001

真值

第一位是符号位,所以机器数的值不等于真实的值。比如上面的1000 0001,最高位为1,代表负数,真实值为-1,而不是129。因此,为了区别,将带符号位的机器数对应的真正数值称为机器数的真值。例如:

1
2
0000 0001的真值 = +000 0001
1000 0001的真值 = -000 0001

原码,反码和补码

在探究为何机器要使用补码之前,我们先来了解原码,反码和补码的概念。对于一个数,计算机要使用一定的编码方式进行存储,原码,反码和补码是机器存储一个具体数字的编码方式。

原码

原码就是符号位加上真值的绝对值,即用第一位表示符号位,其余位表示值,例如:

1
2
[+1]原 = 0000 0001
[-1]原 = 1000 0001

由于第一位是符号位,所以8位二进制数的取值范围是:

1
[1111 1111, 0111 1111]

1
[-127, 127]

反码

反码的表示方法:

  • 正数的反码是其本身
  • 负数的反码是其原码的基础上,符号位不变,其余各个位取反
1
2
[+1] = [0000 0001]原 = [0000 0001]反
[-1] = [1000 0001]原 = [1111 1110]反

可见如果一个反码表示的是负数,人脑是无法通过直接观察来得到它的数值,通常要将它转换成原码再计算。

补码

补码的表示方法:

  • 正数的补码是其本身
  • 负数的补码是其原码的基础上,符号位不变,其余各个位取反,然后+1(即在反码的基础上+1)
1
2
[+1] = [0000 0001]原 = [0000 0001]反 = [0000 0001]补
[-1] = [1000 0001]原 = [1111 1110]反 = [1111 1111]补

对于负数的补码,人脑也是无法通过直接观察来得到它的数值,通常也要转化成原码再计算。

为什么要使用原码,反码和补码

现在我们知道计算机有三种编码方式表示一个二进制数,对于正数,这三种编码方式的结果都相同:

1
[+1] = [0000 0001]原 = [0000 0001]反 = [0000 0001]补

所以无需做过多解释,对于负数,三种编码方式结果都不一样:

1
[-1] = [1000 0001]原 = [1111 1110]反 = [1111 1111]补

可见负数的原码,反码和补码完全不同,既然原码才是人脑直接识别并用于计算的方式,为何还要引入反码和补码?

首先,人脑可以知道第一位是符号位,在计算的时候,我们可以根据第一位的符号位,选择真值区域进行加减。但是对于计算机,加减乘除是最基本的运算,要设计的尽量简单。计算机鉴别符号位显然会让计算机的基础电路设计变得十分复杂,于是人们想到了将符号位也参与进来运算的方法。

我们知道,根据运算法则,减去一个正数等于加上这个数的负数,即:1 - 1 = 1 + (-1) = 0

所以机器可以只有加法而没有减法,这样设计计算机的运算就变得很简单。于是人们开始探索,将符号位也参与运算,并且只保留加法的方法,首先来看原码:

1
1 - 1 = 1 + (-1) = [0000 0001]原 + [1000 0001]原 = [1000 0010]原 = -2

如果用原码表示,让符号位也参与运算,对于减法来说,这个结果显然是不对的,1 - 1怎么也不可能等于-2。这也就是为什么计算机内部不使用原码表示一个数。

为了解决原码做减法的问题,引入了反码:

1
1 - 1 = 1 + (-1) = [0000 0001]原 + [1000 0001]原 = [0000 0001]反 + [1111 1110]反 = [1111 1111]反 = [1000 0000]原 = -0

如果用反码做减法运算,计算结果部分是正确的,问题就出现在-0上。虽然我们知道+0和-0是一样的,但是带符号的0实际上是没有意义的,并且会有[0000 0000][1000 0000]两个编码表示0。

于是出现了补码,解决了0的符号问题以及两个编码的问题:

1
1 - 1 = 1 + (-1) = [0000 0001]原 + [1000 0001]原 = [0000 0001]补 + [1111 1111]补 = [0000 0000]补 = [0000 0000]原 = 0

这样0就可以用[0000 0000]表示,之前出现的-0也就不存在了,而且可以用[1000 0000]表示-128:

1
(-1) + (-127) = [1000 0001]原 + [1111 1111]原 = [1111 1111]补 + [1000 0001]补 = [1000 0000]补 = -128

-1 - 127的结果是-128,与运算结果一致。值得注意的是由于用以前的-0的补码来表示-128,所以-128实际上并没有原码和反码表示(-128的补码[1000 0000]算出来的原码是[0000 0000],这是不正确的)。

回到本文最开始提到的面试题,使用补码,不仅修复了0的符号以及存在两个编码的问题,而且可以多表示一个最低数,这就是为什么8位二进制,用原码或反码表示的范围是[-127, 127],而使用补码表示的范围是[-128, 127]

因为机器使用补码,所以对于编程常用到的32位int型,可以表示的范围是[-231, 231 - 1],因为第一位表示符号位,使用补码时又可以多表示一个最小值。

补充

上面提到了用补码表示8位二进制的范围[-128, 127],为什么要使用8位二进制?

在计算机中,一个字节长度是8位,使用一个字节就可以存放ASCII编码,也就是所有的数字,大小写字母和一些特殊字符(总共有255个)。

8位二进制表示的最大值是255,刚好能表示255个ASCII字符。而我们使用的汉字用UNICODE编码,UNICODE编码要用2个字节,所以要使用16位二进制数才能表示一个UNICODE字符。

另外,一个UTF-8编码占4个字节。