导读 :「40亿个QQ号,限制 1G 内存,如何去重」这道题这两天又上了掘金后端热榜。标准答案是位图(bitmap),大部分文章讲到"每个号码占 1 位,2^32 位正好 512MB"就结束了。可真用 Java 写,第一反应
java.util.BitSet是装不下的:它的下标是 int,最多 2^31 位,QQ号大于 21 亿的直接抛异常。本文把这道题用 Java 从头跑了一遍:BitSet 的上限在哪、怎么用 long 数组自己写一个 512MB 的位图、40亿次写入实际要多久、-Xmx设多少能跑起来,以及为什么布隆过滤器和 HashSet 在这道题里都不划算。
版本说明:JDK 17.0.12、JDK 8u321,macOS(Apple Silicon,16GB 内存)。40亿个号码是用 xorshift 伪随机数在内存里生成的,不是真实数据;从文件读取的速度用 1000 万行的小文件测完再按比例估算,估算的地方会写明。
一、先说清楚:位图是什么,为什么能塞进 1G
先换个说法理解这道题:QQ号是一个不超过 42 亿的正整数(按无符号 32 位算,范围 0~4294967295)。去重要回答的问题只有一个:某个号码之前出现过没有。
最省的记法是准备一排开关,一共 2^32 个,第 N 个开关代表号码 N。读到一个号码,就看对应的开关:关着就打开、记为新号码;已经开着就是重复。每个开关只要 1 个比特,所以:
text
2^32 位 = 4294967296 位 = 536870912 字节 = 512 MB
这排开关就是位图。它的大小只取决于号码的范围,和号码有多少个无关:40亿个也好、400亿个也好,都是 512MB。
不这么做会怎样?最直接的写法是 HashSet<Long>,每个号码要存一个 Long 对象加一个哈希表节点。下面第五节实测,每个元素约 63 字节,40亿个就是 200 多 GB,单机内存根本放不下。
什么时候该用位图:数据是整数、范围已知且不太大(几十亿以内)、只关心有没有或出现过几次。QQ号、手机号后几位、用户 ID、订单号段都符合。如果数据是字符串(比如 URL),或者整数范围大到 2^64,位图就不适用了,后面会讲换什么。
二、第一个坑:java.util.BitSet 装不下 32 位的号码
Java 自带位图实现 java.util.BitSet,很多文章的示例代码直接 new BitSet() 然后 set(qq)。问题在它的方法签名:
java
// JDK 自带 BitSet 的下标全是 int
public void set(int bitIndex)
public boolean get(int bitIndex)
int 的最大值是 2147483647(约 21 亿),所以 BitSet 最多管 2^31 个位,只有 QQ号范围的一半。大于 21 亿的号码转成 int 会变成负数,set 直接抛异常。我写了一个最小的复现:
java
import java.util.BitSet;
// 复现 BitSet 的下标上限:JDK 8 和 17 结果一样
public class BitSetLimit {
public static void main(String[] a) {
BitSet bs = new BitSet();
bs.set(Integer.MAX_VALUE - 1);
System.out.println("set(MAX-1) ok, size bits=" + bs.size());
long qq = 3_456_789_012L;
try {
bs.set((int) qq);
} catch (Exception e) {
System.out.println("set((int)3456789012) -> " + e);
}
System.out.println("(int)3456789012 = " + (int) qq);
}
}
JDK 17.0.12 和 JDK 8u321 输出完全一样:
text
set(MAX-1) ok, size bits=-2147483648
set((int)3456789012) -> java.lang.IndexOutOfBoundsException: bitIndex < 0: -838178284
(int)3456789012 = -838178284
这里有两个坑:
| 现象 | 原因 |
|---|---|
set((int) qq) 抛 IndexOutOfBoundsException: bitIndex < 0 |
3456789012 超过 int 上限,强转后变成负数 -838178284 |
size() 返回 -2147483648 |
BitSet 用到最高位时,内部 long 数组长度是 2^25,乘以 64 正好溢出成负数 |
⚠️ 第二个坑更隐蔽:
size()返回负数不会报错,如果你拿它去算内存或者做循环上限,结果是错的。要知道实际设了多少位,用cardinality();要知道占了多少内存,按下标上限 / 8自己算。
如果号码确定都在 21 亿以内(比如老号段),BitSet 能用;题目说的是 40亿个 QQ号,范围按 32 位算,BitSet 就不够了。
三、用 long 数组自己写一个 2^32 位的位图
BitSet 内部本来就是 long[],每个 long 存 64 位。我们照着这个思路自己写,只是下标改用 long:
- 第 v 个位放在数组的第
v >>> 6个元素里(除以 64) - 在这个 long 里是第
v & 63位(对 64 取余) - 数组长度 2^32 / 64 = 2^26 = 67108864,远小于 Java 数组长度上限,一个数组就够
java
// 2^32 位的位图:一个 long[],2^26 个元素,512 MB
public class LongBitmap {
static final long[] BITS = new long[1 << 26];
static void set(long v) {
BITS[(int) (v >>> 6)] |= 1L << (v & 63);
}
static boolean get(long v) {
return (BITS[(int) (v >>> 6)] & (1L << (v & 63))) != 0;
}
public static void main(String[] a) {
long n = Long.parseLong(a[0]);
long x = 88172645463325252L, dup = 0;
long t0 = System.nanoTime();
for (long i = 0; i < n; i++) {
// xorshift64 生成伪随机数,取低 32 位当 QQ号:0 ~ 4294967295
x ^= x << 13; x ^= x >>> 7; x ^= x << 17;
long qq = x & 0xFFFFFFFFL;
if (get(qq)) dup++; else set(qq);
}
long t1 = System.nanoTime();
// 统计去重后的个数:每个 long 里 1 的个数加起来
long distinct = 0;
for (long w : BITS) distinct += Long.bitCount(w);
Runtime r = Runtime.getRuntime();
System.out.printf("n=%d distinct=%d dup=%d time=%.1fs usedHeap=%dMB%n",
n, distinct, dup, (t1 - t0) / 1e9, (r.totalMemory() - r.freeMemory()) >> 20);
}
}
几个容易写错的细节:
| 写法 | 问题 |
|---|---|
1 << (v & 63) |
少写了 L,int 左移只看低 5 位,第 32~63 位全写错,不报错但结果不对 |
v >> 6 |
号码用 long 存时 >> 和 >>> 结果一样;如果号码用 int 存(大于 21 亿是负数),必须先 Integer.toUnsignedLong 再算 |
new long[1 << 26] 写成 new long[(1 << 32) / 64] |
1 << 32 在 int 上等于 1,数组长度变成 0 |
💡 输出去重结果时,直接从 0 遍历到 2^32-1,位是 1 的下标就是一个不重复的号码,而且天然是排好序的。这是位图顺手送的一个好处:去重和排序一次完成。
四、实测:40亿次写入 46.5 秒,-Xmx 至少要多少
1. -Xmx 设多少能跑起来
位图本身 512MB,但 JVM 堆里还有别的对象,所以 -Xmx512m 是不够的。我用 JDK 17 分别试了三个值,每次只写 1000 个号码,看能不能把数组分配出来:
bash
# JDK 17.0.12,默认 G1
java -Xmx512m -cp . LongBitmap 1000
java -Xmx520m -cp . LongBitmap 1000
java -Xmx600m -cp . LongBitmap 1000
结果:
text
== Xmx512m
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at LongBitmap.<clinit>(LongBitmap.java:3)
== Xmx520m
n=1000 distinct=1000 dup=0 time=0.0s usedHeap=514MB
== Xmx600m
n=1000 distinct=1000 dup=0 time=0.0s usedHeap=514MB
-Xmx512m 直接 OOM,-Xmx520m 就能跑。实际项目里我建议给到 -Xmx700m 左右,留出读文件的缓冲区和其他对象的空间,整个进程控制在 1G 以内。
2. 40亿次写入要多久
接着在 -Xmx1g 下把 4 亿和 40 亿各跑一次,用 /usr/bin/time -l 看进程实际占用的物理内存:
bash
# 4 亿次
/usr/bin/time -l java -Xmx1g -cp . LongBitmap 400000000
# 40 亿次
/usr/bin/time -l java -Xmx1g -cp . LongBitmap 4000000000
| 写入次数 | 耗时 | 去重后个数 | 重复次数 | 进程常驻内存(RSS) |
|---|---|---|---|---|
| 4 亿 | 3.6 秒 | 381941989 | 18058011 | 557 MB |
| 40 亿 | 46.5 秒 | 2602635443 | 1397364557 | 557 MB |
40 亿那次的原始输出:
text
n=4000000000 distinct=2602635443 dup=1397364557 time=46.5s usedHeap=514MB
46.63 real 45.58 user 0.55 sys
584007680 maximum resident set size
两个值得注意的点:
- 内存和数据量无关:4 亿和 40 亿都是 557MB,这就是位图的特点
- 去重后只剩 26 亿:这是因为随机数在 42.9 亿的范围里抽 40 亿次,必然撞号。按概率算,期望的不重复个数是 2^32 × (1 - e^(-40亿/2^32)) ≈ 2602611760,实测 2602635443,误差不到万分之一,说明位图的计数是对的。真实的 QQ号文件如果本来就几乎不重复,去重后会接近 40 亿
3. 真实场景的瓶颈在读文件,不在位图
上面 46.5 秒是纯内存操作。题目里的 40亿个号码一般是放在文件里的,每行一个十位以内的数字,文件本身有 40 GB 左右。我生成了一个 1000 万行的文件(102MB),用 BufferedReader 逐行读、Long.parseLong 解析、写进位图:
java
import java.io.*;
// 从文本文件逐行读号码写入位图,测读文件 + 解析的速度
public class ReadFile {
static final long[] BITS = new long[1 << 26];
public static void main(String[] a) throws IOException {
long t0 = System.nanoTime(), lines = 0, dup = 0;
try (BufferedReader br = new BufferedReader(new FileReader(a[0]), 1 << 20)) {
String s;
while ((s = br.readLine()) != null) {
long v = Long.parseLong(s);
int w = (int) (v >>> 6);
long m = 1L << (v & 63);
if ((BITS[w] & m) != 0) dup++; else BITS[w] |= m;
lines++;
}
}
System.out.printf("lines=%d dup=%d time=%.1fs%n", lines, dup, (System.nanoTime() - t0) / 1e9);
}
}
两次都是 1.1 秒:
text
lines=10000000 dup=11503 time=1.1s
lines=10000000 dup=11503 time=1.1s
按这个速度线性估算,40 亿行要 440 秒左右,也就是 7 分多钟,是纯位图写入的 9 倍多。这个数是估算,没有真的生成 40GB 的文件去跑(本机磁盘放不下),实际还会受磁盘速度影响。要再快,方向是减少字符串对象:按字节读、自己解析数字,或者把文件切几段多线程读、写位图时用 AtomicLongArray。这些优化本文没实测。
4. 两个 BitSet 拼起来行不行
如果不想自己写位操作,也可以用两个 BitSet:号码的第 32 位决定用哪个,低 31 位做下标。
java
import java.util.BitSet;
// 用两个 BitSet 拼出 2^32 位:各 2^31 位、256 MB
public class TwoBitSet {
static final BitSet[] SETS = { new BitSet(Integer.MAX_VALUE), new BitSet(Integer.MAX_VALUE) };
// 返回 true 表示第一次出现
static boolean addIfAbsent(long qq) {
BitSet bs = SETS[(int) (qq >>> 31)];
int idx = (int) (qq & Integer.MAX_VALUE);
if (bs.get(idx)) return false;
bs.set(idx);
return true;
}
public static void main(String[] a) {
long n = Long.parseLong(a[0]);
long x = 88172645463325252L, dup = 0;
long t0 = System.nanoTime();
for (long i = 0; i < n; i++) {
x ^= x << 13; x ^= x >>> 7; x ^= x << 17;
if (!addIfAbsent(x & 0xFFFFFFFFL)) dup++;
}
long t1 = System.nanoTime();
long distinct = (long) SETS[0].cardinality() + SETS[1].cardinality();
System.out.printf("n=%d distinct=%d dup=%d time=%.1fs%n", n, distinct, dup, (t1 - t0) / 1e9);
}
}
用同一组随机数跑 4 亿次,结果和 long 数组版完全一致,但慢了将近一倍:
text
n=400000000 distinct=381941989 dup=18058011 time=6.7s
| 实现 | 4 亿次耗时 | 去重后个数 |
|---|---|---|
| long\[\] 自己写 | 3.6 秒 | 381941989 |
| 两个 BitSet | 6.7 秒 | 381941989 |
BitSet 的 get 和 set 每次都要做边界检查,set 还要判断是否需要扩容。面试时写两个 BitSet 更容易讲清楚,生产代码我更倾向 long 数组,逻辑也就 3 行。
五、为什么不用 HashSet 和布隆过滤器
HashSet:每个元素 63 字节
我用 JDK 17 往 HashSet<Long> 里放了 1000 万个不重复的 long,前后各做一次 GC,看堆增长了多少:
java
import java.util.HashSet;
// 测 HashSet<Long> 每个元素平均占多少字节
public class HashSetCost {
public static void main(String[] a) {
int n = Integer.parseInt(a[0]);
Runtime r = Runtime.getRuntime();
System.gc();
long before = r.totalMemory() - r.freeMemory();
HashSet<Long> s = new HashSet<>();
for (int i = 0; i < n; i++) s.add(3_000_000_000L + i * 7L);
System.gc();
long after = r.totalMemory() - r.freeMemory();
System.out.printf("n=%d size=%d bytes/entry=%.1f total=%dMB%n",
n, s.size(), (after - before) / (double) n, (after - before) >> 20);
}
}
JDK 17、-Xmx4g 下的输出:
text
n=10000000 size=10000000 bytes/entry=63.0 total=600MB
1000 万个就要 600MB,换算到 40 亿个是 250GB 左右。哪怕换成 long 原始类型的哈希集合(比如 fastutil 的 LongOpenHashSet,每个元素至少 8 字节),40 亿个也要 30GB 以上。这类结构的内存和元素个数成正比,而位图的内存只和范围有关,号码越多越划算。
布隆过滤器:在这道题里反而比位图大
布隆过滤器常被拿来回答"海量数据判断是否存在",但它是给范围很大或者不是整数的数据准备的(比如 URL、字符串 ID),而且会有误判:它说"不存在"一定不存在,说"存在"可能是假的。
按布隆过滤器的标准公式算一下 40亿个元素需要多少位(m = -n·ln p / (ln 2)²):
| 允许的误判率 | 需要的内存 | 哈希函数个数 |
|---|---|---|
| 1% | 4.46 GiB | 约 7 个 |
| 0.1% | 6.7 GiB | 约 10 个 |
允许 1% 误判都要 4.46GB,比 512MB 的位图大了将近 9 倍,还要算 7 次哈希,结果还不精确。QQ号这种范围不超过 2^32 的整数,位图在内存、速度、准确性上都更好。
💡 一句话判断:整数且范围小(2^32 以内)用位图;范围大或者是字符串,且能接受少量误判,用布隆过滤器;都不行就分文件。
六、内存比 512MB 还少怎么办:按号码分段
如果题目改成"限制 256MB"甚至更少,一个完整的位图放不下,常见做法是按号码的高位分段,分批处理:
- 第一遍扫描文件,按号码的最高 1 位(或 2 位)把号码写到 2 个(或 4 个)临时文件
- 每个临时文件里的号码范围只有原来的 1/2(或 1/4),用 256MB(或 128MB)的位图处理
- 每段处理完再处理下一段,最后把结果拼起来
代价是多读写一遍磁盘。按第四节估的读文件速度,每多扫一遍大约多 7 分钟。这个方案本文没有实测,只是说明思路;分几段取决于你的内存上限,段数 = 512MB / 可用内存,向上取整。
还有一种不落盘的做法:同一个文件扫 N 遍,第 k 遍只处理高位等于 k 的号码。内存一样省,但要多读 N-1 遍文件,适合不能写临时文件的场景。
七、这道题的常见变形
面试官经常在位图答完后追问几个变形,思路都是在位图上改:
| 变形 | 做法 | 内存 |
|---|---|---|
| 找出所有重复过的号码 | 两个位图:第一个记"出现过",第二个记"出现过两次以上" | 1 GB |
| 找出只出现一次的号码 | 同上,第一个位图是 1、第二个是 0 的就是只出现一次 | 1 GB |
| 判断某个号码在不在 | 位图建好后 get(qq),一次数组访问 |
512 MB |
| 输出排好序的去重结果 | 从 0 遍历到 2^32-1,输出是 1 的下标 | 512 MB |
| 统计不重复的个数 | 每个 long 做 Long.bitCount 加起来 |
512 MB |
⚠️ 前两个变形用两个位图正好 1GB,加上 JVM 本身的开销会超过 1G 的限制。这时要么按第六节分两段处理,要么说明 JVM 进程本身需要额外内存、跟面试官确认"1G"指的是堆还是整个进程。
八、速查表
| 问题 | 结论 | 依据 |
|---|---|---|
| 2^32 位位图多大 | 512 MB | 2^32 / 8 字节 |
java.util.BitSet 最多多少位 |
2^31(下标是 int) | JDK 8、17 实测超过就抛 bitIndex < 0 |
BitSet 用满时 size() |
返回 -2147483648 | 实测,int 溢出 |
| long 数组位图长度 | new long[1 << 26] |
2^32 / 64 |
-Xmx 最少 |
512m 会 OOM,520m 能跑 | JDK 17 G1 实测 |
| 40 亿次写入耗时 | 46.5 秒 | 本机 JDK 17 实测,纯内存 |
| 从文件读 40 亿行 | 约 440 秒 | 1000 万行 1.1 秒线性估算 |
| HashSet<Long> 每元素 | 约 63 字节 | JDK 17 实测 1000 万个 |
| 布隆过滤器 1% 误判 | 4.46 GiB | 公式计算 |
九、常见问题
Q:QQ号现在有 11 位的吗?32 位不够怎么办?
这道题的前提是号码按 32 位无符号整数算(不超过 4294967295)。如果实际数据会超过这个范围,位图大小要按实际最大值重新算:范围扩大一倍,内存就翻一倍。范围大到几百亿以上,位图就不划算了,改用分段或者布隆过滤器。
Q:Redis 的 SETBIT 能不能做?
能,Redis 的字符串最大 512MB,正好是 2^32 位,SETBIT key offset 1 的 offset 上限就是 2^32-1。但 40 亿次网络往返太慢,就算用 pipeline 也比本地内存慢得多。适合的场景是位图需要多个服务共享、数据是逐步写入的(比如用户签到),不适合一次性批量去重。这条本文没有实测。
Q:RoaringBitmap 呢?
RoaringBitmap 是压缩位图,数据稀疏时(比如 40 亿范围里只有几千万个号码)比普通位图省很多内存。这道题里号码有 40 亿个,几乎铺满了整个范围,压缩没有优势。本文没有实测 RoaringBitmap。
Q:多线程写位图要注意什么?
BITS[w] |= m 不是原子操作,两个线程同时改同一个 long 里的不同位,会丢掉其中一个。多线程写要用 AtomicLongArray 配合 CAS,或者按号码范围把线程分开,保证不同线程永远不写同一个 long。
参考链接
- java.util.BitSet API 文档(JDK 17)
- java.util.concurrent.atomic.AtomicLongArray API 文档(JDK 17)
- Redis SETBIT 命令文档
- 掘金热榜原题:40亿个QQ号,限制1G内存,如何去重?
你面试时碰到的是哪个变形:去重、找重复、还是"内存只给 256MB"?当时答的是位图还是布隆过滤器?评论区说说,我挑典型的补进第七节的表里。
觉得有用的话点个赞、收个藏,下次面试前翻得到。