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

问题一:让人疑惑的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 对应二进制为: 11111111

byte b = -127;
int c = b & 0xff;

b:     = 11111111 11111111 11111111 10000001
& 0xff = 00000000 00000000 00000000 11111111
result = 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 11111111
result    =  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 & 0xff
    就是为了保持二进制数据的一致性,说白了就是对低8位数据的复制(可能不是8位);

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

问题二: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之间,其实就相当于对位移数执行 & 0x3f
    操作(也就是执行 & 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) {
    // ...
}

很显然,我们声明 i
NaN
即可。
有关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<=j
j<= i
,并且还要 i != j
,对普通的整数来说,看着好像是无解的呢?

解惑5.2:

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

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

前两个表达式 i <= j
j <= 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_VALUE
    Long.MIN_VALUE
    取负值不会改变它的值;但对 Short.MIN_VALUE
    Byte.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
    ,然后我们后面还是执行同样的操作,因此就变为了无限循环了。

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