在一次代码审查里,有人指着int mid = (low + high) >>> 1;问我:这里为什么要用三个大于号,写成(low + high) / 2不是一样吗?更省事。我当时没直接回答,而是让他把low设成1000000000、high设成2000000000跑一遍。结果他盯着那个负数mid看了半天,说了一句"原来/ 2会溢出啊"。
这个问题的答案横跨了移位运算符的三条主线:右移时符号位到底填什么、移位量超出位宽时算几位、以及语言规范对溢出到底保证什么。<<、>>、>>>这三个符号看起来像语法糖,实际上每一条规则背后都有具体的机器语义和语言设计取舍。我见过太多人把它们当成"乘 2""除 2"的快捷写法,然后在1 << 31、b >>> 1、1 << 32这几个点上连续翻车。
这篇内容适合所有写过位运算的人看:不管你是刚开始接触二进制的新手,还是写了几年业务代码、偶尔要在协议解析或位图里用移位的工程师,下面这些坑基本都会碰到。我会从二分查找那个真实的 bug 讲起,把补码、类型提升、跨语言差异一层层拆开,最后给出我在实际项目里固定检查的几个审查点。
1.(low + high) >>> 1这个写法到底在防什么
1.1 溢出才是二分查找真正的 bug 来源
先把这个经典案例讲透,因为它一次性暴露了移位运算符的三个关键特性。
low + high的结果是int,两个都不超过Integer.MAX_VALUE的正数相加,结果完全可能超过2147483647。在 Java 里,int运算溢出不会抛异常,它会静默回绕:1000000000 + 2000000000 = 3000000000,而3000000000 - 4294967296 = -1294967296。
这时候如果再执行-1294967296 / 2,得到的是-647483648。一个负数下标传给数组,直接ArrayIndexOutOfBoundsException。这就是二分查找历史上真实存在过的缺陷,Joshua Bloch 在 2006 年专门写过一篇博文讲这件事,java.util.Arrays.binarySearch也是在那个时期把写法改成了(low + high) >>> 1。
那为什么(low + high) >>> 1就能拿到正确答案?
把-1294967296写成 32 位二进制是0xB2D05E00,也就是符号位为 1。>>>在右移时往高位补 0,移一位之后变成0x59682F00,换算成有符号整数正好是1500000000,也就是3000000000 / 2的正确答案。
1.2>>为什么替代不了>>>
同样拿-1294967296试>>,因为符号位是 1,算术右移会把最高位继续补 1,结果是0xD9682F00,还是个负数。所以>>在这个场景里完全失效。
这就是>>和>>>的根本分野:>>把最高位当作"符号位"来尊重,>>>把它当作"普通数据位"来处理。前者保号,后者不保号。
顺带提一个容易忽略的点:算术右移对负数的结果等价于向下取整的除法,不是截断除法。-5 >> 1等于-3,而-5 / 2在 Java 里等于-2,因为整数除法是朝零截断的。这两个规则不一致,凡是把>>和/混着用、又涉及负数的代码,几乎必然出现 off-by-one。
1.3 换成long之后,这个写法还成立吗
如果把low和high都提升成long,low + high就不会溢出,此时>>和>>>的结果完全一致,写哪个都对。真正要小心的是运算符优先级:Java 里+的优先级高于>>>,所以
long mid = (long) low + high >>> 1;实际等价于((long) low + high) >>> 1,没问题。但如果你写成
long mid = (long) (low + high) >>> 1;括号里的加法已经在int域里溢出完了,再转long也救不回来。这两种写法的差别只有一对括号,但结果完全不同。我个人的习惯是:只要涉及两个可能很大的数相加取中值,一律写成low + ((high - low) >>> 1)或者显式补上括号,不给自己留犹豫的空间。
2. 补码视角下,右移往高位填的到底是什么
2.1 算术右移:负数右移等于向下取整
想真正记住>>的行为,最好直接从补码推。以 32 位int为例,-5的补码是0xFFFFFFFB,推导方式是~5 + 1,也就是0xFFFFFFFA + 1。
对这个值做>> 1,符号位是 1,所以最高位补 1,整体右移一位后得到0xFFFFFFFD。0xFFFFFFFD取反加一:~0xFFFFFFFD = 0x00000002,加一得 3,所以值是-3。
这个结果其实可以用一句话记住:对有符号整数,x >> n恒等于floor(x / 2^n)。注意是floor,不是截断。Java 里正好有Math.floorDiv可以对照验证:Math.floorDiv(-5, 2) == -3,和-5 >> 1完全一致。
2.2 逻辑右移:把符号位也当成数据
-5 >>> 1就完全是另一回事了。0xFFFFFFFB右移一位,高位补 0,得到0x7FFFFFFD,也就是十进制的2147483645。一个负数经过>>>之后直接变成一个接近Integer.MAX_VALUE的正数,这个跳变幅度第一次看确实会愣一下。
这里有个实操层面的通用结论:对于非负数,>>和>>>的结果完全相同;只有当左操作数可能是负数时,两者才会分叉。所以平时写位图、掩码、下标计算,输入天然非负,用哪个都行。但只要你处理的是哈希值、字节流、有符号 ID,就必须明确自己想不想要符号扩展。
2.3 左移不溢出信息,别指望能还原
<<的行为相对简单:低位补 0,高位直接丢弃。正因为高位被丢掉,左移是不可逆的。
int a = 0x40000000; // 1073741824 int b = a << 1; // 0x80000000,即 Integer.MIN_VALUE int c = b >> 1; // 0xC0000000,即 -1073741824a << 1 >> 1并不等于a,因为中间那一步已经把信息丢了。任何"先左移打包、再右移解包"的代码,都必须保证每一步都在容量范围内,否则就是不可逆的数据损坏。
顺带说一个使用频率很高的场景:从字节流里抽取一个 16 位有符号数时,常用(short) ((raw << 16) >> 16)来做符号扩展。这里的原理是先把目标位推到最高位,再算术右移把符号位"传染"到整个高位区,这是一个非常经典的位技巧。
3. 移位量这件事,各语言的规矩完全不一样
3.1 Java:移位量按0x1F或0x3F取模
Java 语言规范规定得很死:左操作数是int时,移位量只用低 5 位,等价于shift & 0x1F;左操作数是long时,用低 6 位,等价于shift & 0x3F。
System.out.println(1 << 32); // 1,因为 32 & 0x1F == 0 System.out.println(1 << 33); // 2 System.out.println(1 << -1); // -2147483648,因为 -1 & 0x1F == 31 System.out.println(1L << 64); // 1,64 & 0x3F == 0这个规则没有一个"越界就报错"的兜底,它是静默取模。所以1 << 32 == 1这种结果看起来像玄学,其实是规范明文规定的。
我见过实际代码里出现1 << i而i来自配置或循环变量的情况,一旦i到了 32、64、96,掩码就会诡异地重复,而且不会报错,只会让线上的权限判断在某几个编号上错乱。这种 bug 排查起来非常费劲,因为控制台不会有任何异常输出。
3.2 C/C++:越界移位是未定义行为
C 和 C++ 在这件事上比 Java 危险得多。当移位量大于等于类型位宽,或者为负数时,行为是未定义的,编译器可以任意处理。
危险在于,x86 的shl指令本身会把移位量按0x1F截断,所以你在本机测试时看到的结果和 Java 一模一样,可能会以为"没问题"。但换个架构、换个优化等级,或者编译器认定这段代码不可达而直接优化掉,结果就变了。开-O2之后逻辑莫名改变的情况,在位运算代码里并不罕见。
C/C++ 还有一个差异:对有符号整数做左移,如果结果溢出,在 C 里依然是未定义行为;C++20 之后左移被明确定义为按无符号回绕,但如果你面对的是老代码库或者跨标准编译,还是别依赖这个。
3.3 JavaScript:先塞进 32 位再说
JavaScript 的数字是双精度浮点,但所有位运算都会先把操作数转成 32 位整数。<<、>>用的是ToInt32,>>>用的是ToUint32,移位量同样按& 31处理。
console.log(1 << 32); // 1 console.log(1 << 31); // -2147483648 console.log(-1 >>> 0); // 4294967295 console.log(-1 >>> 1); // 2147483647 console.log(2 ** 31 | 0); // -2147483648注意最后一行:2 ** 31本身是2147483648这个正常的浮点数,但一旦参与位运算就被截成-2147483648。所以 JS 里位运算是不能直接用来做大整数计算的,超过 32 位的部分会被直接丢掉。要用超过 32 位的位运算,只能用BigInt,而BigInt只支持<<和>>,没有>>>。
>>> 0这个写法在 JS 社区里几乎是标准操作,用来把任意数字转成无符号 32 位视图。后面讲调试的时候还会用到它。
3.4 防御性写法
不管你用哪种语言,只要移位量来自变量,就应该在边界上做一次夹紧,而不是赌语言规范帮你兜底。
static int safeShift(int value, int shift) { if (shift < 0 || shift > 31) { throw new IllegalArgumentException("shift out of range: " + shift); } return value << shift; }多这一层判断,代价是一次比较,换来的是线上不再出现"某个编号的权限偶尔失效"这种幽灵问题。我在处理权限位和位图索引时,都会在入口处做这个校验。
4. 类型提升带来的经典翻车现场
4.1byte参与移位之前会先变成int
这是我在面试和代码审查里见过最多的一个坑。
byte b = (byte) 0xFF; // 实际值是 -1 System.out.println(b >>> 1); // 2147483647,不是 127 System.out.println((b & 0xFF) >>> 1); // 127,这才对原因很清楚:Java 里byte和short在参与几乎所有算术和位运算之前,都会被提升成int。提升过程中符号扩展,0xFF变成0xFFFFFFFF,然后>>> 1就得到0x7FFFFFFF。
如果你确实想要"把这个字节当成无符号数来右移",必须先& 0xFF把高位清干净,再做移位。这个模式在解析二进制协议、计算校验和、处理 UTF-8 字节序列时几乎是标配。
4.21 << 31是负数,1L << 31才是正数
int a = 1 << 31; // -2147483648 long b = 1L << 31; // 2147483648 long c = 1 << 31; // -2147483648,先算完 int 再拓宽,救不回来第三行是最阴的:变量声明成了long,看起来应该能装下2147483648,但右边整个表达式先在int域里算完了,溢出已经发生,之后拓宽只是把一个负数变成同样数值的long。
处理 64 位掩码时同理,千万不要写1 << 63,那个结果在int里是1 << 31(因为移位量被& 0x1F截成 31),跟你想的完全不是一回事。正确的是1L << 63。
我的经验是:只要掩码可能用到第 31 位及以上,1后面就必须带L。这一条可以直接写进团队的代码规范。
4.3 用long左移时,移位数上限是 63 不是 31
前面说过,左操作数是long时,移位量按& 0x3F取模。这意味着1L << 64等于1L,1L << 65等于2L。如果代码里用循环变量控制移位,而循环上界是靠"位数"算出来的,很容易在 64 这个位置上撞车。
4.4 从字节流拼 32 位整数时,& 0xFF一个都不能少
int value = ((data[0] & 0xFF) << 24) | ((data[1] & 0xFF) << 16) | ((data[2] & 0xFF) << 8) | (data[3] & 0xFF);假设data[1]是(byte) 0x80,也就是-128。如果少了& 0xFF,-128 << 16会得到0xFF800000,跟前面已经放好的高字节一或,整个高 8 位全被污染,结果从0x12800000变成0xFF800000。
有意思的是最后那个字节其实可以省掉& 0xFF,因为它的高位本来就会被或运算忽略。但我在实际代码里从来不省——统一写法的可读性和一致性,比省两个字符重要得多,而且以后改字段顺序时也不容易漏。
5. 工程代码里移位运算符的真实落点
5.1 哈希扰动与容量对齐:两个教科书级的位运算
第一个是HashMap里的扰动函数:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }它的动机是:桶下标计算用的是(n - 1) & hash,而n是 2 的幂且不超过2^30,所以下标只用到hash的低位。如果hashCode的实现里低位区分度不高(比如只用到了低位做递增),高位信息就完全浪费了。
把h无符号右移 16 位再异或回低位,相当于让高 16 位也参与到下标计算里。为什么用>>>而不是>>?因为>>会把符号位复制到高 16 位,如果hashCode是负数,扰动后的高半部会全变成 1,这等于凭空引入了一种结构性偏置。用>>>得到的是一份干净的"无符号高半部"。
第二个是容量向上取整到 2 的幂:
static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }这段代码的巧妙之处在于步长的倍增。第一步把最高的 1 后面一位填成 1,此时顶部有连续 2 个 1;第二步移 2 位,顶部变成连续 4 个 1;第三步 8 个,第四步 16 个,第五步 32 个。5 次操作覆盖 32 位,因为2^5 = 32,这是指数逼近。
理解了这个套路,你自己写"把一个数低位全部填 1"时就不会再傻乎乎地循环 32 次了。同样的思路用在long上,需要一路写到>>> 32。
5.2 位图与 BitSet:用移位代替除法和取模
java.util.BitSet内部用long[]存位,寻址逻辑就是移位的标准应用:
public void set(int bitIndex) { int wordIndex = bitIndex >> 6; // 等价于 / 64 words[wordIndex] |= 1L << bitIndex; // 1L << (bitIndex & 63) }bitIndex >> 6是除以 64,1L << bitIndex里隐含的bitIndex & 0x3F是模 64。这里用>>还是>>>都行,因为bitIndex必须非负。但用1L而不是1就很关键——用1的话,第 32 位以上全部算错。
我在用户标签、每日签到、活跃度统计这类场景里用位图用得比较多。千万级用户的签到状态,按每位 1 bit 算,一千万人一天 1.25 MB,一年也才 450 MB 出头,比一行行记录省了不止一个数量级。取某一位的状态用(words[i >>> 6] >>> (i & 63)) & 1L,或者更省事的Long.numberOfTrailingZeros、Long.bitCount这些现成方法。
5.3 权限位掩码:int装 32 个开关,long装 64 个
public static final int READ = 1 << 0; public static final int WRITE = 1 << 1; public static final int DELETE = 1 << 2; public static final int SHARE = 1 << 3; int perm = READ | WRITE; boolean canWrite = (perm & WRITE) != 0; perm |= DELETE; perm &= ~WRITE;这套写法的好处是增删查都是一次位运算,而且可以把整个权限组合存成数据库的一个int字段,读写都便宜。判断权限时一定要用!= 0而不是== 1,因为perm & WRITE的结果是2不是1,写成== 1会永远判断失败。
超过 32 个标志位就换long,超过 64 个就得考虑BitSet或者拆成多个字段。这里有一个很容易被忽略的副作用:把权限掩码存进数据库再读出来时,如果用了会做符号处理的方式打印或传输,负数掩码(比如用到了第 31 位)可能会出问题。Java 里安全打印可以用Integer.toUnsignedString(perm),或者干脆约定最高几位不用。
5.4 颜色通道与协议字段的拼装
ARGB 颜色的打包和解包是另一个高频场景:
int argb = (a << 24) | (r << 16) | (g << 8) | b; int a2 = (argb >>> 24) & 0xFF; int r2 = (argb >> 16) & 0xFF; int g2 = (argb >> 8) & 0xFF; int b2 = argb & 0xFF;这里>>> 24和>> 24配合& 0xFF的结果其实一样,但用>>>意图更清楚:我要的是那 8 个数据位。写>>也没错,只是需要读者自己知道后面有& 0xFF兜底。
再比如 IPv4 地址和整数的互转、UTF-8 变长编码里的续字节判断(b & 0xC0) == 0x80、Base64 每 3 字节拆成 4 个 6 位索引,都是同一类操作。这些代码看多了会发现一个规律:凡是要"把若干小字段塞进一个大整数",必然用到<<;凡是要"从大整数里抠出某一段",必然用到>>加&掩码。
6. 一张表把跨语言差异钉死
6.1 到底哪些语言有>>>
| 语言 | 左移 | 算术右移 | 逻辑右移 | 备注 |
|---|---|---|---|---|
| Java | << | >> | >>> | 移位量按0x1F/0x3F取模 |
| C / C++ | << | >> | 无 | >>对有符号数是实现定义,越界移位未定义 |
| C# | << | >> | >>>(C# 11 起) | 移位量同样取模 |
| JavaScript | << | >> | >>> | 全部按 32 位处理,BigInt无>>> |
| Python | << | >> | 无 | 任意精度,右移即向下取整 |
| Go | << | >> | 无 | 有符号走算术、无符号走逻辑,移位量无上限 |
| Rust | << | >> | 无 | 有wrapping_shl、checked_shl等显式变体 |
| Kotlin | shl | shr | ushr | 以中缀函数形式提供 |
这张表里最需要注意的是 C/C++:它没有>>>,所以"把有符号数当无符号处理"必须显式转成无符号类型再做右移,或者靠& 0xFF之类的掩码自己清理高位。
6.2 溢出与越界移位的行为差异
| 场景 | Java | C/C++ | JavaScript | Python | Go |
|---|---|---|---|---|---|
| 左移溢出 | 静默回绕 | 有符号是未定义 | 按 32 位截断 | 不会溢出 | 静默回绕 |
| 移位量 >= 位宽 | 取模后执行 | 未定义 | 取模后执行 | 结果变成 0(大数) | 结果为 0 或按位填充 |
| 移位量为负 | 取模后执行 | 未定义 | 取模后执行 | 抛异常 | 运行时 panic |
| 负数右移 | 保号或补 0,由运算符决定 | 实现定义(通常是保号) | 同 Java | 向下取整 | 有符号保号 |
把这张表背下来意义不大,但你至少要知道:只有 Java 和 JavaScript 这一路语言给了你"越界不报错"的静默取模行为,其他语言要么报错要么未定义。所以在 Java/JS 里更依赖主动校验,在 C/C++ 里更依赖防御性代码和不写越界移位。
6.3 Python 和 Go 为什么没有这个烦恼
Python 的整数是任意精度的,1 << 1000就是一个 1000 位的整数,不存在溢出概念。代价是它没法映射到单条 CPU 指令,性能上比定长整数慢,所以在需要极限性能的位运算场景里,Python 通常会借助numpy的定长类型或者干脆换语言。
Go 的选择是显式声明:int8、uint32这些类型泾渭分明,>>对无符号类型就是逻辑右移,对有符号类型就是算术右移,不需要额外的运算符。移位量没有上限,var x uint8 = 1; x << 8直接得到 0,行为完全确定。这种设计在写底层协议代码时体验很好,因为不需要记"到底该用哪个大于号"。
7. 排查与验证:我怎么快速确认一个移位结果
7.1 打印二进制比打印十进制有用得多
位运算出问题时,看十进制数字基本看不出所以然。我习惯第一时间把它转成补齐的 32 位二进制。
int x = -5; String bits = String.format("%32s", Integer.toBinaryString(x)).replace(' ', '0'); System.out.println(bits); // 11111111111111111111111111111011console.log((-5 >>> 0).toString(2).padStart(32, '0')); // 11111111111111111111111111111011print(format(-5 & 0xFFFFFFFF, '032b')) # 11111111111111111111111111111011注意 Java 的Integer.toBinaryString对负数返回的是补码形式,但不会补齐到 32 位,所以要用长度格式化补零。上面 Java 那句里的格式化字符串用%32s就够了,不需要额外转义。
7.2 我固定检查的几个审查点
每次代码审查看到移位运算符,我会按这个顺序过一遍:
- 所有
1 << n里的1,是不是应该写成1L?特别是掩码可能用到第 31 位以上的场景。 - 所有右移,是不是应该用
>>>?判断标准是左操作数是否可能为负,以及负值时符号扩展是否是我想要的。 - 所有对
byte、short的移位,前面有没有& 0xFF或& 0xFFFF清理高位? - 移位量是变量吗?如果是,有没有夹紧到合法区间?
- 表达式里混用了移位和其他运算符吗?有没有依赖优先级?我倾向于全部加括号。
- 移位量来自数组长度、配置项或用户输入时,有没有可能超过 31 或者变成负数?
第 5 条值得单独说一句。移位运算符的优先级比加减法低,比关系运算符和按位与高。也就是说:
int a = 1 << 2 + 3; // 等价于 1 << 5 == 32,不是 (1 << 2) + 3 == 7 int b = 0xFF & 1 << 8; // 等价于 0xFF & (1 << 8) == 0这两行如果按直觉理解都很容易出错。我的做法是:只要移位表达式里出现了别的运算符,就无条件加括号,不给自己和别人留推断优先级的负担。
7.3 什么时候应该放弃移位运算
最后说一下我自己的取舍标准。移位本质上是一种暴露底层表示的写法,它省掉了除法和取模的开销,但把"这个数字是几进制、宽度是多少、符号怎么处理"这些问题直接摆到了读者面前。
我大概遵循这几条:
- 如果位本身就是需求(掩码、位图、协议字段、颜色通道),那就大方地用移位,这时候用除法反而绕。
- 如果只是"乘以 2"或"除以 2",直接用
* 2、/ 2,让 JIT 或编译器去决定要不要优化成移位。现代编译器对 2 的幂次乘除的优化已经非常成熟,手写移位带来的性能收益基本可以忽略。 - 如果是在循环体里做大量位运算,先测再改。我见过不止一次"用移位替换除法后性能反而下降"的情况,因为改变了数据依赖关系,或者破坏了向量化机会。
那次代码审查结束之后,我把(low + high) >>> 1这一段单独抽出来做了一次团队分享,重点讲了>>和>>>在负数上的分叉。后来有人在项目里真的抓到了一个类似的 bug:一个用>>计算分页偏移的地方,当偏移量因为某种原因变成负数之后,算出来的页号也变成了负数,最终查了一个空结果集,接口返回正常、日志没有异常,只有用户反馈"翻到某几页之后列表是空的"。这类问题如果不理解符号扩展,光看代码是看不出来的。