一道面试题的思考
最近找工作,面试了几家公司,其中有一道面试题印象深刻:
1 | Integer a = 12; |
正确答案是第一个true
,第二个为false
。
面试官又问原因,我回答:因为Integer中有一个常量池,在-128 ~ 127
这个范围内的数会直接从常亮池中获取(具体在Integer源码中有实现)。
面试官接着又问:为什么常亮池规定范围是-128 ~ 127
,而不是其他?emmm……这个还真没仔细研究过。
为了彻底弄明白,事后我又查了很多资料,发现这货原来就是我们大学时候计算机基础里面的原码,反码和补码那一块内容。毕业这么多年早忘了,这是以前只有考试时候才会临时抱佛脚死记硬背一遍的知识,整理这篇文章,就算是重新复习一下吧。
机器数和真值
在了解原码,反码和补码之前,我们首先来了解一下机器数和真值的概念。
机器数
一个数在计算机中的二进制表示形式,叫做这个数的机器数。机器数是带符号的,在计算机中用一个数的二进制最高位存放符号,正数为0,负数为1。例如:
1 | 十进制 二进制 |
真值
第一位是符号位,所以机器数的值不等于真实的值。比如上面的1000 0001
,最高位为1,代表负数,真实值为-1,而不是129。因此,为了区别,将带符号位的机器数对应的真正数值称为机器数的真值。例如:
1 | 0000 0001的真值 = +000 0001 |
原码,反码和补码
在探究为何机器要使用补码之前,我们先来了解原码,反码和补码的概念。对于一个数,计算机要使用一定的编码方式进行存储,原码,反码和补码是机器存储一个具体数字的编码方式。
原码
原码就是符号位加上真值的绝对值,即用第一位表示符号位,其余位表示值,例如:
1 | [+1]原 = 0000 0001 |
由于第一位是符号位,所以8位二进制数的取值范围是:
1 | [1111 1111, 0111 1111] |
即
1 | [-127, 127] |
反码
反码的表示方法:
- 正数的反码是其本身
- 负数的反码是其原码的基础上,符号位不变,其余各个位取反
1 | [+1] = [0000 0001]原 = [0000 0001]反 |
可见如果一个反码表示的是负数,人脑是无法通过直接观察来得到它的数值,通常要将它转换成原码再计算。
补码
补码的表示方法:
- 正数的补码是其本身
- 负数的补码是其原码的基础上,符号位不变,其余各个位取反,然后+1(即在反码的基础上+1)
1 | [+1] = [0000 0001]原 = [0000 0001]反 = [0000 0001]补 |
对于负数的补码,人脑也是无法通过直接观察来得到它的数值,通常也要转化成原码再计算。
为什么要使用原码,反码和补码
现在我们知道计算机有三种编码方式表示一个二进制数,对于正数,这三种编码方式的结果都相同:
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个字节。