Java移位运算符详解:<<、>> 和 >>> 有什么区别

生活资讯4小时前发布 jdkjadmin
0 00
Java移位运算符详解:<<、>> 和 >>> 有什么区别

Java移位运算符详解:<<、>> 和 >>> 有什么区别

Java 的移位运算符看起来简单:把一个整数向左或向右移动若干位。但实际使用时,符号扩展、移位距离的处理、类型提升和溢出常常让结果出乎意料。理解这些规则,才能正确地处理位字段、标志位、哈希计算和底层数据。

Java 提供三种移位运算符:

  • <<:左移。
  • >>:带符号右移,也称算术右移。
  • >>>:无符号右移,也称逻辑右移。

它们只作用于整数类型。理解它们的关键,是明确左操作数的类型、右操作数的有效移位距离,以及右移时如何填充空出来的高位。

一、从二进制看三种移位

Java 的整数采用固定宽度的二进制补码表示。一个 int 有 32 位,一个 long 有 64 位。以 8 位形式演示运算原理(Java 实际没有 8 位移位类型):

00001100  // 十进制 12

1. 左移:<<

<< 将各位向左移动,右侧空出的位用 0 填充,左侧移出去的位被丢弃:

00001100 << 2
00110000  // 十进制 48

在不发生溢出的情况下,左移 n 位的效果相当于乘以 2n。但这只是帮助理解的关系,不等于可以无条件将乘法替换为左移:固定宽度的整数溢出、负数和位宽限制都可能影响结果。

2. 带符号右移:>>

>> 将各位向右移动,左侧空出的位由原数的符号位填充。正数的符号位为 0,负数的符号位为 1。这样做可以保留数值的正负号。

00001100 >> 2
00000011  // 十进制 3

如果原数是负数,右移时左侧补 1。例如:

// 8 位示意;Java 的 int 实际是 32 位
11110000 >> 2
11111100

对非负整数而言,>> 的效果通常可以理解为向下取整地除以 2 的幂。对负数要特别谨慎:Java 的整数除法向零截断,而算术右移会进行符号扩展,两者结果不总相同。例如 -3 / 2 是 -1,而 -3 >> 1 是 -2。

3. 无符号右移:>>>

>>> 也向右移动,但左侧空出的位一律补 0,不保留符号位。它把相同的位模式当作不带符号的位串进行右移;运算结果的 Java 类型仍然是有符号的 int 或 long。

例如,int 的最高位为 1 时,该数作为 Java 有符号整数是负数。使用 >>> 后高位补 0,结果通常成为非负数;若使用 >>,高位则继续补 1。

Java 没有内置的无符号整数原始类型,但提供了 Integer.toUnsignedString、Integer.compareUnsigned 等方法来处理部分无符号整数需求。>>> 并不会改变变量类型,也不会自动把结果转换成某种无符号类型。

二、最重要的边界:移位距离会被屏蔽

许多语言对过大的移位距离有不同规则;Java 的规则是确定的:移位距离只取其低位,效果相当于对位宽取余。

  • 左操作数经类型提升后为 int:只使用移位距离的低 5 位,即实际距离为 s & 0x1f,范围是 0 到 31。
  • 左操作数经类型提升后为 long:只使用移位距离的低 6 位,即实际距离为 s & 0x3f,范围是 0 到 63。

因此,int 移位 32 位,实际等于移位 0 位;移位 33 位,实际等于移位 1 位。long 移位 64 位,实际同样等于移位 0 位。负数距离也不会触发异常,而是按低位规则处理。

public class ShiftDistanceDemo {
    public static void main(String[] args) {
        int value = 1;

        System.out.println(value << 0);   // 1
        System.out.println(value << 1);   // 2
        System.out.println(value << 31);  // -2147483648
        System.out.println(value << 32);  // 1:距离按 32 取模,等于 0
        System.out.println(value << 33);  // 2:距离按 32 取模,等于 1
        System.out.println(value << -1);  // -2147483648:-1 & 31 等于 31
    }
}

这个规则容易造成严重的逻辑错误。例如,开发者可能以为 1 << 32 是一个包含第 32 位的掩码,实际结果却是 1。如果业务要求参数只能在某个范围内,应该显式校验,而不是依赖移位运算符帮忙限制距离。

三、类型提升:byte、short 和 char 会变成 int

Java 没有针对 byte、short 或 char 的独立移位结果类型。移位表达式会先对左操作数进行一元数值提升:

  • byte、short、char 提升为 int。
  • int 保持为 int。
  • long 保持为 long。

