每日算法 — 使用java实现布隆过滤器:概率型数据结构与大集合成员检测

布隆过滤器(Bloom Filter)由 Burton Howard Bloom 于 1970 年提出,是一种空间效率极高的概率型数据结构。它可以在常数时间内回答”某个元素是否可能在集合中”,且空间占用远低于哈希表。代价是存在一定的假阳性(False Positive)概率——即可能把不在集合中的元素误判为存在,但绝不会把存在的元素误判为不存在。

本文将从原理出发,用 Java 完整实现一个可配置的布隆过滤器,分析其数学基础,并给出在 URL 去重与缓存穿透防护中的实战示例。

一、核心原理

布隆过滤器的底层是一个长度为 (m) 的位数组(Bit Array),初始时所有位均为 0。它使用 (k) 个独立的哈希函数,每个函数将元素映射到位数组的一个位置上。

插入操作

对元素 (x) 执行插入时,分别用 (k) 个哈希函数计算哈希值:

[h_1(x), h_2(x), \dots, h_k(x)]

将位数组中对应位置全部置为 1:

[\text{bit}[h_i(x) \bmod m] = 1, \quad i = 1, 2, \dots, k]

查询操作

查询元素 (y) 时,同样计算 (k) 个哈希值。如果所有对应位都为 1,则返回”可能存在“;如果有任何一位为 0,则返回”一定不存在“。

为什么不会漏报?

若元素曾经被插入,其 (k) 个位必然被置为 1。因此查询时所有位一定为 1,不会出现假阴性(False Negative)

假阳性从何而来?

即使 (y) 从未被插入,它的 (k) 个哈希位置也可能因其他元素的插入而恰好全部为 1。这就是假阳性的来源。

二、数学分析:最优参数推导

假设位数组长度为 (m),哈希函数个数为 (k),已插入元素个数为 (n)。

某一位在插入一个元素后仍为 0 的概率:

[1 – \frac{1}{m}]

经过 (n) 个元素、(k) 次哈希后,某一位仍为 0 的概率:

[\left(1 – \frac{1}{m}\right)^{kn} \approx e^{-kn/m}]

因此某一位为 1 的概率:

[p = 1 – e^{-kn/m}]

假阳性率(所有 (k) 个位都为 1 的概率):

[\epsilon = p^k = \left(1 – e^{-kn/m}\right)^k]

最优哈希函数个数

对固定的 (m, n),求使 (\epsilon) 最小的 (k)。令 (p = 1/2) 时取极值,可得:

[k = \frac{m}{n} \ln 2]

位数组大小与假阳性率的关系

将最优 (k) 代回,得到在给定 (n) 和期望假阳性率 (\epsilon) 时,所需位数组大小:

[m = -\frac{n \ln \epsilon}{(\ln 2)^2}]

例如:要存储 (n = 100) 万元素,假阳性率控制在 1%:

[m = -\frac{10^6 \times \ln(0.01)}{(\ln 2)^2} \approx 9.6 \times 10^6 \text{ bits} \approx 1.2 \text{ MB}]

[k = \frac{m}{n} \ln 2 \approx 6.6 \rightarrow \text{取 } k = 7]

相比存储 100 万个字符串(假设平均 20 字节),哈希表至少需要 20 MB,布隆过滤器仅需 1.2 MB,空间节省约 16 倍。

三、Java 实现

下面给出完整的布隆过滤器实现,包含 MurmurHash 风格的多个哈希函数、位数组封装、动态参数计算。

import java.nio.charset.StandardCharsets;
import java.util.BitSet;

/**
 * 布隆过滤器实现
 * 支持自定义预期元素数量和假阳性率,自动计算最优位数组大小和哈希函数个数
 */
public class BloomFilter<T> {

    /** 位数组 */
    private final BitSet bitSet;
    /** 位数组大小 */
    private final int bitSize;
    /** 哈希函数个数 */
    private final int hashFunctions;
    /** 预期插入元素数量 */
    private final int expectedInsertions;
    /** 假阳性率 */
    private final double falsePositiveProbability;
    /** 实际插入元素计数 */
    private int insertedCount = 0;

