Java19 Integer 位操作精解:compress与expand《Hacker‘s Delight》(第二版,7.4节)

compress(int i, int mask)

这个方法是Java 19中新增的一个强大的位操作函数。

compress 方法的核心功能可以理解为 "按位过滤和压缩" 。

  1. 过滤 (Filter) : 它使用 mask(掩码)作为过滤器。对于输入整数 i,只有那些在 mask 中对应位为 1 的比特才会被保留。
  2. 压缩 (Compress) : 它将所有被保留下来的比特,按照它们在 i 中从低到高的原始顺序,紧凑地排列到返回值的低位端。

一个直观的例子

文档中给出的例子非常经典:compress(0xCAFEBABE, 0xFF00FFF0)

  • 输入 i : 0xCAFEBABE -> 1100 1010 1111 1110 1011 1010 1011 1110
  • 掩码 mask : 0xFF00FFF0 -> 1111 1111 0000 0000 1111 1111 1111 0000

执行过程:

  1. mask 的高8位是 FF,所以 i 的高8位 CA 被选中。
  2. mask 的次高8位是 00,所以 i 的次高8位 FE 被丢弃。
  3. mask 的次低12位是 FFF,所以 i 的对应12位 BAB 被选中。
  4. mask 的最低4位是 0,所以 i 的最低4位 E 被丢弃。
  5. 选中的比特 CABAB 被按顺序紧凑地排列,得到 0x000CABAB

expand

public static int expand(int i, int mask) 是一个静态方法,用于根据一个给定的掩码 mask 来"扩展"或"解压缩"整数 i 的位(bits)。这个操作可以看作是 Integer.compress(int i, int mask) 方法的逆过程。

核心功能 : 对于 mask 中每一个为 1 的位,该方法会从输入整数 i 中按顺序(从最低位开始)取一个位,并将其放置到结果中与 mask 中那个 1 位对应的位置。结果中所有对应 mask0 位的位置都将被清零。

Javadoc 注释解析与示例

我们先来看一下该方法的 Javadoc 注释,特别是它给出的示例,这有助于直观地理解其功能。

java 复制代码
// ... existing code ...
     * @apiNote
     * Consider the simple case of expanding the digits of a hexadecimal
     * value:
     * {@snippet lang="java" :
     * expand(0x0000CABAB, 0xFF00FFF0) == 0xCA00BAB0
     * }
     * Starting from the least significant hexadecimal digit at position 0
     * from the right, the mask {@code 0xFF00FFF0} selects the first five
     * hexadecimal digits of {@code 0x0000CABAB}. The selected digits occur
     * in the resulting expanded value in order at positions 1, 2, 3, 6, and 7.
// ... existing code ...

让我们来分解这个例子:

  • 输入 i : 0x0000CABAB
  • 掩码 mask : 0xFF00FFF0
  • 预期结果 : 0xCA00BAB0

分析过程:

  1. mask (0xFF00FFF0) 的二进制形式定义了哪些位是"有效"的目标位置。它的十六进制表示中,值为 F 的位置(第1, 2, 3, 6, 7位)代表了目标位置。
  2. i (0x0000CABAB) 是源数据。我们从 i 的最低位开始,依次取出位。
  3. expand 操作会将 i 的位填充到 mask 指定的有效位置中:
    • i 的最低的4位是 B (1011),它被放置在 mask 第一个有效区域(十六进制位 1),结果的 ...xxxB0 部分形成。
    • i 的接下来4位是 A (1010),它被放置在 mask 第二个有效区域(十六进制位 2),结果的 ...xxAB0 部分形成。
    • i 的再接下来4位是 B (1011),它被放置在 mask 第三个有效区域(十六进制位 3),结果的 ...xBABA0 部分形成。
    • mask 的第4、5位是 00,所以结果的对应位置也是 00
    • i 的再接下来4位是 A (1010),它被放置在 mask 第四个有效区域(十六进制位 6),结果的 ...A00BAB0 部分形成。
    • i 的最后4位是 C (1100),它被放置在 mask 第五个有效区域(十六进制位 7),最终形成 0xCA00BAB0

Javadoc 还提到了一个关键恒等式:expand(compress(x, m), m) == x & m。这清晰地表明 expandcompress 是一对互逆的操作。

源码解析:compress和expand是如何实现的?

实现源自经典著作《Hacker's Delight》(第二版,7.4节),是一种不包含分支和循环(Java代码中的for循环在编译后会展开)的高效并行算法。

以下分析改自 《Hacker's Delight》(第二版,7.4节)

compress

