搜索框的联想词接口,P99 从 9 ms 涨到 47 ms,堆内存从 2 GB 顶到 5 GB,Full GC 从一天两次变成一小时一次。原因是我们把 200 万条 query 塞进了一个 HashMap<Character, Node> 的 Trie,还是照着某篇博客抄的默认写法。
这篇文章说清三件事:那 2.2 GB 到底花在哪个字段上;为什么照着「用数组替代 HashMap 更快」改完内存反而更大;以及相当多的前缀匹配需求,本来就不该用 Trie。
200 万条 query,节点数是算不出来的
数据规模:200 万条搜索词,平均长度 12 个字符,去重后总字符约 2400 万。中文为主,前缀共享度高(「北京」「北京时间」「北京天气预报」共享 2 个节点),实际节点数约 1500 万。
注意这个数字:Trie 的节点数取决于前缀共享程度,跟词条数不是线性关系。后面所有选型都建立在「节点数 ≈ 字符数 × 共享系数」这个估算上。中文 query 的共享系数一般在 0.5~0.7 之间,英文单词在 0.4 左右。先把你的数据算一遍,再决定要不要往下看。
当时的代码,一字未改:
public final class NaiveTrie {
private static final class Node {
Map<Character, Node> children; // 绝大多数节点只有 1 个孩子
boolean terminal;
}
private final Node root = new Node();
public void insert(String key) {
Node cur = root;
for (int i = 0; i < key.length(); i++) {
char c = key.charAt(i);
if (cur.children == null) {
cur.children = new HashMap<>(); // 坑在这
}
cur = cur.children.computeIfAbsent(c, k -> new Node());
}
cur.terminal = true;
}
public boolean startsWith(String prefix) {
Node cur = root;
for (int i = 0; i < prefix.length(); i++) {
if (cur.children == null) return false;
cur = cur.children.get(prefix.charAt(i));
if (cur == null) return false;
}
return true;
}
}
逻辑挑不出毛病,问题全在内存。用 JOL 量:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
// JDK 17,默认 -XX:+UseCompressedOops(堆小于 32 GB 时开启)
NaiveTrie trie = new NaiveTrie();
for (String kw : keywords) {
trie.insert(kw);
}
long bytes = GraphLayout.parseInstance(trie).totalSize();
System.out.println(bytes / 1024 / 1024 + " MB");
// 实测输出:2288 MB
拆开看这 2.2 GB 的构成(1500 万节点里有孩子的约 1300 万个):
| 组成 | 单节点字节 | 数量 | 合计 |
|---|---|---|---|
Node 对象(对象头 12 + children 引用 4 + terminal 1 + 对齐 7) |
24 | 1500 万 | 343 MB |
HashMap 对象本身(对象头 12 + table/size/modCount/threshold 各 4 + loadFactor 4 + 对齐 4) |
40 | 1300 万 | 496 MB |
HashMap 的 table 数组(未指定容量时 16 槽:数组头 16 + 16 × 4) |
80 | 1300 万 | 992 MB |
HashMap.Node 链表节点(对象头 12 + hash 4 + key 引用 4 + value 引用 4 + next 引用 4 + 对齐 4) |
32 | 1500 万 | 457 MB |
合计 2288 MB,和 GraphLayout 的输出严格对得上。
一句话:Node 对象本身只占 15%,剩下的 85% 全是 HashMap 的固定开销。
坑一:new HashMap<>() 的默认容量
上面 992 MB 纯粹是 table 数组。不指定初始容量时 HashMap 默认 16 槽,而 1500 万个节点里 96% 只有 1 个子节点,15 个槽永远空着。
改一行:
cur.children = new HashMap<>(2, 0.75f);
tableSizeFor(2) 得到 2,第一次 put 才真正分配 new Node[2],24 字节。整体降到 1.6 GB,白省 0.6 GB。
new HashMap<>(1) 也能把 table 压到 1 槽,但负载因子 0.75 意味着放第二个元素就扩容到 2,多一次 resize 和数组拷贝。new HashMap<>(2) 是这棵树的最优解。
别小看这一行。JDK 的 HashMap 是懒分配 table 的,构造时只记 threshold,所以空节点上的 HashMap 对象是 40 字节,不是 40 + 80。
坑二:用 Map<String, Node> 存单个字符
另一版代码用的是 Map<String, Node>,理由是「以后要支持多字符 key」。结果:
Map<String, Node> children = new HashMap<>(2);
children.put(String.valueOf(c), node); // 每个字符一个新 String
Character 装箱有 -128..127 缓存,Character.valueOf('a') 返回的是同一个对象,不分配。String.valueOf('a') 每次都是新对象:String 对象 24 字节,内部还有一个至少 24 字节的 byte[]。1500 万节点 × 48 字节 ≈ 720 MB,纯浪费。
改回 Map<Character, Node> 就好。如果真需要多字符 key,那也不是 Trie 该管的事。
换成 26 叉数组,内存反而涨了
「用 Node[26] 替代 HashMap,查询 O(1) 还省内存」——这个说法流传很广。我照着改了一版:
private static final class Node {
Node[] children; // 懒初始化
boolean terminal;
}
// 插入时,假设只处理小写字母
if (cur.children == null) {
cur.children = new Node[26];
}
cur = cur.children[c - 'a'];
查询确实快了一点,但用 GraphLayout 量出来是 1.9 GB,比优化过的 HashMap 版(1.6 GB)还差。
原因:数组是按节点懒分配的,但分配了的节点里 96% 只有 1~2 个孩子。约 1300 万个数组,每个 16(数组头)+ 26 × 4 = 120 字节,光数组就 1.56 GB。再加上 Node 对象本身 343 MB。
更麻烦的是 GC:这 1300 万个 120 字节的小数组散在堆里,每轮 Young GC 都要扫一遍。
还有一层:中文根本没法用 26 个槽。要用 char 直接索引就是 65536 槽(256 KB 一个节点),不可能。退回去做字符重映射,代码复杂度立刻上一个台阶。
26 叉数组只在分支密集时划算:DNA 序列(4 叉)、IP 路由表(2 叉)。IPv4 路由表用二进制 Trie 是正解,最多 32 层,每个节点 2 个引用 + 对象头,压到 24 字节以内,比任何 Map 都强。
Radix Tree:把单链压掉,节点数掉一个数量级
Trie 的问题在于「一个字符一个节点」。中文 query 里有大量无分叉的长链(比如「北京天气预报」这一整串在词库里只有这一条),这些链路完全可以压成一条边。
压缩后的结构叫 Radix Tree(也叫压缩前缀树、Patricia Trie)。核心改动:每个节点存一段字符串 edge 而不是一个字符。
完整可运行实现:
public final class RadixTree {
private static final Node[] EMPTY = new Node[0];
private static final class Node {
String edge; // 从父节点到本节点的这段字符串
Node[] children = EMPTY;
boolean terminal;
Node(String edge) { this.edge = edge; }
Node childOf(char c) {
for (Node ch : children) {
if (ch.edge.charAt(0) == c) return ch;
}
return null;
}
void addChild(Node n) {
Node[] arr = new Node[children.length + 1];
System.arraycopy(children, 0, arr, 0, children.length);
arr[children.length] = n;
children = arr;
}
void replaceChild(Node old, Node now) {
for (int k = 0; k < children.length; k++) {
if (children[k] == old) { children[k] = now; return; }
}
}
}
private final Node root = new Node("");
public void insert(String key) {
if (key.isEmpty()) { root.terminal = true; return; }
Node cur = root;
int i = 0;
while (true) {
Node next = cur.childOf(key.charAt(i));
if (next == null) {
// 没有分支,剩下的整段挂成一个叶子
Node leaf = new Node(key.substring(i));
leaf.terminal = true;
cur.addChild(leaf);
return;
}
String e = next.edge;
int j = 0;
while (j < e.length() && i + j < key.length() && e.charAt(j) == key.charAt(i + j)) {
j++;
}
if (j == e.length()) {
// 这条边被完整吃掉,继续往下
i += j;
cur = next;
if (i == key.length()) { cur.terminal = true; return; }
continue;
}
// 只匹配了一部分,分裂 next
Node split = new Node(e.substring(0, j));
next.edge = e.substring(j);
split.addChild(next);
cur.replaceChild(next, split);
i += j;
if (i == key.length()) {
split.terminal = true;
} else {
Node leaf = new Node(key.substring(i));
leaf.terminal = true;
split.addChild(leaf);
}
return;
}
}
public boolean startsWith(String prefix) {
if (prefix.isEmpty()) return root.terminal || root.children.length > 0;
Node cur = root;
int i = 0;
while (i < prefix.length()) {
Node next = cur.childOf(prefix.charAt(i));
if (next == null) return false;
String e = next.edge;
int j = 0;
while (j < e.length() && i + j < prefix.length() && e.charAt(j) == prefix.charAt(i + j)) {
j++;
}
i += j;
if (j < e.length()) {
// prefix 在边中间就用完了,是前缀当且仅当刚好用完
return i == prefix.length();
}
cur = next;
}
return true;
}
public static void main(String[] args) {
RadixTree t = new RadixTree();
t.insert("北京");
t.insert("北京时间");
t.insert("北京天气预报");
t.insert("上海");
System.out.println(t.startsWith("北")); // true
System.out.println(t.startsWith("北京")); // true
System.out.println(t.startsWith("北京时间")); // true
System.out.println(t.startsWith("北京时区")); // false
System.out.println(t.startsWith("上海")); // true
System.out.println(t.startsWith("")); // true
}
}
节点数从 1500 万降到约 300 万(200 万叶子 + 100 万分支)。每个节点的开销:
Node对象:对象头 12 + edge 引用 4 + children 引用 4 + terminal 1 + 对齐 3 = 24 字节edge是String:对象头 12 + hash 4 + hashIsZero 1 + coder 1 + byte[] 引用 4 + 对齐 2 = 24 字节- 内部
byte[]平均 6 字节:16 + 6 + 对齐 2 = 24 字节 - 合计每节点约 72 字节
300 万 × 72 = 216 MB,加上分叉节点的 children 数组约 24 MB,总共 240 MB。比 HashMap Trie 省了 9 倍。
代价在哪
substring 会拷贝。 JDK 7 之后 String.substring 不再共享底层数组,e.substring(j) 是一次真实的数组拷贝。插入 200 万条词时这部分的 GC 压力不小,实测构建耗时 3.2 秒——但已经比 HashMap 版的 8 秒快得多,因为省掉了 1500 万个 HashMap 的构造。
查询不一定更快。 一次跳一段看着爽,但每个节点仍要做 String.charAt,而且多一层 String → byte[] 的解引用。实测随机 100 万次前缀查询:HashMap 版 0.42 μs/op,Radix 0.38 μs/op。差距很小。
Radix 的真正收益是内存和构建速度,不是查询速度。如果你的瓶颈是查询延迟,Radix 救不了你。
分裂逻辑是 bug 高发区。 特别是 replaceChild 和 addChild 的顺序,写反了会丢子树。上面代码是先 split.addChild(next) 再 cur.replaceChild(next, split),顺序反了 next 就会从树上消失,而且查询时表现为「某些词突然查不到」,极难定位。
双数组 Trie:150 MB,代价是「只能读、重建慢」
如果内存还要再压一个数量级,就得用双数组 Trie(Double-Array Trie,DAT)。Aho-Corasick 的很多实现内部就是它。
原理不复杂:两个 int 数组 base[] 和 check[],判断状态 s 经过字符 c 的转移是否合法,就看 check[base[s] + c] == s。每个状态 8 字节,1500 万状态 = 120 MB,算上空槽浪费实际约 150 MB。
构建分两步:
- 先建一棵普通 Trie(节点用 int 编号,别用对象)
- BFS 遍历,给每个节点挑一个
base值,使得它所有子字符对应的位置base + c都还没被占用
找 base 的核心循环:
// free 是一条双向链表,串起所有还没被占用的下标
int findBase(int[] childChars) {
int b = freeNext;
while (true) {
boolean ok = true;
for (int c : childChars) {
if (check[b + c] != 0) { ok = false; break; }
}
if (ok) return b;
b = nextFree[b]; // 顺着空闲链表往后跳
}
}
这里有个中文场景的大坑:c 直接用 char 的值当偏移,那 check[base + '中'] 里的 '中' 是 20013,意味着每个有「中」字分支的节点,base 至少要 20013 起步,数组立刻膨胀到几十万槽,而实际只用几个。我见过一份实现因为这个数组开到 8000 万槽,吃了 640 MB。
正确做法是做字符重映射:统计全量数据里每个字符的出现频率,按频率重新编号成 1、2、3……高频字符编号小,数组才紧凑。darts-clone 就是这么干的。
代价清单:
- 构建 25 秒(要先建普通 Trie 再压缩),期间内存峰值是最终大小的 3 倍左右
- 增加/删除一个词几乎等于重建整棵树
- 实现复杂,
check数组的边界和 0 号状态的约定极易写错
如果你只是要做前缀查询、数据每天全量更新一次、QPS 高到 CPU 打满,DAT 是对的选择。否则别碰。
有些情况下,排序数组 + 二分就够了
这句话我希望能早点有人告诉我:如果你的需求只是「判断某个前缀是否存在」,把词排好序二分查就够了,不需要任何树。
import java.util.Arrays;
import java.util.Collection;
public final class SortedPrefixSet {
private final String[] sorted;
public SortedPrefixSet(Collection<String> keys) {
this.sorted = keys.toArray(new String[0]);
Arrays.sort(sorted);
}
public boolean startsWith(String prefix) {
int lo = lowerBound(prefix);
// 排序后,所有以 prefix 开头的串一定连续排在 lowerBound(prefix) 之后
return lo < sorted.length && sorted[lo].startsWith(prefix);
}
private int lowerBound(String key) {
int lo = 0, hi = sorted.length;
while (lo < hi) {
int mid = (lo + hi) >>> 1;
if (sorted[mid].compareTo(key) < 0) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
}
不直接用 Arrays.binarySearch 的原因:它返回「命中下标」或「-(插入点+1)」,要额外处理负数和 0 的边界,肉眼容易看错。自己写 6 行 lowerBound,逻辑直白。
内存:200 万个 String 对象约 112 MB(紧凑字符串下每个 56 字节),加上数组引用 8 MB,一共 120 MB。比 HashMap Trie 省 18 倍。
实测查询 1.1 μs/op,比 Radix 慢 3 倍。21 次比较,每次都要走 String.compareTo,中文还会走 UTF-16 分支,比 Latin1 慢一截。
它换来的是:
- 构建 1.2 秒,比 Radix 快
- 没有任何指针追逐,CPU cache 友好
- 天然支持范围查询(比如「取出所有以 北京 开头的前 10 条」,直接定位起点往后扫,不用遍历子树)
- 可以整体序列化成一个文件,重启 mmap 进来,零构建
想让二分再快一点,就别存 String,直接把 key 按 UTF-8 编码成 byte[] 排序,比较退化成 Arrays.compareUnsigned,省掉 coder 判断那一层。中文字典序会从 Unicode 码点序变成 UTF-8 字节序,两者对 BMP 字符是一致的,可以放心换。
几个真的会咬人的边界
空字符串 key。 trie.insert("") 会把 root.terminal 置 true,之后任何查询都命中。词库里混进一条空行,整个过滤就形同虚设。插入前统一 if (key.isEmpty()) continue;。如果业务确实需要匹配空串,那也不该由 Trie 来表达。
大小写和全半角。 插入时归一化,查询时忘了归一化,就是漏匹配。中文场景还要考虑繁简。归一化逻辑必须封装在一个函数里,两边都调,不要复制粘贴。toLowerCase() 还要带 Locale.ROOT,否则土耳其语的 I 会转成 ı,这个 bug 我在两个项目里见过。
surrogate pair。 emoji 在 Java 里是两个 char。用 charAt 逐字符建树,插入和查询都一致的话结果是对的,但如果你在节点上按「字符数」做长度限制,就会算错。需要精确字符数用 key.codePointCount(0, key.length())。
前缀匹配 ≠ 子串匹配。 Trie 只能回答「以 P 开头」,不能回答「包含 P」。要子串匹配用 Aho-Corasick(多模式串一次扫描),要后缀用后缀自动机。我见过有人拿 Trie 做敏感词过滤,把每个词的所有后缀都插进去,词库直接膨胀 10 倍,还没上线就被内存干掉了。
递归深度。 下面这种写法很常见:
// 错误示范:递归插入
void insert(Node node, String key, int depth) {
if (depth == key.length()) { node.terminal = true; return; }
// ...
insert(child, key, depth + 1); // key 长度 10 万就 StackOverflow
}
默认栈 512 KB~1 MB,一帧几十字节,大概 1 万层就炸。词库里混进一篇长文本当 key 的时候,服务直接挂。所有 Trie 操作都写成循环,不要递归。
热更新的内存峰值。 双 buffer 切换的写法是对的:
private volatile RadixTree current;
void reload(List<String> keys) {
RadixTree fresh = new RadixTree(); // 这一刻内存是 2 倍
for (String k : keys) fresh.insert(k);
this.current = fresh; // 原子切换,老树等 GC
}
但 2.2 GB 的 HashMap Trie 你切不起,两次切换之间要是来一次 Full GC,接口直接超时。做容量规划时按 2.5 倍算,别卡着装。
选型对照
200 万条 query、平均 12 字符、1500 万节点的实测数据(JDK 17,M1,单线程,-Xmx8g):
| 方案 | 内存 | 单次前缀查询 | 构建耗时 | 动态增删 | 适用 |
|---|---|---|---|---|---|
| HashMap Trie(默认容量) | 2.2 GB | 0.42 μs | 8.1 s | 支持 | 别用 |
| HashMap Trie(容量 2) | 1.6 GB | 0.42 μs | 7.9 s | 支持 | 小数据量、频繁增删 |
| 26 叉数组 Trie | 1.9 GB | 0.31 μs | 6.4 s | 支持 | 字符集小、分支密集 |
| Radix Tree | 240 MB | 0.38 μs | 3.2 s | 支持(有分裂成本) | 通用首选 |
| 双数组 Trie | 150 MB | 0.09 μs | 25 s | 基本不支持 | 只读、超高 QPS |
| 排序数组 + 二分 | 120 MB | 1.1 μs | 1.2 s | 不支持(要重排) | 中小规模、要范围查询 |
这些数字换台机器会变,但相对关系基本稳定。
我们最后选了什么
Radix Tree,配双 buffer 热切换。词库每天凌晨全量重建一次,3 秒建完,切过去之后老树等 GC。
顺带做了一件比换数据结构收益更大的事:把词库从 200 万条裁到 60 万条,砍掉 PV 低于阈值的长尾。内存从 240 MB 降到 80 MB,查询 P99 从 47 ms 回到 6 ms。
如果哪天 QPS 再翻三倍、CPU 打满,下一步是把 Radix 换成 DAT,代价是重建时间从 3 秒变 8 秒、内存峰值翻倍,还能接受。但那是被逼到墙角才做的事,不是现在。