这天,我正在一个技术交流群跟群友交流学(mo)习(yu)经验,忽然看到了这样的一个问题。
看到这个问题,我想到了之前的一个场景是要获取近30天的日期列表,我的思路是通过System.currentTimeMillis()获取当前时间戳,然后依次减去对应的毫秒数(24 * 60 * 60 * 1000),后来发现问题:30 * 24 * 60 * 60 * 1000因为超过了int的上限值而变为了一个负数。于是我回复他:
这个时候有位热心的大佬给解释了一波,然后我发现又特喵的触及到自己的知识盲区了。
17世纪至18世纪的德国数学家莱布尼茨,是世界上第一个提出二进制记数法的人,现代的电子计算机技术全部采用的是二进制数,因为它只使用0、1两个数字符号,易于用电子方式实现。计算机内部处理的信息(例如文本、图片、视频、动画等)也都是通过二进制数(Binary)来表示的。
tip:二进制与十进程还是有很大差异的,十进制是我们生活中最常用的一种进制,基本上都是用作计数,而二进制可以通过计算机经过复杂的处理从而表现出文本、图像、动画等一些复杂的表现形式。
计算机只能识别0,1这种二进制数据,所以需要处理的数据也必须先转换为二进制进行存储,而人脑最容易理解的表示方式无非是直接将10进制转换为二进制进行表示,例如10进制的2可以转换为转换为2进制的10,为了区分正负数可以将最高位作为符号位,0代表正数,1代表负数。所以10进制的2在8位二进制中的表示方法是 “0000 0010”,符号位右边的叫做真值位(也可以理解成绝对值),符号位+真值位就构成了计算机中的原码。
反码的计算方式如下
- 正数的反码是其本身
- 负数的反码:符号位不变、真值位取反
例如:1000 0010转换为反码后为 1111 1101
为什么要引入反码这个概念呢?原因是这样的:如果计算机基于原码对两个正数做减法运算,那么就需要对符号位进行复杂的处理,来判断最终的结果是正数还是负数,这样显然会让计算机的实现变得异常复杂。
tip:人们根据数学中的运算法则"减去一个正数等于加上这个数的相反数"简化了计算机的运算逻辑使其只有加法而没有减法。
// 基于原码的减法运算 1 - 1 = 1 + (-1) 0000 0001 + 1000 0001 = 1000 0010 // -2 (十进制)
上面这个例子可以看到通过原码计算出来的结果并非准确的,这也是为何计算机内部不使用原码进行计算的原因。然后再来看一下基于反码的减法运算。
// 基于反码的减法运算 1 - 1 = 1 + (-1) 0000 0001 (反码) + 1111 1110 (反码) = 1111 1111 (反码) = 1000 0000 (原码) = -0 (十进制)
可以看到计算机通过反码将符号位也参与运算的方式实现了减法运算。
反码虽然已经实现了减法运算,但依旧还是存在一些弊端
0的存在两种表示方式
+0 = 0000_0000(反码) = 0000_0000(原码)
-0 = 1111_1111 (反码) = 1000_0000(原码)
负数加负数的运算逻辑有偏差
//基于反码的减法运算 -1 - 2 = -1 + (-2) 1111 1110 (反码) + 1111 1101 (反码) = 1111 1011 (反码) = 1000 0100 (原码) = -4
根据上面这个例子可以看到反码的运算逻辑并不适用于两个负数相加的情况,为了解决上述的这两种问题,于是引入了补码,计算方式如下
- 正数的补码是其本身
- 负数的补码:符号位不变、真值位取反后+1
- 补码的补码就是原码
这样一来在补码中就可以用0000_0000这种方式来唯一表示十进制的0,同时在补码中因为1000_0000失去了意义,所以可以规定1000_0000(补码)对应的十进制数为-128,这样一来8位二进制数能够表示的范围就达到了[-128,127],即[1000_0000,0111_1111]
// 基于补码的减法运算 -1 - 2 = -1 + (-2) 1111 1111 (补码) + 1111 1110 (补码) = 1111 1101 (补码) = 1000 0011 (原码:补码的补码就是原码) = -3 (十进制)
tip:位长(有的地方也叫位宽、字长),我的理解是存储单元的数量,上述示例基本都是采用"8位二进制数"来表示的,这里的"8"指的就是位长,代表着有8个存储单元(存储0/1)。
经过上面的介绍,我们应该也能了解到不同位长所能够存储的数据范围也是不一样的,那么肯定会出现一种情况就是当前位长无法容纳计算后的结果(通俗一些就是说计算后的结果超出了当前位宽所能表示的最大范围),这种情况被称之为溢出。
通常情况下只有两个相同符号的数计算时才有可能出现溢出的情况,两个正数相加产生的溢出叫做正溢出,两个负数相加产生的溢出叫做负溢出。(一个正数加一个负数是不会产生溢出的)
计算机是通过最高位进位状态与次高位进位状态异或后的结果来判断当前计算是否有溢出,异或结果是1则有溢出,异或结果是0则无溢出
下图是溢出的4种场景(位长是4)
这里需要额外关注一下例2和例4,可以看到这两个例子计算后的结果是一个5位字长的补码,由于4位字长只能存储4位二进制数,所以最左边的一位将会被舍弃,例如10111 = 0111。
程序中的所有数在计算机内存中都是以二进制补码的形式储存的。位运算就是直接对整数在内存中的二进制位进行操作。比如,and运算本来是一个逻辑运算符,但整数与整数之间也可以进行and运算。举个例子,6的二进制是110,11的二进制是1011,那么6 and 11的结果就是2,它是二进制对应位进行逻辑运算的结果(0表示False,1表示True,空位都当0处理)。
相同位的两个数字都为1,则为1;若有一个不为1,则为0。
&符号在计算机中用作位与运算的运算符,也叫做and运算,与逻辑与运算基本相同, 通常用于二进制的取位操作,例如一个数 and 1的结果就是取二进制的最末位。这可以用来判断一个整数的奇偶,二进制的最末位为0表示该数为偶数,最末位为1表示该数为奇数。
// 取6的最后一位
6 & 1 = 0000 0110 & 0000 0001 = 0000 0000 = 0
// 取6的后两位
6 & 3 = 0000 0110 & 0000 0011 = 0000 0010 = 2
相同位只要一个为1即为1。
|符号在计算机中用作位或运算的运算符,也叫做or运算,与逻辑或运算基本相同,通常用于二进制特定位上的无条件赋值,例如一个数or 1的结果就是把二进制最末位强行变成1。如果需要把二进制最末位变成0,对这个数or 1之后再减一就可以了,其实际意义就是把这个数强行变成最接近的偶数。
// 对6的最末位无条件赋值
6 | 1 = 0000 0110 | 0000 0001 = 0000 0111 = 7
// 对5的最末位无条件赋值
5 | 1 = 0000 0101 | 0000 0001 = 0000 0101 = 5
// 对6的倒数第二位无条件赋值
6 | 2 = 0000 0110 | 0000 0010 = 0000 0110 = 6
相同位不同则为1,相同则为0。
异或运算的符号是^,一般也叫做xor运算,xor运算的逆运算是它本身,也就是说两次异或同一个数最后结果不变,即(a xor b) xor b = a。xor运算可以用于简单的加密,比如我想对我MM说1314520,但怕别人知道,于是双方约定拿我的生日199807作为密钥。1314520 xor 199807= 1508007,我就把1508007告诉MM。MM再次计算1508007 xor 199807的值,得到1314520。
// 127 对 6 进行异或运算
127 ^ 6
= 0111 1111 ^
0000 0110
= 0111 1001
= 121
这里稍微提一下异或运算的一个应用:怎样不声明第三个变量,从而实现交换两个int变量的值。
public static void main(String[] args) { int a = 1; int b = 2; a = a ^ b; b = a ^ b; a = a ^ b; System.out.println(a); System.out.println(b); }
tip:这个问题我曾经在面试中遇到过,当时我只知道可以通过位运算来进行实现,具体实现方式却不太清楚。
// a = a ^ b a = 0000 0001 ^ 0000 0010 = 0000 0011 = 3 // b = a ^ b b = 0000 0011 ^ 0000 0010 = 0000 0001 = 1 // a = a ^ b a = 0000 0011 ^ 0000 0001 = 0000 0010 = 2
取反运算的定义是把内存中的0和1全部取反。
a << b就表示把a转为二进制后左移b位(在后面添b个0)
a >> b表示把a转换为二进制后,右移b位(去掉末b位),相当于a除以2的b次方(取整)。
正数右移高位补0,负数右移高位补1(相当于是保留符号位)
无符号右移。无论是正数还是负数,高位通通补0(不保留符号位)。