☰
用按位与优化热点方法:取模、状态判断的位运算实战
2026/10/18 10:58:52 网站建设 项目流程

最近和一同事做性能排查,聊到系统里一个被调了几百万次的热点方法,里面其实没什么惊天动地的逻辑,开头三个普通得不能再普通的操作:一个取余,一个整除判断,一组状态判断。他花了几分钟,把这三处改成按位与运算(&),压测数据当场就有了可感知的改善,用他自己的话说就是“运行效率直接起飞”。

这里说的“与运算”,就是大多数编程语言里的位运算符&。它是在二进制位层面做的按位与操作,速度天然比乘除法快。但真正让我觉得值得写下来的,不是“位运算快”这个结论,而是他在实际代码里怎么判断哪些地方能换、哪些地方不能换,以及换完之后的收益到底怎么算。这篇文章就把这套思路完整拆开,从优化前代码长什么样、为什么慢,到用&改完之后的对比结果,再到中途踩过的坑和排查技巧,一次说透。

1. 先看优化前的代码:一段热路径上的瓶颈

1.1 一个典型场景:分片、采样与状态判断

先说背景。某内部系统的下单链路里,有一段高频执行的逻辑,每次订单创建都会走一遍。它做了三件事:

  • 根据订单号算一个分片号,然后路由到对应的存储节点。
  • 判断当前订单序号是否落在采样窗口内,如果命中就进入风控抽样。
  • 检查订单当前状态,判断是否需要进行后续推送。

三个操作都算不上复杂,单看任何一个都“没什么好优化”。但问题是这段代码的执行频率极高,一次大促峰值期间可能每秒几十万次。当每个请求在这里多花几百纳秒,累积起来的CPU开销就非常可观。

所以优化方向也很直接:把这段热路径上每个没用但耗时的操作挑出来,换成更廉价的等价实现。

1.2 慢在哪:取模、比较与多次判断的开销

第一个可疑点是取模运算:

int shard = orderId % SHARD_COUNT;

这里SHARD_COUNT在系统里是运行时从配置中心拉取的,对编译器和JIT来说,它不是一个编译期常量。问题就在这:当除数是动态值时,CPU没法做“乘以魔数再移位”的优化,只能老老实实走整数除法指令。而整数除法指令的延迟和吞吐,在几乎所有主流CPU上都是乘法的几倍到几十倍。

第二个点是整除判断:

boolean needSample = (orderSeq % 64) == 0;

虽然这里的64是常量,编译器有机会优化,但在某些动态场景下依然绕不开除法。而且这行代码本身的语义是很典型的“每隔64次采样一次”。

第三个点是状态判断:

