用按位与优化热点方法:取模、状态判断的位运算实战

发布时间:2026/10/18 10:59:07
用按位与优化热点方法:取模、状态判断的位运算实战 最近和一同事做性能排查聊到系统里一个被调了几百万次的热点方法里面其实没什么惊天动地的逻辑开头三个普通得不能再普通的操作一个取余一个整除判断一组状态判断。他花了几分钟把这三处改成按位与运算压测数据当场就有了可感知的改善用他自己的话说就是“运行效率直接起飞”。这里说的“与运算”就是大多数编程语言里的位运算符。它是在二进制位层面做的按位与操作速度天然比乘除法快。但真正让我觉得值得写下来的不是“位运算快”这个结论而是他在实际代码里怎么判断哪些地方能换、哪些地方不能换以及换完之后的收益到底怎么算。这篇文章就把这套思路完整拆开从优化前代码长什么样、为什么慢到用改完之后的对比结果再到中途踩过的坑和排查技巧一次说透。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 15x % 64等价于x 63x % 256等价于x 255为什么成立因为 2 的幂在二进制里只有一个位是 1。比如 16 的二进制是1000016 的倍数在低 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);状态一多这种链条又长又容易漏。更关键的是每次判断都要单独比较、单独跳转分支数量拉满。改成位标志之后每个状态占独立的 bitprivate 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 7800-800911-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 除了性能与运算还能解决什么问题最后聊点扩展。与运算不只是性能工具它本身就是一套数据表达方式。最典型的是位图权限系统。给每个权限分配一个 bitint 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 的幂这里能不能用值不值得为它加一行注释。多问一句往往就能多省一点。