表达式的结果类型由提升后的左操作数决定,不由右操作数决定。右操作数可以是 int、long 等整数类型;它先进行一元数值提升,再按左操作数的宽度屏蔽移位距离。

byte b = 3;
int result = b << 2;  // b 先提升为 int,结果类型是 int

long value = 1L;
long result2 = value << 40;  // 左操作数是 long,结果类型是 long

下面的赋值会报错,因为移位结果是 int,不能直接赋给 byte:

byte b = 1;
// b = b << 1;  // 编译错误:结果类型是 int
b = (byte) (b << 1);  // 显式窄化转换

复合赋值是一个常见例外:Java 会在复合赋值中隐式进行相应的转换,因此下面的写法可以编译:

byte b = 1;
b <<= 1;  // 等价于 b = (byte) (b << 1)

但“可以编译”不代表结果一定符合预期。窄化回 byte 时,高位可能被丢弃,数值也可能变成负数。需要保留较大范围时,应使用 int 或 long,而不是依靠复合赋值。

四、完整示例:观察符号扩展和零填充

以下程序可以直接保存为 ShiftDemo.java,使用 javac ShiftDemo.java 编译,再使用 java ShiftDemo 运行。它用二进制字符串展示 int 的 32 位结果。

public class ShiftDemo {
    private static String bits(int value) {
        String s = Integer.toBinaryString(value);
        return "0".repeat(32 - s.length()) + s;
    }

    public static void main(String[] args) {
        int positive = 12;
        int negative = -12;

        System.out.println("12       = " + bits(positive));
        System.out.println("12 << 2  = " + bits(positive << 2));
        System.out.println("12 >> 2  = " + bits(positive >> 2));
        System.out.println("12 >>> 2= " + bits(positive >>> 2));
        System.out.println();

        System.out.println("-12      = " + bits(negative));
        System.out.println("-12 >> 2 = " + bits(negative >> 2));
        System.out.println("-12 >>> 2= " + bits(negative >>> 2));
        System.out.println();

        System.out.println("12 << 2 的十进制结果 = " + (positive << 2));
        System.out.println("-12 >> 2 的十进制结果 = " + (negative >> 2));
        System.out.println("-12 >>> 2 的十进制结果 = " + (negative >>> 2));
    }
}

注意:Integer.toBinaryString 对负数返回的是其 32 位补码表示,不会在前面添加负号。因此,用 32 位字符串比较 >> 和 >>>,能直观看到高位填充方式的差别。

示例中的 String.repeat 从 Java 11 开始提供。如果使用更早版本,可自行编写补零循环,移位运算的规则不受影响。

五、常见适用场景

1. 位掩码和标志位

用不同的二进制位表示多个开关,是移位的常见用途。通常使用 1 << n 构造第 n 位掩码,再用按位与、按位或判断和设置标志。

public class FlagsDemo {
    private static final int READ  = 1 << 0;
    private static final int WRITE = 1 << 1;
    private static final int ADMIN = 1 << 2;

    public static void main(String[] args) {
        int permissions = READ | WRITE;

        boolean canRead = (permissions & READ) != 0;
        boolean canAdmin = (permissions & ADMIN) != 0;

        permissions |= ADMIN;    // 设置 ADMIN 位
        permissions &= ~WRITE;   // 清除 WRITE 位

        System.out.println(canRead);   // true
        System.out.println(canAdmin);  // false
        System.out.println(permissions);
    }
}

使用这类写法时,要确认位编号合法,并选用足够宽的类型。对 int,可用于单个位掩码的位编号是 0 到 31;对 long,是 0 到 63。尤其是编号为 31 的 int 掩码,1 << 31 的结果是负数,因为最高位是符号位。它仍然可以作为位掩码使用,但不能把它当作非负数大小来比较。

2. 从整数中提取或组合字段

如果多个小字段按约定打包在一个整数中,可以通过右移和掩码取出字段,也可以通过左移后按位或组合字段。以下示例把三个字节打包成一个 int:

int a = 0x12;
int b = 0x34;
int c = 0x56;

int packed = (a & 0xff) << 16
           | (b & 0xff) << 8
           | (c & 0xff);

int extractedA = (packed >>> 16) & 0xff;
int extractedB = (packed >>> 8) & 0xff;
int extractedC = packed & 0xff;

这里先用 & 0xff 保留低 8 位,再通过移位放到目标位置。提取时使用 >>>,并再次与 0xff,明确只保留目标字段。若输入来自有符号的 byte,掩码尤其重要:直接扩展到 int 时,负数会进行符号扩展。

