十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入理解移位运算符:补码、符号扩展与位运算实战

深入理解移位运算符:补码、符号扩展与位运算实战 移位运算符这三个符号——、、——大概是每个写代码的人最早遇到、也最容易糊弄过去的一组东西。我刚开始写业务代码那几年对它们的印象就是左移等于乘 2右移等于除 2然后就没有然后了。直到有一次线上一个位图Bitmap统计模块在高并发下算错了去重结果我盯着那段1 offset看了两个晚上才意识到自己对这三个符号的理解其实停在知道的层面而不是懂的层面。后来做序列化协议、做 ID 生成器、做哈希扰动几乎每隔一段时间就要跟它们打一次交道慢慢就攒下了一些不成体系但很实用的经验。这篇东西写给两类人看一类是刚学编程、课本上讲位运算那章翻过去了但心里没底的朋友另一类是把、、用在生产代码里但从来没系统清理过其中陷阱的同行。我会把三种运算符的行为、补码的来龙去脉、主流语言的差异、以及可以直接抄进项目的六七个实战用法全部摊开讲重点放在为什么会这样和踩过什么坑上而不是罗列语法。看完之后你至少能做到两件事看到一个位运算表达式能准确说出它的结果以及在写底层代码时主动避开那几类最常见的翻车点。1. 先弄清楚移位运算符到底在移什么1.1 从一个位图统计 bug 说起那个位图模块的逻辑很朴素用一个long[]数组当位图每个 bit 表示一个用户 ID 是否出现过bitIndex是用户 ID 对总容量取模的结果。取位和置位的代码大概是这样的boolean get(int bitIndex) { int wordIndex bitIndex 5; int offset bitIndex 31; return (words[wordIndex] (1 offset)) ! 0; } void set(int bitIndex) { int wordIndex bitIndex 5; int offset bitIndex 31; words[wordIndex] | (1L offset); }看着没问题对吧问题出在两处一是get里用的是1 offset而不是1L offset1是 int当offset等于 31 的时候1 31得到的是-2147483648一个负数然后它被提升为 long 参与运算时会做符号扩展变成0xFFFFFFFF80000000L高位全被污染二是wordIndex用的是而不是虽然这里bitIndex非负所以结果一样但一旦上游传进来负数会把它变成负的数组下标直接抛数组越界。这个 bug 的迷惑性在于它在绝大多数情况下是对的只有在特定的 ID 和特定的偏移组合下才出错灰度测试根本发现不了上了量才炸。我把它拿出来当开篇是因为它一次性暴露了移位运算的三个核心知识点——类型宽度决定结果、符号扩展会污染高位、算术右移和逻辑右移在负数上分道扬镳。这三件事后面会一个一个拆开讲。1.2 二进制视角左移右移的本质把话说白了移位运算符做的事情只有一个把一串二进制位整体往某个方向挪动若干个位置空出来的位置补东西挪出去的位直接扔掉。就这么简单。拿 8 位来举例数字 5 的二进制是0000 01015 1整体左移 1 位右边补 0得到0000 1010也就是 10。5 2得到0001 0100也就是 20。5 1整体右移 1 位左边补符号位正数补 0右边丢掉的 1 扔掉得到0000 0010也就是 2。所以左移 n 位等于乘 2 的 n 次方这个说法是有条件的只要挪出去的那些位全是 0也就是没有溢出它就严格成立。一旦高位有 1 被挪出去结果就不再是乘法了而是乘法之后再对 2 的位宽次方取模。比如 8 位下的1000 0001 1得到的是0000 0010也就是 2而129 * 2 258明显对不上因为 258 超出 8 位能表示的范围了。右移的等于除以 2 的 n 次方就更微妙。对正数来说没问题5 右移 1 位是 25 / 2也是 2都是向下取整。但对负数就不一样了这个坑我在第 3 节会重点讲这里先埋个伏笔。理解移位的最快方式是把它当成指针在二进制串上滑动。你可以自己在纸上画一个 8 格或者 16 格的方框写几个数字进去然后手动挪一遍挪三五次之后对这三类运算符的直觉基本就建立起来了比看十遍文档都管用。1.3 三种符号的分工、、三个符号本质上回答的是两个问题往哪个方向移以及空出来的位置补什么。运算符方向空位补什么是否保留符号常见叫法左补 0不涉及左移右补符号位是算术右移右补 0否逻辑右移 / 无符号右移只有一种行为没什么歧义。真正容易混淆的是后两个同样是往右移会看你原来这个数是正是负负数就补 1正数就补 0结果始终保留原来的正负性不管你原来是正是负一律补 0相当于是把二进制串当成无符号数来看待。这里有个重要的现实问题并不是所有语言都有。它是 Java 和 JavaScript 的原生运算符C# 要到 11.0 才加进来C、C、Python、Go、Rust、PHP、Swift 这些语言里都没有。没有的语言要表达逻辑右移就得先把数转成无符号类型再移写法各不相同。这也是为什么很多人从 Java 转到 Go 或者 Rust 之后会发现以前顺手写的突然编译不过了。2. 补码是理解与的分水岭2.1 一条捷径理解补码要讲清楚和的区别绕不开补码。但我见过太多人一看到原码反码补码这套三连就头疼所以我换个讲法。补码的核心作用只有一个让减法变成加法让符号位可以和其他位一起参与运算。硬件上只需要加法器不需要单独的减法器成本就降下来了。求一个负数的补码最实用的捷径是从右往左遇到第一个 1 之前含这个 1的位保持不变左边的位全部取反。用 8 位演示一下 -5先写 5 的二进制0000 0101从右往左找第一个 1位置在最右边那一位这一位和它右边的位没有保持不变左边的位全取反得到1111 1011这就是 -5 的补码验证一下1111 1011加上0000 0101逐位相加最高位进位丢掉结果是1 0000 0000低 8 位全 0。完美闭环。这条捷径的好处是快不用先取反再加一心算的时候基本一秒出结果。我当年准备面试的时候把它写在便签上贴显示器边上用了不到一周就形成肌肉记忆了。2.2 算术右移为什么必须补符号位理解了补码和的分歧就一目了然了。在补码体系下最高位最左边那一位是权重为负的位。8 位补码里最高位代表的不是 128而是 -128。所以1111 1111这个数算下来是-128 64 32 16 8 4 2 1 -1正好就是 -1。现在问题来了如果把1111 1111右移一位左边补 0 会怎样得到0111 1111也就是 127。一个 -1 除以 2 突然变成了 127这个结果显然是荒谬的。所以必须补符号位把最高位的 1 继续复制到左边1111 1111 1得到1111 1111还是 -1。这在数学上是自洽的因为 -1 除以 2 向下取整确实等于 -1。这里衍生出一个非常经典的坑-1 n永远等于 -1不管 n 是多少。看起来人畜无害但如果你的循环写成这样int x -1; while (x ! 0) { x 1; }这就是个死循环x一直保持在 -1永远不可能到 0。正确的写法是x 1那样x会变成2147483647、1073741823……最后变成 0。这个坑我在 Code Review 里至少见过三次每次都是有人写逐位遍历的时候忘了初始值可能是负数。而补 0 的语义等价于把内存里这 32 个 bit 当成一个 0 到 4294967295 之间的无符号整数来看待然后做普通的右移。所以-1 1在 Java 里的结果是2147483647-1 0是4294967295。用 0把有符号数伪装成无符号数的技巧在哈希、CRC 校验、序列化里到处都是。2.3 位宽、类型宽度与移位距离还有一个必须搞清楚的概念移位是在多宽的空间里进行的。这个宽度由操作数的类型决定不是由数值大小决定。同样是左移 1 位byte、short、int、long的结果可能完全不同。而且很多语言在运算前会做整数提升比如 Java 里byte和short参与算术运算时会先提升为int所以(byte)(1 7)里1 7实际是在 32 位空间里算的结果是 128转回 byte 时被截断成 -128。另一个高频陷阱是移位距离的处理方式Java 的int移位距离只取低 5 位也就是对 32 取模long移位取低 6 位对 64 取模。JavaScript 的位运算全部在 32 位空间进行移位距离取低 5 位。C 和 C 中移位距离大于等于类型宽度是未定义行为编译器想怎么优化就怎么优化可能得到任何结果。Python 的整数是任意精度的移位距离多大都合法1 1000会真的算出来一个三百多位的十进制数。Go 的右移如果移位距离超过宽度无符号类型得到 0有符号类型得到 -1。这几条差异直接决定了同一段通用的位运算代码在换语言之后会出什么问题。我个人的习惯是只要移位距离是变量就一定要在代码里显式地对它取模或者做范围校验别指望语言帮你兜底。3. 三种运算符逐个拆解与语言差异3.1左移便宜但不免费左移是三个运算符里行为最一致的几乎所有语言都是低位补 0高位溢出直接丢弃。它的主要价值在于乘 2 的幂比乘法指令快——在早期的 CPU 上这个差距很明显移位指令一般只要 1 个时钟周期整数乘法要 3 到 5 个周期。不过在今天的 CPU 上这个性能差异基本可以忽略。现代处理器的乘法器已经很成熟编译器也会把x * 8自动优化成x 3你手写不手写都一样。所以左移的价值已经从性能优化转移到了语义表达上当你要表达的是第 n 位、第 n 个标志位、扩大 2^n 倍的空间这类概念时用比用乘法更能传达意图。真正要小心的还是溢出。Java 里1 31是-2147483648而不是2147483648因为它是在 32 位有符号空间里算的。如果你需要第 32 位必须写成1L 31先声明成 long。这个1和1L的一字之差是我在 Bitmap、布隆过滤器、权限位这些场景里见过最多的低级错误。记忆口诀凡是涉及超过 31 位的位运算左边那个常量一定要带 L。3.2算术右移负数的地板除法对正数的行为很好理解就是除以 2 的幂再向下取整。但负数是重灾区。Java 里-7 1等于多少先把 -7 写成补码1111 1111 1111 1111 1111 1111 1111 1001。算术右移一位左边补符号位 1得到1111 ... 1100。这个数是多少呢它的最高位是 1是负数取绝对值的方法是取反加一取反得到0000 ... 0011加一得到0000 ... 0100也就是 4。所以结果是 -4。而如果用除法来算-7 / 2在 Java 里等于 -3因为整数除法是向零截断的。-4 和 -3 之间的这个差异就是右移和除法最本质的分歧右移是向下取整floor除法是向零取整truncate。表达式结果取整方式-7 1Java-4向下取整-7 / 2Java-3向零取整-7 // 2Python-4向下取整-7 1Python-4向下取整所以在 Java、C、C、Go、Rust 里如果你把一个可能是负数的变量用右移代替除法结果会比你预期的更小一点。这个偏差在计算数组下标、分页偏移的时候特别危险——本来想取第 3 页的起始位置结果算出来是第 4 页边界条件就错位了。Python 因为是向下取整的除法//和的行为正好对得上所以在 Python 里用替代// 2是安全且推荐的。3.3逻辑右移只有部分语言才有的设计初衷很明确我想把这块内存当成一堆没有正负概念的裸 bit 来搬不要给我自动补符号位。在 Java 里最常见的三个用途是把有符号数转成无符号表示int x -1; long unsigned x 0xFFFFFFFFL;或者更直观的Integer.toUnsignedLong(x)。避免负数下标像mid (low high) 1这样即使low high溢出成负数右移之后也能得到正确的非负中点。逐位遍历时防止死循环前面提到的x 1把负数最终移到 0。JavaScript 的还有个特殊行为它会把左操作数先转成 32 位无符号整数ToUint32然后右移结果也以无符号整数返回。所以-1 0在 JS 里是4294967295而在 JS 里1 31是-2147483648。这两个结果放在一起看就能体会到 JS 的位运算全部在 32 位整数这个沙盒里进行超出这个范围的部分会被直接砍掉。JS 的Number虽然能安全表示到 2^53但位运算只能操作低 32 位。3.4 主流语言行为对照表下面这张表是我这些年陆陆续续攒出来的每次换语言都会翻一遍实测下来能省很多查文档的时间语言移位距离超宽时的行为Java有算术有取低 5 位int/ 低 6 位longJavaScript有算术有取低 5 位结果转 uint32C有实现定义C20 起算术无未定义行为C有实现定义C20 起算术无未定义行为C#有算术11.0 起有取低 5 位 / 低 6 位Go有算术无无符号得 0有符号得 -1Rust有算术无debug 下 panicrelease 下按掩码处理Python有算术无合法整数任意精度PHP有算术无未明确不建议依赖有一点要特别提醒C 和 C 里对负数右移的行为在 C20 之前是实现定义的也就是说标准允许编译器自己决定只不过绝大多数编译器都选择了算术右移。这就是为什么你在 Linux 上写-7 1得到 -4换到某些嵌入式编译器上可能就不一样了。C20 已经把这个行为统一成了算术右移但老代码里的历史包袱还在。4. 六个可以直接抄的实战用法4.1 位标志与权限组合最经典的用法几乎每个后台系统里都有。用不同的 bit 表示不同的权限一个 int 就能装下 32 种权限public final class Perm { public static final int READ 1 0; // 0000 0001 public static final int WRITE 1 1; // 0000 0010 public static final int EXEC 1 2; // 0000 0100 public static final int DELETE 1 3; // 0000 1000 public static boolean has(int perm, int flag) { return (perm flag) ! 0; } public static int grant(int perm, int flag) { return perm | flag; } public static int revoke(int perm, int flag) { return perm ~flag; } }相比用一个SetString存权限这种写法的存储开销小了不止一个数量级判断和合并也都是常数时间。数据库里存一个 int 就够了查出来直接位运算省掉了一堆关联表的 join。注意revoke里必须用 ~flag不能写成 (!flag)后者在 Java 里编译都过不了C 里则会被解释成布尔取反结果全是错的。4.2 RGB 颜色打包与拆解一个像素的 RGB 值可以用一个 int 装下高 8 位是红中间 8 位是绿低 8 位是蓝。int pack(int r, int g, int b) { return ((r 0xFF) 16) | ((g 0xFF) 8) | (b 0xFF); } int redOf(int rgb) { return (rgb 16) 0xFF; } int greenOf(int rgb) { return (rgb 8) 0xFF; } int blueOf(int rgb) { return rgb 0xFF; }拆解时的 0xFF绝对不能省。原因很简单如果这个颜色是深色比如rgb 0x800000红分量是 128最高位是 1那rgb 16得到的是0xFFFFFF80一个负数而不是 128。 0xFF的作用就是把符号扩展出来的那些 1 全部清掉只留低 8 位。这个坑我在做图片处理的时候真真切切踩过当时图片的深色区域全变成了奇怪的颜色排查了半天才反应过来是符号扩展的问题。从此以后我拆任何位段都会顺手加 掩码哪怕当前看来不需要。4.3 高低位拆分与重组把一个 64 位的 long 拆成两个 32 位 int在网络协议和数据库存储里非常常见long x 0x1122334455667788L; int high (int) (x 32); // 0x11223344 int low (int) x; // 0x55667788 long y ((long) high 32) | (low 0xFFFFFFFFL);这里的两个细节都是血泪教训。第一拆高位必须用而不是否则负数的高位会被符号扩展拆出来的high全是 1。第二重组时的low 0xFFFFFFFFL更关键low是 int如果它的最高位是 1也就是负的提升为 long 时会符号扩展成 64 个 1 打头的数直接和high 32做或运算会把高位全部污染。加一个 0xFFFFFFFFL把它变成只有低 32 位有值的 long才能安全拼接。这段代码我在无数个序列化库的源码里见过也在无数个初学者的提交里见过漏掉掩码的版本。4.4 雪花 ID 的位段拼接分布式 ID 生成器是位运算的集大成者。一个典型的 64 位 ID 布局是1 位符号位恒为 041 位毫秒时间戳10 位机器 ID12 位序列号。long timestamp System.currentTimeMillis() - EPOCH; // 41 位 long workerId 3L; // 10 位 long sequence 17L; // 12 位 long id (timestamp 22) | (workerId 12) | sequence; long ts id 22; long wid (id 12) 0x3FF; // 0x3FF 10 个 1 long seq id 0xFFF; // 0xFFF 12 个 1这个方案的巧妙之处在于ID 天然按时间递增所以按 ID 排序等价于按创建时间排序B 树索引的写入性能非常好不会像 UUID 那样造成随机的页分裂。而位数怎么分完全是靠移位间隔来控制的时间戳左移 22 位正好给后面 10 12 位让出空间。拆解回机器 ID 的时候(id 12) 0x3FF里那个掩码就是 10 个 1。掩码的位数必须和当初分配出去的位数严格对应多一位少一位都会串位。这也是为什么这类代码里到处是0x3FF、0xFFF这种看起来莫名其妙的常量其实它们每一个都对应着明确的位宽。4.5 哈希扰动与 ZigZag 编码JDK 里 HashMap 的哈希函数是位运算的教科书级案例static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }为什么要做这一步因为 HashMap 的数组长度总是 2 的幂计算下标的公式是(n - 1) hash。当 n 比较小的时候比如 16n - 1是 15只有低 4 位参与运算hashCode()的高 28 位完全被忽略。而很多对象的 hashCode 高位的分布是很有特征的比如某些 ID 类对象低位很规律只取低位会导致大量冲突。h ^ (h 16)就是把高 16 位异或到低 16 位上让高位信息也参与进来冲突率明显下降。另一个值得一看的是 Protocol Buffers 用的 ZigZag 编码它把有符号整数映射成无符号整数让小的负数也能用变长编码省空间int zigzag(int n) { return (n 1) ^ (n 31); } int unzigzag(int z) { return (z 1) ^ -(z 1); }n 31对正数得到 0对负数得到 -1全 1所以异或的效果就是正数直接翻倍负数翻倍再全位取反。0 和 -1 都映射到很小的值符合实际数据中小编号密集的分布。4.6 二分中点与向上取整到 2 的幂(low high) 1是 JDK 里 Arrays.binarySearch 的原始写法int mid (low high) 1;为什么不用(low high) / 2因为两者都是 intlow high可能溢出成负数除以 2 之后还是负数拿它当数组下标直接就崩了。而把溢出后那个负数的补码当成无符号数来看右移一位正好等于数学上正确的中值。这个技巧在数组接近 2^31 长度时才会生效但成本几乎为零所以 JDK 一直这么写。另一类是求不小于 x 的最小 2 的幂HashMap 的 tableSizeFor 就是这个逻辑static int nextPowerOfTwo(int x) { x--; x | x 1; x | x 2; x | x 4; x | x 8; x | x 16; return x 1; }它的原理是不断把最高位的 1 向右抹开第一次复制 1 位第二次复制 2 位第三次 4 位指数式扩散五步之内就能把 32 位里最高位以下的所有位全部填成 1最后加一就得到了进位后的 2 的幂。这种对数级扩散的写法在手写位运算里非常经典值得记住。5. 踩坑记录与排查速查表5.1 优先级括号永远不嫌多在 Java 里移位运算符的优先级低于加法高于关系运算符。所以a 1 2实际是a 3而不是(a 1) 2。我第一次看到这个规则的时候愣了几秒因为它反直觉。更麻烦的是、|、^这一组的优先级低于和!所以if ((flags MASK) MASK) { ... } // 正确 if (flags MASK MASK) { ... } // 编译错误或语义错误第二种写法在 Java 里会因为类型不匹配直接编译失败算是幸运的但在 C 里它会静默地按照flags (MASK MASK)来算MASK MASK是 1结果变成flags 1逻辑完全跑偏编译器还一声不吭。这类 bug 极难通过阅读发现只能靠规矩来防。我给自己定的规矩很简单只要一行里出现两种以上的位运算符就无条件加括号。哪怕优先级我确定是对的加上括号也不会有人抱怨省下的排查时间却是实实在在的。5.2 移位距离超过位宽这个坑前面提过这里展开说下具体表现。Java 里1 32等于 11 33等于 2因为移位距离对 32 取模了。这意味着如果你的代码里有1 n而 n 是个可能达到 32 的变量你会得到一个循环的值而不是期望中的 0。我见过一段生成唯一 key 的代码就是用1 (i % 64)去构造位图结果在 i 到 64 的时候又绕回来了。防这个坑的办法有两个一是明确需要的位宽如果超过 31 位就用 long并且用1L二是对移位距离做显式约束写成1L (n 63)让意图清楚地写在代码里。C 和 C 的情况更糟。因为移位数超宽是未定义行为编译器可以假设它永远不会发生然后基于这个假设做一些激进的优化导致代码的实际行为和你的直觉完全脱节。比如if (n 32) x a n;这样的判断在优化之后可能被整个删掉因为编译器认为n 32恒成立。这类问题在开 -O2 之后才暴露非常难查。5.3 符号扩展没清干净第三类坑也是实际项目里最高频的从窄类型往宽类型提升时符号位被自动复制把高位污染了。典型场景是解析二进制协议。从字节流里读出的byte如果值大于 127它的最高位就是 1提升成 int 的时候会变成负数byte b (byte) 0x80; // -128 int bad b; // -128符号扩展 int good b 0xFF; // 128正确所以在做协议解析、图片处理、哈希计算的时候只要涉及 byte 转 int一定要加 0xFFshort 转 int 加 0xFFFF。这个习惯我从写第一个网络协议解析器开始就养成了后来发现它在任何涉及二进制的地方都适用。判断有没有清干净有个简单的自检方法如果你拿到一个 int不确定它的高位是什么就先和掩码与一下再往下用。多一次与运算几乎不花时间但能避免一整类问题。5.4 常见问题速查表现象最可能的原因排查方向位图在第 32/64 位附近出错用了1而不是1L检查常量类型和位宽负数右移后和除法结果对不上右移是向下取整除法是向零取整确认是否需要用Math.floorDiv循环用x 1不退出负数右移永远到不了 0改用x 1深色像素通道值全是 255右移后没加掩码符号扩展补上 0xFF拼接 long 后高位不对low 部分没做无符号提升补上 0xFFFFFFFFL1 n在 n 大于 31 时行为诡异移位距离被取模改用 long 并显式取模位运算判断条件总是不成立优先级问题缺括号给每个子表达式加括号不同平台结果不一致未定义行为或实现定义行为查语言标准对负数右移的规定排查这类问题我的习惯是把中间值用二进制打印出来看。Java 里可以用Integer.toBinaryStringPython 用format(x, 032b)把原始值和移位后的值并排打出来一眼就能看出是补位补错了还是掩码位数不对。这比盯着十进制数字猜要快得多。System.out.println(Integer.toBinaryString(-1 16)); // 输出000000000000000011111111111111115.5 一个小技巧用掩码位数反推最后分享一个我在排查位段错位时常用的小技巧。当你看到一段移位代码但不确定每一位代表什么的时候可以从掩码反推位宽再反推位移量。比如(id 12) 0x3FF看到0x3FF就知道这是 10 个 1也就是要取 10 位右移了 12 位说明这 10 位原来占据的是 [12, 21] 这个区间。再看id 0xFFF里0xFFF是 12 个 1占据 [0, 11]。两段严丝合缝中间的 12 和 10 加起来是 22正好和timestamp 22里的 22 对上。这种位图对账的方法能在没有文档的情况下快速还原出一个 ID 或者状态字的字段布局。养成这个习惯之后我读别人的位运算代码快了很多基本上扫一眼掩码就能把结构画出来。这个技能在做协议逆向、读存储格式、分析第三方 SDK 的时候特别有用因为那些场景下文档要么没有要么早就过期了。我个人在实际操作中的体会是移位运算符的难度从来不在语法上语法就三个符号五分钟就能学会难的是它把数值和内存表示这两层概念搅在了一起让你必须同时在两个层面思考。真正把这三个符号用顺手靠的不是背规则而是养成两个习惯写的时候显式声明位宽和掩码读的时候用二进制打印来验证。这两个习惯保持住前面列的那些坑基本都能提前绕开。
返回列表