
1. 从一次内存优化说起为什么位运算值得深挖几年前我接手一个处理海量用户标签系统的项目每天要处理上亿条数据的过滤和匹配。最初的方案用的是常规的布尔数组和HashMap逻辑清晰但性能瓶颈很快出现尤其是在做“包含任意一个标签”或者“同时包含多个标签”这类集合运算时内存和CPU都吃紧。直到我把目光投向了角落里那个看似古老的技术——位运算。用几个long型整数64位就能表示最多64个标签的状态一次按位与操作就能完成复杂的多标签交集判断性能直接提升了一个数量级内存占用更是断崖式下降。那一刻我才真正体会到位运算不是八股文里的冷知识而是解决特定高性能、高密度数据问题的利器。在Java的世界里我们习惯了面向对象、设计模式、框架集成这些“高层建筑”位运算更像是底层的“砖石”不常直接使用但一旦用对地方效果立竿见影。无论是面试中考察你对计算机基础的理解还是在实战中优化算法、处理协议、设计状态机位运算都扮演着关键角色。今天我们就抛开那些枯燥的定义从“为什么用”和“怎么用好”的角度把Java中的位运算彻底讲透。2. 位运算的核心与、或、异或、非、移位要玩转位运算首先得熟悉手里的这几把“手术刀”。它们直接操作整数在内存中的二进制位bit效率极高。2.1 按位与精准的掩码与清零工具按位与的规则很简单两个位都为1时结果才为1否则为0。它的核心用途是“掩码Masking”和“清零”。1. 提取特定位掩码操作假设我们有一个int类型的变量status它的低8位分别代表8个不同的开关状态1开0关。我们想检查第3个开关从右往左数从0开始是第2位是否打开。int status 0b00101100; // 二进制表示假设第2位值为4是1表示开关打开 int mask 0b00000100; // 掩码只有第2位是1 int result status mask; // result 0b00000100 非0 if ((status mask) ! 0) { System.out.println(第3个开关是打开的); }这里mask就像一个探针操作后只有status中对应mask为1的位被保留下来其他位全部被置为0。通过判断结果是否为0就能知道目标位是1还是0。2. 将特定位清零如果我们想关闭上面那个开关即把第2位置0可以这样做int mask ~0b00000100; // 对掩码取反得到 0b11111011 status status mask; // 与操作后第2位变成0其他位不变 // 或者更常见的写法status ~maskValue;~是按位取反操作符下面会讲到。这个操作确保了只修改目标位不影响其他位这在并发编程或硬件寄存器操作中至关重要。注意和逻辑运算符有本质区别。是位运算符对整数的每一个位进行操作是逻辑运算符只操作布尔值并且具有短路特性如果左边为false右边不再计算。(a b)和(a b)在a、b为布尔值时结果可能相同但运算过程完全不同。2.2 按位或|高效的位设置器按位或的规则是两个位有一个为1结果就为1。它常用来将特定位设置为1。设置特定位为1继续上面的例子如果我们要打开第5个开关第4位int status 0b00101100; int setMask 0b00010000; // 第4位为1 status status | setMask; // 或操作后第4位变为1其他位不变 // 等价于 status | setMask; // status 现在为 0b00111100无论该位原来是0还是1|操作后都保证它是1。这在初始化标志位或组合多个选项时非常有用。例如在文件打开模式中READ | WRITE就表示同时拥有读和写权限。2.3 按位异或^巧妙的位翻转与交换异或的规则是两个位相同为0不同为1。它有几个非常巧妙且强大的应用。1. 翻转特定位异或可以实现“开关”功能。与一个位进行异或如果该位是1则翻转1变00变1。int status 0b00101100; int toggleMask 0b00000100; // 第2位 status status ^ toggleMask; // 第一次第2位从1变为0 // status 0b00101000 status status ^ toggleMask; // 第二次第2位从0变回1 // status 0b00101100 // 等价于 status ^ toggleMask;这个特性在实现状态切换、简单的加密解密或图形处理的反色操作中很常见。2. 不使用临时变量交换两个数这是异或运算最著名的技巧。int a 5; // 二进制 0101 int b 9; // 二进制 1001 a a ^ b; // a 现在为 1100 (12) b a ^ b; // b 1100 ^ 1001 0101 (5) 此时b变成了原来的a a a ^ b; // a 1100 ^ 0101 1001 (9) 此时a变成了原来的b其原理基于异或的两个性质x ^ x 0和x ^ 0 x。虽然现代编译器和解释器优化得很好这个技巧在实战中未必比用临时变量快但它深刻体现了位运算的数学美感也是面试高频题。3. 寻找只出现一次的数字LeetCode上经典的题目一个非空整数数组除了某个元素只出现一次其他每个元素均出现两次。找出那个只出现一次的元素。利用a ^ a 0和a ^ 0 a的性质所有出现两次的数异或后都为0最后剩下的就是只出现一次的数。public int singleNumber(int[] nums) { int result 0; for (int num : nums) { result ^ num; } return result; }2.4 按位非~整体的位取反按位非是一元运算符规则是将操作数的每一位取反0变11变0。int a 5; // 0000 0000 0000 0000 0000 0000 0000 0101 int b ~a; // 1111 1111 1111 1111 1111 1111 1111 1010 System.out.println(b); // 输出 -6这里容易产生困惑为什么~5等于-6这涉及到计算机中的补码表示法。Java中的整数是以补码形式存储的。正数的补码是其本身负数的补码是其绝对值的二进制表示取反后加1。5的二进制32位0000...0101按位取反后1111...1010这个1111...1010是某个负数的补码。要知其值将其减1再取反补码转原码的逆过程减1得1111...1001取反得0000...0110即6。所以原数是-6。因此~x等价于-x - 1。这个特性有时被用于边界条件判断或从掩码中创建“反掩码”如前文清零操作中的~mask。2.5 移位运算, , 高效的乘除与位移动移位运算将整数的二进制位整体向左或向右移动。1. 左移将位向左移动低位补0。左移n位相当于乘以2的n次方在不溢出的前提下。int a 8; // 二进制 1000 int b a 2; // 向左移动2位变成 100000即32 // 8 * (2^2) 32左移常用于快速计算2的幂次或者组装数据如将几个字节拼成一个int。重要提示左移时高位会被移出丢弃。对于int类型移动位数超过31位对于long类型超过63位时实际移动位数会对32或64取模。例如a 32对于int来说等于a 0即不移动。这是Java语言规范的规定。2. 有符号右移将位向右移动高位补符号位即正数补0负数补1。右移n位相当于除以2的n次方并向下取整。int a 16; // 二进制 10000 int b a 2; // 向右移动2位变成 100即4 // 16 / (2^2) 4 int c -16; int d c 2; // 结果为 -4 // 对于负数-16 / 4 -4 符合“向下取整”的规则向负无穷方向取整。有符号右移保持了数的符号是算术右移。3. 无符号右移将位向右移动高位一律补0。对于正数和结果一样。对于负数结果会变成一个很大的正数因为它忽略了符号位。int a -16; // 二进制 1111 1111 1111 1111 1111 1111 1111 0000 (补码) int b a 2; // 高位补0结果是一个很大的正数 (2^30 - 4)无符号右移在处理原始的、不代表数值的位数据时很有用比如从网络包或文件中读取的字节流我们可能只关心位的模式而不关心其符号。移位运算的陷阱与经验优先级移位运算符的优先级低于加减法但高于比较运算符。建议在复杂表达式中使用括号明确优先级例如(a 2) 1。只用于整数移位运算符只能用于整数类型byte, short, char, int, long。对char、byte、short进行移位时它们会先被提升为int类型结果也是int。如果需要将结果赋回更小的类型需要强制转换但要注意可能的数据丢失。移动负数位Java语言规范规定如果右操作数是负数或者超过了类型的最大位数实际移动的位数是右操作数 (类型位数-1)。例如a -3对于int相当于a 29。但写出这样的代码可读性极差应绝对避免。3. 实战进阶位运算的典型应用场景剖析理解了基本操作我们来看看位运算在哪些实际场景中大放异彩。这些场景往往是对性能和空间有极致要求的地方。3.1 状态标志与权限系统这是位运算最经典的应用。用一个整数的不同位来表示一组布尔状态极其节省空间。 假设我们有一个文件系统文件有四种权限可读R、可写W、可执行X、隐藏H。我们可以用4个位来表示public class Permission { public static final int READ 1 0; // 0001 第0位 public static final int WRITE 1 1; // 0010 第1位 public static final int EXECUTE 1 2; // 0100 第2位 public static final int HIDDEN 1 3; // 1000 第3位 private int permissions; // 添加权限 public void addPermission(int perm) { permissions | perm; } // 移除权限 public void removePermission(int perm) { permissions ~perm; } // 判断是否有某个权限 public boolean hasPermission(int perm) { return (permissions perm) ! 0; } // 判断是否拥有所有指定权限 public boolean hasAllPermissions(int... perms) { for (int perm : perms) { if ((permissions perm) 0) { return false; } } return true; } // 判断是否拥有任意一个指定权限 public boolean hasAnyPermission(int... perms) { for (int perm : perms) { if ((permissions perm) ! 0) { return true; } } return false; } }使用示例Permission file new Permission(); file.addPermission(Permission.READ); file.addPermission(Permission.WRITE); System.out.println(file.hasPermission(Permission.READ)); // true System.out.println(file.hasAllPermissions(Permission.READ, Permission.WRITE)); // true System.out.println(file.hasAnyPermission(Permission.EXECUTE, Permission.HIDDEN)); // false一个int可以管理32个独立的状态标志一个long可以管理64个。相比使用boolean数组或EnumSet在状态数量固定且较多时位运算方案在内存和判断速度上优势明显。Java标准库中的java.util.concurrent.locks.AbstractQueuedSynchronizerAQS就大量使用了这种模式来管理线程状态。3.2 紧凑的数据结构位图Bitmap/Bitset当需要表示一个非常大范围内元素的存在与否例如判断一个数是否在10亿个整数集合中使用HashSet会消耗海量内存。位图则用一个位来表示一个数是否存在极度节省空间。public class SimpleBitmap { private long[] words; // 用long数组存储位 private static final int ADDRESS_BITS_PER_WORD 6; // 2^6 64, 每个long有64位 private static final int BITS_PER_WORD 1 ADDRESS_BITS_PER_WORD; public SimpleBitmap(int maxBitIndex) { // 计算需要多少个long来存储到maxBitIndex位 words new long[(maxBitIndex ADDRESS_BITS_PER_WORD) 1]; } public void set(int bitIndex) { // 找到对应的long数组下标 int wordIndex bitIndex ADDRESS_BITS_PER_WORD; // 计算在该long中的位偏移并设置位 words[wordIndex] | (1L bitIndex); } public void clear(int bitIndex) { int wordIndex bitIndex ADDRESS_BITS_PER_WORD; words[wordIndex] ~(1L bitIndex); } public boolean get(int bitIndex) { int wordIndex bitIndex ADDRESS_BITS_PER_WORD; return (words[wordIndex] (1L bitIndex)) ! 0; } }这里的关键是位索引的计算bitIndex 6等价于bitIndex / 64高效地找到了对应的long单元。1L bitIndex中的bitIndex是相对于该long内部的偏移bitIndex % 641L是long型字面量确保移位不会溢出。应用场景大数据去重与判存如上述10亿整数判重位图只需约125MB内存10^9 bit ≈ 119 MB而HashSet可能需要数GB。布隆过滤器Bloom Filter一种概率型数据结构核心就是多个位图多个哈希函数用于快速判断“某元素一定不存在”或“可能存在”广泛应用于缓存穿透、爬虫URL去重。数据库索引某些数据库对低基数列如性别、状态使用位图索引进行多条件AND/OR查询时效率极高。3.3 算法优化快速判断奇偶、交换、取模位运算在算法中常常能替代一些耗时的操作。1. 判断奇偶性常规做法n % 2 0。位运算做法(n 1) 0。 原理二进制最后一位为0是偶数为1是奇数。 1操作只保留最后一位比取模运算快得多。2. 计算2的n次幂1 n直接得到结果效率远高于Math.pow(2, n)。3. 取模运算当除数是2的幂时n % 32可以写成n 31。 原理对于除数M2的k次幂n % M等价于n (M-1)。因为M-1的二进制低位全是1高位全是0操作恰好能取出低k位即余数。HashMap中计算桶下标(n - 1) hash就利用了这个原理来替代耗时的取模运算。4. 判断一个数是否是2的幂n 0 (n (n - 1)) 0原理2的幂的二进制表示中只有一位是1如 8: 1000。n-1则是低位全部变成17: 0111。两者相与结果为0。5. 绝对值对于intint abs (n ^ (n 31)) - (n 31);这是一个技巧性的实现。n 31对于负数得到 -1所有位为1对于非负数得到0。异或操作相当于对负数取反^ -1对非负数无影响^ 0。最后再减去n31对负数来说就是(~n) - (-1) -n对非负数是n - 0 n。虽然现代JVM对Math.abs()优化得很好但这个位运算版本揭示了其底层逻辑。3.4 颜色处理与数据编码/解码在图形编程或网络协议中经常需要将多个值如RGB颜色分量打包到一个整数中或者从一个整数中解包出多个值。1. 将ARGB颜色打包成intint alpha 255; // 0-255 int red 100; int green 150; int blue 200; int argb (alpha 24) | (red 16) | (green 8) | blue; // 二进制视图: [alpha 8位][red 8位][green 8位][blue 8位] // 相当于 argb (255 24) | (100 16) | (150 8) | 200;2. 从int中解包ARGB分量int alpha (argb 24) 0xFF; int red (argb 16) 0xFF; int green (argb 8) 0xFF; int blue argb 0xFF;这里 0xFF即255的操作至关重要。右移之后高位可能带有符号扩展的位对于负数 0xFF可以确保只取最低的8位得到一个0-255之间的干净值。Java中java.awt.Color类的构造和获取方法内部就采用了类似的位操作。3. 网络协议中的字段提取假设从一个16位的短整型short中提取高5位、中间6位、低5位的数据short packet 0xABCD; // 假设的协议数据 int fieldHigh (packet 11) 0x1F; // 取高5位 (0x1F 31 11111b) int fieldMid (packet 5) 0x3F; // 取中间6位 (0x3F 63 111111b) int fieldLow packet 0x1F; // 取低5位4. 避坑指南位运算中的那些“暗礁”位运算虽然强大但使用不当也会带来隐蔽的bug。下面是我在实战中踩过或见过的典型坑。4.1 运算符优先级陷阱位运算符的优先级普遍低于比较运算符这是一个常见的错误来源。// 错误示例意图是判断a和b是否都是奇数 if (a 1 1 b 1 1) { // 编译错误 // ... } // 因为 优先级高于 实际被解析为 a (11) b (11) 类型不匹配。 // 正确写法必须加括号 if ((a 1) 1 (b 1) 1) { // ... }经验法则只要涉及位运算和比较/逻辑运算混用无脑给位运算部分加上括号这样最安全代码也最清晰。4.2 符号扩展与类型提升的“幽灵”这是最隐蔽的坑之一尤其在处理byte、short、char等小于int的类型时。byte b (byte) 0x8F; // 二进制 10001111 作为byte是负数 (-113) int i b; // 自动类型提升为int结果是 0xFFFFFF8F (-113) int j b 0xFF; // 结果是 0x0000008F (143)当byte8位提升为int32位时如果byte是负数最高位为1Java会进行符号扩展高位全部补1以保持数值不变。但有时我们并不想要这个数值意义而是想要无符号的位模式比如从文件读取的原始字节。这时需要先和0xFF做操作屏蔽掉高位多余的1得到一个0-255的正数。同样的问题也出现在右移中byte b -1; // 0xFF (补码表示) int i b 4; // 算术右移符号扩展结果是 0xFFFFFFFF (-1) int j (b 0xFF) 4; // 先转无符号再右移结果是 0x0F (15) int k b 4; // 无符号右移但b先被提升为int 0xFFFFFFFF再4结果是 0x0FFFFFFF对于byte和short运算符同样会先将其提升为int再进行无符号右移这可能不是你想要的结果。处理原始字节数据时务必小心符号扩展。4.3 移位运算的边界与性能误区1. 移位位数过大或为负如前所述Java规范规定对于int实际移位位数是右操作数 31对于long是右操作数 63。a 32等于a 0。写出这样的代码通常意味着逻辑错误而非利用特性。2. 误用移位代替乘除虽然a 1比a * 2快是古老的传说但在现代JVMHotSpot上简单的乘除常数尤其是2的幂早已被JIT编译器优化为移位指令。你应该为了代码的清晰性而使用*2或/2除非是在对性能极其敏感且已证明这里是热点的代码段中。为了微乎其微的性能提升而牺牲代码可读性得不偿失。3. long类型移位的坑long mask 1 32; // 危险你可能会以为mask是2的32次方。但实际上1是int字面量1 32对于int是1 0 1然后这个int值1再被赋值给long。正确写法是1L 32使用long字面量。4.4 位运算的可读性维护难题位运算写起来很酷但读起来可能很痛苦。几个月后连你自己都可能忘记flags 0x0F00到底想提取哪个字段。// 难以理解的魔法数字 public static final int FLAG_A 0x0001; public static final int FLAG_B 0x0002; public static final int FLAG_C 0x0004; // ... 使用时 if ((status (FLAG_A | FLAG_C)) ! 0) { ... } // 或者使用更清晰的位移定义 public static final int FLAG_A 1 0; public static final int FLAG_B 1 1; public static final int FLAG_C 1 2;为每一个掩码定义有意义的常量名并添加清晰的注释这是必须遵守的编码规范。如果状态组合非常复杂考虑使用EnumSet它内部可能就是用位运算实现的它在提供类型安全和高性能的同时保持了良好的API可读性。5. 从原理到实践位运算的底层逻辑与性能考量要真正用好位运算不能只停留在语法层面还需要理解其背后的计算机原理并对其性能有客观的认识。5.1 为什么位运算快在硬件层面CPU拥有直接对寄存器中位进行操作的指令如AND, OR, XOR, NOT, SHL, SHR。这些指令通常在一个时钟周期内就能完成是CPU最基础、最快的操作之一。相比之下一次函数调用、甚至一次条件判断都可能涉及更多的指令和流水线停顿。因此在密集的循环中将一系列布尔判断替换为位运算可能带来显著的性能提升。但请注意这种提升只有在算法核心热点处才值得追求。在99%的业务代码中代码的清晰性和可维护性远比那一点微秒级的优化重要。5.2 补码负数位运算的基石Java使用补码Two‘s Complement表示有符号整数。理解补码是理解位运算尤其是涉及负数时行为的关键。正数的补码就是其本身的二进制表示。负数的补码将其对应正数的二进制表示全部取反~然后加1。补码的好处统一了加法和减法运算减法可以转化为加法并且0的表示唯一全0。这也解释了为什么~n等于-n - 1。因为对一个数取反相当于求其“相反数减一”的补码。5.3 位运算的线程安全性与原子性基本的位运算如a b | c在Java语言级别是原子的吗对于long和double以外的原始类型的简单赋值和读取在Java内存模型JMM中这些操作本身是原子的。但是在多线程环境下问题在于可见性和复合操作。可见性线程A修改了一个int标志位线程B可能不会立即看到这个改变因为值可能还停留在CPU缓存中。解决方法是使用volatile关键字修饰该变量或者使用原子类如AtomicInteger或者正确的锁同步。复合操作flags | MASK这个操作是“读-改-写”三步不是原子的。如果两个线程同时执行可能会丢失更新。对于需要原子更新的位标志应该使用java.util.concurrent.atomic包中的AtomicInteger或AtomicLong它们提供了getAndUpdate,compareAndSet等原子方法。private final AtomicInteger flags new AtomicInteger(0); // 原子地设置位 public void setFlagAtomic(int mask) { int oldVal, newVal; do { oldVal flags.get(); newVal oldVal | mask; } while (!flags.compareAndSet(oldVal, newVal)); // CAS操作 }5.4 何时该用何时不该用应该使用位运算的场景性能关键路径在已被性能分析证实的、对执行速度要求极高的核心算法或数据结构中如哈希算法、压缩算法、位图、状态机。内存极度受限在嵌入式环境或需要处理海量数据且内存是主要瓶颈时用位来存储布尔值可以大幅节省空间。与硬件或底层协议交互处理网络数据包、文件格式、硬件寄存器时数据本身就是按位组织的使用位运算是最自然的方式。实现特定的巧妙算法如使用异或找唯一数、使用n (n-1)消去最低位的1等。应谨慎或避免使用位运算的场景一般的业务逻辑除非有明确且重大的收益否则优先使用boolean、EnumSet、条件判断等更清晰的方式。可读性是第一生产力。团队共识不足如果团队其他成员不熟悉位运算滥用会导致代码难以理解和维护增加隐性成本。过早优化在未进行性能测评前不要假设位运算一定更快。JVM的优化能力常常超乎想象。位运算是一把锋利的双刃剑。它源于计算机最底层的设计理解它能让你更深刻地理解程序是如何在机器上运行的。在合适的场景下运用它可以写出既优雅又高效的程序。但始终记住在软件工程中代码首先是写给人看的其次才是给机器执行的。在追求性能的极致时不要牺牲了可读性和可维护性的基石。希望这篇长文能帮你不仅搞懂位运算的语法更能掌握其精髓在需要的时候自信而准确地挥舞这把利器。