从数组越界到 JDK 源码:补码与移位运算符避坑指南

发布时间:2026/9/18 7:59:07
从数组越界到 JDK 源码:补码与移位运算符避坑指南 某次排查一个数组越界的问题最后定位到一行看着毫无破绽的代码int mid (low high) / 2;。low 和 high 都接近 int 上限相加溢出成负数除法之后 mid 变成负值直接拿去索引数组当场炸掉。改成(low high) 1之后问题消失。那一刻我才意识到日常写业务代码的人对、、这三个符号的理解大多数停留在左移乘2、右移除2的层面而真正决定程序正确性的那些边界全在教科书一笔带过的地方。这篇内容就把这三个运算符从头到尾拆一遍补码是怎么把整数变成比特串的、算术右移和逻辑右移到底差在哪里、移位数超过位宽时各语言是怎么处理的、JDK 源码里那些移位写法为什么那么写。适合已经会写代码但没系统梳理过位运算的人也适合准备面试想把这些细节一次搞明白的人。1. 三个符号的分歧点其实只有符号位1.1 补码把整数变成了一串可运算的比特要理解移位先得接受一个前提计算机里没有负数这个东西只有比特。负数是一套人为约定也就是补码。补码的规则很简单-x的表示等于~x 1也就是把x的每一位取反再加一。用 8 位举例8是0000 1000取反得到1111 0111加一得到1111 1000这就是-8。你可以自己验证一下8 (-8) 0000 1000 1111 1000 1 0000 0000丢掉溢出的最高位正好是 0加法器不需要为负数做任何特殊电路。这套约定带来一个非常关键的副作用最高位最左边那一位天然成了符号位。非负数的最高位是 0负数的最高位是 1而剩下的位仍然参与正常的数值计算。所以当你在 32 位 int 上写-8内存里实际躺着的是0xFFFFFFF8它不是负号加 8而是一长串几乎全是 1 的比特。这个事实后面会反复用到因为和的分歧完全建立在这串比特上。顺带说一句为什么是补码而不是反码或者原码。原码的问题是0和-0两种表示加法器要额外判断反码的问题是加减法要循环进位。补码把减法统一成了加法硬件成本最低所以从早期机器一直用到现在。你现在写的每一行a - b底层跑的都是a (~b 1)。1.2 左移补零、算术右移补符号、逻辑右移补零三个运算符的规则用一句话就能概括但每个字都要抠左移整体向左挪右边空出来的位置一律补 0。右移算术右移整体向右挪左边空出来的位置补原来的符号位符号位是 0 就补 0是 1 就补 1。右移逻辑右移整体向右挪左边空出来的位置一律补 0不管原来符号位是什么。也就是说和的补位规则是确定的只有是看情况的。这是三个符号里唯一一个依赖数据内容的行为也是唯一一个会在正负数之间产生不同结果的运算符。拿-80xFFFFFFF8来试一遍三个结果完全不同-8 1111 1111 1111 1111 1111 1111 1111 1000 -8 1 1111 1111 1111 1111 1111 1111 1111 0000 -16 -8 1 1111 1111 1111 1111 1111 1111 1111 1100 -4 -8 1 0111 1111 1111 1111 1111 1111 1111 1100 2147483644注意-8 1得到-4这是除以 2 向下取整的结果而-8 1直接把符号位当数据位用掉了变成一个接近 21 亿的大正数。这个差异在某些语言里只是不常用在另一些语言里就是根本没有。比如 Java 和 JavaScript 有C、C、Go、Rust、Python 都没有这个运算符它们靠操作数是不是无符号类型来决定右移是算术还是逻辑。一个容易记混的点不是右移两位的意思它的三个字符是一个整体符号读作无符号右移位移量仍然写在后面比如x 3。2. 左移 乘 2 的捷径也是溢出事故的高发区2.1 为什么左移一位就等价于乘 2二进制里每一位的权重是 2 的幂往左挪一位每个 1 的权重都翻倍所以整个数的值乘 2。这个推理在无符号数上无懈可击。在有符号数上只要不越过符号位结论一样成立。所以x n在数值上等于x * 2^n前提是没有溢出。但这个等于有三个前提条件缺一不可没有溢出。1 31在 32 位 int 上等于-2147483648因为最高位被 1 占掉整串比特被解释成了负数。它不等于 2^31因为 int 装不下 2^31。x 是整数。浮点数不能移位这是编译期就拦下来的类型错误。没有副作用。移位是纯值运算不会修改原变量a 1不改变a必须写a a 1或a 1。那为什么大家还是喜欢用左移替代乘法早期编译器优化能力弱x * 8可能真的会生成一条乘法指令而x 3是一条移位指令在那些乘法要几十个时钟周期的老架构上差距巨大。现在的情况变了现代编译器对乘 2 的幂几乎都会自动转成移位JIT 也会做同样的事所以你手写在性能上基本没有收益。今天的左移更多是一种表达意图的写法用在位操作、掩码、标志位这些语义上而不是用来做乘法优化。我实测过一个循环里i * 2和i 1在服务端 JIT 编译后的差异跑了几亿次耗时在噪声范围内没有稳定差距。所以别为了这点性能去牺牲可读性除非你确实在写对指令数敏感的底层代码。2.2 移位数超过位宽时一门语言一个规矩这是最容易踩坑、也最少被提到的地方。假设你写x 32在 32 位 int 上等于什么Java的规范里写得非常明确移位运算符的右操作数会先对 32 取模对 int或对 64 取模对 long。所以x 32等价于x 0结果就是x本身x 33等价于x 1。Java 里1L 64 1是千真万确的我第一次见到的时候也盯着屏幕看了半天。C 和 C里这就是未定义行为。标准原文说的是如果移位量大于等于操作数的位宽行为未定义。实际的 x86 硬件上shl指令只取移位量的低 5 位32 位操作数或低 6 位64 位操作数看起来和 Java 一样但编译器在优化阶段可能会基于不会发生这个假设做出各种变换导致在不同优化等级下结果不一样。所以我从来不写x 32这种代码懒得去赌编译器的心情。Go的处理方式最数学移位量可以任意大结果被定义为 0无符号数或有符号非负数或者保持符号扩展负数。也就是说uint32(1) 32在 Go 里是0不是1也不是未定义。这个设计我挺喜欢的至少结果是确定的。JavaScript会把操作数先转成 int32 再做移位移位量同样取模 32所以1 32 1和 Java 一致。C#对 int 和 long 分别取模 32 和 64行为接近 Java。语言x 3232位规范性质Java等于x明确定义C / C未定义行为标准不保证Go等于0明确定义JavaScript等于x明确定义C#等于x明确定义实操心得任何你在写根据配置计算移位量的代码都要在移位之前对位宽取模或者干脆加一句断言。别人接手你的代码时看到1 shift而 shift 来自外部输入第一反应应该是去查边界。2.3 标志位与打包左移真正的主战场左移在工程里最正当的用途是构造位掩码。一个 32 位 int 能塞下 32 个布尔开关这在权限系统、状态机、协议头里非常常见。public final class Perm { public static final int READ 1 0; // 0001 public static final int WRITE 1 1; // 0010 public static final int EXEC 1 2; // 0100 public static final int DELETE 1 3; // 1000 public static boolean has(int mask, int flag) { return (mask flag) ! 0; } }为什么用1 n而不是直接写1、2、4、8因为当标志多起来的时候人眼数不清0x4000是第几位但1 14一眼就看得出。这是可读性和可维护性的胜利不是性能的胜利。这里有个必须提的坑1 31是负数。1 31得到0x80000000也就是-2147483648。所以如果你用 int 存标志位最多安全使用 31 位第 32 位虽然能用但会让整个掩码变成负数在做比较和打印的时候非常容易出错。超过 31 个标志就换 long或者是EnumSet。另一个高频坑是运算符优先级。在 Java 里移位运算符的优先级低于加减法所以a b 1实际是(a b) 1不是a (b 1)。同理的优先级高于所以if (mask FLAG FLAG)会被解析成mask (FLAG FLAG)这个表达式在 Java 里直接编译不过int 和 boolean 不能做位与但换成别的语言可能就静默跑出错误结果了。我的习惯是只要表达式里出现位运算就无条件加括号哪怕编译器不需要。3. 和 负数才是真正的分水岭3.1 算术右移的符号扩展的行为是右移之后左边空出来的位用原来的符号位填满。这个填充动作叫符号扩展。它的效果是对负数右移高位不断补 1数值向负无穷方向靠拢。用 8 位看更清楚-8 1111 1000-8 1 1111 1100 -4 -8 2 1111 1110 -2 -8 3 1111 1111 -1 -8 4 1111 1111 -1注意从第 3 次开始就卡住了。因为所有位都变成 1 了再怎么右移补的还是 1结果永远是-1。这就是为什么 Java 里-1 任意非零数永远等于-1。如果你在写一个每次减半直到 0的循环用处理负数会变成死循环这是一个真实发生过的 bug。再对比一下除法和右移在负数上的差异这个坑极深int a -7; System.out.println(a / 2); // -3 System.out.println(a 1); // -4-7 / 2在 Java 里是-3因为整数除法是向零截断的。而-7 1是-4因为右移是向下取整的。对非负数两者一致对负数就分道扬镳了。如果你的业务逻辑里有把金额除以 2 分账这种需求把/ 2改成 1会让负数金额的结果偏差 1 分钱这种 bug 在账单对不上的时候能查到你怀疑人生。结论很明确负数场景永远不要用替代除法。就算你确信数据非负也要想一下将来会不会有人传负数进来。3.2 逻辑右移的补零语义的存在意义只有一个它把符号位当成普通数据位处理不管原来是什么左边一律补 0。这带来一个非常有用的性质x 0可以把有符号数重新解释为无符号数同时值被限制在 0 到 2^32-1 范围内。JavaScript 里这个技巧被用烂了因为它是最短的取整方式1.9 0 // 1 -1.9 0 // 4294967295注意这不是 -1是个巨大的正数 4294967296 0 // 0超出 uint32 范围回绕 (-1) 0 // 42949672951.9 0得到 1是因为内部会先做 ToUint32 转换小数部分被丢掉。这个写法确实短但不建议用在对负数有预期的场景因为-1会变成 4294967295如果有人拿这个值去做数组下标会直接越界。Java 里x 0没有变化因为 int 本来就是 32 位无符号重解释在类型层面拿不到结果除非赋给 long。所以 Java 里更常用的是Integer.toUnsignedLong(x)或者Integer.compareUnsigned(a, b)。最经典的用法是处理哈希值和混合位。因为哈希值可以是负数而取模运算对负数会返回负数做数组下标会崩所以需要把它转成非负。JDK 的HashMap里就是这么干的static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }这里h 16把高 16 位挪到低 16 位然后和原值异或目的是让高位参与哈希桶的定位。因为 JDK 定位桶用的是hash (n - 1)当数组长度很小时比如 16n - 1只有低 4 位有效高位信息全浪费了。扰动函数把高位信息混到低位来能显著减少碰撞。这个设计是 JDK 8 引入的读懂了这一行你就理解了为什么 HashMap 的哈希函数长这样。3.3 一表看清三种移位在正负数下的结果用 32 位 int取8和-8各做一次移位结果如下表达式二进制结果32位十进制说明8 10000...0001 000016乘 2-8 11111...1111 0000-16乘 2保持符号8 10000...0000 01004补 0-8 11111...1111 1100-4补 1向下取整8 10000...0000 01004和非负时一致-8 10111...1111 11002147483644符号位当数据位-1 11111...1111 1111-1卡死在 -1-1 10111...1111 11112147483647等于Integer.MAX_VALUE-1 1等于Integer.MAX_VALUE这个等式非常值得记它在很多二分查找和位运算的边界处理里会冒出来。4. 工程现场源码里那些用移位写出来的东西4.1 JDK 里的两段经典代码读懂了少走三年弯路第一段是Arrays.binarySearch里的中点计算int mid (low high) 1;为什么不写(low high) / 2因为low high可能溢出。当 low 和 high 都接近Integer.MAX_VALUE时相加会变成一个负数除法之后 mid 是负的拿去索引数组直接抛ArrayIndexOutOfBoundsException。而 1把结果当成无符号数来理解即使溢出得到的仍然是正确的平均位置。我第一次读到这段代码的时候很震惊原来 JDK 作者早就知道这个坑而我还在用除法。顺便说一句如果你的数组长度不可能接近 21 亿这个坑你一辈子也遇不到但一旦遇到它会以随机数组越界的形式出现非常难查。这也是为什么我建议直接抄 JDK 的写法不要自作聪明改回去。第二段是HashMap.tableSizeFor用来把任意容量向上取到最近的 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。经过 5 次和或运算任意 32 位数的最高位之后都会被填满 1最后加一就得到最近的 2 的幂。这个技巧的核心在于每次移位量翻倍1、2、4、8、16五次就能覆盖 32 位。如果你不明白为什么是 5 次可以自己模拟一遍cap 17n 从 16 开始第一次变 24第二次变 30第三次变 31后面就不变了最后加一得 32。这个模式我在很多地方见过比如求一个数需要几个比特位来表示、构造全 1 掩码、位图的对齐处理。记住移位量翻倍次数等于 log2(位宽)这个规律比死记代码有用得多。4.2 颜色值、ZigZag 与协议编解码中的移位ARGB 颜色是最直观的打包例子。一个 32 位 int 里塞下透明度、红、绿、蓝四个通道每个 8 位int color (a 24) | (r 16) | (g 8) | b; int a2 (color 24) ; // 0-255 int r2 (color 16) 0xFF ; int g2 (color 8) 0xFF ; int b2 color 0xFF ;取 r 的时候为什么必须先右移再与0xFF因为如果颜色值恰好是负数比如纯白0xFFFFFFFF在 Java 里是-1color 16会得到0xFFFFFFFF符号扩展直接取出来就是 -1 而不是 255。所以正确写法要么用要么老老实实与上0xFF。我见过有人写(color 16) 0xFF和(color 16) 0xFF争论哪个对实际上加上掩码之后两者完全等价但用更能表达我在按无符号理解这一段数据的意图。另一个经典是 protobuf 用的 ZigZag 编码它把有符号整数映射成无符号整数让绝对值小的负数也能用很短的字节数表示// 编码 int zigzag (n 1) ^ (n 31); // 解码 int original (zigzag 1) ^ -(zigzag 1);n 31这个操作很妙对负数n 的所有位都是 1右移 31 位之后得到全 1也就是-1对非负数右移 31 位得到 0。所以这个表达式的意思是负数时把左移后的结果全部取反非负数时保持原样最终实现了一个交替映射的序列0、-1、1、-2、2、-3、3……这个技术在序列化框架里非常普遍如果你做过后端协议设计迟早会遇到。4.3 各语言差异对照与手写替代方案不是所有语言都有写跨语言代码的时候特别容易翻车。下面这张表是我自己整理过的供参考语言算术右移逻辑右移备注Java有符号/无符号都有对应运算符JavaScript操作数先转 int32结果转 uint32C / C有符号类型无符号类型无符号类型的就是逻辑右移Go有符号类型无符号类型没有移位量可任意大Rust有符号类型无符号类型移位量溢出在 debug 下 panicC#C# 11 起早期版本只能靠无符号类型Python无整数任意精度没有固定位宽Python 是个特例需要单独说。Python 的整数是任意精度的没有 32 位或 64 位的概念-8 1得到-4算术右移语义但根本没有这个东西。如果你需要在 Python 里模拟 Java 的无符号右移必须自己做掩码def unsigned_rshift(x, n, bits32): mask (1 bits) - 1 return ((x mask) n) mask这个函数先把 x 截断到 32 位再右移再把结果截断。如果不加第一层掩码负数会一直保持无限位宽的符号扩展结果完全不对。我在这上面浪费过一个下午所以印象特别深。JavaScript 还有个坑BigInt 不支持。因为 BigInt 是任意精度的逻辑右移的语义没法定义。如果你写1n 1n浏览器会直接抛TypeError。但和是可以用的。如果你的代码里混用了 Number 和 BigInt这个不一致会在运行时才暴露出来TypeScript 的类型检查也拦不住只能靠测试覆盖。5. 常见问题与排查技巧实录5.1 移位运算符常见故障速查表下面这些是我和身边同事实际踩过的坑按现象—原因—处理整理成一张表遇到问题可以直接对照着查现象根本原因处理办法循环减半处理负数时死循环-1 n恒等于-1永远到不了 0改用普通除法或在循环里单独判断符号金额分账出现 1 分钱偏差负数的 1是向下取整/ 2是向零截断负数场景禁用移位替代除法数组下标为负导致越界哈希值为负时用%取模得到负数用hash (n - 1)或hash 1转非负标志位比较结果总是 truemask FLAG FLAG优先级错误全部加括号(mask FLAG) FLAG1 n结果是 0n 超过位宽语言层面发生的取模或未定义行为移位前对位宽取模并加断言颜色通道取出来是 -1用取高位时发生符号扩展改用或者与上0xFFJSON 里的数字在 JS 里对不上超过 2^53 的数字被回绕成 uint32大整数用字符串或 BigInt 传输Go 里1 64得到 0 而不是报错Go 的移位量不取模超过位宽结果就是 0靠断言或测试发现不要指望编译器这张表里我个人觉得最值得反复看的是第一条和第五条因为这两类问题排查起来最费时间往往要花几个小时才能定位到一行代码。5.2 几条我认为最值钱的实操心得第一条位运算表达式一律加括号。不要试图去记各语言的优先级顺序那玩意儿光是 Java 就有十好几级C 更多。加括号的成本是零不加括号的成本可能是半天调试。我现在的写法是((a b) | c)宁可多几个括号也不省。第二条遇到负数就停下来想一秒。、/、%这三个运算在负数上的语义都不完全一致而且不同语言之间还有差异。只要你的数据里可能出现负数就别用移位替代算术运算。这条规则帮我躲过了至少三次线上问题。第三条不要用移位做性能优化。现代编译器和 JIT 对乘除 2 的幂会自动优化你手写的和*生成的机器码是一样的。反过来手写移位会让代码可读性下降还给后面的维护者增加心智负担。真要用移位理由应该是我在操作比特而不是我想让它快一点。第四条看源码的时候多留意。你会在 JDK、各种序列化框架、哈希实现里反复看到它。看到它的地方通常意味着作者在处理符号位污染的问题或者在做哈希混合。理解了这一层读源码的速度会有明显提升。我自己就是从读懂HashMap的那个扰动函数开始才真正把这些运算符用顺手。第五条写测试的时候专门针对边界值。对于任何涉及移位的函数至少覆盖0、1、-1、Integer.MIN_VALUE、Integer.MAX_VALUE这几个输入。Integer.MIN_VALUE特别有意思它的位模式是1000...0000 1之后得到1100...0000而 1之后得到0100...0000两者差了整整 2^30 的量级。把这两个结果写进测试里比看十遍文档记得牢。最后一个我个人的习惯在代码里看到移位运算符时如果它不是用来做位打包或掩码的我会格外警惕——因为那通常意味着这里有一个被优化过的除法而优化过的除法在处理边界值时往往藏着问题。