Java解惑系列(三): 让人疑惑的0xff

2,297 阅读21分钟
原文链接: mp.weixin.qq.com

问题一:让人疑惑的0xff


在我们学习源码的时候,能经常见到类似于这种操作的场景:b & 0xff,因为我们平时不经常与十六进制,或者说不经常与逻辑运算符打交道,所以刚看到的时候,或许不太清楚它的具体实现含义,我们这里先来简单分析一下它的实现,然后再以一个示例来说明它的使用场景。

解惑1:

前文已经说过,当整型从较窄类型向较宽的类型进行扩展时,除了char类型,都将采用符号扩展:

如果原数值是正数,则高位补0;如果原数值是负数,则高位补1;

由于计算机是使用补码来进行二进制操作的。正数的补码等于原码;而负数的补码等于反码+1,这些我们前面也已经说过了。对非负数来说,符号扩展与零扩展都是一样的,而对于负数来说,因为符号位的原因,则就不一样了,所以我们这里的举例也都是用负数来举例。

1.1 符号扩展,零扩展与0xff

这里我们以byte类型的-127扩展为int类型来举例:

byte类型 -127 原码: 11111111 补码: 10000001符号扩展为32位int类型:补码: 11111111 11111111 11111111 10000001原码: 10000000 00000000 00000000 01111111最终结果:  -127(int类型)

可以看出,从byte到int类型的扩展,保证了十进制数值的一致性;但如果是采用零扩展呢,我们也来看一下:

byte类型-127 原码: 11111111 补码: 10000001零扩展为32位int类型:补码: 00000000 00000000 00000000 10000001原码: 00000000 00000000 00000000 10000001最终结果: 129(int类型)

而通过零扩展的话,能够保证二进制数据的一致性。

看完了符号扩展以及零扩展,这时候我们就来看一下我们最开始说的b & 0xff

首先, 0xff 对应二进制为: 11111111byte b = -127;int c = b & 0xff;b:     = 11111111 11111111 11111111 10000001& 0xff = 00000000 00000000 00000000 11111111result = 00000000 00000000 00000000 10000001

可以看到,针对32位的0xff而言,前24位都是补0,0xff 就相当于执行了零扩展,也就相当于保持了二进制数据的一致性。

这里在进行&操作时,会先将byte扩展为32位,再与0xff进行操作。

1.2 为什么要用0xff

为什么要用0xff,也就是为什么要保持二进制数据的一致性呢?

原因有很多,我们都知道,很多时候我们需要将各种流转换为byte数组,然后进行数据通信,再然后再将byte数组转换为其他类型,中间的过程中我们是不关心这个byte数组中的值的十进制数值的,我们关心的就是数据传输中二进制数据的一致性。

所以说在比如将byte转换为int的时候,我们就能经常看到b & 0xff这样的操作,这种方式说白了就是保持低八位数据在转换的过程中不变,也就是二进制的一致性。

