直接用 Unsafe 实现堆外缓存需自行管理内存布局、哈希冲突、扩容与显式释放,否则易致内存泄漏或越界崩溃;仅适用于延迟敏感且能承担高维护成本的场景,如 Spark UnsafeRow。

直接用 Unsafe 实现堆外缓存不是“加个依赖就能跑”的事,而是要自己扛内存布局、哈希冲突、扩容逻辑和显式释放——稍有疏漏就会内存泄漏或越界读写。它只适合对延迟极度敏感、且能承担维护成本的场景,比如 Spark 的 UnsafeRow 或自研中间件底层。
unsafe.allocateMemory 后必须自己管理生命周期
堆外内存不会被 GC 自动回收,allocateMemory 返回的地址一旦丢失引用,就彻底泄露。常见错误是只在构造时分配,却没在 close 或 finalize 中调用 freeMemory:
- 每次
put新字符串时,要先freeMemory旧的valAddr,否则反复写入会累积泄漏 -
freeMemory必须且仅能调用一次,重复释放会导致 JVM 崩溃(SIGSEGV) - 不能依赖
finalize:JVM 不保证何时执行,生产环境等同于不释放 - 推荐配合
Cleaner(Java 9+)注册清理动作,但 Cleaner 本身也有延迟,关键路径仍需显式释放
内存布局设计决定缓存正确性与扩展性
你看到的 key 结构 { next_addr(8), hash(4), val_addr(8), size(1/4), len(?) } 不是约定俗成,而是你亲手定义的二进制协议。错一个字节偏移,getInt 就会读到错误的 hash 值,导致查找失败或覆盖错误位置:
-
size字段用 1 字节还是 4 字节,取决于值长度上限;若用 1 字节但实际存了 300 字节的字符串,后续len解析必然错乱 - 字符串内容必须以 UTF-8 字节数组形式写入,不能直接用
String.getBytes()而不指定StandardCharsets.UTF_8,否则平台默认编码可能不一致 - 每个 key entry 的起始地址必须按 8 字节对齐(64 位系统常见要求),否则
getAddress或getInt可能抛IllegalArgumentException - 扩容不是简单 realloc,要遍历老表所有非空 entry,重新计算 index 并 copy 到新 base,否则数据丢失
Unsafe.putXXX / getXXX 的类型与边界风险
Unsafe 的读写方法不校验地址有效性,越界访问不会抛 IndexOutOfBoundsException,而是静默破坏相邻内存或触发 JVM crash:
-
unsafe.getInt(addr + 8)前必须确认addr + 8在已分配内存范围内,建议每次访问前做addr + offset 检查(调试期开启,线上可关) -
putString写入时,要确保目标区域足够容纳size + len + (size 字节,否则踩到下一个 key 的 <code>next_addr - 用
copyMemory替代多次putByte更高效,但 src 和 dest 区域绝对不能重叠,否则行为未定义 -
setMemory(null, addr, bytes, (byte)0)清零比循环putByte快得多,但bytes必须是精确长度,多清或少清都会埋坑
为什么 Spark 用 UnsafeRow 却不推荐你照搬
Spark 的 UnsafeRow 能安全运行,是因为它:
- 所有内存由
TaskMemoryManager统一分配与回收,上层不直接碰allocateMemory - 二进制格式固定且经过大量 fuzz 测试,字段偏移硬编码在生成代码里,不靠运行时解析
- 整个生命周期绑定到 shuffle 或 sort 阶段,任务结束即批量释放,不支持细粒度增删
- 搭配 codegen 动态生成内存访问代码,绕过反射开销
而你自己写的缓存,如果既要支持高并发 put/get,又要动态扩容、LRU 驱逐、序列化导出,那 Unsafe 只是其中一环——真正难的是把这一环严丝合缝嵌进整个内存生命周期里。最容易被忽略的,是那个没人提醒你的点:你写的每行 unsafe.xxx,背后都对应着 C 级别的责任。


