分步移动的数学原理​

  • ​总体目标​​:

    每个位需右移的距离 = 该位 对应mask ​​右侧 0 的数量​ ​(记为 d

  • ​分治策略​​:

    d拆解为二进制分量,分 5 轮处理:

    d = d₀ + 2×d₁ + 4×d₂ + 8×d₃ + 16×d₄ (其中 dᵢ ∈ {0,1} 是二进制系数)

    • ​第 j 轮​ ​:处理 ​​2ʲ​​ 的权重分量

    • ​移动条件​ ​:当轮需移动的位 = d二进制展开中 ​​2ʲ 的系数 dⱼ 为 1​​ 的位

​示例​ ​:某位需移动 d=6(二进制 110

  • 第一轮(j=0):移动 2⁰=1位(因 d₀=0→ ​​不移动​​)

  • 第二轮(j=1):移动 2¹=2位(因 d₁=1→ ​​移动​​)

  • 第三轮(j=2):移动 2²=4位(因 d₂=1→ ​​移动​​)

源码:

java 复制代码
    @IntrinsicCandidate
    public static int compress(int i, int mask) {
        // See Hacker's Delight (2nd ed) section 7.4 Compress, or Generalized Extract

        i = i & mask; // Clear irrelevant bits
        int maskCount = ~mask << 1; // Count 0's to right

        for (int j = 0; j < 5; j++) {
            // Parallel prefix
            // Mask prefix identifies bits of the mask that have an odd number of 0's to the right
            int maskPrefix = parallelSuffix(maskCount);
            // Bits to move
            int maskMove = maskPrefix & mask;
            // Compress mask
            mask = (mask ^ maskMove) | (maskMove >>> (1 << j));
            // Bits of i to be moved
            int t = i & maskMove;
            // Compress i
            i = (i ^ t) | (t >>> (1 << j));
            // Adjust the mask count by identifying bits that have 0 to the right
            maskCount = maskCount & ~maskPrefix;
        }
        return i;
    }

假设输入为:

x = abcd efgh ijkl mnop qrst uvwx yzAB CDEF,(Java代码里的 i

m = 1000 1000 1110 0000 0000 1111 0101 0101,

其中 x 中的每个字母代表一个比特(值为 0 或 1)。

x 中对应的比特 向右移动位数 等于该比特右边 m 中 0 的数量

首先清除 x 中不相关的比特会很方便,得到:

x = a000 e000 ijk0 0000 0000 uvwx 0z0B 0D0F。


首先确定哪些比特需要(向右)移动奇数个位置,并将它们移动一个比特位。​

这可以通过计算 mk = ~m << 1 并对结果执行 parallelSuffix 来完成。

得到:

mk = 1110 1110 0011 1111 1110 0001 0101 0100,

mp = 1010 0101 1110 1010 1010 0000 1100 1100。

可以观察到,

  • mk 标识了 m 中紧邻右侧是 0 的比特位,
  • mp 从右到左对这些位进行模 2 加法(parallelSuffix)。

因此,mp 标识了 m 中右侧有奇数个 0 的比特位。


​将要移动一个位置的比特是那些位于严格右侧有奇数个 0 的位置(由 mp 标识)并且在原始掩码中为 1-比特的位。​

这可以简单地通过 mv = mp & m 计算得出:

mv = 1000 0000 1110 0000 0000 0000 0100 0100。

m 的这些比特可以通过赋值语句移动:

m = (m ^ mv) | (mv >> 1);

x 的相同比特可以通过两个赋值语句移动:

t = x & mv;

x = (x ^ t) | (t >> 1);

(移动 m 的比特更简单,因为所有选中的比特都是 1。)

这里的异或操作是关闭 m 和 x 中已知的 1-比特,而或操作是打开 m 和 x 中已知的 0-比特。

这些操作也可以分别替换为异或,或者减法和加法。

​将由 mv 选择的比特向右移动一个位置后,结果是:​

m = 0100 1000 0111 0000 0000 1111 0011 0011,

x = 0a00 e000 0ijk 0000 0000 uvwx 00zB 00DF。


​现在我们必须为第二次迭代准备一个掩码,在这次迭代中,我们识别要向右移动 2 的奇数倍位置的比特。​

注意,mk & ~mp 这个量标识了那些在原始掩码 m 中紧邻右侧有偶数 0 的比特。【因为奇数0的位置 被删除了

这个量如果从右侧用 parallelSuffix 求和,就能识别出那些向右移动 2 的奇数倍(2, 6, 10 等)位置的比特。

因此,该过程就是将这个量赋给 mk ,并执行上述步骤的第二次迭代。

​修订后的 mk 值为:​

mk = 0100 1010 0001 0101 0100 0001 0001 0000。

expand

compress的逆向移动

java 复制代码
public static int expand(int i, int mask) {
        // Save original mask
        int originalMask = mask;
        // Count 0's to right
        int maskCount = ~mask << 1;
        int maskPrefix = parallelSuffix(maskCount);
        // Bits to move
        int maskMove1 = maskPrefix & mask;
        // Compress mask
        mask = (mask ^ maskMove1) | (maskMove1 >>> (1 << 0));
        maskCount = maskCount & ~maskPrefix;

        maskPrefix = parallelSuffix(maskCount);
        // Bits to move
        int maskMove2 = maskPrefix & mask;
        // Compress mask
        mask = (mask ^ maskMove2) | (maskMove2 >>> (1 << 1));
        maskCount = maskCount & ~maskPrefix;

        maskPrefix = parallelSuffix(maskCount);
        // Bits to move
        int maskMove3 = maskPrefix & mask;
        // Compress mask
        mask = (mask ^ maskMove3) | (maskMove3 >>> (1 << 2));
        maskCount = maskCount & ~maskPrefix;

        maskPrefix = parallelSuffix(maskCount);
        // Bits to move
        int maskMove4 = maskPrefix & mask;
        // Compress mask
        mask = (mask ^ maskMove4) | (maskMove4 >>> (1 << 3));
        maskCount = maskCount & ~maskPrefix;

        maskPrefix = parallelSuffix(maskCount);
        // Bits to move
        int maskMove5 = maskPrefix & mask;

        int t = i << (1 << 4);
        i = (i & ~maskMove5) | (t & maskMove5);
        t = i << (1 << 3);
        i = (i & ~maskMove4) | (t & maskMove4);
        t = i << (1 << 2);
        i = (i & ~maskMove3) | (t & maskMove3);
        t = i << (1 << 1);
        i = (i & ~maskMove2) | (t & maskMove2);
        t = i << (1 << 0);
        i = (i & ~maskMove1) | (t & maskMove1);

        // Clear irrelevant bits
        return i & originalMask;
    }

parallelSuffix(int maskCount)

这个方法是 Integer.compressInteger.expand 的核心辅助函数。它的名字虽然叫 parallelSuffix(并行后缀),但其实现的是一种"并行前缀异或扫描"(Parallel Prefix XOR Scan)算法。

此方法计算一个"前缀和",但使用的不是加法,而是^(按位异或)运算。对于返回结果 maskPrefix 中的任意比特位 k,它的值等于输入 maskCount 中从第 0 位到第 k 位所有比特的异或总和。

result[k] = maskCount[0] ^ maskCount[1] ^ ... ^ maskCount[k]

该算法采用分治策略,在对数时间内完成计算:

java 复制代码
// ... existing code ...
    @ForceInline
    private static int parallelSuffix(int maskCount) {
        // 步骤1: 计算相邻比特的异或和
        int maskPrefix = maskCount ^ (maskCount << 1);
        // 步骤2: 计算相邻2比特块的异或和
        maskPrefix = maskPrefix ^ (maskPrefix << 2);
        // 步骤3: 计算相邻4比特块的异或和
        maskPrefix = maskPrefix ^ (maskPrefix << 4);
        // 步骤4: 计算相邻8比特块的异或和
        maskPrefix = maskPrefix ^ (maskPrefix << 8);
        // 步骤5: 计算相邻16比特块的异或和
        maskPrefix = maskPrefix ^ (maskPrefix << 16);
        return maskPrefix;
    }
// ... existing code ...
  • maskPrefix = maskCount ^ (maskCount << 1);: 第一步,每个比特位与它右边(低位)的比特进行异或。现在每个比特位存储了它自己和右边邻居的异或结果。
  • maskPrefix = maskPrefix ^ (maskPrefix << 2);: 第二步,将相邻的2比特块进行异或。这会把之前的结果组合起来,现在每个比特位存储了原始值中连续4个比特的异或和。
  • 后续步骤: 这个过程不断重复,块的大小翻倍(4, 8, 16),直到最终每个比特位都包含了从最低位到当前位所有比特的异或总和。

compressexpand 方法中,需要将源整数中的某些位根据掩码(mask)移动到新的位置。这个移动的距离不是固定的。parallelSuffix 的作用就是高效地、并行地计算出所有需要移动的位应该移动多远,是实现这两个复杂位操作算法的关键基石。

@ForceInline 注解建议JIT编译器将这个短小精悍的函数直接内联到调用它的地方(compressexpand),以消除函数调用的开销,追求极致性能。