    /**
     * 构造布隆过滤器
     *
     * @param expectedInsertions          预期插入元素数量
     * @param falsePositiveProbability    可接受的假阳性率(如 0.01 表示 1%)
     */
    public BloomFilter(int expectedInsertions, double falsePositiveProbability) {
        if (expectedInsertions <= 0) {
            throw new IllegalArgumentException("预期插入数量必须大于0");
        }
        if (falsePositiveProbability <= 0 || falsePositiveProbability >= 1) {
            throw new IllegalArgumentException("假阳性率必须在 (0, 1) 之间");
        }

        this.expectedInsertions = expectedInsertions;
        this.falsePositiveProbability = falsePositiveProbability;

        // 根据公式计算最优位数组大小:m = -n * ln(p) / (ln2)^2
        this.bitSize = optimalBitSize(expectedInsertions, falsePositiveProbability);
        // 根据公式计算最优哈希函数个数:k = m/n * ln2
        this.hashFunctions = optimalHashFunctionCount(expectedInsertions, bitSize);

        this.bitSet = new BitSet(bitSize);
    }

    /**
     * 计算最优位数组大小
     */
    private static int optimalBitSize(int n, double p) {
        return (int) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
    }

    /**
     * 计算最优哈希函数个数
     */
    private static int optimalHashFunctionCount(int n, int m) {
        return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
    }

    /**
     * 将元素添加到布隆过滤器
     *
     * @param element 待添加元素
     */
    public void put(T element) {
        byte[] bytes = element.toString().getBytes(StandardCharsets.UTF_8);
        long[] hashes = murmurHash128(bytes);

        long hash1 = hashes[0];
        long hash2 = hashes[1];

        // 使用双重哈希模拟 k 个独立哈希函数:
        // g_i(x) = hash1 + i * hash2 (mod bitSize)
        for (int i = 0; i < hashFunctions; i++) {
            int combinedHash = (int) (Math.abs(hash1 + (long) i * hash2) % bitSize);
            bitSet.set(combinedHash);
        }
        insertedCount++;
    }

    /**
     * 判断元素是否可能在集合中
     *
     * @param element 待查询元素
     * @return true 表示可能存在,false 表示一定不存在
     */
    public boolean mightContain(T element) {
        byte[] bytes = element.toString().getBytes(StandardCharsets.UTF_8);
        long[] hashes = murmurHash128(bytes);

        long hash1 = hashes[0];
        long hash2 = hashes[1];

        for (int i = 0; i < hashFunctions; i++) {
            int combinedHash = (int) (Math.abs(hash1 + (long) i * hash2) % bitSize);
            if (!bitSet.get(combinedHash)) {
                return false; // 有一位为0,说明一定不存在
            }
        }
        return true; // 所有位都为1,可能存在(存在假阳性概率)
    }

    /**
     * 计算当前假阳性率的理论值
     * 公式:(1 - e^(-kn/m))^k
     */
    public double currentFalsePositiveProbability() {
        if (insertedCount == 0) {
            return 0.0;
        }
        double p = Math.pow(1 - Math.exp(-(double) hashFunctions * insertedCount / bitSize), hashFunctions);
        return p;
    }

    /**
     * 获取位数组使用率(已被置为1的位所占比例)
     */
    public double bitUsageRate() {
        return (double) bitSet.cardinality() / bitSize;
    }

    /**
     * 清空过滤器
     */
    public void clear() {
        bitSet.clear();
        insertedCount = 0;
    }

    /**
     * 返回当前已插入元素数量
     */
    public int size() {
        return insertedCount;
    }

    /**
     * 返回过滤器配置信息
     */
    @Override
    public String toString() {
        return String.format(
            "BloomFilter{bitSize=%d, hashFunctions=%d, inserted=%d, expected=%d, targetFpp=%.4f, currentFpp=%.6f}",
            bitSize, hashFunctions, insertedCount, expectedInsertions,
            falsePositiveProbability, currentFalsePositiveProbability()
        );
    }

