上周做代码评审,同事提交了一个很小的性能优化 commit,整体改动不到十行,但其中几行让全组人都停下来看了很久。
java复制// 优化前
int slot = Math.abs(hash) % slotCount;
// 优化后
int slot = (hash & Integer.MAX_VALUE) & (slotCount - 1);
第一眼看到这种写法,很多人会愣住。取模运算 % 换成位与运算 &,结果真的一样吗?为什么说“运行效率直接起飞”?我把这个 commit 里里外外翻了一遍,又在自己的机器上做了几组压测,今天就把“与运算”做性能优化的底层原理、适用场景、参数计算和踩坑记录一次性说清楚。
如果你正在写网关路由、缓存分片、哈希表扩容、状态机控制这类代码,或者只是好奇同事为什么用 & 代替 %,这篇文章都值得看完。我会从原理讲到实操,再从实操讲到避坑,全是这段时间的真实体会。
1. 一次代码评审的现场:取模为什么会被盯上
1.1 那个请求分片组件的性能账单
先说背景。同事维护的是一个请求分发组件,核心逻辑是把海量请求按照某种签名 hash 值映射到有限的几个处理节点上,各节点负责不同分片的数据。这其实就是最常见的“一致性哈希” 的简化版本:先用一个整数签名,再取模落到分片。
这个组件跑了一段时间后,发现一个现象:整体吞吐一直上不去,CPU 火焰图里有相当大一块耗在路由计算上。仔细看,就是那一句 Math.abs(hash) % slotCount。为啥一句取模会成为热点?因为它是每个请求必经之路,调用频率极高,天天百万千万次地执行。只要这条指令稍贵一点,放大之后就是肉眼可见的 CPU 开销。
当时组里几个人还停留在“这不就是一次取模嘛,能贵到哪去”的印象里,直到看了编译后的指令数,才意识到问题不是“取模”本身,而是请求量太大了,任何单条高成本指令都会被放大成巨额开销。于是目标很明确:把路由计算从“昂贵运算”降级成“廉价运算”。
1.2 一句话点醒:模数是 2 的幂就能用位与
同事的做法是在代码里加了一个约束:把 slotCount 对齐到 2 的幂。为什么这么做?因为整数对某个 2^n 取模,恰好等价于保留这个数的低 n 位二进制。而保留低 n 位,只需要一次位与运算:x & (2^n - 1)。
取模 % 在 CPU 指令层面需要执行除法或者查微码,往往要花费十几个甚至几十个时钟周期;而位与 & 是数字电路里最基础的逻辑门操作,CPU 通常一个时钟周期就能完成。请求量大时,单次差距虽然只有几十纳秒级别,但累计到千万次就是几百毫秒的差别,足以影响整个服务的吞吐上限。
当然这里有个大前提:只有模数是 2 的幂时,才能直接替换。不是随便把 x % 7 改成 x & 6 就完事,那样结果完全是错的。后面我会专门讲这个边界问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 与运算的底层逻辑:为什么按位与能快
2.1 先看硬件的态度:位运算有多便宜
要理解优化收益,先得知道硬件怎么看待位运算。CPU 内部处理位与、位或、位异或这类操作时,走的是 ALU(算术逻辑单元)里最基础的逻辑电路,一组数据进去,一组结果出来,几乎不涉及复杂的进位、借位、循环判断。
而除法指令不一样。无论是整数除法还是取模,CPU 内部往往需要微码辅助,先做符号处理、再做移位和减法迭代,长除法流程比单条逻辑运算复杂一个量级。在 x86 体系下,整数除法指令的延迟通常是几十个周期,老一些的架构上甚至可以上百周期;而同一条位与指令通常只要 1 个周期。这就是性能差距的根源。
用个生活化的类比:位运算像是按目录页码直接翻到指定章节,而除法取模像是一页一页地翻书。页码当然更快,但前提是你知道书的页码规则——对 2 的幂取模,恰好给了我们一个可预测的页码规则。
2.2 取模对 2 的幂的特殊性质
为什么 x & (2^n - 1) 等于 x % 2^n?我举个例子,假设 n = 4,也就是模数 16。
二进制的 16 是 1 0000,16 - 1 = 15,二进制是 1111。任何一个整数除以 16 的余数,只可能是 0~15,而 0~15 恰好可以用低 4 位二进制完整表示。高位部分一定是 16 的倍数,对取模结果没有贡献。所以 x & 15 就是把高位的“倍数部分”全部清零,只留下低 4 位——这正是取模 16 的余数。
举个例子:x = 29,二进制 11101。29 % 16 = 13,而 29 & 15 得到 01101 = 13,完全一致。这里的核心思想是:对 2 的幂取模,本质上就是对二进制低位进行截断。
这也是为什么哈希表、缓存分片系统普遍把容量设计成 2 的幂。容量为 16、32、64、1024,索引计算就能用 hash & (capacity - 1) 代替 hash % capacity,高位完全丢弃,低 n 位直接作为数组下标,既快又简单。
3. 用与运算能改写的四类典型代码
3.1 奇偶判断:n % 2 换成 n & 1
最经典也最容易上手的场景是判断奇偶。传统写法:
java复制if (value % 2 == 1) {
// 奇数分支
}
优化后:
java复制if ((value & 1) == 1) {
// 奇数分支
}
原理一句话:二进制最低位为 1 表示奇数,为 0 表示偶数。判断奇偶只需要看最低位,所以 value & 1 直接返回最低位的值,1 就是奇数,0 就是偶数。
不过这里有个新手容易掉进去的坑:如果直接用 value % 2 == 1 判断负数,结果可能不符合预期。因为 Java、C 这类语言里,负数的取模结果会保留负号,比如 -3 % 2 == -1,而不是 1。所以用 % 判断负数奇偶,正确的写法应该是 value % 2 != 0。而 value & 1 没有这个烦恼,负数的二进制补码最低位照样能反映奇偶性,-3 & 1 的结果是 1,稳定能判断出奇数。仅从语义角度看,位运算的写法反而更省心。
3.2 哈希分桶与缓存分片:hash & (capacity - 1)
这是性能优化收益最明显的一类。几乎所有分桶、分片、路由算法都会涉及“把一个数值映射到有限槽位”的过程。在槽位数为 2 的幂的前提下:
java复制// 优化前
int index = hash % bucketCount;
// 优化后
int index = hash & (bucketCount - 1);
常见的哈希表实现,比如某些语言标准库里的 HashMap,在扩容后容量就是 2 的幂,索引计算直接使用掩码方式,目的之一就是省掉取模。
注意,这类替换要求 hash 是非负数。如果 hash 可能是负数,得先处理符号,比如 (hash & Integer.MAX_VALUE) & (bucketCount - 1),或者用 Math.abs(hash) 之后再做位运算。但 Math.abs(Integer.MIN_VALUE) 仍然是负数,这一点我会在后面的避坑章节详细说。
3.3 状态位掩码:一个整数管理多个开关
除了算数优化,与运算在“标志位管理”里的用法也很常见。比如某个对象需要同时记录 8 个布尔状态:是否可用、是否被锁、是否已验证、是否需要重试……传统做法是定义 8 个布尔字段,每个字段占至少 1 字节,序列化时还可能产生大量冗余。
更轻量的做法是使用一个整数作为掩码:
java复制public static final int STATUS_AVAILABLE = 1 << 0; // 1
public static final int STATUS_LOCKED = 1 << 1; // 2
public static final int STATUS_VERIFIED = 1 << 2; // 4
public static final int STATUS_RETRY = 1 << 3; // 8
int flags = STATUS_AVAILABLE | STATUS_LOCKED;
判断某个状态是否开启:
java复制if ((flags & STATUS_LOCKED) != 0) {
// 已锁定
}
打开或关闭某个状态:
java复制flags |= STATUS_VERIFIED; // 开启
flags &= ~STATUS_RETRY; // 关闭
这种方式的好处非常明显:状态集合用一个 int 就装下了,复制、比较、序列化都极其高效。尤其在做批量状态判断时,可以用一次与运算同时检查多个标志位是否为一个整体模式。代价是需要给每个位定义清晰的常量名,否则代码可读性会迅速下降,这一点我后面也会专门讲。
3.4 快速清除低位:x & ~mask 与内存对齐
还有一种隐藏用法:快速清除某些位。比如你想让一个数对齐到 16 字节的边界,通常的做法是把低 4 位清零:
java复制int aligned = value & ~15;
这里 ~15 是二进制 ...11110000,与操作之后低 4 位全部归零,高位保持不变。类似能力在内存池、缓冲区设计中很常见,用来把游标位置对齐到缓存行。
这类写法的性能意义在于:不需要分支判断,不需要循环进位,直接对数字做一次逻辑运算就完成了对齐。如果在一个高频分配器里,这种对齐可能每次分配都会执行,节省下来的周期就相当可观。
4. 实操复现:一个请求分片组件从取模到位运算
4.1 优化前的代码与瓶颈
为了让你能直接照着复现,我写一个简化版的请求分片组件。假设有一个签名 hash 来源,我们需要把它路由到 slotCount 个分片之一。优化前的核心代码大约这样:
java复制public final class RequestRouter {
private final int slotCount;
public RequestRouter(int slotCount) {
this.slotCount = slotCount;
}
public int route(int hash) {
return Math.abs(hash) % slotCount;
}
}
这段代码在请求量不高时看不出问题。但在单机每秒几万请求的压力下,Math.abs(hash) 本身有一次分支,% slotCount 又是一次真正的取模运算。slotCount 是从构造函数传入的运行时变量,JVM 无法把它折叠成常量,也就很难自动优化成位运算指令。每秒几万次的取模,CPU 开销立刻被放大。
4.2 优化后的完整实现
优化思路分两步。第一步,保证 slotCount 是 2 的幂。第二步,用位与替换取模。为了处理负数 hash,我用 hash & Integer.MAX_VALUE 把符号位先去掉,得到一个非负整数,再做掩码操作。
java复制public final class RequestRouter {
private final int slotMask;
public RequestRouter(int slotCount) {
if ((slotCount & (slotCount - 1)) != 0) {
throw new IllegalArgumentException("slotCount 必须是 2 的幂");
}
this.slotMask = slotCount - 1;
}
public int route(int hash) {
return (hash & Integer.MAX_VALUE) & slotMask;
}
}
这里有一种情况需要单独说明:如果调用方因为某种原因必须使用非 2 的幂节点数,比如 3 台、5 台机器,不能直接套用模 4 或模 8 的掩码。通常的妥协方案是把槽位总数对齐到 2 的幂,然后在代码里维护一张“物理节点到逻辑槽位”的映射表。比如实际有 3 台机器,可以把逻辑槽位定为 4,多余的一个槽位指向备份节点或者自动降级节点。这个设计细节是实践中很容易忽略的。
4.3 压测结果与结论
我在本机用相同请求量做了简单压测,两种实现各跑了一亿次路由计算。先说结论:优化后的版本在耗时上有明显优势,差距大概在 4 到 6 倍之间,具体数值和 CPU 型号、JVM 版本、是否预热都有关。但趋势是非常稳定的,位运算版本几乎没怎么波动。
为什么 JVM 没有自动做这个优化?关键在于 slotCount 是变量。如果写成 hash % 8,编译器看到模数是常量 8,很容易转换成 hash & 7。可一旦模数来自外部配置,在运行时才能确定,编译器无法证明它必须是 2 的幂,就不敢贸然改写成位运算。手动优化的意义就在这里:把“编译器无法证明的约束”变成“代码结构上强制保证的约束”。
这种优化也不能盲目吹捧。在某些语言和编译器组合里,比如 C 语言编译时已经知道模数是 2 的幂常量,编译器早就帮你优化了,你再手写 & 基本没有额外收益。所以每次动手前,先想清楚运行环境里的优化能力边界,别为了秀技巧而优化。
5. 避坑手册:位运算常见问题与排查实录
5.1 优先级坑:为什么 flags & LOCKED == 1 是错的
位运算在大多数语言里的优先级都比相等判断要低。所以下面的代码很可能不是你想要的意思:
java复制if (flags & STATUS_LOCKED == 1) {
// 实际上被解析成 flags & (STATUS_LOCKED == 1)
// 括号里先做布尔比较,然后拿布尔值去和 flags 做与运算
}
这种写法在 Java 里直接编译不过,但在一些动态语言里可能被静默接受,产生非常隐蔽的 bug。正确写法一定要加括号:
java复制if ((flags & STATUS_LOCKED) == STATUS_LOCKED) {
// 正确
}
这是我见过最多的位运算翻车原因。你看着代码逻辑没问题,但优先级已经把表达式拆得七零八落。排查这种问题,最直接的办法是把表达式拆成多行临时变量,再打印每一行的中间结果。
5.2 负数坑:取模和位运算对负数的处理并不一样
在 Java、C++ 这类语言中,-14 % 8 的结果是 -6,而 -14 & 7 的结果是 2。两者完全不一样。如果原来的取模代码用了 Math.abs(hash),改成位运算时千万别忘了保留“先取非负”的逻辑。
我自己就踩过一次:优化的第一步先把 Math.abs(hash) 去掉了,以为位运算天然等价,结果分片数据错乱,排查了一下午才发现是符号问题。而且 Math.abs 并非绝对安全,Integer.MIN_VALUE 取绝对值后还是负数,这就是 Java 里的经典边界问题。我在上面的实现里用 hash & Integer.MAX_VALUE 替代 Math.abs(hash),从根源上避免了这个边界坑。
顺便说一句,如果要判断奇偶,负数场景下位运算反而更安全。-5 & 1 是 1,能正确判断出奇数;而 -5 % 2 == -1,如果按 == 1 去判断就错了。
5.3 不是所有取模都能换成与运算:模数必须是 2 的幂
这一点再怎么强调都不为过。x & (n - 1) 能等价替换 x % n 的唯一前提是 n 是 2 的幂。如果 n = 10,那 x & 9 的结果和 x % 10 完全是两回事。很多人看完性能优化文章,欢天喜地地把所有取模都改成与运算,结果分片瞬间乱套。
一个安全的做法是在构造函数或者初始化阶段显式校验槽位数:
java复制if ((slotCount & (slotCount - 1)) != 0) {
throw new IllegalArgumentException("槽位数必须是 2 的幂");
}
这个判断本身就是位运算的经典技巧:2 的幂二进制只有一个 1,减 1 之后低位全部变成 1,两者相与必然为 0。如果不是 2 的幂,相与结果不为 0。可以用一句话记住:与运算优化的是一个“特殊的取模”,不是一个“万能的取模”。
5.4 可读性与维护性的平衡:别把代码写成谜语
位运算在带来性能的同时,也可能让代码变得难读。hash & 4095 到底在干什么?如果不知道 4095 是 2^12 - 1,看到代码的第一反应往往是疑惑。维护半年后,连你自己都可能忘记这里为什么不用 %。
我的经验是三层防护:
- 第一,给魔数起名字。不要裸写
4095,定义成SOCK_MASK = 4095或者BUFFER_MASK = capacity - 1。 - 第二,把约束写在注释里。注释可以明确说明:这里要求容量必须是 2 的幂,所以掩码值等于容量减一。
- 第三,用构造函数入口统一处理。容量从外部传进来时先校验,保证下游所有使用掩码的地方都安全。
5.5 语言差异:不是每个运行时都把 % 当瓶颈
不同语言对取模的优化能力差别很大。C 语言里面对编译期常量模数,编译器几乎总能生成位运算指令;Java 的 JIT 也能处理一部分常量场景;而 Python、JavaScript、Lua 这类动态语言,取模之前要先做类型检查,逻辑分支更多,手动写位运算在特定热点函数里通常收益更明显。
所以我在做优化前,一定会先确认三件事:这个函数是不是真正的热点?运行时的编译器优化能力如何?优化后是否引入了可维护性成本?如果只是偶尔调用一次的函数,用不用位运算根本无所谓,不如把精力放在真正的瓶颈上。位运算是武器库里的利器,但它只适合在关键场景亮出来。
6. 我的一点实操体会
那次代码评审之后,我养成了一个习惯:看到任何 x % 常量 或者 x % 运行时变量 的代码,会下意识判断这个常量或变量是不是 2 的幂,再想想这个函数是不是高频调用路径。这个习惯帮助我发现过不少可以低成本优化的地方,也让我避开了好几次“为了优化而优化”的冲动。
位运算本质上是在利用计算机数字表示的天然结构。它不是银弹,但只要符合“模数是 2 的幂”这个前提,又落在高频路径上,一次替换往往就能带来立竿见影的效果。我个人更喜欢的优化顺序是:先想清楚约束,再动手改代码,最后补上注释和校验。因为真正难的不是把 % 改成 &,而是让你和你的同事在半年后依然看得懂这段代码为什么这么写。
最后分享一个小技巧:下次你再看到别人用位运算,别急着说“炫技”,先问一句“模数是不是 2 的幂,函数是不是热点”。如果两个答案都是肯定的,那这大概不是一个炫技,而是一次漂亮的性能优化。