1.3 如说我们想将一个int类型转换为一个byte数组:
/** * int -> byte[] * @param i  int * @return byte[] */public static byte[] intToByteArray(int i) {    byte[] bytes = new byte[4];    // 将int从高位依次到低位放入bytes数组    bytes[0] = (byte) ((i >> 24) & 0xff);    bytes[1] = (byte) ((i >> 16) & 0xff);    bytes[2] = (byte) ((i >> 8) & 0xff);    bytes[3] = (byte) (i & 0xff);    return bytes;}

我们来简单看一下 (i >> 24) & 0xff

i         =  00000001 00000011 00000111 00001111(i >> 24) =  00000000 00000000 00000000 00000001& 0xff    =  00000000 00000000 00000000 11111111result    =  00000000 00000000 00000000 00000001

可以看到,恰好将int的高8位获取到,然后低位截取保存到bytes数组中,剩余操作也是类似;

举一反三,知道了如何将int转换为byte数组,那么要将byte数组再转换为int就比较简单了:

/** * byte[] -> int * @param bytes byte[] * @return int */public static int byteArrayToInt(byte[] bytes) {    int result = 0;    int length = bytes.length;    // 依次左移24位,16位,8位,0位    for (int i = 0; i < length; i ++) {        result += (bytes[i] & 0xff) << ((length - 1 - i) * 8);    }    return result;}

或者说,我们采用|的方式:

public static int byteArrayToInt2(byte[] bytes) {    int temp0 =(bytes[0] & 0xff) << 24;    int temp1 =(bytes[1] & 0xff) << 16;    int temp2 =(bytes[2] & 0xff) << 8;    int temp3 =bytes[3] & 0xff;    return temp0 | temp1 | temp2 | temp3;}

简单优化:

public static int byteArrayToInt3(byte[] bytes) {    int temp = 0;    int length = bytes.length;    for (int i = 0; i < length; i ++) {        temp |= ((bytes[i] & 0xff) << (length - 1 - i) * 8);    }    return temp;}

有关0xff的使用,这里有一个不错的例子可以参考下:是一个通过 0xff转换ip地址的过程,Convert Decimal to IP Address, with & 0xFF,地址为:https://mkyong.com/java/java-and-0xff-example

小结:
  1. 整型从窄到宽的扩展中,补符号位,可以保证十进制数据不变;而补符号位,可以保证补码的一致性,也就是二进制数据的一致性,但十进制有可能是会变化的;

  2. 一般情况下,我们使用b &amp; 0xff就是为了保持二进制数据的一致性,说白了就是对低8位数据的复制(可能不是8位);

  3. 很多情况下,我们使用b &amp; 0xff的时候会配合逻辑或 |运算符,达到字节拼接的效果;并且也会经常与移位运算符&gt;&gt; &lt;&lt;等一起使用;

问题二:Integer.MAX_VALUE的问题


看下面这个程序,最终将会打印什么呢?

public class Main {    private static final int END = Integer.MAX_VALUE;    private static final int START = END - 100;    public static void main(String[] args) {        int count = 0;        for (int i = START; i <= END; i++) {            count++;        }        System.out.println(count);    }}

这段程序会打印100,还是会打印101呢?很遗憾,它什么都没有打印,并且这个程序不会停止,将一直进入无限循环。

解惑2:

如果我们仔细看的话,就会发现,这和我们平时所使用的循环有点不太一样,因为一般我们使用循环时,都是在循环索引小于终止值时执行程序,而该程序则是在循环索引小于或等于终止值时执行程序,在这个例子中我们的目的是想让循环在 i=Integer.MAX_VALUE时终止,但按照流程来说,它会在 i= Integer.MAX_VALUE+1时终止,但遗憾的是它终止不了,因为:

Integer.MAX_VALUE + 1 = Integer.MIN_VALUE:在Java中,当i达到 Integer.MAX_VALUE的时候,如果再次执行增量操作,那么它又绕回了Integer.MIN_VALUE

这个例子就告诉我们:

无论你在何时操作整数类型,都要意识到整型的边界问题。

至于解决方式,就比较简单了,我们可以指定一个long类型的循环索引:

for (long i = START; i <= END; i++) {

或者借助于do while循环:

public static void main(String[] args) {    int count = 0;    int i = START;    do {        count ++;    } while (i++ != END);    System.out.println(count);}

问题三:移位操作的问题


同样是循环,来看下下面的代码打印什么?

public class Main {    public static void main(String[] args) {        int i = 0;        while (-1 << i != 0) {            i++;        }        System.out.println(i);    }}

因为整数类型的-1的32位都是1,并且是左移操作,所以正常来说,这个循环将执行32次迭代之后停止,并且会打印32。很遗憾,这个程序也将进入一个无限循环,并且不会打印任何内容。

解惑3:

问题就在于-1 << 32的结果是-1,而不是0。那么为什么会是这样的呢?其实,这个在Java开发规范中有说明,我们直接引用下:

If the promoted type of the left-hand operand is int, then only the five lowest-order bits of the right-hand operand are used as the shift distance. It is as if the right-hand operand were subjected to a bitwise logical AND operator & (§15.22.1) with the mask value 0x1f (0b11111). The shift distance actually used is therefore always in the range 0 to 31, inclusive.

If the promoted type of the left-hand operand is long, then only the six lowest-order bits of the right-hand operand are used as the shift distance. It is as if the right-hand operand were subjected to a bitwise logical AND operator & (§15.22.1) with the mask value 0x3f (0b111111). The shift distance actually used is therefore always in the range 0 to 63, inclusive.

简单梳理下,对于位移操作,就是:

  • 如果左侧操作数的类型为int,则仅将右侧操作数的最低5位用作移位长度;无论右侧位移多少,最终位移的范围都将落在0~31之间,其实就相当于对位移数执行& 0x1f操作(也就是执行 & 0b11111),其实也就相当于对32取余;而如果恰好是32或者32的倍数,自然就是相当于移位距离是0;

  • 如果左侧操作数的类型是long,则仅将右侧操作数的最低6位用作移位长度;无论右侧位移多少,最终位移的范围都将落在0~64之间,其实就相当于对位移数执行&amp; 0x3f操作(也就是执行 &amp; 0b111111),其实也就相当于对64取余;

看到这我们也就知道这个问题了,如果试图对一个int类型移位32位,或者对一个long类型移位64位,都值会返回这个数值本身。

没有任何移位长度可以让一个int数值丢弃所有的32位,或者是让一个long数值丢弃所有的64位。

那么这个问题的解决方式也就很简单了。我们不再让-1重复的移位不同的位移长度,而是将前一次移位操作的结果保存起来,并且让它在每一次迭代时都向左再移1位:

public static void main(String[] args) {    int i = 0;    for (int val = -1; val != 0; val <<= 1) {        i++;    }    System.out.println(i);}

还有一点可能也需要注意,就是当位移长度是负数的时候,比如对一个int 右移-1位,则是相当于右移了 31位,无论位移长度是正数还是负数,对int而言都是对32取余,对long而言则是对64取余。

问题四:正无穷大的问题


下面需要我们来动动手写写代码了,首先是看下面的代码,我们该如何声明,能够让下面的循环变为一个无限循环呢?

while (i == i + 1) {    //...}

什么样的数字会等于它本身加1呢?正常来说这应该是无法实现的,但如果这个数字是无穷大的话又会怎样呢?

解惑4:

Java中强制要求使用 IEEE754浮点数算术运算,它可以让我们用一个double或者float来表示一个无穷大的数字。正如我们在学校里学过的,无穷大加1还是无穷大。对这个问题而言,如果i初始的时候就是无穷大,那么 i+1将依旧是无穷大,所以循环不会终止,比如:

double i = Double.POSITIVE_INFINITY;
4.1 正无穷,负无穷,非数字

在Java中提供了三个特殊的浮点数值:正无穷大、负无穷大、非数字,用于表示溢出或者其他特殊场景:

  • 正无穷大:用一个正浮点数除以0将得到一个正无穷大,通过Double或Float的POSITIVE_INFINITY表示 ;打印的话,会展示:Infinity

  • 负无穷大:用一个负浮点数除以0将得到一个负无穷大,通过Double或Float的NEGATIVE_INFINITY表示 ;打印的话,会展示:-Infinity

  • 非数字:0.0除以0.0或对一个负数开方将得到一个非数字,通过Double或Float的NaN表示;打印的话,会展示:NaN(含义: Not a Number)

  • 所有的正无穷大的数值都是相等的,所有的负无穷大的数值都是相等;而NaN不与任何数值相等,甚至和NaN自身都不相等;

来看下下面的例子:

public static void main(String[] args) {    double i = Double.POSITIVE_INFINITY;    float f = Float.POSITIVE_INFINITY;    System.out.println(i == f);    // output: true    System.out.println(i);         // output: Infinity    i = Double.NEGATIVE_INFINITY;    f = Float.NEGATIVE_INFINITY;    System.out.println(i == f);    // output: true    System.out.println(f);         // output: -Infinity    i = Double.NaN;    f = Float.NaN;    System.out.println(i == f);    // output: false    System.out.println(f);         // output: NaN}

当然,不必将i初始化为无穷大以确保循环永远执行,任何足够大的浮点数都可以实现这一目的:因为一个浮点数值越大,它和其后继数值之间的间隔就越大;对一个足够大的浮点数加1不会改变它的值,因为1不足以 填补它与其后继者之间的空隙

  • 浮点数操作返回的是最接近其精确数学结果的浮点数值,一旦毗邻的浮点数值之间的距离大于2,那么对其中的一个浮点数值加1将不会产生任何效果,因为其结果没有达到两个数值之间的一半;

  • 对Float类型,加1不会产生任何效果的最小基数是2^25,也就是33554432;而对Double类型,最小基数是2^54,大约是1.8*10^16;

简单看下下面的例子,返回的将是true:

public static void main(String[] args) {    float i = 123456789F;    System.out.println(i == i + 1);  // output: true}

毗邻的浮点数值之间的距离被称为一个ulp,它是最小单位(unit in the last place)的首字母缩写词,从JDK5.0之后,引入了Math.ulp方法来计算float或者double数值的 ulp

因此,我们需要记住:

  • 用一个float或者double的数值是可以用来表示无穷大的;

  • 将一个很小的的浮点数加到一个很大的浮点数上时,将不会改变大浮点数的值;

4.2 非数字

了解了这些问题,那下面的这个例子就比较简单了。我们该如何声明,能够让下面的循环变为一个无限循环:

while (i != i) {    // ...}

很显然,我们声明iNaN即可。

有关NaN,我们再多说一点:

首先,前面已经说过,NaN不与任何浮点数相等;其次,任何浮点操作,只要它的一个或多个操作数为NaN,那么其结果都是NaN;

public static void main(String[] args) {    double i = 0.0 / 0.0;    System.out.println(i  + 1); // output: NaN}

最后, Java中有关无穷大,非数字的类型,都是基于IEEE 754 浮点运算规范,有兴趣的可以去翻下该规范。

问题五:还是循环?


5.1 循环1

接着看下面的例子,和上面的例子类似,我们该如何声明,能够让下面的循环变为一个无限循环,但前提是不能声明为浮点数类型:

while (i != i + 0) {    //...}

如果不能用浮点类型,那么有能解决该问题的其他数值类型么?

解惑5.1:

很显然,我们想来想去,不通过浮点型,只通过其他数值类型是没有能解决该问题的;那么针对+操作,很自然,我们就能想到String操作,因为String中, + 操作符用于字符串连接,所以我们可以将i声明为任何字符串。

通常来说,我们程序中见到的i都是被声明为了整型变量名;而上面这种方式很明显不是一种可读性很好的方式;所以我们还是应该按照可读性更高的声明方式来声明变量。

5.2 循环2

还是接着来看循环例子,和上面的类似,我们该如何声明,能够让下面的循环变为一个无限循环:

while (i <= j && j <= i && i != j) {    // ...}

对这个例子而言,i<=jj<= i,并且还要i != j,对普通的整数来说,看着好像是无解的呢?

解惑5.2:

对一般的常数来说,这的确是的,但不要忘记了Java中还有自动装箱与自动拆箱呢,当比较的对象是包装类的时候,那么=操作比较的就不一定是数值了,我们可以声明如下:

Integer i = new Integer(0);Integer j = new Integer(0);

前两个表达式i <= jj <= i,会将对象拆箱成基本数值进行比较;而i != j则是在两个对象引用上进行比较。很显然,为什么编程规范没有规定:当 =操作符作用于装箱的数值对象时,执行值比较。官方给的答案也很简单:兼容性。因为过去的代码如果这么写就是false的,那么新的规范就必须接着保持这个false。

5.3 循环3

还是接着上面来说,我们该如何声明,能够让下面的循环变为一个无限循环:

while (i != 0 && i == -i) {    // ...}

因为这里涉及到一元操作符-,也就是说这个 i必须是数值类型,那么问题来了,除了0,还有哪个整数等于它的负值呢?

解惑5.3:

这时候,我们需要寻找一个非0的数字类型数值,它等于自己的负值。先来看浮点数有没有,正常的浮点数肯定是没有的(浮点数:符号位,尾数,指数),那么来看NaN,正无穷大,负无穷大,同样这些都不满足,那又回到了整数。

对int来说,总共存在个偶数个int数值---准确的来说,是2^32个,其中一个用来表示0,剩下奇数个int数值用来表示正整数和负整数,这意味着正的和负的int数值的数量必然不相等。换句话说,这暗示着至少有一个int数值,其负数不能正确的表示为int数值。

没错,恰好就有一个这样的数值,那就是Integer.MIN_VALUE,该值的负值就是它本身;当然,还有 Long.MIN_VALUE,这两个数值都能满足我们的条件。Java对这两个值取负值将会产生溢出,但是Java在整型计算中忽略了溢出,所以这两个数值才能满足我们的要求:

int i = Integer.MIN_VALUE;
  • java使用二进制的补码的算术运算,是不对称的。对于每一种有符号的整数类型(int,long,byte,short),负的数值总是比正的数值多一个,这个多出来的值总是这种类型所能表示的最小值;

  • Inteeger.MIN_VALUELong.MIN_VALUE取负值不会改变它的值;但对Short.MIN_VALUEByte.MIN_VALUE则需要取负值后将所产生的int数值再转回short/byte,返回的同样是最开始的值;

5.4 循环4

同样还是循环,我们该如何声明,能够让下面的循环变为一个无限循环:

while (i != 0) {    i >>>= 1;}

无符号右移操作,右移的过程中,左侧都是补0;这个看起来有些麻烦,我们来直接看下吧。

解惑5.4:

为了使这里的位移操作合法,这里的i必须是一个整数类型。前面有关复合操作符的操作我们了解到: 复合操作符可能会自动的执行窄化原生类型转换。而依据这个特性,我们可以通过下面的方式实现:

short i = -1;

来简单梳理下实现流程:

  1. 在执行移位操作的时候,首先就会将i提升为int类型, 所有算术操作都会对short,byte和char类型的操作数执行这样的提升,这种操作是通过符号扩展拓宽原生类型,不会有信息丢失(11111111 … 11111111);

  2. 无符号右移1位(01111111 … 11111111),最后这个结果被存回i中,这时候将int数值存入到short中,会自动丢弃高16位,这样最终又变回了 11111111 11111111,结果还是-1,然后我们后面还是执行同样的操作,因此就变为了无限循环了。

到这里,循环的内容就告一段落了。