位运算完全指南:AND、OR、XOR、移位与位掩码实战
翻开一份旧的 PostgreSQL 迁移脚本,看到 permissions & 0b100;同事上线一个把 32 个布尔值塞进单个整数的 feature flag 系统;Kubernetes 吐出 192.168.1.0/24,你得在代码里提取网络地址。三个场景,一项底层技能:位运算。
应用层开发平时几乎不会主动写 & 或 ^,直到突然需要。这份指南覆盖六个位运算符、补码表示、九个值得记住的实战模式,以及各语言的坑(JavaScript 尤其多)。代码用 JS、Python、Go、C 四种语言对照,例子全部可运行。
另开一个标签页打开进制转换器,后面几节会让你输入数字,实时观察位模式的变化。
为什么 2026 年还要学位运算
高级语言没让位运算消失,只是把它藏了起来。你今天可能没意识到自己正在依赖它,但它确实还活着:
- PostgreSQL 行级安全用一个整数位图存 ACL 权限(
SELECT、INSERT、UPDATE、DELETE等) - Linux capabilities 用 40 多个权限位替代了「要么 root 要么普通用户」的老模型,合并权限用
| - JWT 算法头把哈希算法编码进一个小字段,库层经常做位级比较
- Snowflake / ULID / UUIDv7 把时间戳、机器 ID、序列号用左移打包进 64 位或 128 位整数
- Redis 的
BITCOUNT/BITOP把位运算原语直接暴露给应用层,用于基数估算和 A/B 分桶 - 图像处理读 32 位 RGBA 像素时,用
&和>>抽取通道 - 微信小程序与支付宝开放平台的权限位模型,查询接口权限靠一次 AND 搞定
位运算在 CPU 指令级是 O(1)。把 32 个布尔塞进一个整数省下 31 字节内存,更关键的是,「这 32 个标志位有任意一个被设置吗」只需要一次 != 0 判断。
先打好二进制基础
本文假设你已经会看二进制。如果需要复习,先读进制转换完全指南再回来。
先过一遍词汇表:
- bit(位):0 或 1
- nibble(半字节):4 位,对应一个十六进制字符
- byte(字节):8 位
- word(字):通常 32 位或 64 位,取决于 CPU
大多数语言的整数是定宽的:8、16、32、64 位。位宽在位运算里特别关键,因为移位会把位挤出边界,而有符号整数的符号位正好坐在最左边。
试一下:打开进制转换器,把十进制 170 输进去,二进制输出应该是 10101010,这个交替模式后面会反复出现。
六个位运算符
主流语言给你的是同样的六个运算符,语法差异很小。&、|、^、~、<<、>> 在 JavaScript、Python、Go、Rust、C、C++、Java、C# 里都一样。JavaScript 多一个 >>>,无符号右移。
AND(&):位过滤器
只有两个输入位都是 1 时,输出位才是 1。
| A | B | A & B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
把 AND 想成一道闸门:只有两边都置位的位才能通过。最常见的用途是掩码,保留某些位,把其他位清零。
// 抽取低 4 位(最右的 nibble)
const value = 0b11010110; // 214
const low4 = value & 0x0F; // 0b00000110 = 6
// 判奇偶
const isOdd = (n) => (n & 1) === 1;
isOdd(7); // true
isOdd(42); // false
# Python 写法一致
value = 0b11010110
low4 = value & 0x0F # 6
def is_odd(n):
return (n & 1) == 1
OR(|):位设置器
只要任一输入位是 1,输出位就是 1。
| A | B | A | B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 1 |
OR 用来合并标志。若 READ = 1、WRITE = 2、EXECUTE = 4,那么 READ | WRITE 就是 3,两个权限同时开启。
const READ = 0b001;
const WRITE = 0b010;
const EXEC = 0b100;
const rw = READ | WRITE; // 0b011 = 3
READ, WRITE, EXEC = 0b001, 0b010, 0b100
rw = READ | WRITE # 3
XOR(^):位翻转器
两位不同则输出 1。
| A | B | A ^ B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
XOR 有三条代数性质,撑起了计算机科学里一批聪明技巧:
a ^ a = 0,任何值和自己 XOR 都变零a ^ 0 = a,和零 XOR 是恒等a ^ b ^ a = b,XOR 是自己的逆
第三条解释了 XOR 为何出现在奇偶校验、流密码,以及那道经典面试题——「在一个所有数都出现两次、唯一一个数只出现一次的数组里找出那个数」。
// 在每个数都出现两次、只有一个数出现一次的数组里找出那个数
const findUnique = (arr) => arr.reduce((a, b) => a ^ b, 0);
findUnique([4, 1, 2, 1, 2]); // 4
from functools import reduce
from operator import xor
find_unique = lambda arr: reduce(xor, arr, 0)
find_unique([4, 1, 2, 1, 2]) # 4
NOT(~):位反转器
一元 ~ 翻转所有位:0 变 1,1 变 0。
~0b00001111 // -16(JavaScript 强转 32 位有符号)
~5 // -6
~5 # -6
// Go 的一元位非用 ^,注意区别
var x int8 = 5
fmt.Println(^x) // -6
几乎所有主流语言里 ~5 都是 -6,初学者一看就懵。原因是补码表示,下一节会讲。先记住:在使用补码表示负数的语言里(所有主流语言都用),~x 恒等于 -(x + 1)。
左移(<<):二的幂次乘法
x << n 把 x 所有位向左移 n 位,右侧补 0。数学上等价于乘以 2ⁿ。
1 << 0 // 1 (2^0)
1 << 1 // 2 (2^1)
1 << 3 // 8 (2^3)
1 << 10 // 1024 (2^10 = 1 KiB)
// 构造标志位
const FLAG_ADMIN = 1 << 0;
const FLAG_EDITOR = 1 << 1;
const FLAG_REVIEWER = 1 << 2;
1 << n 的妙处在于生成一个只在位置 n 上有 1 的数,那一位就是一个标志。
注意溢出。JavaScript 里 1 << 31 是 -2147483648(不是 2147483648),因为 JS 位运算强制走 32 位有符号整数。
右移(>> vs >>>):带符号还是补零?
右移把位向右挪。问题是:左侧空出来的位填什么?
>>(算术右移):保留符号位,负数依然为负>>>(逻辑/无符号右移):补 0,JavaScript 独有的专门运算符
-8 >> 1 // -4 (保留符号位)
-8 >>> 1 // 2147483644 (符号位当作数据位)
8 >> 1 // 4
8 >> 2 // 2
C 语言里有符号类型的 >> 究竟是算术还是逻辑右移,由实现定义。大多数编译器做算术右移,但不查文档就依赖这点风险很大。Go 要求移位数必须是无符号整数,对有符号/无符号类型区分得很清楚。Python 没有 >>>,因为它根本没有定宽整数。
补码:计算机怎么存负数
位只能是 0 或 1,那 -5 怎么编码?上世纪 60 年代计算机界定下来的答案是补码(two’s complement),所有现代 CPU 都这么干。
最朴素的思路是拿出一位做符号,但有两个毛病:第一,会同时存在 +0 和 -0,别扭;第二,加减电路得检查符号位,硬件更复杂。补码同时解决了这两个问题。
规则很短:
- 写出正数的二进制表示
- 翻转每一位(这叫「反码」或「一的补码」)
- 加 1
动手算一遍,在 8 位补码里表示 -5:
5 的二进制: 0000 0101
翻转所有位: 1111 1010 (这是补码里的 -6!)
加 1: 1111 1011 ← 这是 -5
用进制转换器验证:在进制转换器里输入十进制 251,二进制输出就是 11111011。在 8 位有符号上下文里,11111011 是 -5;在 8 位无符号上下文里,同样的位模式是 251。位一模一样,解释不同。
这也解释了前面 ~5 = -6 的反直觉。位非运算给你反码,补码等于反码加 1,所以:
~x = -(x + 1) // 任何用补码的语言都成立
~5 = -6
~(-3) = 2
对 n 位有符号整数,可表示范围是 -2ⁿ⁻¹ 到 2ⁿ⁻¹ − 1。8 位有符号能覆盖 -128 到 127,32 位有符号覆盖大约 -21 亿 到 +21 亿。
九个必会的位操作模式
这九个模式大致覆盖你会写的 95% 的位操作。记下来,你会在系统代码里到处看见它们。
设置某一位:x | (1 << n)
打开第 n 位,其他位不变。
let flags = 0b0100;
flags = flags | (1 << 0); // 0b0101
清除某一位:x & ~(1 << n)
关闭第 n 位,其他位不变。~(1 << n) 是一个除了第 n 位外所有位都置 1 的掩码。
let flags = 0b0111;
flags = flags & ~(1 << 1); // 0b0101
翻转某一位:x ^ (1 << n)
不管当前状态,翻转第 n 位。
let flags = 0b0100;
flags = flags ^ (1 << 2); // 0b0000
flags = flags ^ (1 << 2); // 0b0100 变回来
检查某一位:(x >> n) & 1
第 n 位为 1 则返回 1,否则返回 0。等价写法:(x & (1 << n)) !== 0。
const flags = 0b0101;
const isBit2Set = (flags >> 2) & 1; // 1
提取最低置位:x & -x
只保留 x 最右边那个 1。秘诀在于补码下 -x 等于 ~x + 1,把最低置位及以下的位全部翻转。
const x = 0b10110100;
const lowest = x & -x; // 0b00000100 = 4
这是 Fenwick 树(树状数组)实现 O(log n) 前缀和的核心技巧。
统计置位数(popcount)
数一个整数里有几个 1 位。多数语言现在都有原生函数:
// JavaScript(手写版本)
const popcount = (n) => {
let count = 0;
while (n) { count += n & 1; n >>>= 1; }
return count;
};
popcount(0b10110100); // 4
# Python 3.10+
(0b10110100).bit_count() # 4
// Go
import "math/bits"
bits.OnesCount(0b10110100) // 4
不用临时变量的 XOR 交换
经典面试题:不借第三个变量交换两个整数。生产代码千万别用(比用临时变量慢,而且如果 a 和 b 指向同一内存还会出错),但值得理解。
let a = 5, b = 9;
a = a ^ b; // a = 5 ^ 9
b = a ^ b; // b = (5 ^ 9) ^ 9 = 5
a = a ^ b; // a = (5 ^ 9) ^ 5 = 9
// a = 9, b = 5
判断 2 的幂:(x & (x - 1)) === 0
2 的幂只有一个位是 1。减 1 会把那位关掉、下面所有位置 1。AND 之后只有 2 的幂(和 0)得到零,所以加一个 x > 0 保护。
const isPow2 = (x) => x > 0 && (x & (x - 1)) === 0;
isPow2(16); // true
isPow2(17); // false
快速判奇偶:x & 1
在某些语言里比 x % 2 稍快,编译器优化后往往持平。热循环里或者就是在想位的时候值得用。
const isOdd = (x) => (x & 1) === 1;
真实代码里的位掩码标志
位运算从技巧升级到实践,就看这一节。
用 32 位打包 32 个布尔
别再写 32 个字段的布尔结构体,把它们塞进一个整数:
const FLAGS = {
DARK_MODE: 1 << 0,
NEW_NAV: 1 << 1,
AI_SUGGESTIONS: 1 << 2,
BETA_EDITOR: 1 << 3,
// ... 一直到 1 << 31
};
let userFlags = 0;
userFlags |= FLAGS.DARK_MODE | FLAGS.AI_SUGGESTIONS; // 启用
if (userFlags & FLAGS.AI_SUGGESTIONS) {
showSuggestions();
}
userFlags &= ~FLAGS.DARK_MODE; // 关闭
32 个布尔压进 4 字节,任意子集查询只要一次 AND。数据库尤其喜欢这种模式,一列替代 32 列。
Unix 文件权限
chmod 755 本质就是位运算。三个八进制数字对应三组三位:
7 = 111 (owner: rwx)
5 = 101 (group: r-x)
5 = 101 (others: r-x)
试一下:打开进制转换器,源进制选八进制,输入 755,二进制输出是 111101101。文件系统就是这么存权限字段的。
加上「组写」权限:
const perms = 0o755;
const withGroupWrite = perms | 0o020; // 0o775
IP 子网掩码
给定 192.168.1.10/24,用 AND 掩码提取网络地址:
const ip = 0xC0A8010A; // 192.168.1.10
const mask = 0xFFFFFF00; // 255.255.255.0(/24)
const network = ip & mask; // 0xC0A80100 = 192.168.1.0
打包 ID:Snowflake
Twitter 的 Snowflake 把时间戳、机器 ID、序列号打包进一个 64 位整数:
┌─ 1 位 ─┬─── 41 位 ───┬─ 10 位 ─┬─ 12 位 ─┐
│ 符号 │ 时间戳 │ 机器 ID │ 序列 │
└────────┴─────────────┴─────────┴─────────┘
编码一个 ID 只要两次移位和两次 OR:
const id = (BigInt(timestamp) << 22n) |
(BigInt(machineId) << 12n) |
BigInt(sequence);
解码反过来,右移加掩码。要对比什么时候该用 Snowflake、什么时候用 ULID 或 UUIDv7,看分布式 ID 对比。
各语言的坑
JavaScript:32 位强转陷阱
JavaScript 会把操作数强转成 32 位有符号整数再做位运算,结果再转回 Number。任何超过 2³¹ − 1 = 2147483647 的值都会溢出:
2147483647 | 0 // 2147483647 (还好)
2147483648 | 0 // -2147483648 (溢出了!)
4294967295 | 0 // -1 (全 1 被解释为有符号)
要做 64 位运算得用 BigInt,它有独立的位运算符,没有位宽限制:
(2n ** 40n) | 1n // 1099511627777n
运算符优先级 bug
这是现实中最常见的位运算 bug 之一:
// 有 bug:实际被解析为 (x & (1 == 0)),因为 == 优先级高于 &
if (x & 1 == 0) { /* ... */ }
// 正确:加括号
if ((x & 1) == 0) { /* ... */ }
C、JavaScript、Python、Go 等主流语言里,比较运算符优先级都高于位 AND/OR/XOR。拿不准就加括号。
语言对照表
| 语言 | 位宽强转 | 负数 >> | BigInt 支持 |
|---|---|---|---|
| JavaScript | 强制 32 位有符号;>>> 是无符号 | 算术 | BigInt 有独立运算符 |
| Python | 任意精度,无定宽 | 算术 | 原生 |
| Go | 严格,移位数必须无符号 | 有符号类型走算术 | math/big |
| C/C++ | 随类型;int、unsigned 等 | 有符号时实现定义 | 无内置 |
| Rust | 严格,debug 下溢出 panic | 有符号类型走算术 | u128 或外部 crate |
Python 的无限宽度陷阱
Python 整数没有固定位宽,补码逻辑概念上向左延伸到无穷。这就是为什么 ~5 是 -6(不是 250 或 65530):Python 把结果当作负整数,不是定宽位模式。如果你想要环绕语义,显式加掩码:
# 模拟 8 位 NOT
(~5) & 0xFF # 250
2026 年的性能现实
常见说法是位运算「永远更快」。2026 年的真相是一半对一半错。
编译器早就会做显而易见的重写。现代优化器会自动把 x * 2 改成 x << 1。应用代码里刻意写 x << 1 图速度是 cargo cult 式的性能调优,没帮上忙,可读性倒是崩了。
位运算真正赢下来的场景:
- 数值密集的热循环:popcount、前导/尾随零计数、位棋盘国际象棋引擎
- 紧凑数据结构:Bloom filter、roaring bitmap、Fenwick 树
- 硬件寄存器与内存映射 I/O:嵌入式、内核、固件
- 密码学原语:AES、ChaCha20、SHA,底子就是 XOR、循环移位、shift
- 压缩解压:Huffman 编码、行程编码、打包整数
- 数据库引擎:位图索引、Parquet 字典编码等打包列存格式
位运算帮不上忙的场景:在每个请求跑两遍的业务逻辑函数里把 x % 2 改成 x & 1。加速测不出来,可读性代价倒是实在。
位操作永远赢的一点是内存占用。32 个 flag 打包成一个 int 比 32 个布尔省 31 字节。规模上去后,几百万用户、上亿事件,就是缓存友好与缓存雪崩的差别。
速查表
| 操作 | 运算符 | 示例 | 结果 | 典型用途 |
|---|---|---|---|---|
| AND | & | 0b1100 & 0b1010 | 0b1000 | 掩码/提取 |
| OR | | | 0b1100 | 0b1010 | 0b1110 | 合并标志 |
| XOR | ^ | 0b1100 ^ 0b1010 | 0b0110 | 翻转/检测差异 |
| NOT | ~ | ~0b1100 | ...11110011 | 构造反向掩码 |
| 左移 | << | 1 << 3 | 8 | 乘以 2ⁿ |
| 右移 | >> | 16 >> 2 | 4 | 除以 2ⁿ(带符号) |
| 无符号右移(JS) | >>> | -1 >>> 0 | 4294967295 | 按无符号解释 |
设位 n | | | x | (1 << n) | 打开位 | |
清位 n | & ~ | x & ~(1 << n) | 关闭位 | |
翻位 n | ^ | x ^ (1 << n) | 翻转位 | |
查位 n | & | (x >> n) & 1 | 0 或 1 | 测试位 |
| 最低置位 | & - | x & -x | 提取最低位 | |
| 判 2 的幂 | & | x > 0 && (x & (x-1)) == 0 | 布尔 | 检测 2 的幂 |
FAQ
逻辑 AND(&&)和位 AND(&)有什么区别?
逻辑 AND 作用在布尔值上并会短路,条件判断用它;位 AND 作用在整数的每一位上,两边都会求值,做位掩码时才用。
为什么 ~1 在大多数语言里是 -2?
位非会翻转所有位得到反码,而补码表示下 ~x 恒等于 -(x + 1),所以 ~1 = -2、~0 = -1、~(-1) = 0,规律一致。
x << 1 真的比 x * 2 快吗?
现实里不会。现代编译器会把 x * 2 自动优化成同样的移位指令,可读性优先写 x * 2;只在构造位掩码等真正在想位的场景用 <<。
JavaScript 支持 64 位位运算吗?
普通 &、|、^、<<、>> 不支持,它们会强转成 32 位有符号整数;要做 64 位以上运算必须用 BigInt 字面量,例如 1n << 40n。
怎么高效统计置位数?
直接用语言内置函数:Go 的 bits.OnesCount、Java 的 Integer.bitCount、Python 3.10+ 的 .bit_count(),在 x86/ARM 上会编译成单条 POPCNT 指令。
什么时候该用位掩码标志,什么时候该用布尔结构体?
位掩码适合紧凑存储大量标志(数据库、网络协议、文件格式)和快速组合测试;字段类型不同或代码可读性比内存更重要时,优先用结构体。
移位量超过位宽会怎样?
C/C++ 里是未定义行为;JavaScript 里移位量对 32 取模,所以 1 << 32 等于 1;Python 没有位宽,1 << 100 仍是合法大整数,别依赖超宽行为。
为什么 Python 的 ~5 是 -6 而不是 2?
Python 整数没有固定位宽,补码概念向左延伸到无穷,所以 ~5 等于 -(5 + 1) = -6;要得到 8 位反转后的 250,自己加 & 0xFF 掩码即可。
XOR 加密安全吗?
XOR 加密只有用与明文等长的真随机一次性密钥时(OTP)才安全;短密钥循环的所谓「XOR 加密」轻易可破,AES、ChaCha20 里的 XOR 只是众多步骤之一。
怎么手算一个负数的补码?
按目标位宽写出正数二进制,翻转每一位,再加 1。例:8 位 -5 = 00000101 → 11111010 → 11111011,用进制转换工具验证 251 即可。
相关工具和延伸阅读
- 进制转换工具:输入数字实时看位模式
- 进制转换完全指南:二进制、八进制、十六进制的前置阅读
- UUID v4 vs v7 vs ULID vs Snowflake 对比:分布式 ID 里的位打包实战
- 安全最佳实践:权限位图及其陷阱