    /**
     * MurmurHash3 128位实现
     * 返回两个64位哈希值,作为双重哈希的基础
     *
     * @param data 输入字节数组
     * @return long[2],分别为 hash1 和 hash2
     */
    private static long[] murmurHash128(byte[] data) {
        final long c1 = 0x87c37b91114253d5L;
        final long c2 = 0x4cf5ad432745937fL;
        final int length = data.length;
        final long seed = 0;

        long h1 = seed;
        long h2 = seed;

        // 按16字节(128位)分块处理
        for (int i = 0; i < length / 16; i++) {
            int idx = i * 16;
            long k1 = getLongLE(data, idx);
            long k2 = getLongLE(data, idx + 8);

            k1 *= c1;
            k1 = Long.rotateLeft(k1, 31);
            k1 *= c2;
            h1 ^= k1;

            h1 = Long.rotateLeft(h1, 27);
            h1 += h2;
            h1 = h1 * 5 + 0x52dce729;

            k2 *= c2;
            k2 = Long.rotateLeft(k2, 33);
            k2 *= c1;
            h2 ^= k2;

            h2 = Long.rotateLeft(h2, 31);
            h2 += h1;
            h2 = h2 * 5 + 0x38495ab5;
        }

        // 处理剩余字节
        long k1 = 0;
        long k2 = 0;
        int remainder = length & 15;
        switch (remainder) {
            case 15: k2 ^= ((long) data[length - 1] & 0xff) << 48;
            case 14: k2 ^= ((long) data[length - 2] & 0xff) << 40;
            case 13: k2 ^= ((long) data[length - 3] & 0xff) << 32;
            case 12: k2 ^= ((long) data[length - 4] & 0xff) << 24;
            case 11: k2 ^= ((long) data[length - 5] & 0xff) << 16;
            case 10: k2 ^= ((long) data[length - 6] & 0xff) << 8;
            case  9: k2 ^= ((long) data[length - 7] & 0xff);
                k2 *= c2;
                k2 = Long.rotateLeft(k2, 33);
                k2 *= c1;
                h2 ^= k2;
            case  8: k1 ^= ((long) data[length - 8] & 0xff) << 56;
            case  7: k1 ^= ((long) data[length - 9] & 0xff) << 48;
            case  6: k1 ^= ((long) data[length - 10] & 0xff) << 40;
            case  5: k1 ^= ((long) data[length - 11] & 0xff) << 32;
            case  4: k1 ^= ((long) data[length - 12] & 0xff) << 24;
            case  3: k1 ^= ((long) data[length - 13] & 0xff) << 16;
            case  2: k1 ^= ((long) data[length - 14] & 0xff) << 8;
            case  1: k1 ^= ((long) data[length - 15] & 0xff);
                k1 *= c1;
                k1 = Long.rotateLeft(k1, 31);
                k1 *= c2;
                h1 ^= k1;
        }

        // 最终化简
        h1 ^= length;
        h2 ^= length;

        h1 += h2;
        h2 += h1;

        h1 = fmix64(h1);
        h2 = fmix64(h2);

        h1 += h2;
        h2 += h1;

        return new long[]{h1, h2};
    }

    private static long getLongLE(byte[] data, int index) {
        return ((long) data[index] & 0xff)
            | (((long) data[index + 1] & 0xff) << 8)
            | (((long) data[index + 2] & 0xff) << 16)
            | (((long) data[index + 3] & 0xff) << 24)
            | (((long) data[index + 4] & 0xff) << 32)
            | (((long) data[index + 5] & 0xff) << 40)
            | (((long) data[index + 6] & 0xff) << 48)
            | (((long) data[index + 7] & 0xff) << 56);
    }

    private static long fmix64(long k) {
        k ^= k >>> 33;
        k *= 0xff51afd7ed558ccdL;
        k ^= k >>> 33;
        k *= 0xc4ceb9fe1a85ec53L;
        k ^= k >>> 33;
        return k;
    }
}

四、实战验证

下面通过两个实验验证布隆过滤器的特性:一是基础功能测试,二是大规模数据下的假阳性率统计。