if (status == PROCESSING || status == WAITING || status == SHIPPED) { // 推送 }

三个条件、三次比较、多次跳转。如果判断条件更多,分支预测miss的概率也会增加。这个可以优化成一次位掩码判断。

1.3 为什么同事换成了“与运算”

同事当时给出的理由特别朴素:“这三个场景本质上都在和‘2的幂’打交道。”

  • 分片数SHARD_COUNT经过调整后定为 16。
  • 采样窗口是 64。
  • 几个状态想合并成一次判断,也可以用位标志表示。

只要目标值和2的幂相关,那么“取余”这种操作就能用“取低位”来替代。因为对一个2的幂取模,本质上就是取这个数二进制表示中的低n位。低n位之外的更高位,都是这个数的倍数部分,取模之后必然被丢掉。

于是他把这三处都替换成了&:

int shard = orderId & (SHARD_COUNT - 1); boolean needSample = (orderSeq & 63) == 0; if ((status & PROCESS_FLAGS) != 0) { // 推送 }

之所以说“起飞”,是因为这三个操作全部从“多周期、可能分支跳转”变成了“单周期、无分支”的纯位运算。

2. 与运算的三个高价值用法:不是炫技,是精准替换

2.1 用法一:把取模缩成取低位

先记住一个数学等式:

当且仅当n是 2 的幂时,x % n等价于x & (n - 1)。

例如:

  • x % 16等价于x & 15
  • x % 64等价于x & 63
  • x % 256等价于x & 255

为什么成立?因为 2 的幂在二进制里只有一个位是 1。比如 16 的二进制是10000,16 的倍数在低 4 位上全是 0,所以取模 16 的结果一定只和低 4 位有关。15的二进制是1111,做按位与之后,恰好就是把低 4 位保留下来、高位置零。这个结果就是余数。

实际代码里最常见的应用场景是路由分片和哈希表桶定位:

// 优化前 int index = hash % bucketCount; // 优化后(bucketCount 调整为 2 的幂) int index = hash & (bucketCount - 1);

要注意一个前置条件:被取模的数必须是非负整数。Java 里负数取模结果也是负数,而按位与hash & (bucketCount - 1)得到的一定是非负值,这两者结果不一致。这个问题后面专门讲。

提示:把分片数从任意值改成 2 的幂要有全局意识。分片数变化会改变数据分布和路由结果,不是单纯改一行代码的事。如果是已经存了历史数据的系统,改分片数要考虑数据迁移或兼容读路径。

2.2 用法二:一次判断多个状态

第二种用法是把多个布尔状态打包成一个整数位段,再用&做一次性判断。

以前常见的写法:

boolean isReady = (state == INIT || state == RUNNING || state == PAUSED);

状态一多,这种链条又长又容易漏。更关键的是,每次判断都要单独比较、单独跳转,分支数量拉满。

改成位标志之后,每个状态占独立的 bit:

private static final int FLAG_INIT = 1; // 0001 private static final int FLAG_RUNNING = 2; // 0010 private static final int FLAG_PAUSED = 4; // 0100 private static final int READY_STATES = FLAG_INIT | FLAG_RUNNING | FLAG_PAUSED; // 0111

判断是不是在这几个状态里,一行搞定:

if ((state & READY_STATES) != 0) { // 执行推送 }

这里的原理是:state里的每个 bit 表示一个独立状态。只要state中READY_STATES对应的任意一个 bit 是 1,按位与的结果就不是 0。也就是“多个状态取并集”的判断,一次指令完成。

这种做法的收益在于:把多分支的判断变成单条无分支逻辑,CPU 不用跳来跳去。同时状态扩展也方便,新增状态只需要再定义一个独立的 bit 位,不用改判断逻辑。

2.3 用法三:整除判断与二次幂判断

第三种高频场景是把“是否能被某个2的幂整除”的判断换成位运算。

最常见的是判断一个数字是不是偶数:

// 优化前 boolean even = (x % 2) == 0; // 优化后 boolean even = (x & 1) == 0;

同理,判断是否能被 8 整除:

boolean mod8 = (x & 7) == 0;

还有一个经典技巧:判断一个正整数是否是 2 的幂:

boolean powerOf2 = (x > 0) && ((x & (x - 1)) == 0);

原理也很直观:如果x是 2 的幂,它二进制只有一位是 1,那么x - 1会让这一位变 0、更低位置 1,两者按位与一定是 0。反过来,如果x有多个 1 位,x & (x - 1)至少会留下高位的 1。

这个判断不需要循环,也不需要除法,在很多底层组件、连接池扩容策略里都看得到。

2.4 什么时候不能用:边界与负数

这块是重点中的重点。位运算替换取模有两个严格边界,没守住会出线上事故。

第一,除数必须是 2 的幂。x % 10是没法直接换成x & 9的,因为 10 的二进制是1010,它的倍数在低位的规律并不等于低若干位,按位与的结果没有任何数学上的取模意义。强行换,结果百分百错。

第二,被取模数必须非负。Java 里-17 % 16的结果是-1,而-17 & 15的结果是15。两者差了十万八千里。

所以在使用之前一定要确认取值范围。如果数据源不可控,可以先做一次归一化:

int positive = hash & Integer.MAX_VALUE; int index = positive & (bucketCount - 1);

不能用Math.abs代替,因为Math.abs(Integer.MIN_VALUE)还是负数,会继续踩坑。

注意:在无符号语言(例如部分脚本语言把按位运算统一按32位无符号处理)或数值语义不同的语言里,边界行为可能不同。换语言的时候不要无脑照搬这个等式。

3. 回归实操:代码改动前后对比与压测记录

3.1 改动前的完整代码

为了大家看得更直观,我把当时的场景抽成一个简化版本,方法里包含了三个需要优化的小原语:

public class OrderRouter { private static final int SHARD_COUNT = 16; private static final int SAMPLE_WINDOW = 64; public int dispatch(int orderId, long orderSeq, int status) { // 1. 分片路由 int shard = getShardFromConfig(); // 运行时配置,动态值 int shardIndex = orderId % shard; // 2. 是否进入风控采样 boolean sample = (orderSeq % SAMPLE_WINDOW) == 0; // 3. 状态判断,决定是否推送 boolean shouldNotify = (status == 1 || status == 2 || status == 3); if (sample) { riskControl(orderId, shardIndex); } if (shouldNotify) { notifyCustomer(orderId); } return shardIndex; } private int getShardFromConfig() { return SHARD_COUNT; } }

这段代码没有明显错误,但它身上背着一个隐患:shard是运行时从配置读取的,JIT 无法把它当成编译期常量来优化除法。只要它不走常量传播优化的路径,取模就要老老实实执行整数除法指令。

3.2 改动后的完整代码

同事改完之后是这个样子:

public class OrderRouter { private static final int SHARD_COUNT = 16; private static final int SHARD_MASK = SHARD_COUNT - 1; // 0b1111 private static final int SAMPLE_WINDOW = 64; private static final int SAMPLE_MASK = SAMPLE_WINDOW - 1; // 0b111111 private static final int FLAG_INIT = 1; private static final int FLAG_RUNNING = 2; private static final int FLAG_PAUSED = 4; private static final int FLAG_NOTIFY = FLAG_INIT | FLAG_RUNNING | FLAG_PAUSED; public int dispatch(int orderId, long orderSeq, int status) { // 1. 与运算:取低位,等价于取模 int shard = getShardFromConfig(); int shardIndex = orderId & (shard - 1); // 2. 与运算:整除判断 boolean sample = (orderSeq & SAMPLE_MASK) == 0; // 3. 与运算:多状态合并 boolean shouldNotify = (status & FLAG_NOTIFY) != 0; if (sample) { riskControl(orderId, shardIndex); } if (shouldNotify) { notifyCustomer(orderId); } return shardIndex; } }

改动非常集中,函数签名没变,返回值没变,唯一改变的是三处运算表达式的写法。

3.3 压测方法:用数据说话

我建议所有做这类优化的人都用基准测试框架跑数据,别凭感觉判断“快没快”。当时我们是在一台4核8G的云主机上,用基准测试框架分别测改动前后的dispatch方法,预热后取中位数。

下面是我们当时用的简化测试思路:

@Benchmark @BenchmarkMode(Mode.Throughput) @Fork(1) @Warmup(iterations = 5, time = 1) @Measurement(iterations = 5, time = 1) public class RouterBenchmark { private final OrderRouter router = new OrderRouter(); private final int[] orderIds = new int[1024]; private final long[] orderSeqs = new long[1024]; private final int[] statuses = new int[1024]; @Setup public void init() { Random random = new Random(12345); for (int i = 0; i < 1024; i++) { orderIds[i] = random.nextInt(Integer.MAX_VALUE); } } @Benchmark public void optimized(Blackhole hole) { for (int i = 0; i < 1024; i++) { hole.consume(router.dispatch(orderIds[i], orderSeqs[i], statuses[i])); } } }

我们测了两组:一组是“动态除数”,模拟shard每次从配置读取;另一组是“常量除数”,模拟代码里直接写死 16。

结果大概是这样:

场景优化前耗时(中位数)优化后耗时(中位数)提升
动态除数约 28 ns/次约 9 ns/次约 3 倍
常量除数约 12 ns/次约 9 ns/次约 25%

动态除数的场景收益最明显,因为没办法被编译器优化掉。常量场景收益小但不是没有,位运算省掉了指令选择和可能的边界修正逻辑。如果方法整体只有几个指令,这点差距已经不小,放大到每秒几十万次调用,正面影响就会体现。

3.4 收益为什么没有“起飞”:理性看待优化增益

我得说句公道话:不是所有类似改动都能“起飞”。如果你的代码里只有一个孤零零的% 16,而且这个表达式不是热点路径,那收益几乎可以忽略。

位运算优化的本质是省时间,但能省多少,取决于:

  • 执行频率:每秒一万次和每秒一百万次,积累效果完全不同。
  • 原运算的耗时占比:如果方法里 80% 时间在等IO,优化这 5% 的取模意义不大。
  • 动态除数还是常量除数:动态除数经常是最大的赢面。
  • 分支数量:多分支改成无分支,不只是省指令,还降低分支预测压力。

所以拿到一段代码,第一件事不是急着改成&,而是先用性能分析工具确认这个方法是热点,再决定改不改。如果你顺手就把一个冷方法改成位运算,代码可读性反而会变差,得不偿失。

实操心得:改完之后一定要在真实流量或模拟流量上做回归。有些人的测试数据是用固定种子生成的,无法覆盖负数边界,容易把问题带上线。

4. 常见问题与排查技巧实录

4.1 为什么 x % 8 和 x & 7 结果不一样?

这是最容易翻车的点。x % 8和x & 7在x >= 0时完全一致,但是当x是负数时,结果不同。

比如在 Java 里:

  • -9 % 8结果是-1。
  • -9 & 7结果是7。

如果你原本靠“余数为0”判断整除,那么-8 % 8 == 0,但(-8 & 7) == 0也是 0,负数情况恰好没踩雷。但换成表格就很容易看出边界差异:

xx % 8x & 7
800
-800
911
-9-17

-9 就是典型事故现场。所以使用前归一化,或者在代码里加注释强调“入参必须非负”。

4.2 编译器/JIT 为什么不自动替换?

很多刚接触位运算优化的同学会问:既然x & (n - 1)更快,编译器为什么不自动把x % n替换掉?

原因在于编译器必须保证语义完全一致。前面已经说了,负数场景下两者结果不一致。除非编译器能证明x一定非负,否则就不能做这种等价替换。而“证明非负”这种事,大多数时候做不到,除非上下文里有明确范围。

所以在很多语言里,x % 16会被优化为带修正的乘法序列,而不是直接变成x & 15。这就是为什么手工把取模改成&依然有收益——因为你去掉了编译器为了保证语义正确而不得不加上的边界修正逻辑。

4.3 源码里那些眼熟的 & 运算

这种优化模式在成熟的代码库里并不少见。经典例子是哈希表扩容策略:当容量是 2 的幂时,计算桶下标就可以用hash & (capacity - 1),而不是hash % capacity。这样既保证了分布均匀,又避免了取模带来的开销。

另外一个例子是时间轮、环形缓冲区里使用位掩码取下标:

int index = cursor & (bufferSize - 1);

学习这些源码里的写法有个好处:能看到它们是怎么保证bufferSize始终为 2 的幂的。比如初始化固定为 16、32、64,或者在扩容时只做<< 1左移一位,而不是随意翻倍。

4.4 位运算影响可读性,要不要用?

这是最常被争论的问题。我的建议是:分场景。

如果代码在热路径上、方法被高频调用,并且你有数据证明性能瓶颈在这里,那就值得用,但一定要留下清晰注释。比如:

// 仅当分片数为2的幂时成立,当前固定为16 int shardIndex = orderId & 15;

如果代码只是普通业务逻辑,一天跑个几千次,那刻意换成位运算就是过度设计。同事之间协作最重要的是可读性,没人希望在普通业务代码里看到一堆晦涩的&和>>。

有一个中间方案:把位运算封装成带名字的方法,比如roundToPowerOfTwo、nextSamplingIndex、containsAnyState,既保留语义,又隐藏了具体实现。

4.5 除了性能,与运算还能解决什么问题

最后聊点扩展。与运算不只是性能工具,它本身就是一套数据表达方式。

最典型的是位图权限系统。给每个权限分配一个 bit:

int PERM_READ = 1 << 0; int PERM_WRITE = 1 << 1; int PERM_DELETE = 1 << 2; int userPerm = PERM_READ | PERM_WRITE; boolean canWrite = (userPerm & PERM_WRITE) != 0;

一个 int 能装 32 个独立标志,一个 long 能装 64 个。判断任意一个权限只需要一次按位与,这正是后端系统里常见的优化方式。

布隆过滤器底层的位数组也是同一个思路:多个哈希函数算出来多个下标,然后对位图中的对应 bit 做置位和查询。查询时只要发现某个 bit 是 0,就可以确定元素一定不在集合中。这套机制里到处是&的身影。

还有一个实用小技巧:判断一个值是否被 2 的幂对齐,用(value & (align - 1)) == 0,在内存池、对象池里用来检查地址对齐非常方便。

我在实际项目里用过最顺手的一个扩展是把“时间片”落到位运算上。比如每天 24 小时,用 24 个 bit 表示一天内的可调度时段,判断当前时间是否可调度就取对应 bit 做与判断。整个判断连移位带按位与,几条指令完成,比查一堆配置表利索多了。

回到最初那个问题:同事用“与运算”改了这几行代码,运行效率确实明显提升。但真正有价值的不是这几行代码本身,而是他判断“哪里能改、为什么能改、改了之后要验证什么”的那套逻辑。希望这篇复盘也能让你在下次面对一个%的时候,多想一层:这里是不是一个 2 的幂?这里能不能用&?值不值得为它加一行注释。多问一句,往往就能多省一点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询