效率拉开差距的地方,往往不是换了新框架,而是一行看起来平平无奇的与运算。上周 Code Review 的时候,我的同事提交了不到十行改动,把环形缓冲区和哈希桶的下标计算从取模换成了按位与,压测数据直接上了一个台阶,连做性能测试的同学都跑来问“是不是数据算错了”。
这里说的与运算,是按位与(bitwise AND),也就是代码里常见的&,不是逻辑与&&。它确实不是什么新东西,大学第一学期就学过,但真正到了生产代码里,能用好它的人并不多。这篇文章我就把那次改动掰开揉碎,从原理、适用场景、实测数据到容易翻车的边界,完整讲一遍。如果你平时写后端、写客户端,或者做数据密集型计算,这几页内容应该能让你在下次 Code Review 里少走不少弯路。
1. 那一行 diff 引发的效率起飞:按位与优化的第一现场
1.1 同事提交的这几行 diff
我先把当时印象最深的几处改动还原出来,你可以先感受一下“就这?”的感觉:
- int bucket = hash % TABLE_LEN; + int bucket = hash & (TABLE_LEN - 1); - int next = (head + 1) % BUFFER_SIZE; + int next = (head + 1) & (BUFFER_SIZE - 1); - int flag = state % 2; + int flag = state & 1;第一眼看过去,改动量确实小得可怜,甚至有点像“为了炫技而炫技”。但问题的关键不在“长得多像”,而在于这些代码被放在什么地方。这三行分别在三个最高频的路径上:哈希桶定位、环形缓冲写入、状态判断。在我们的某消息队列客户端里,这几个函数每秒钟会被调用数百万次,属于典型的“热到发烫”的代码。
把改动部署到压测环境之后,同一条消息链路的吞吐从每秒几十万跳到上百万,CPU 占用也明显降了一截。整个过程并没有什么高深的架构调整,性能的瓶颈也不是什么网络或 IO 问题,就是 CPU 在整数除法上烧掉了太多周期。用位运算绕开除法,是这次改动的全部秘密。
1.2 位与在 CPU 眼中的真实身价
要理解为什么&这么“值钱”,得从 CPU 执行指令的角度看。整数除法在现代 CPU 上依然是很贵的操作,一条整数除法指令的延迟通常在二十个周期以上,而且它是一个复杂的微码操作,占用的执行单元多、流水线还不好乱序隐藏。而按位与这种位运算,是 CPU 最擅长的活儿之一,一个周期内就能完成,甚至能在多个执行单元里并行跑。
打个比方:取模运算像是让你把一箱鸡蛋重新分装成每盒固定数量,你不仅得数,还得处理剩下的零头;按位与则像是直接看箱子上印的标签,高位不看,只取低位那几格,一眼就知道答案。计算机里那个“箱子标签”,就是二进制数的最低若干位。
很多人会问:为什么x & (n - 1)能等价于x % n?前提是 n 得是 2 的幂。比如 n = 16,二进制是00010000,n - 1 = 15,二进制是00001111。任何整数和 15 做按位与,结果只保留低 4 位,高位置零。而除以 16 的余数,恰好也正是这个数的低 4 位。不是巧合,而是二进制本身就是这么编码的。
所以当代码里出现% 16、% 64、% 256这种模 2 的幂的运算时,你都可以在语义不变的前提下把它改写为& (n - 1)。注意,我说的是语义不变,后面会专门讲负数和动态模数的情况,那个坑比很多同事想象的大得多。
2. 哪些业务代码真正值得用与运算:四个高频场景实录
2.1 哈希表桶下标计算
第一个值得替换的场景,是哈希表定位桶下标。大多数语言的哈希表在扩容时都会把容量翻倍,所以容量天然就是 2 的幂。只要容量是 2 的幂,hash % table.length和hash & (table.length - 1)在非负整数的语义下完全一致。
// 改造前 int bucket = hash % table.length; // 改造后 int bucket = hash & (table.length - 1);你可能觉得,反正哈希表内部的长度对外是隐藏的,这个优化交给语言作者做不就行了?其实很多语言的标准库就是这么干的,但轮到自己手写哈希分片、一致性哈希抽象、缓存分桶时,很容易就写成% size。如果 size 保证是 2 的幂,这里就是标准的替换位置。
我当时的做法是给长度加了约束:所有分桶数、表长度都必须走统一的nextPowerOfTwo方法生成,从源头保证 2 的幂约束,然后桶下标全部切到& (len - 1)。这样在代码里就形成了一种约定,后面的人看到位运算,立刻能明白长度约束的存在。
2.2 环形队列的索引回绕
第二个高频场景是环形缓冲区。无论是日志采集、消息队列、音频帧处理还是流式数据管线,都会有(索引 + 1) % 容量这种回绕写法。容量一旦固定为 2 的幂,就能改成:
int next = (head + 1) & (BUFFER_SIZE - 1);这里比哈希桶更敏感,因为环形缓冲的读写往往是循环里的最内层动作,取模成本会被直接顶进每次读写的延迟里。数据量一大,差距就非常明显。
我在自己的本地 demo 里测过:同样长度的环形队列,用%的方式写入一千万条数据,耗时比&多出 3 到 5 倍。这个倍数在其他语言里可能因为 JIT 优化而不同,但方向是一致的。
还要注意一个细节:&的运算符优先级比+低。写(head + 1) & (BUFFER_SIZE - 1)时括号一定要加上,不然很容易变成head + (1 & (BUFFER_SIZE - 1)),结果完全错乱。身边栽在优先级上的同事不止一两个。
2.3 标志位和权限集合
第三个场景不太像“性能优化”,但同样是用与运算解决实际问题:一组布尔状态或者权限,可以用一个整型字段的多个位来存储。判断某个权限是否打开,只需要和对应的掩码做一次按位与:
public static final int PERM_READ = 1 << 0; public static final int PERM_WRITE = 1 << 1; public static final int PERM_EXEC = 1 << 2; // 检查权限 boolean canWrite = (flags & PERM_WRITE) != 0; // 追加权限 flags |= PERM_WRITE; // 撤销权限 flags &= ~PERM_WRITE;这里出现了一个容易忽略的点:flags &= ~PERM_WRITE本质上也是按位与,只不过另一个操作数先被按位取反了。这个组合可以精准地把某一个位清零,同时保留其他所有位不变。相比之下,如果用布尔数组或一组布尔字段,光是判断逻辑就需要读多个内存位置,缓存友好度和可读性都打了折扣。
我见过不少老代码手写一组 getter 和 setter,每个字段独立占用一个 int,结构清晰但内存浪费严重。在同步做网络包解析的程序里,几千个连接各自带几十个状态开关,这种空间开销是会累积的。
2.4 向上对齐到 2 的幂边界
第四个场景稍微隐蔽一点,但出现频率同样不低:把某个数字向上对齐到 2 的幂边界。典型例子是内存池分配、大页对齐、帧缓冲大小计算:传入 100 字节,希望得到 128;传入 257,希望得到 512。
标准写法是一个看着很吓人的表达式:
size_t aligned = (size + ALIGN - 1) & ~(ALIGN - 1);拆开看就没什么魔法了。ALIGN - 1是低位掩码,size + ALIGN - 1把 size 顶过了边界,& ~(ALIGN - 1)再把低位清零。比如 ALIGN = 64,~(ALIGN - 1)是高位全部保留、低 6 位清 0 的掩码,整体效果就是向上取整到 64 的倍数。
这个操作的效率同样比“先模 64,再判断余数是否大于 0”的方案高得多,而且代码更紧凑。唯一的提醒是注意整数溢出:如果size接近类型上限,size + ALIGN - 1可能溢出。实际使用里,先判断类型上限再决定要不要用这个技巧,是个必要的习惯。
3. 用数据说话:同一台机器上的与运算和取模差多少
3.1 测试设计
光说“位运算快”不够,我总喜欢拿出点能让人信服的数据。当时我写了一个很小的 C 语言测试,核心逻辑就是拿一个随机数数组,分别做“取模”和“按位与”,各跑固定次数,记录总耗时。为了避免编译器把模数当成常量直接优化掉,模数故意设计成由命令行参数传入,这样编译器无法在编译期把% 8192替换成& 8191。
// 伪代码示意,实际测试略去了初始化等细节 for (long i = 0; i < ITERS; i++) { sum += hashArr[i] % mod; // mod 从参数传入,编译器无法常量折叠 } for (long i = 0; i < ITERS; i++) { sum += hashArr[i] & (mod - 1); }编译时开-O2,运行一万次取平均。为了让结果更有可比性,我还特意加了两组对照:一组是字面量% 64,另一组是非 2 的幂% 7。两种都很有参考价值,前者能看出编译器的“自带优化”到底能做到什么程度,后者能提醒大家位运算替换的前提条件。
3.2 数据结果与解读
不同机器、不同编译器可能会有数字上的偏差,但我在我那台普通开发机上跑出来的量级关系基本是稳定的:
| 操作 | 1 亿次耗时 | 相对差距 |
|---|---|---|
x % 8192(动态模数) | 约 340 ms | 基准 |
x & 8191(与掩码) | 约 70 ms | 快约 4-5 倍 |
x % 64(字面量,编译器优化后) | 约 75 ms | 快约 4.5 倍 |
x % 7(动态模数,非 2 的幂) | 约 610 ms | 慢约 1.8 倍 |
看到x % 64那一行,你应该明白了:如果用字面量写死“模 64”,编译器往往会在编译期就把除法优化成位运算,所以测出来的耗时跟位运算差不多。这也解释了为什么有些同事总觉得“取模也没多慢”,那只是因为编译器帮你把活干了。
但真实世界不会永远给你字面量。很多性能热点里,容量来自配置、来自动态计算、来自传入的变量,这时候编译器无法推导出模数是 2 的幂,除法只能实打实地执行。此时&方案的收益就会完全显现出来。
3.3 两个容易被忽略的观测结果
第一个意外是:非 2 的幂取模会慢得超乎想象。% 7不是简单地比% 8192慢一点,而是慢很多,因为编译器不能用移位和掩码做任何近似,只能调用真正的除法指令序列。如果你的代码里恰好有一批模数的值分布得很散,那这个热点带来的开销比想象中大得多。
第二个意外是:分支密集的代码里,位运算的优势会被进一步放大。比如if (x % 2 == 0)表面上是一趟除法加比较,改成if ((x & 1) == 0)后,不仅能省掉除法,还能帮助 CPU 分支预测器减轻压力。CPU 做分支预测时,更简单、更规律的表达式能显著提高预测准确率,这对流水线性能的影响可能比省掉一条除法指令还大。
所以“位运算比取模快多少倍”并没有一个统一的答案,它是一个从 1 倍到几十倍都有可能的范围。这是好事,说明优化的空间是真实的;也是坏事,因为它要求你必须在自己的机器、自己的数据、自己的编译环境下做验证,不能拿网上一个数字到处套。
4. 容易翻车的边缘场景:与运算不是万能钥匙
4.1 非 2 的幂:数学上根本不成立
前面反复强调前提,是因为这里太容易翻车了。x % 7和x & 6完全是两个东西。你说高速路上有条岔路“可以抄近道”,但这条路只到 7 的倍数为止,硬走只会冲到悬崖下面。
逻辑很简单:x & (n - 1)背后的数学关系是只看二进制低位。只有 n 是 2 的幂时,低位组合才恰好等于余数。如果 n = 7,n - 1 = 6,二进制是00000110,低三位里中间那位是 0,结果天然不是完整的低三位,跟“除以 7 的余数”扯不上关系。
所以替换前的第一步永远是确认模数是 2 的幂。我个人的习惯是在代码里写一个assert((n & (n - 1)) == 0),如果 n 不是 2 的幂,编译器的断言就能抓到。不要靠代码评审时人来记,约束要交给代码本身。
4.2 负数场景:取模结果和位与结果南辕北辙
很多人习惯性以为“取模”就是取余数,其实不同语言对负数的定义还不一样。C、C++、Java 里-8 % 16的结果是 -8,而-8 & 15的结果是 8。因为 -8 在补码表示里,低 4 位是1000,和1111按位与之后是1000,也就是 8。
那是不是只要没有负数就能随便换?也不是,动态语言里更麻烦。以 JavaScript 为例,位运算会先把操作数转成 32 位有符号整数,而%保留的是浮点语义。4294967297.5 % 16算出来是 1.5,但4294967297.5 & 15会先把数值转成整数,小数位直接被丢弃。这类细碎的边界情况,在数据量大的业务代码里偶尔就会出现,很难靠肉眼看出来。
因此我的建议是:位运算替换取模之前,先确认数据的取值范围和语言语义,正好也给自己一个写边界测试的机会。这种测试不亏,它能在上线前拦住一堆只在特定数据下爆发的诡异 bug。
4.3 可读性与维护成本:只有你看得懂没有意义
任何优化都有代价,位运算最大的代价是“可读性”。对一个不熟悉位操作的人来说,x & (SIZE - 1)比x % SIZE难读得多。如果这个代码出现在一个只有一个人维护的脚本里还好,但如果它出现在多人合作的业务项目里,就必须回应一个问题:后来的人能不能在两个星期之后仍然看懂它?
我的做法是,在替换的地方加一行注释,把数学前提写清楚:
// SIZE 固定为 2 的幂,因此 & (SIZE - 1) 等价于 % SIZE,但更快 int next = (head + 1) & (SIZE - 1);你也可以把约束条件提成一个函数,比如isPowerOfTwo(size),在入口处调用一次。这样接手的同事一眼看到的不只是“这段代码用了位运算”,而是“这个位运算是被验证过的、有前提的”。
4.4 别在非热点地方无脑“优化”
这是我在这次优化里学到的态度问题。位运算再快,它也只能去优化真正占用 CPU 的地方。如果一段代码总共被调用几百次,把取模换成位运算,节省的时间可能连 1 毫秒都不到,却白白增加理解和维护成本。
判断热点的工具不是感觉,而是 profiler。我先跑一遍性能剖析,找到排名靠前的函数,再决定要不要动。那次同事改的代码,要不是已经被 profiling 报告标红,这三行改动在 Code Review 里恐怕也消停不了。性能优化不是把每个能改的都改了,而是把最值得改的改了。
5. 从这次优化里沉淀下来的方法论:热点、编译器与人
5.1 先找热点,再谈与运算
一次成功优化的起点,永远不是“我知道有个骚操作”,而是“我从数据里发现这里真的在烧时间”。那次改动之所以有说服力,是因为我们先用 profiler 定位到了取模指令占据了明显比例,然后才有针对性地替换。
找到热点之后,还有一件事不能省:建立简单的性能基线。改动前记录一组吞吐数据,改动后跑同样的压测脚本,用同一条指标来对比。无论是线上演示还是 Code Review 展示,基线数据都是最有力的证据,它比任何“理论上更快”都更有说服力。
5.2 给编译器最大的确定性
编程语言和编译器已经替我们做了很多事情,但前提是它能看到足够的信息。如果代码把容量定义成常量、把长度约束标在类型或注释里,编译器就有机会在编译期完成更多优化。
换句话说,与运算不是用来和编译器打架的武器,而是帮助编译器缩短路径的手段。你把SIZE写成常量,把“2 的幂”这个前提通过 assert 固化下来,编译器在生成机器码时就能更自信地使用位运算。反过来,如果你把容量藏在外部配置里,编译器什么都推断不出来,那手写位运算也只是帮它完成一小部分工作。
5.3 性能基线、测试和注释一起提交
最后,凡是触碰这类底层的优化,我都会逼自己在同一个 PR 里带上三样东西:基准测试脚本、边界测试、注释。没有基准测试,后面没人知道我改完变好还是变坏;没有边界测试,负数和非常量模数的问题只能在线上引爆;没有注释,这个位运算迟早会被不懂的人改回去,或者被另一个人再“优化”一遍。
这次改动给了我一个印象很深的结论:好的优化不是代码炫技,而是把约束讲清楚、把验证讲清楚、把收益也讲清楚。后来我在 Code Review 里看到取模和除法,都会下意识多看一眼它是不是热点,如果不是,我基本不会提出“你可以换成位运算”;如果是,我会顺手让作者把前提和测试一起补上。这样做下来,代码变快了,团队也没有因为可读性而吵架。把优化做进流程里,比把优化写在代码里更难,也更重要。