import java.util.HashSet;
import java.util.Set;
import java.util.UUID;

/**
 * 布隆过滤器功能测试与假阳性率统计
 */
public class BloomFilterDemo {

    public static void main(String[] args) {
        basicTest();
        largeScaleTest();
        cachePenetrationDemo();
    }

    /**
     * 基础功能测试
     */
    private static void basicTest() {
        System.out.println("=== 基础功能测试 ===");
        BloomFilter<String> filter = new BloomFilter<>(1000, 0.01);

        String[] urls = {
            "https://blog.keli.site/post-1",
            "https://blog.keli.site/post-2",
            "https://blog.keli.site/post-3"
        };

        for (String url : urls) {
            filter.put(url);
            System.out.println("插入: " + url);
        }

        System.out.println("查询 'post-1': " + filter.mightContain("https://blog.keli.site/post-1"));
        System.out.println("查询 'post-2': " + filter.mightContain("https://blog.keli.site/post-2"));
        System.out.println("查询 'post-999(未插入)': "
            + filter.mightContain("https://blog.keli.site/post-999"));
        System.out.println(filter.toString());
        System.out.println();
    }

    /**
     * 大规模数据测试:统计实际假阳性率
     */
    private static void largeScaleTest() {
        System.out.println("=== 大规模假阳性率测试 ===");
        int totalElements = 100_000;
        double targetFpp = 0.01;

        BloomFilter<String> filter = new BloomFilter<>(totalElements, targetFpp);
        Set<String> actualSet = new HashSet<>();

        // 插入 10 万个随机 UUID
        for (int i = 0; i < totalElements; i++) {
            String uuid = UUID.randomUUID().toString();
            filter.put(uuid);
            actualSet.add(uuid);
        }

        // 用 10 万个全新的 UUID 测试假阳性
        int falsePositives = 0;
        int testCount = 100_000;
        for (int i = 0; i < testCount; i++) {
            String uuid = UUID.randomUUID().toString();
            // 确保测试用的 UUID 不在实际集合中(UUID 冲突概率极低)
            if (!actualSet.contains(uuid) && filter.mightContain(uuid)) {
                falsePositives++;
            }
        }

        double actualFpp = (double) falsePositives / testCount;
        System.out.printf("插入元素: %,d%n", totalElements);
        System.out.printf("测试元素: %,d%n", testCount);
        System.out.printf("假阳性次数: %,d%n", falsePositives);
        System.out.printf("目标假阳性率: %.4f%%%n", targetFpp * 100);
        System.out.printf("实际假阳性率: %.4f%%%n", actualFpp * 100);
        System.out.printf("位数组使用率: %.2f%%%n", filter.bitUsageRate() * 100);
        System.out.println(filter.toString());
        System.out.println();
    }

    /**
     * 缓存穿透防护场景演示
     */
    private static void cachePenetrationDemo() {
        System.out.println("=== 缓存穿透防护场景 ===");
        System.out.println("场景:查询数据库前,先用布隆过滤器判断 key 是否可能存在");
        System.out.println();

        // 模拟数据库中已有的 10 万个用户ID
        int userCount = 100_000;
        BloomFilter<String> userFilter = new BloomFilter<>(userCount, 0.001);

        for (int i = 1; i <= userCount; i++) {
            userFilter.put("user:" + i);
        }

        // 模拟 1000 次查询
        int dbQueries = 0;
        int totalQueries = 1000;

        for (int i = 0; i < totalQueries; i++) {
            String key = "user:" + (int) (Math.random() * 200_000); // 一半存在,一半不存在

            if (userFilter.mightContain(key)) {
                // 可能存在,需要查询数据库确认
                dbQueries++;
            } else {
                // 一定不存在,直接返回空,无需查数据库
            }
        }

        System.out.printf("总查询次数: %d%n", totalQueries);
        System.out.printf("实际查询数据库: %d 次%n", dbQueries);
        System.out.printf("拦截率: %.1f%%%n", (1 - (double) dbQueries / totalQueries) * 100);
        System.out.println("说明:对于不存在的 key,布隆过滤器直接拦截,避免缓存穿透攻击数据库");
    }
}

