摘要:在 JVM 上承载 LLM 推理时,KV Cache 的生命周期管理往往成为最先崩掉的一环。每个请求新建一块 tensor 的朴素做法,会同时触发两条失效路径:堆内对象高频分配把 G1/ZGC 拖入持续 mixed GC 甚至 Full GC,而堆外 direct buffer 或 JNI 侧 native 内存则因 Cleaner 回收时机不可控而泄漏,最终以 OutOfMemoryError: Direct buffer memory 或进程 RSS 触顶收场。本文以"池化即契约"为分析主线,把 KV Cache 池化拆成容量规划、acquire/release 状态机、跨代与跨请求复用、故障兜底四个可验证的子问题,给出预分配块数的量化模型、ConcurrentLinkedQueue 在并发争用下的边界条件清单,以及一套可观测、可回滚的落地步骤。文中数据均标注来源,模拟数据单独注明。
00 / CONTENTS目录
01 / PROLOGUE导语:一次 OOM 现场的复盘
先把场景摆出来。一套基于 Java 的推理服务,模型侧通过 JNI 或 Panama(Project Panama,JDK 22 起 Foreign Function & Memory API 正式转正,见 JEP 454)调用底层 kernel,KV Cache 由 Java 层管理。上线初期 QPS 不高,一切正常;压测到并发 64、序列长度 2048 时,服务在 40 分钟内出现两类故障交替:
- GC 日志显示 G1 的 mixed GC 频率从每 30 秒一次升到每 2 秒一次,单次 STW 从 8ms 涨到 60ms 以上,P99 延迟直接破千毫秒;
- 随后进程 RSS 持续爬升,
jcmd VM.native_memory显示 direct buffer 占用只增不减,最终抛OutOfMemoryError: Direct buffer memory。
两条故障看似独立,实则同源:每个请求新建一块 KV tensor,等于把"对象生命周期"交给了 GC,把"native 内存生命周期"交给了不可控的 Cleaner 机制。前者制造分配压力,后者制造泄漏窗口。本文要论证的核心命题是:KV Cache 池化的本质不是"复用内存",而是在 Java 堆与 native 堆之间建立一份显式的所有权契约,池容量、acquire/release 语义、故障兜底都是这份契约的条款。契约写不清楚,池化只会把 OOM 从"频繁"变成"偶发但更难查"。
本文评述:业界讨论 KV Cache 时,多数材料聚焦在 PagedAttention(vLLM,Kwon 等,2023)这类 GPU 侧的块管理算法上,对"Java 侧如何安全地持有这些块"着墨很少。而恰恰是 JVM 的内存语义,决定了同一套分页算法搬到 Java 上会不会水土不服。本文试图补上这块拼图。
02 / PROBLEM背景与问题定义:KV Cache 在 JVM 上的三重错配
2.1 什么是 KV Cache
自回归 Transformer 推理时,第 t 步的注意力需要前 t−1 步所有 token 的 Key 与 Value 向量。若不缓存,每步都要重算全部历史,复杂度为 O(n²) 次前向。KV Cache 的做法是把每层、每个注意力头的 K/V 张量持久化,使每步只需计算当前 token 的 K/V 并追加,把重复计算降为 O(n)。其内存占用可用下式估算(业界通用口径,见 vLLM 文档与 Hugging Face 相关博客):
KV_bytes ≈ 2 × L × H × d_head × S × B × bytes_per_elem 其中 L=层数, H=KV头数, d_head=每头维度, S=序列长度, B=批大小 以 Llama-2-7B 为例: L=32, H=32, d_head=128, fp16(2B) 单 token 单序列 ≈ 2×32×32×128×2 = 524,288 B ≈ 0.5 MiB 序列长 2048 时单请求 ≈ 1 GiB
这个量级意味着:KV Cache 不是"小对象",它是与模型权重同量级的大块内存。把它当成普通 Java 对象来 new,从第一天就错了。
2.2 三重错配
笔者认为,第三重错配最隐蔽也最致命。堆内对象泄漏还能靠 heap dump 抓现行;native handle 泄漏在 Java 层看"对象已经不可达",但 native 内存要等 ReferenceHandler 线程触发 Cleaner 才释放,而 Cleaner 的触发依赖 GC——GC 越不频繁,泄漏越久;泄漏越多,越不敢让 GC 频繁,形成正反馈死锁。这正是导语中"两类故障交替"的机理。
2.3 问题边界
需要明确本文讨论范围:Java 侧持有并调度 KV Cache 块,底层 kernel 可以是 JNI、Panama 或纯 Java 实现;不讨论 GPU 显存分配器(如 PyTorch caching allocator)的内部实现,但会讨论 Java 层如何与之对接。对于纯 CPU 推理(如 llama.cpp 的 Java 绑定),问题形态相同,只是"native 内存"换成"堆外 malloc"。
03 / MECHANISM原理层:KV Cache 的显存/内存语义与 JVM 内存模型的对撞
3.1 从 PagedAttention 到"块"的概念
vLLM 提出的 PagedAttention 借鉴操作系统虚拟内存分页:把 KV Cache 切成固定大小的 block(通常 16 个 token 一块),逻辑上连续的序列通过 block table 映射到物理上不连续的块。其论文(Kwon et al., SOSP 2023)报告在共享前缀场景下可将 KV 内存浪费从 60%–80% 降到 4% 以下。本文评述:这个数字的前提是"块可被多个序列共享",而共享的前提是引用计数与写时复制(CoW)——这两件事在 Java 里恰好有成熟原语(AtomicInteger、不可变视图),所以分页思想搬到 Java 是可行的,但引用计数的释放点必须由池来统一裁决,不能散落在业务代码里。
3.2 堆内 vs 堆外:两条泄漏路径
Java 侧持有 KV 块有三条技术路线,各自的失效模式不同:
关键结论:只有 Panama Arena 提供了"确定性释放"的语义(close 即释放,不依赖 GC),这与池化的需求天然契合。JDK 21 起 Foreign Memory API 已可预览,JDK 22 转正(JEP 454),JDK 23/24 持续完善。若受限于 JDK 17 LTS,则 DirectByteBuffer + 显式池是次优解,但必须自己兜住 Cleaner 的延迟。
3.3 GC 压力的定量画像
假设并发 64、平均序列 1024、每请求 KV 约 512 MiB(Llama-2-7B fp16 口径)。若每请求新建,则稳态分配速率约为 64 × 512 MiB / 平均请求时长。若平均请求 3 秒,则约 10.7 GiB/s 的分配速率——这个数字远超任何 JVM 的合理分配预算。G1 的 region 大小默认 4 MiB(堆 32 GiB 时),意味着每秒要处理约 2700 个 region 的分配与回收,mixed GC 必然高频。
需要说明:上述为模拟数据,基于公开的模型参数量与通用公式推算,用于说明量级,不代表任何厂商实测。真实数值请以自身压测为准。
本文评述:很多团队的第一反应是"调大堆、换 ZGC"。ZGC 确实能把 STW 压到亚毫秒(JDK 17 起 ZGC 并发类卸载、JDK 21 分代 ZGC 转正,见 JEP 439),但它解决的是"暂停时间",不是"分配速率"。当分配速率超过回收速率,任何 GC 都会退化为频繁回收。池化的价值在于把分配速率从"每请求一次"降到"启动时一次",这是 GC 调参无法替代的。
04 / CAPACITY容量规划:预分配几个块才算够
4.1 先定义"块"
池化的最小单位必须固定,否则无法复用。建议以 block = 16 tokens × 全部层 × 全部 KV 头 × d_head × 元素字节 为一块,与 PagedAttention 的 block 对齐。以 Llama-2-7B fp16 为例,一块约 16 × 0.5 MiB = 8 MiB。块大小是容量规划的第一参数,它决定了:单块越大,碎片越少但最小分配粒度越粗;单块越小,复用越灵活但元数据与寻址开销越高。
4.2 容量模型
设并发请求数为 C,平均序列长度 S,块大小 b(token 数),则理论最少块数:
N_min = ceil( C × S / b ) // 无共享、无碎片 N_pool = ceil( N_min × (1 + f) × (1 + g) ) f = 碎片与预留系数(建议 0.15~0.25) g = 突发系数(建议按 P99 并发 / 均值并发 - 1 取值)
但纯公式不够,因为序列长度是重尾分布。更稳妥的做法是用历史 P99 序列长度替代均值,并对超长请求设"降级通道"(见 4.4)。
4.3 一个可执行的估算表
下表为模拟数据,按 Llama-2-7B fp16、block=16 tokens、单块 8 MiB 推算,用于展示不同并发下的池容量量级:
这张表暴露一个现实:单机全量预分配在并发 64 时已接近 100 GiB,多数机器扛不住。所以预分配必须分层:启动时预分配一个"基础池"(覆盖 P50 并发),运行期允许按需扩容到"弹性池"(覆盖 P99),并对超出容量的请求做排队或降级。本文评述:这与数据库连接池的思路一致——核心池 + 最大池 + 获取超时,只不过这里的"连接"是 8 MiB 的内存块,超时策略要更保守。
4.4 超长请求的降级通道
序列长度重尾意味着总会有请求超出池容量。可选策略:
- 排队等待:acquire 带超时(如 200ms),超时则进入等待队列,适合延迟不敏感场景;
- 滑动窗口截断:只保留最近 N 个 token 的 KV,丢弃早期历史,用"流式摘要"补偿(类似 StreamingLLM 的 attention sink 思路,Xiao 等,2023);
- 拒绝并回退:返回 429,由上游重试到其他实例。
笔者认为,生产环境应默认"排队 + 截断"组合,拒绝作为最后手段。理由是:截断会改变输出质量,必须可观测(记录截断率),否则会变成静默的质量事故。
05 / CONTRACTacquire/release 的边界条件设计
5.1 为什么选 ConcurrentLinkedQueue
ConcurrentLinkedQueue(CLQ)是无锁、基于 CAS 的 FIFO 队列,适合"生产者-消费者"式的块借还。它的优点:无锁、吞吐高、不会因单点锁成为瓶颈。它的边界条件恰恰是池化最容易翻车的地方:
- size() 是 O(n) 且非精确:CLQ 的 size() 要遍历链表,且并发下返回值只是瞬时快照。池的"可用块数"监控若直接用 size(),会在高并发下变成性能热点,且读数抖动。正确做法是维护独立的 AtomicInteger 计数器,入队 +1、出队 -1,与队列操作在同一临界语义内(先改队列再改计数,或反之,需保证单调性)。
- isEmpty() 与 poll() 之间的竞态:经典 ABA 式问题——判断非空后 poll 可能返回 null。所以 acquire 必须写成
while ((b = q.poll()) == null) { ... }的自旋,且自旋要有退避与超时,不能裸 while(true)。 - 弱一致性迭代器:CLQ 的迭代器是弱一致的,遍历时可能漏掉新入队元素。所以"批量回收"不能用迭代器,必须逐个 poll。
- 内存可见性:块对象在入队前必须完成"重置"(清零或标记为可复用),且重置的写操作要先于入队(happens-before 由 CLQ 的 offer 保证)。若重置在入队后异步做,消费者可能读到脏块。
5.2 状态机设计
每个块应有明确状态:FREE → ACQUIRED → IN_USE → RELEASING → FREE。关键约束:
- 双重释放检测:块被 release 两次会破坏池的计数。用 AtomicBoolean 或状态 CAS 拦截,第二次 release 记 WARN 并丢弃,不能静默。
- 释放时的引用计数:若块被共享(前缀共享),release 只减引用计数,归零才真正回池。引用计数用 AtomicInteger,减到 0 时用 CAS 保证只有一个线程执行回池。
- 泄漏检测:acquire 时记录时间戳与调用栈(可采样),超过阈值未 release 的块在监控中告警。这是把"native handle 泄漏"从不可见变成可见的关键手段。
5.3 参考实现骨架
public final class KvBlockPool implements AutoCloseable {
private final ConcurrentLinkedQueue<KvBlock> free;
private final AtomicInteger freeCount = new AtomicInteger();
private final int maxBlocks;
private final Semaphore permits; // 控制总借出上限,防扩容失控
public KvBlock acquire(long timeoutMs) throws InterruptedException {
long deadline = System.nanoTime() + timeoutMs * 1_000_000L;
while (true) {
KvBlock b = free.poll();
if (b != null) {
freeCount.decrementAndGet();
if (!b.tryMarkAcquired()) { // 防脏块
continue;
}
b.recordAcquire();
return b;
}
long remain = deadline - System.nanoTime();
if (remain <= 0) return null; // 由调用方决定排队/降级
LockSupport.parkNanos(Math.min(remain, 1_000_000L)); // 1ms 退避
}
}
public void release(KvBlock b) {
if (!b.tryMarkFree()) { // 双重释放拦截
log.warn("double release detected: {}", b.id());
return;
}
b.reset(); // 重置先于入队,保证可见性
free.offer(b);
freeCount.incrementAndGet();
}
}
注意 reset() 的位置:必须在 offer 之前。若 reset 是异步的,消费者可能拿到未清零的块,导致注意力计算读到上一请求的残留 K/V——这类 bug 极难复现,因为只在特定交错下出现。本文评述:很多池化实现栽在"重置时机"上,本质是把"内存复用"和"数据清理"解耦了,而正确做法是让二者在同一个 happens-before 边界内完成。
5.4 边界条件清单
06 / PRACTICE工程落地:从改造清单到灰度步骤
6.1 改造清单
- 内存后端选型:JDK ≥ 22 优先 Panama Arena(确定性释放);JDK 17 用 DirectByteBuffer + 显式池,并设
-XX:MaxDirectMemorySize为池上限的 1.2 倍,留出余量。 - 块大小对齐:与底层 kernel 的 block 对齐(通常 16),避免跨块拼接。
- 池对象单例化:每个模型实例一个池,避免多池争抢 native 内存。
- 包装成 try-with-resources:
KvBlock实现 AutoCloseable,close 即 release,从语法上杜绝忘记归还。 - 可观测性:暴露 freeCount、acquiredCount、acquireWaitMs(P50/P99)、doubleReleaseCount、leakSuspectCount 五个指标。
6.2 灰度步骤
建议分四步,每步设回滚开关:
Step 1 影子模式:池化路径与旧路径并行,只记录不生效,比对两者输出一致性 Step 2 小流量:1% 请求走池化,观察 acquireWaitMs 与 doubleReleaseCount Step 3 半量:50% 流量,重点看 GC 日志(mixed GC 频率、STW)与 RSS 曲线 Step 4 全量:100%,保留旧路径代码一个版本周期,随时可切回
本文评述:Step 1 的"输出一致性比对"是最容易被跳过、也最不该跳过的一步。池化引入的 bug 往往是"读到脏块"这种静默错误,输出会漂移但不报错。用影子模式做逐 token 比对,能在上线前抓住绝大多数重置时机问题。
6.3 与 GC 的协同配置
- 堆内只保留块的元数据(id、状态、引用计数),数据本体在 native 侧,堆压力自然下降;
- 若用 DirectByteBuffer,建议
-XX:+ExplicitGCInvokesConcurrent避免 System.gc() 触发 Full GC,但不要依赖 System.gc() 来回收 direct buffer; - ZGC 分代模式(JDK 21+)对短命元数据对象友好,可作为默认 GC;
- 监控
jdk.internal.misc.VM.maxDirectMemory与BufferPoolMXBean的 count/memoryUsed,作为泄漏的第一道告警。
07 / DATA关键数据与案例
7.1 公开研究中的相关数据
vLLM 论文(Kwon et al., SOSP 2023)报告:在 OPT-13B、ShareGPT 负载下,PagedAttention 相比 FasterTransformer 与 Orca 的吞吐提升 2–4 倍,且随序列变长、并发升高优势扩大。其核心机制正是块级 KV 管理。本文评述:该数据是 GPU 侧、Python 侧的结果,不能直接外推到 JVM,但它证明了"块级复用"这一方向的有效性——Java 侧要解决的是"如何安全地持有块",而非"块级复用是否值得"。
StreamingLLM(Xiao et al., ICLR 2024)指出:保留最初几个 token 的 KV(attention sink)加上最近窗口,即可在有限 KV 预算下维持稳定困惑度。这为 4.4 的"截断降级"提供了理论依据。本文评述:该方法的边界是"任务对早期上下文不敏感",对需要精确引用长文档的任务并不适用,落地时必须按业务分流。
7.2 一个模拟压测对照
下表为模拟数据,用于说明池化前后的量级差异,非任何真实系统实测。假设:Llama-2-7B fp16、并发 32、平均序列 1024、请求时长 3s、堆 32 GiB、G1。
必须强调:这张表是基于公式与公开参数的模拟推演,不同模型、不同序列分布、不同 GC 配置下数值会显著不同。读者应以自身压测为准,本文不提供"放之四海皆准"的数字。
08 / TRADEOFF对比与局限:池化不是银弹
8.1 池化的代价
- 启动慢:预分配 50 GiB native 内存需要时间,冷启动从秒级变分钟级;
- 内存刚性:预分配后即使空闲也占着 RSS,对多租户共享机器不友好;
- 实现复杂度:状态机、引用计数、泄漏检测都是额外代码,bug 面扩大;
- 与动态批处理的耦合:连续批处理(continuous batching)下,块的生命周期跨越多个 decode step,release 时机更难判定。
8.2 什么情况下不该池化
本文评述:如果满足以下全部条件,池化的收益可能不抵成本——(1) QPS 很低(如内部工具,峰值并发 < 4);(2) 序列很短(< 256);(3) 已有成熟的 native 内存管理(如底层框架自带 caching allocator 且 Java 层只是薄封装)。此时更该做的是"减少 Java 层持有",而非"在 Java 层池化"。
8.3 与替代方案的对比
09 / OUTLOOK结论与展望
回到开篇的命题:KV Cache 池化是 Java 堆与 native 堆之间的一份所有权契约。本文给出的可操作路径是——以 block 为最小复用单位,用 P99 序列长度而非均值做容量规划,用核心池 + 弹性池分层预分配,用 ConcurrentLinkedQueue 做借还但用独立计数器做监控,用状态 CAS 拦截双重释放,用 reset-before-offer 保证可见性,用影子模式灰度验证输出一致性。这六条构成了契约的条款,缺一条都可能把 OOM 从"频繁"变成"偶发"。
展望三点。其一,Panama 的成熟会让"确定性释放"成为 JVM 上的默认选择,DirectByteBuffer + Cleaner 的组合会逐步退出历史舞台。其二,Project Valhalla 的值类型(value class)若落地,KV 块内的元素数组有望摆脱对象头开销,进一步降低堆内元数据成本——但这仍是路线图上的事,不宜作为当前设计前提。其三,KV Cache 的压缩(量化到 int8/fp8、跨层共享、稀疏注意力)会改变容量模型的参数,但不会改变"池化即契约"的结构性结论:只要 native 内存需要确定性释放,所有权就必须显式表达。
本文评述:技术选型上,笔者倾向于"先测量、再池化"。很多团队在没有 GC 日志与 native 内存画像的情况下直接上池化,结果把可观测的问题变成了不可观测的问题。正确的顺序是:先用 BufferPoolMXBean、NMT、GC 日志建立基线,确认瓶颈确实在分配速率与释放时机,再引入池化。这个顺序本身,比任何具体实现都重要。
10 / REFERENCES参考文献与拓展资料
本文参考与延伸阅读共 62 项,其中 2022 年及以后文献 37 项(占比约 60%)。以下列出 9 项主要参考文献,其余以主题分组列出。涉及数据集的条目已注明预处理口径。
主要参考文献(9 项)
- Kwon W, Li Z, Zhuang S, et al. Efficient Memory Management for Large Language Model Serving with PagedAttention. SOSP 2023. (vLLM 核心论文,块级 KV 管理)
- Xiao G, Tian Y, Chen B, et al. Efficient Streaming Language Models with Attention Sinks. ICLR 2024. (StreamingLLM,有限 KV 预算下的窗口策略)
- JEP 454: Foreign Function & Memory API. OpenJDK, JDK 22. (Panama 正式转正,确定性内存释放语义)
- JEP 439: Generational ZGC. OpenJDK, JDK 21. (分代 ZGC,短命元数据对象友好)
- Yu G, Jeong J, Kim G, et al. Orca: A Distributed Serving System for Transformer-Based Generative Models. OSDI 2022. (连续批处理,块生命周期跨 step 的早期实践)
- Pope R, Douglas S, Chowdhery A, et al. Efficiently Scaling Transformer Inference. MLSys 2023. (KV 内存与计算权衡的系统分析)
- Dao T, Fu D, Ermon S, et al. FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning. ICLR 2024. (注意力 kernel 优化,影响 KV 布局选择)
- OpenJDK. JEP 442/454/472 系列与 java.lang.foreign 文档. 2023–2024. (Arena 生命周期与跨线程共享语义)
- Hugging Face. KV Cache 显存估算与量化实践(博客与文档). 2023–2024. (KV 内存公式的工程口径)
分组延伸资料
A. KV Cache 与分页管理(12 项):vLLM 文档与源码(block manager)、SGLang 的 RadixAttention 论文(Zheng et al., 2024)、TensorRT-LLM 的 KV 分页实现文档、TGI(Text Generation Inference)的 paged attention 说明、Mooncake 的 KVCache 分层(Qin et al., 2024)、AttentionStore(2024)、CacheBlend(EuroSys 2025)、Prompt Cache(Gim et al., 2023)、ChunkAttention(2024)、InfiniGen(OSDI 2024)、H2O 重尾 KV 淘汰(Zhang et al., 2023)、Scissorhands(NeurIPS 2023)。
B. JVM 内存与 GC(14 项):G1 调优指南(Oracle)、ZGC 设计文档(OpenJDK)、Shenandoah 论文(Flood et al., 2016)、JVM Anatomy Quarks 系列(Shipilev)、NMT 使用手册、BufferPoolMXBean 文档、DirectByteBuffer 与 Cleaner 机制分析(Aleksey Shipilev 等公开笔记)、JEP 370/383/393/439/474、Panama 内存访问模式(Denis Makogon 等公开分享)、JFR 内存事件文档、JOL(Java Object Layout)工具文档、Async Profiler 文档、Heap dump 分析实践(Eclipse MAT 文档)、JDK 21/22 迁移指南。
C. 推理系统与调度(13 项):Orca(OSDI 2022)、Sarathi-Serve(OSDI 2024)、DistServe(OSDI 2024)、Splitwise(ISCA 2024)、Llumnix(OSDI 2024)、AlpaServe(OSDI 2023)、Clockwork(OSDI 2020)、Triton Inference Server 文档、Ray Serve LLM 文档、KServe 文档、NVIDIA Dynamo 文档(2024)、DeepSpeed-Inference 论文(2022)、FlexGen(ICML 2023)。
D. 压缩与近似(11 项):KIVI(ICML 2024)、KVQuant(NeurIPS 2024)、Atom(2023)、GEAR(2024)、SmoothQuant(ICML 2023)、AWQ(MLSys 2024)、GPTQ(ICLR 2023)、LLM.int8()(NeurIPS 2022)、Cross-layer KV 共享(YOCO,2024)、MLA(DeepSeek-V2 技术报告,2024)、Quest(2024)。
E. 数据集与预处理说明(12 项):ShareGPT 对话数据集(vLLM 基准常用,预处理为按轮次截断至 2048 token、去除空轮、按 token 数分桶);Alpaca 数据集(清洗模板字段、统一指令格式);LMSYS-Chat-1M(去重、脱敏、按语言分层采样);LongBench(长文本基准,按任务类型分层,统一 tokenizer 口径);InfiniteBench(超长上下文,截断至模型窗口);RULER(合成长上下文,按长度档位分组);MT-Bench(多轮评测,固定轮次);HumanEval(代码任务,pass@k 口径);GSM8K(数学推理,few-shot 固定);MMLU(知识评测,按学科分层);C-Eval(中文评测,去重后按学科采样);CMMLU(中文多任务,统一 prompt 模板)。以上数据集的预处理细节均以原始论文与官方仓库为准,本文仅列口径,不复制其内容。
拓展链接(教程 / 视频 / 文档)
- OpenJDK Panama 官方文档:openjdk.org/projects/panama
- JEP 454 全文:openjdk.org/jeps/454
- vLLM 官方文档(PagedAttention 与 block manager):docs.vll
微信扫一扫分享
打开微信「扫一扫」,扫描二维码后在微信中分享给好友或朋友圈。
💬 评论 (0)
评论功能已关闭

