前言
在阅读jdk源码的时候,发现很多地方都用到了位移运算,如:ArrayList扩容的时候,对capacity>>1;在阅读ThreadPoolExecutor源码时,其状态属性用-1<<29, 0<<29,1<< 29,2<<29,3<<29等标识;hashmap中计算hash值时,h>>>16的操作。这时就有一个疑问在脑海中,这样做的好处是什么?虽然平时工作中很少使用到,但出于好奇心还是要弄清楚其原理,拒绝有知识盲区。
二进制原理
在学习位移运算之前,先回顾下二进制原理(在计算机中是如何表示一个整数的),因为位移运算就是基于此的。在刚开始学习java基础时,会有这样一个疑惑:java中的基础类型byte,为什么取值范围是-128~127?
数据类型(整数) | 占用字节空间 | 取值范围 | 默认值 |
---|---|---|---|
byte | 1字节 | -128~127 | 0 |
short | 2字节 | -32768~32767 | 0 |
int | 4字节 | -2^31 ~ 2^31-1 | 0 |
long | 8字节 | -2^63 ~ 2^63-1 | 0L |
要弄清楚这个问题,就需要了解计算机中的原码、反码、补码机制。
原码
把一个整数,按照其绝对值大小转换成二进制数并加上符号位,称为原码。原码是人脑最能理解和计算的表示方式。如byte b = 1, 在java中byte只占一个字节,即8位,那整数1的二进制数表示为:00000001,人们规定第一位是符号位,0表示为正数,1表示为负数;由此可知-1的二进制为10000001。同理,如果定义的是int类型,那就是占用4个字节,1的二进制00000000 00000000 00000000 00000001,-1的二进制为10000000 00000000 00000000 00000001。
原码是人脑最易理解和计算的表示方式,看见原码就能直观的看出它的数值,如果计算机就以原码的方式来表示整数那该多好啊!首先如果让人来计算,因为规定了第一位为符号位,那直接把除符号位以外其他真值位相加减就可以了。但是对于计算机而言,加减乘除虽说是最基本的功能,但是对其设计要尽量简单,如果让计算机再去识别符号位,那计算机的基础电路设计会变得更加复杂。于是人们想出让符号位也参与计算,根据我初中数学水平可知一个数减去另外一个数等于这个数加上另外一个数的负数,即1-1等于1+(-1)=0,这样计算机可以只设计加法电路,不用设计减法电路。如下图,用原码做减法运算,二进制相加满2向前进1,得到结果10000010,也就是数值-2,这显然不科学啊!
反码
既然原码在计算机中不能解决减法的问题,人类继续想招儿,于是出现了反码。规定,正数的反码=原码,负数的反码=原码除符号位不变,其他位取反。
举个栗子:
- 正数:1=00000001(原)=00000001(反);
- 负数:-1=10000001(原)=11111110(反);
- 反码计算减法:1-1=1+(-1)=00000001(原)+10000001(原)=00000001(反)+11111110(反)=11111111(反)=10000000(原)=-0
通过反码运算,得到的结果也是反码,再将反码转成原码,其值等于-0,结果是符合预期了,但是多了一个负号,也就是说通过反码计算真值位符合预期,但是符号位不符合,我们知道数学里0就是0,没有什么-0,+0之说。既然-0也是0,那这样的话岂不是一个0可以有[10000000]和[00000000]两种二进制表示了,这显然也不科学啊!
补码
为了解决通过反码计算减法得到0有符号以及两个编码表示0的问题,人类继续想招儿,于是补码出现了。规定,正数的补码是其本身,也就是说正数的补码=反码=原码,而负数的补码是其反码加1。
举个栗子:
- 正数: 1=00000001(原)=00000001(反)=00000001(补);
- 负数:-1=10000001(原)=11111110(反)=11111111(补);
- 补码计算减法:1-1=1+(-1)=00000001(原)+10000001(原)=00000001(补)+11111111(补)=00000000(补)=00000000(原)=0
通过补码计算1-1终于等于0了,解决了之前反码的-0问题,同时0的二进制编码只有一种方式即00000000。
再看之前的疑惑,byte取值范围为什么是-128~127?
首先正数的最高位为符号位0,所以最大值为01111111,即127;(-1)+(-127)=10000001(原)+11111111(原)=11111111(补)+10000001(补)=10000000(补)=-128;在补码运算结果中,10000000(补)就是-128,-128没有原码和反码。
总结:如果用反码来计算,只能表示-127到+127,而-0和0都是0,会浪费一个字节【10000000】,而使用补码不仅修复了0的符号以及存在两个编码的问题,而且还能够多表示一个最低数,所以java中的byte取值范围-128127,int类型-2^312^31-1。所以,计算机中,所有的数字都是通过补码的形式来计算 。
位移运算
在Java中,常见的有三种位移计算操作符,即左移<<、右移>>、无符号右移>>>。
左移运算符:<<
左移,即把一个数的二进制数往左边移动n位,然后右边补n个0。下面分别以正数10(int类型)和负数 -10(int类型)为例,分别左移不同的位数,看能得到什么值。
正数左移
在Java语言中,int类型占用4个字节,正数10,用二进制表示为:00000000 00000000 00000000 00001010
- 先左移1位试试
如上图,根据规则,整个二进制位左移1位,右边补一个0,一眼可以看出结果为20,为了防止看走眼了,用代码
验证下:
public static void main(String[] args) {
int a = 10;
System.out.println(a<<1);
//结果20
}
- 再左移28位试试
如上图,左移28位后,最高位变成了1,此时得到的数据应该为一个负数,一眼也可以看出结果为-2^29次方,为了放心,还是用代码验证一下:
public static void main(String[] args) {
int a = 10;
System.out.println(a<<28);
System.out.println(Integer.toBinaryString(a<<28));
//结果:
//-1610612736
//10100000000000000000000000000000
}
tip:左移一位,相当于原数乘以2,可以提高计算效率,追求极致性能,但是要注意避免变成负数的情况,即需要考虑符号位的变化
- 放大招,左移32位试试
如上图,左移32位后,全部补0,那结果自然就是0了?代码验证如下:
public static void main(String[] args) {
int a = 10;
System.out.println(a<<32);
System.out.println(Integer.toBinaryString(a<<32)); //idea会有警告提示超过32位了
//结果:
//10
//00000000000000000000000000001010
}
打脸了,验证结果不是0,还是10。这里的原因在于:当int类型进行左移操作时,左移位数大于等于32位时,会先求余(%)后再进行左移操作。 所以这里32%32=0,相当于没有移动,结果保持不变。
- 再次验证下,左移33位试试
这里就不画图了,和上面是一样的,使用代码验证下:
public static void main(String[] args) {
int a = 10;
System.out.println(a<<33);
System.out.println(Integer.toBinaryString(a<<33)); //idea会有警告提示超过32位了
//结果:
//20
//00000000000000000000000000010100
}
代码验证结果为20。左移33位后,相当于左移了1位,结果20,符合预期
负数左移
在Java语言中,int类型占用32个bit位,所以整数-10的二进制表示为:11111111 11111111 11111111 11110110
- 先左移1位试试
如上图,根据正数左移1位就相当于该整数乘以2的经验,负数应该也是一样的,代码验证下:结果为-20
public static void main(String[] args) {
int a = -10;
System.out.println(a<<1);
System.out.println(Integer.toBinaryString(a<<1));
//-20
//11111111111111111111111111101100
}
- 再左移28位试试
如上图,左移2位后,其符号位变成了0,移动后结果为0110000 00000000 00000000 00000000,反推得到原码,其值为:1610612736,代码验证下:
public static void main(String[] args) {
int a = -10;
System.out.println(a<<28);
System.out.println(Integer.toBinaryString(a<<28));
//1610612736
//01100000000000000000000000000000 补码
}
- 左移32位
这里和正数左移32位规则一样,左移位数大于等于32时,就要取余数,在左移,结果自然还是-10,不再多说。
- 左移33位
与正数左移33位一致,不在重复描述,结果自然是-20
总结
- 不管是正数还是负数,左移一位,相当于乘以2,在执行效率上比直接做乘法运算要快,这也是为什么在JDK源码中随处可见左移运算的原因 【不追求完美的程序员,不是一个好的架构师】
- 正数在左移到一定位数后,即达到高位为1时,会变成负数;同理负数也会变成正数,在使用时需要特别注意,以免出现bug
右移运算符:>>
规则:>>,表示有符号右移,右移后,如果是正数左边补0,如果是负数左边补1
正数右移
二进制位往右边移动n位,然后左边高位补n个0。还是以数字10为例:
- 右移1位,预期结果为5
代码验证下:结果为5
public static void main(String[] args) {
int a = 10;
System.out.println(a>>1);
System.out.println(Integer.toBinaryString(a>>1));
//结果:
//5
//00000000000000000000000000000101
}
整数右移1位,相当于整数除以2,性能相比直接10/2更好,所以ArrayList中扩容时,使用capacity + capacity>>1,扩容为原来的1.5倍。
- 右移4位,高位补0,结果应该是0
代码验证如下,结果果然是0
public static void main(String[] args) {
int a = 10;
System.out.println(a>>4);
System.out.println(Integer.toBinaryString(a>>4));
//结果:
//0
//00000000000000000000000000000000
}
- 右移32位,同左移一样,会先32%32=0,相当于没有移动,结果还是10;如果右移33位,相当于右移1位,结果是5。
负数右移
如果是-10,往右移动会和正数一样吗?
- 右移1位
计算机中都是以补码的形式来表示负数的,-10=10000000 00000000 00000000 00001010(原)=11111111 11111111 11111111 11110101(反)=11111111 11111111 11111111 11110110(补)
如图,-10右移1位,得到结果为-5,也是相当于-10/2。
注意:这里>>表示带符号右移,和>>>不带符号位移不同。正数右移,高位补正数符号位0,负数右移,高位补符号位1
- 右移4位
右移4位,高位全部补1,结果就是11111111 11111111 11111111 11111111,反码为11111111 11111111 11111111 11111110,原码10000000 00000000 00000000 00000001,数值就是-1
public static void main(String[] args) {
int a = -10;
System.out.println(a>>4);
System.out.println(Integer.toBinaryString(a>>4));
//结果 -1
//1111111111111111111111111111111
}
由此例可见,如果右移位置在4~32之间,结果都是-1
- 右移>=32位,同上,会先取模,获取余数,再移动余数位
无符号右移:>>>
无符号右移,如果是正数和有符号右移>>没有任何区别;如果是负数,右移后,左边高位不在是补符号位,而是补0。还是以-10为例:
- 右移1位,如下图,移动后的结果为2147483643
代码验证下:
public static void main(String[] args) {
int a = -10;
System.out.println(a>>>1);
System.out.println(Integer.toBinaryString(a>>>1));
}
//结果
//2147483643
//01111111111111111111111111111011
- 右移4位
如上图,右移4位后,高位补0,结果就是2^0+2^1+...+2^27=268435455
- 右移32位及以上,都是先取模计算,然后再移动,不再多说了
其他逻辑运算符
与计算:&
真真为真,真假为假,假假为假,一句话总结:有假为假,没假为真。
&运算在jdk中也是非常常见,例如在ReentrantReadWriteLock中,int类型的state变量,高16位表示读锁占用的情况,低16位用于表示写锁占用的情况,通过state & 65535就可以计算出写锁的占用线程重入次数了。
或计算:|
真真为真,真假为真,假假为假,一句话总结:有真为真,没真为假。
异或计算:^
相同取0,不相同取1,换句话说,真真为假,真假为真,假假为假
非计算:~
取反,~1=0,~0=1
如上图,~10得到的结果就是-11啦
总结
- 学习了各种码,各种位移后,对咱们看一些源码有所帮助
- 平时在写代码时,进行简单的乘除时,咱也可以使用位移计算,装装逼了,提升一点性能,但是要注意正负号的变化,以免装逼失败,就尴尬了,哈哈哈。但是,作为一个被各种奇葩需求毒打过的程序员,做任何事情都变得非常谨慎,此时问了自己一个问题:“位移运算一定就会比直接乘除运算快吗?”,要知道我们Java可是有编译器的,还有JIT哦,会不会被优化一下下呢?以后再说吧