1. 第一次看到那几行改动时,我确实有点懵
事情发生得很平常:代码评审,同事指着我写的一段循环逻辑,说“这里可以换个写法”。他当场改了几行,把取余运算换成了按位与,把几个分散的布尔判断合并成一个掩码判断。我当时第一反应是“这能差多少?”毕竟现代 CPU 跑一个%运算符也就是纳秒级的事,业务代码里动不动就几万次循环,省个几纳秒有什么意义?
但他让我先别急着下结论,跑一下 benchmark 再说。
我后来把改动前后的逻辑分别压测,结果确实不吭声了。在处理一个百万级条目的数据分组任务时,原实现的耗时大概是 1800 毫秒,改动后直接掉到 400 毫秒左右。也就是同事用“与运算”改掉的那几行代码,运行效率直接起飞——四倍多差距,这已经不是“心理安慰”级别的优化了。
这个经历让我重新审视了位运算在业务代码里的价值。说实话,按位与(&)并不是什么新东西,它是 C 语言时代就有的基础运算,几乎所有语言都支持。但很多写业务代码的人,工作好几年都没在真实项目里用过它——因为大家默认“性能瓶颈不在这”“编译器会帮我优化”“可读性更重要”。这些话都对,但都不完整。
真正的问题是:什么时候该用&,什么时候不该用&,以及为什么它在某些场景下能带来数量级的收益,而在另一些场景下却毫无意义。
这篇文章就围绕这三件事展开。我会把同事那次改动里的核心逻辑拆开讲,然后用几个可以“抄作业”的代码示例说明具体改法,最后把我自己踩过的坑、总结的判断标准一并交代清楚。不想堆理论,只想给出一份能直接指导实践的判断清单。
先说结论:n & 1这种技巧确实快,但它根本算不上真正的性能杠杆。真正有杠杆的地方,是动态除数取模和位掩码状态管理这两种场景。同事那几行代码改动之所以有效,正是因为切中了后者。
2. 为什么一个“&”符号能换来几倍的效率差
要理解收益从哪里来,得先弄清楚 CPU 在底层到底做了什么。
%(取模)操作,本质是整数除法。除法的硬件实现比加法、位运算要慢得多,这一点从 CPU 指令的时钟周期数就能看出来:一次 64 位整数除法大约要消耗 20 到 90 个时钟周期,而一次按位与运算只需要 1 个周期。差距是两到三个数量级。
那有人会问了:编译器不是有优化吗?x % 2这种固定模 2 的写法,编译器确实会自动转换成x & 1,这也是很多人说“现代编译器很聪明,你手动优化没必要”的底气来源。但这只能覆盖“除数在编译期就确定”的情况。
一旦除数变成运行期变量(比如hash % len,len是动态计算的数组长度),编译器没法提前知道除数是什么,就无法把它优化成位运算,只能老老实实干一次整数除法。而整数除法在底层用的还是“试商法”那一套——即便今天的 CPU 有各种加速,它的成本也远高于一条and指令。
更关键的是,除数的变化范围可能非常大(数组可能扩容到任意大小),CPU 的分支预判和微码流水线也很难提前做优化。所以业务代码里那些“取模分布到桶里”“取模做分页”“取模做哈希取余”的逻辑,一旦数据量上来,性能开销确实很可观。
我实测过一个典型的哈希取余场景,逻辑是这样:
function getBucket(key, bucketCount) { return key % bucketCount; }bucketCount是运行时才确定的数组长度,每次都要做一次除法。这个函数在一亿次调用下的耗时,比我换成下面写法慢了大约 12 倍:
function getBucketFast(key, mask) { return key & mask; }条件是这个 mask 必须等于bucketCount - 1,而bucketCount必须是 2 的整数次幂(比如 16、32、64、256)。
这个对比直白地说明了一件事:按位与的快,不是快在“求余数”本身,而是快在“跳过了除法指令”。你在热路径(hot path)上少敲一次除法的时间,积少成多就是肉眼可见的性能差距。
除此以外,&还有一个容易被忽略的好处:它是纯位运算,不会触发 CPU 的除法硬件单元,也不会产生整数溢出的异常分支。在高频调用的热路径上,这能显著减少微操作的数量,从而让整个过程的延迟更稳定。对于搜索引擎底层的哈希表、即时通讯的会话路由这类高并发服务,这种稳定性有时比绝对耗时更宝贵。
3. 按位与的真正用武之地:这几个场景收益最明显
讲原理容易,难的是知道该在哪个业务场景里落地。我总结了三个最适合用按位与的场景,基本覆盖了日常开发中收益最大的几类问题。
3.1 哈希桶索引:为什么底层实现都要求容量是 2 的幂
最典型、收益最大的场景是哈希表容量设计。
你去看很多高性能哈希表实现的源码,不管是某些开源内存数据库,还是某些语言的运行时容器,都会把“初始容量”和“扩容后的容量”设计成 2 的幂。比如 16、32、64、128,翻倍扩容。这么做不是为了好看,就是为了把“取模”替换成“按位与”。
计算哈希桶索引的标准写法是:
index = hash & (capacity - 1)当capacity是 2 的幂时,capacity - 1的二进制形式全为 1,hash & (capacity - 1)的结果就等于hash % capacity,但只耗费一条位运算指令,没有除法。
这个技巧不是某个天才发明的,而是整个高性能基础设施领域普遍采用的工程共识。我见过不少业务系统,刚开始用取模分布数据,数据量小的时候毫无压力;等数据涨到千万级,每次写入都要对一堆动态长度数组做取模操作时,CPU 占用率立刻凸起一块尖峰。把容量固定成 2 的幂、换成位运算之后,尖峰就平下去了。
3.2 位掩码状态管理:用一个数字装下一组开关
第二个场景是状态和权限的压缩管理。
假设一个订单系统,订单可能同时具备“已支付”“已发货”“已签收”“已评价”“已申请售后”等若干个状态。常规做法是定义五个布尔变量,或者五个is_xxx字段,每次判断都要写五条if。改用位掩码后,一个整数就能把所有这些开关状态全部装下:
const FLAG_PAID = 1 << 0; // 00001 const FLAG_SHIPPED = 1 << 1; // 00010 const FLAG_SIGNED = 1 << 2; // 00100 const FLAG_REVIEW = 1 << 3; // 01000 const FLAG_REFUND = 1 << 4; // 10000一次按位与就能同时判断多个状态是否同时满足:
// 判断是否“已支付且已发货” if ((order.flags & (FLAG_PAID | FLAG_SHIPPED)) === (FLAG_PAID | FLAG_SHIPPED)) { // 执行后续逻辑 }这个写法看起来有点像“老派 C 程序员的浪漫”,但它在现代系统里依然非常实用。比如推荐系统的兴趣标签匹配——用户身上背着几十个标签,每个标签一个位,判断“是否命中某一组标签组合”就是一次位运算的事情,连数据库查询都能省掉。
3.3 大数据集合标记:位图与交集计算加速
第三个场景是位图(Bitmap)。
当你需要对大量 ID 做标记、去重、交集判断时,位图是内存占用极小、速度极快的一种数据结构。比如某服务要计算两个千万级用户集合的交集,用 Set 存储的话每个元素都要占用几十字节的内存,而位图方案只需要两个字节数组,每位表示一个用户是否在集合中。判断交集的时候,两个字节数组逐字节做与运算,结果中仍然为 1 的位置就是交集。
这种做法的核心就是在位图层面反复使用按位与。它比“遍历 Set 逐个判断”快一个量级,内存占用小一到两个量级。
我做过一个粗略的对比:
| 方案 | 存储 1000 万个 ID | 交集判断时间 | 代码复杂度 |
|---|---|---|---|
| Set 集合 | 约 300 MB 以上 | 秒级 | 低 |
| 位图 | 约 1.25 MB | 毫秒级 | 中 |
差距大到什么程度?内存差两百多倍,时间差几十倍。真实业务里当然要考虑 ID 值域大小、分布稀疏度等因素,值域太大时位图也未必适合。但只要值域可控(比如用户 ID 是连续分配的整数),这个方案可以说是碾压级的。
3.4 判断奇偶和翻倍这类“直观操作”
最后是最基础的两个用法:n & 1判断奇偶,n << 1实现乘 2。
严格说,这两个操作的性能收益在现代编译器面前几乎可以忽略——编译器早就能把n % 2优化成n & 1,把n * 2优化成n << 1。所以我不建议为了性能刻意把代码里的% 2改成& 1,收益微乎其微,反而降低了可读性。
但有一种情况例外:有些动态语言的解释器优化不到位,或者有些嵌入式环境的编译器配置关闭了某些优化选项。在这种环境下,手动写位运算确实还能捞到一点性能。我不会,也没有理由说这是错误的优化方式,只能说在普通业务代码里它的优先级很低。
4. 代码具体怎么改:三个可以直接“抄作业”的示范
理论归理论,实操时往往不知道该从哪儿下手。我挑了三个最常见的业务场景,给出改动前后的完整代码,配合当时的思考过程,方便你对照着改造自己手里的模块。
4.1 场景一:哈希路由,把请求均匀分布到多个实例
假设某个消息推送服务,要把用户请求均匀路由到 8 个后端实例之一。最初写的是:
function routeByUserId(userId) { const instanceIndex = userId % 8; return instances[instanceIndex]; }8是常量,编译器能优化,但这行代码后续很容易被改成动态配置。假设后来改为从配置中心读取实例数量:
function routeByUserId(userId, instanceCount) { const instanceIndex = userId % instanceCount; return instances[instanceIndex]; }这时候instanceCount是运行时变量,编译器没法优化。热点路径上每次路由都要做一次整数除法。
如果实例数量允许强制设计为 2 的幂,改动如下:
function routeByUserId(userId, instanceMask) { const instanceIndex = userId & instanceMask; // instanceMask = instanceCount - 1 return instances[instanceIndex]; }注意这里的关键约束:instanceCount必须是 2 的幂,比如 8、16、32。对应的 mask 是 7、15、31。我强烈建议在注释里写明“instanceCount 必须保持 2 的幂”,否则后来接手的人一旦把数量改成 10,整个路由逻辑就崩了。
4.2 场景二:权限校验,把一个用户的多组权限压进一个整数
用户权限判断是个经典场景。常见写法是:
const canEdit = user.permissions.edit; const canDelete = user.permissions.delete; const canPublish = user.permissions.publish; if (canEdit && canDelete && canPublish) { // 执行管理操作 }用位掩码之后的写法:
const PERM_EDIT = 1 << 0; const PERM_DELETE = 1 << 1; const PERM_PUBLISH = 1 << 2; // 授予用户“编辑+删除+发布”权限 user.permissions = PERM_EDIT | PERM_DELETE | PERM_PUBLISH; // 一次性校验三组权限 if ((user.permissions & (PERM_EDIT | PERM_DELETE | PERM_PUBLISH)) === (PERM_EDIT | PERM_DELETE | PERM_PUBLISH)) { // 执行管理操作 }这种写法在数据模型层面也有很多好处:一张用户表只要一个整数列,就能同时表达几十种权限组合。查询“拥有某种权限的所有用户”也变成了一条位运算 SQL,比如WHERE permissions & 4 = 4,比对多个布尔列的查询高效得多。
4.3 场景三:特征标记,在实时推荐接口里快速过滤候选集
实时推荐或广告召回接口里,通常要过滤掉用户已经看过、买过、屏蔽过的物品。如果给物品打上几个“可推荐”类别的标记,判断条件可能长这样:
const isSuitable = product.category !== 'electronics' && product.isAvailable && !product.isBlocked && product.qualityScore >= threshold;用位掩码改写判断逻辑:
const FLAG_ELECTRONICS = 1 << 0; const FLAG_AVAILABLE = 1 << 1; const FLAG_BLOCKED = 1 << 2; const FLAG_QUALIFIED = 1 << 3; // 每个产品预先计算这几个标记 const suitableMask = FLAG_AVAILABLE | FLAG_QUALIFIED; // 期望必须为 1 的位置 // 过滤过程中的一次判断搞定全部条件 if ((product.flags & (suitableMask | FLAG_ELECTRONICS | FLAG_BLOCKED)) === suitableMask) { candidates.push(product); }这段逻辑可以一次性过滤掉“电子类”“不可用”“已屏蔽”“不达标”四类产品。原理是:FLAG_ELECTRONICS和FLAG_BLOCKED这两个位期望为 0,FLAG_AVAILABLE和FLAG_QUALIFIED这两个位期望为 1;把四个位一起按位与之后,等于suitableMask时说明所有条件同时满足。
这种写法尤其适合过滤链路非常长的场景。十几个条件判断合并成一个整数运算,不只是省 CPU,还能让代码里的“过滤规则”集中在一个常量里定义,规则变更时改起来也更集中。
5. 别踩这些坑:不是所有取余都能换成与运算
按位与好用,但把它当成“万能加速器”就是灾难的开始。我自己在这上面吃过几次亏,展开说说。
5.1 最大的坑:除数不是 2 的幂,结果直接出错
这是最典型的错误。很多人记住了“取模换按位与很爽”,但没记住前提条件——只有在除数为 2 的幂时,x & (n - 1)才等价于x % n。
比如x % 5,你写成x & 4,结果就完全不一样。举例:17 % 5 = 2,而17 & 4 = 0。这种错非常隐蔽,因为代码不一定会立刻崩溃,只是有些数据会落进错误的桶里,导致后续逻辑不正确地跳过一些记录。
我建议的防护措施是两层:
- 代码注释里写明“capacity 必须为 2 的幂,否则结果错误”。
- 初始化或配置校验时强制检查:
if ((capacity & (capacity - 1)) !== 0) { throw new Error('capacity must be power of 2'); }
这个检查本身也是位运算,判断一个数是否为 2 的幂,一行代码搞定,执行成本几乎为零。
5.2 负数参与运算时的符号问题
不同编程语言的%结果符号规则不同。JavaScript 里-7 % 4 = -3,Go 里-7 % 4 = -3,Python 里-7 % 4 = 1。而位运算操作的是二进制补码,结果往往和直觉完全不同。
看一个具体例子:
-7 & 3 // 结果是 1,而不是 3 或 -3如果你原来的取模逻辑是给哈希分桶的,分桶值必须是 0 到 n-1 之间的非负整数,那么直接把%换成&很可能产生负数索引用到数组上,轻则越界,重则访问到错误的内存地址(在 C/C++ 里就是非法内存访问)。
所以在涉及负数参与运算的代码路径里,要么先确保被运算的值非负,要么保留取模并单独处理符号。位运算不是“取模的免费替代品”,它只在你明确规定数值范围和运算语义时才是。这一点务必先确认清楚再动手改。
5.3 可读性崩盘:当“炫技”盖过“协作”
我见过一些项目,位运算被用得飞起,状态全压进一个 32 位整数里,权限判断写了一大串& |组合,没注释,没常量定义,后来接手的人看得头皮发麻。
可读性不是玄学,它是代码生命力的直接指标。如果一段位运算逻辑需要读者在脑子里回溯“哪一位代表什么含义”,那这段代码的维护成本已经超过它带来的那点性能收益了。
我的做法是:位运算常量和掩码必须集中定义,命名必须自解释,每个关键位置必须带注释。上面的权限示例里,PERM_EDIT = 1 << 0就是一种自解释的定义方式。再配合短注释说明“位 0=编辑,位 1=删除”,基本就不会造成阅读障碍。如果业务规则复杂到一段注释说不清,那说明这个场景可能根本不适合用位掩码。
5.4 编译器已经优化掉的场景,别再手动折腾了
还有一个误区我必须单独提醒:不是所有取模都值得改位运算。
固定常量的取模(比如x % 2、x % 8、x % 1000)在几乎所有主流编译器和 JIT 里都会被优化成更高效的指令序列,有些甚至直接就是位运算。你手动改一遍,收益趋近于零,反而让代码“看起来很高级但毫无必要”。
判断标准就一句话:除数是不是运行时变量?是不是处于高频调用的热路径上?两条都满足,才值得动手;只满足一条,先压测再决定;都不满足,别改。
6. 性能优化的核心不是“炫技”,是准确地判断该在哪里做
回到同事改的那几行代码。他那次改动的精髓,不是单纯把%换成&,而是精准地识别出了几个关键特征:数据量大、路径高频、除数动态。这几条全占齐了,位运算才派得上用场。
事后我也反思过,为什么我最初会下意识觉得“没意义”?因为我在脑子里默认把它归类成了“雕虫小技”。但性能优化往往就是由这些看似微小、实则切中要害的改动积累起来的。关键在于你有没有能力判断哪些“雕虫小技”值得做,哪些只是在浪费时间。
有了这次经验后,我在做性能优化时会先问三个问题:
- 这段代码是否在热路径上?答不上来就先上 profiling。
- 优化前后逻辑是否完全等价?尤其要确认符号、边界、溢出的语义一致。
- 代码是否在可读性上做了足够弥补?常量命名、注释、范围检查是否到位?
三个问题都有明确答案之后,再动手写那几行“起飞”的代码。
最后再分享一个实际操作中的小技巧:当你把取模换成按位与时,不要在代码里默默改,务必留下两条痕迹。一条是注释,说明“cn 保持 2 的幂,改动时需同步调整校验”;另一条是显式校验函数,在入口处检查capacity确实为 2 的幂。这两个习惯,让我避免了好几次因后续人员盲目调整配置而引发的线上事故。
同事后来的评价只有一句:“你终于知道为什么很多人喜欢位运算了,不是因为它难,而是因为它足够基础、足够快、足够精确。” 这句话我到现在都很认同。