3. 除以二的幂或构造快速索引

对非负整数,将其右移 k 位,等效于整除 2k。这有时用于分桶或提取高位索引。但不要仅凭“移位更快”就替换普通乘除法:现代 Java 虚拟机和处理器会优化常见运算,实际性能需要针对真实代码进行测量;而负数、溢出和边界处理可能使替换改变程序含义。

左移也可用于构造以 2 的幂为单位的值或索引,但必须检查移位距离和溢出。溢出不会抛出异常,结果只保留固定宽度内的位。

六、常见错误和容易忽略的规则

错误一:把大于等于位宽的距离当成零结果

Java 不会因为 int 移位 32 位就得到 0,也不会因为移位距离过大就抛出异常。距离会屏蔽低 5 位或低 6 位。若程序接收外部提供的位编号,必须在移位前验证范围。

错误二:以为负数左移或乘法溢出会报错

移位和普通整数运算一样采用固定宽度结果。被移出有效位宽的位会丢弃,不会自动扩展到 long,也不会抛出算术溢出异常。想以 long 运算,应在移位前确保左操作数是 long:

int n = 31;
int a = 1 << n;       // int 运算,结果为负数
long b = 1L << n;     // long 运算,结果为正数

只把结果赋给 long 并不能挽回已经发生的 int 溢出:

long wrong = 1 << 31;    // 先执行 int 移位,再扩展到 long
long right = 1L << 31;   // 从一开始就在 long 中移位

错误三:混淆右移运算符

处理普通有符号数值时,通常考虑 >>;需要把位串右移并让高位补零时,通常考虑 >>>。例如解码一个可能含最高位的打包整数,使用无符号右移再掩码往往更容易表达意图。不要只看运算结果是否为正就判断运算符正确与否,还应明确要保留的是符号语义还是位模式。

错误四:忽略运算优先级

移位运算的优先级低于加法和减法,高于关系运算。因此:

int x = value >> 1 + 2;

会按 value >> (1 + 2) 解析,也就是右移 3 位,而不是先右移 1 位再加 2。复杂表达式应使用括号明确意图:

int a = value >> (1 + 2);
int b = (value >> 1) + 2;

错误五:误以为 >>> 能让数值变成无符号类型

>>> 只规定右移时高位补 0。结果仍然是 int 或 long,后续比较、打印和运算仍受 Java 有符号类型规则影响。需要无符号解释时,应使用对应的标准库方法,或明确通过掩码转换到更宽的类型。

七、安全使用:校验输入,也不要把移位当作加密

移位可以用于解析协议字段、处理权限标志和构造索引,但不能代替边界校验。假设代码用 1 << index 检查某个标志,如果 index 来自用户输入,超范围的值会按照 Java 的距离屏蔽规则折返到另一个位,并不会自动失败。应先检查,例如要求 0 <= index && index < 32,再执行 int 移位。

位运算也不能自动消除溢出风险。若移位结果用于数组长度、缓冲区大小、权限判断或资源分配,应先验证输入范围,并在合适时使用更宽类型或显式溢出检查。仅仅先转成 long 也不意味着永远安全,因为 long 同样是固定宽度类型。

不要把 <<、>> 或 >>> 当作加密、保密或安全散列方案。位移本身是可逆或信息丢失的简单位操作,不提供密码学安全性。即便某些密码学算法内部使用位运算,也不能据此自行拼装加密算法;应使用经过审查的标准算法和 Java 密码学 API。

如果移位出现在密码学或安全敏感代码中,也不能仅凭代码中没有分支就断定执行时间恒定。JIT 编译器、处理器和周边数据访问都可能影响时序特征;是否具备抗侧信道能力,需要针对具体实现和环境分析。

八、快速判断口诀

  • 左移 <<:低位补 0,高位丢弃;可能溢出。
  • 带符号右移 >>:高位补符号位,适合保留有符号数的符号扩展语义。
  • 无符号右移 >>>:高位补 0,适合按位模式进行逻辑右移。
  • int 的实际移位距离为 s & 31;long 为 s & 63。
  • byte、short、char 左操作数会提升为 int;结果类型由提升后的左操作数决定。
  • 超范围距离不会自动报错。业务范围需要自己校验,运算结果也不会自动检测溢出。

写移位表达式时,先问自己三个问题:左操作数到底是 int 还是 long?右移后高位应该保留符号还是补零?移位距离和结果范围是否经过验证?把这三点想清楚,大多数 Java 移位相关的问题就能避免。

© 版权声明

相关文章