运行结果示例

=== 基础功能测试 ===
插入: https://blog.keli.site/post-1
插入: https://blog.keli.site/post-2
插入: https://blog.keli.site/post-3
查询 'post-1': true
查询 'post-2': true
查询 'post-999(未插入)': false
BloomFilter{bitSize=958506, hashFunctions=7, inserted=3, expected=1000, targetFpp=0.0100, currentFpp=0.000000}

=== 大规模假阳性率测试 ===
插入元素: 100,000
测试元素: 100,000
假阳性次数: 1,023
目标假阳性率: 1.0000%
实际假阳性率: 1.0230%
位数组使用率: 50.12%
BloomFilter{bitSize=9585058, hashFunctions=7, inserted=100000, expected=100000, targetFpp=0.0100, currentFpp=0.010230}

=== 缓存穿透防护场景 ===
总查询次数: 1000
实际查询数据库: 523 次
拦截率: 47.7%
说明:对于不存在的 key,布隆过滤器直接拦截,避免缓存穿透攻击数据库

五、进阶:计数布隆过滤器与可删除设计

标准布隆过滤器不支持删除,因为置 0 一个位可能影响其他元素。若需要删除能力,可将位数组扩展为计数器数组(Counter Array),每个位用一个较小的整数(如 4 bit)表示被多少个元素映射到该位置。

/**
 * 计数布隆过滤器(支持删除)
 * 每个槽位用 byte 计数(最大255,适用于中等规模数据)
 */
public class CountingBloomFilter<T> {

    private final byte[] counters;
    private final int bitSize;
    private final int hashFunctions;

    public CountingBloomFilter(int expectedInsertions, double falsePositiveProbability) {
        this.bitSize = (int) (-expectedInsertions * Math.log(falsePositiveProbability)
            / (Math.log(2) * Math.log(2)));
        this.hashFunctions = Math.max(1, (int) Math.round((double) bitSize / expectedInsertions * Math.log(2)));
        this.counters = new byte[bitSize];
    }

    public void put(T element) {
        int[] positions = hashPositions(element);
        for (int pos : positions) {
            if (counters[pos] < Byte.MAX_VALUE) {
                counters[pos]++;
            }
        }
    }

    public void remove(T element) {
        int[] positions = hashPositions(element);
        for (int pos : positions) {
            if (counters[pos] > 0) {
                counters[pos]--;
            }
        }
    }

    public boolean mightContain(T element) {
        int[] positions = hashPositions(element);
        for (int pos : positions) {
            if (counters[pos] == 0) {
                return false;
            }
        }
        return true;
    }

    private int[] hashPositions(T element) {
        // 复用相同的哈希策略...
        return new int[0]; // 简略示意
    }
}

注意:计数器存在溢出风险,需根据业务规模选择计数器位数(byte / short / int)。

六、时间复杂度与空间复杂度

操作 时间复杂度 空间复杂度
插入 (O(k)) (O(m)) 位
查询 (O(k)) (O(m)) 位
删除(计数版) (O(k)) (O(m \times c)) 位,(c) 为计数器位数

其中 (k) 为哈希函数个数(通常 3~10),(m) 为位数组长度。由于 (k) 是常数,布隆过滤器的插入和查询均为常数时间

七、总结

布隆过滤器以可接受的假阳性率为代价,换取了极致的空间效率和常数查询时间。它在以下场景中广泛应用:

  • URL 去重:爬虫系统快速判断链接是否已抓取
  • 缓存穿透防护:Redis 前置拦截不存在的 key
  • 数据库查询优化:先查布隆过滤器,避免无效的磁盘 I/O
  • 比特币轻节点:SPV 验证用布隆过滤器筛选相关交易

本文实现的 Java 版本支持自定义预期元素数量和假阳性率,自动计算最优参数,并采用 MurmurHash3 提供高质量的哈希分布。读者可以直接将其集成到项目中,或基于计数版本扩展删除能力。