写在开头

上文介绍了多级缓存的背景以及多级缓存带来的一些新问题,本文将重点介绍本地缓存

通过本文,您可以了解到:

  • 本地缓存的一些开源解决方案
  • 堆外缓存(Off-Heap Cache)的一种实现:OHC
  • 使用堆外缓存需要注意的问题:序列化、内存管理、容量评估等

开源实现

本地缓存的开源实现有很多,对于堆内缓存(On-Heap Cache)的方案则更多,比如 Guava 或者 Ehcache,选型相对容易。这里我们重点关注的是堆外缓存的开源实现。目前了解到的主要有:

  • Ehcache 3.0:基于其商业公司一个非开源的堆外组件实现。
  • Chronicle Map:采用 LGPL V3 协议,高级特性非开源。
  • OHC:来源于 Cassandra 3.0,Apache v2 协议。
  • Ignite:一个规模宏大的内存计算框架,堆外缓存只是其功能的一部分,Apache 项目。

选型主要考虑成熟度、License、功能、性能、代码质量等因素,此处不做详细的方案比较。如果需要进行定制化,一个满足需求且简单可控的框架可能是更好的选择。

本文后续的内容将以 OHC 作为基础进行介绍。

堆外缓存核心关注点

序列化

从上一节我们大概可以了解到,存放一个 Java 对象到堆外缓存中,需要有一个从 Java 对象到 ByteBuffer 的转换过程。为了尽量简化 API 的使用,使用者不需要为对象编写特定的序列化逻辑,因此我们需要一个高效的序列化框架:先通过序列化框架序列化,再转成堆外的 ByteBuffer 上。

在堆外缓存的使用场景中,该序列化框架应该至少具备以下要求:

  • 高性能:毋庸置疑,这是基础要求。
  • 序列化数据开销(Overhead)小:由于堆外缓存本来就占用内存资源多,如果序列化框架造成的开销过大是无法接受的。其实如果序列化框架造成的开销是定长的话是最完美的,省去了中间转换的过程,可以直接把 Java 对象序列化到堆外,大大提高了性能也能降低 GC 的频率。
  • 简单易用:使用要尽量简单,尽量减少侵入性,最好是用户根本感知不到具体实现细节。所以那些基于 IDL 的如 PB、Thrift,以及基于 Schema 的如 Avro 就不是很好的选择;如果还要代码生成则更难以维护,这些大部分是基于跨语言通讯的需要。
  • 兼容性:这里的兼容性包括几个方面:

    • 序列化协议的兼容性:不同版本间序列化的协议尽量要稳定,不然就放得进去读不出来。这里还要考虑向前向后兼容,至少要向后兼容,即老版本写的数据新版本可以读出来。
    • 数据模型的兼容性:如果你的数据模型发生了变化,比如增加属性、减少属性、更改类型等等,随着业务的发展这是非常平常的事。如果序列化框架默认无法直接支持,那最好有方法让用户自己做兼容性标记,如读的时候可以忽略增加或者减少的属性等等。
    • :当然如果你的数据没有持久化,又或者你的应用不支持热加载(如 OSGi),可以不要考虑该问题。

关于序列化协议的选择,有一个非常不错的 benchmark 可以参考 jvm-serializers,里面对各种序列化协议从各种不同维度进行了对比。最后我们选择了 Kryo,因为 Kryo 在性能上以及序列化造成的开销上均表现非常优异,且使用对用户透明。

一个 Kryo 的使用例子:

// Setup ThreadLocal of Kryo instances
private static final ThreadLocal<Kryo> kryos = new ThreadLocal<Kryo>() {
    protected Kryo initialValue() {
        Kryo kryo = new Kryo();
        // configure kryo instance, customize settings
        return kryo;
    }
};

Kryo kryo = kryos.get();
// ...
Output output = new Output(new FileOutputStream("file.bin"));
SomeClass someObject = ...
kryo.writeObject(output, someObject);
output.close();
// ...
Input input = new Input(new FileInputStream("file.bin"));
SomeClass someObject = kryo.readObject(input, SomeClass.class);
input.close();

这里需要简单封装一下,因为 Kryo 不是线程安全的,且每次使用创建的成本较高,所以要么使用对象池(Pooling),要么使用线程本地存储(ThreadLocal)。

OHC 需要先知道序列化的对象的大小用来提前分配堆外内存,然后构造成 DirectByteBuffer,接着你需要把对象序列化到 ByteBuffer 中。

public interface CacheSerializer<T> {
    void serialize(T value, ByteBuffer buf);

    T deserialize(ByteBuffer buf);

    int serializedSize(T value);
}

如果说你的 Key/Value 比较简单,能够比较容易计算得出它序列化后的大小,其实最好的方式是绕过 Kryo 自己直接实现 OHC 的序列化接口,避免了额外的一次 Kryo 序列化和少了一次中间结果的内存复制。

另外,如果你在序列化前想要计算一个字符串的长度,如果是可能包含非 ASCII 码字符的,一定不要通过 String.getByte("utf-8") 来计算得到,根据 Unicode 的规范只需要简单遍历比较即可,性能至少提高 5 倍以上:

int strlen = str.length();
int utflen = 0;
int c;

for (int i = 0; i < strlen; i++) {
    c = str.charAt(i);
    if ((c >= 0x0001) && (c <= 0x007F))
        utflen++;
    else if (c > 0x07FF)
        utflen += 3;
    else
        utflen += 2;
}
return utflen;

内存管理

Java 堆外内存分配

堆外内存的分配大家比较熟悉的是使用如下的 JNI 方式进行分配:

java.nio.ByteBuffer.allocateDirect(int)           

大抵的原理是其内部还是使用的 Unsafe 进行的内存分配,从下面的 API 声明可知该方法是一个 JNI 的封装。详细请参考:Unsafe

public native long allocateMemory(long bytes)

通过 Unsafe 进行分配的内存受限于 XX:MaxDirectMemorySize 的配置。换句话说,无论哪里用到了堆外缓存,只要通过 Unsafe 的方式进行的分配都是共享该 Quota 的。最好的方式其实是不同用途的堆外内存可以隔离开来,比如堆外缓存的一块,Netty 的一块。

JNA(Java Native Access) 是社区开发的一套类库,号称是 JNI 的终结者。传统需要调用本地方法既要写 C 代码生成 DLL/SO,又要写 Java 代码进行封装才能提供 Java 调用,既繁琐效率又低。而 JNA 是通过 libffi 来实现的,提供了一套接口,用户只需要通过 Java 代码定义本地方法和数据类型,JNA 负责把 Java 方法的调用进行 Dispatch 到相应的本地方法调用,你不再需要不厌其烦地为每个本地方法都写个 C 和 Java 的 wrapper 方法对。限于篇幅,请参考另外一篇。

先来看一下,通过 JNA 的 malloc 是怎么做的:

com.sun.jna.Native.malloc(long)

方法声明如下:

public static native long malloc(long size);

通过 JNA 不仅开发效率更高,性能上也有了提高,以 malloc 为例。在我本地的 Mac (2.7 GHz Intel Core i5) 上做了一个 mallocfree 的组合测试,JNA 的性能是 Unsafe 的两倍以上。

由于使用 JNA 进行堆外内存的分配,完全绕过了 Bits 的内存大小的管理,即不会占用 XX:MaxDirectMemorySize 的空间。所以只要物理内存上足够,你完全不需要为了引入堆外缓存而更改原有的 XX:MaxDirectMemorySize 配置,同时也起到了较好的隔离。

这里还有一个关于 JNA vs Unsafe 的讨论,有兴趣的也可以看一下。

本地内存分配管理

除了要有一个高效的 Java 堆外分配的方法之外,一个高效的本地内存分配管理的策略和库也非常重要。OHC 就强烈推荐使用 Jemalloc 替换 glibc 的 malloc

网上有各种相关的性能评测报告和原理分析,Jemalloc 在现代多核的处理器架构的情况下性能表现较为优异,主要得益于它的 Thread Local Cache 的引入。在类似 Java 的 TLAB (Thread Local Allocation Buffer) 的内存管理策略下,在分配小内存的时候可以直接在 TLC 里分配,减少锁的竞争。但是据说在存在大量的小内存分配的时候,额外的内存浪费会比 Google 的 tcmalloc 大,具体的没有详细研究,以后可以找时间详细对比测试一下。

可以参考:

OHC 内部结构

OHC 默认的实现其实就是一个堆外的 ConcurrentHashMap,大致结构如下:

OHC Data structure

Segment 的数量默认是 CPU 核心数的两倍,每个 Segment 的 bucket 数量默认是 8192,loadFactor 是 0.75。应用需要根据自己的 Key 的数量来合理地设置以上参数,否则可能会导致 rehash 次数过多或者访问效率过低。实现方式跟普通的 ConcurrentHashMap 差不多,只是从上图可知每个 bucket 及每个 entry 的数据都存放在一个特定的堆外数据结构当中。每个 bucket 8 个字节存放这个 bucket 首个 entry 的内存地址,每个 entry 也是一块连续的内存,由固定长度的 64 个字节的头和 key length + value length 的 data 组成。

当往 cache 中放一个 K/V 时,会动态申请一块新的内存,删除后直接释放。为什么没有像 Netty 一样通过维护一组不同大小的 buffer 来组合复用内存呢?个人觉得 cache 主要解决的是读多写少的问题,数据变的频率较低。另外堆外缓存存放的是百万甚至千万的 entry,所以对内存大小比较敏感,最好是一点也不浪费,要多少则申请多少,而不是使用固定大小的内存块,大小总归不一定匹配,会有一些浪费。

最后,OHC 也提供了一个参数用于配置堆外缓存的总内存大小。如果你是需要缓存全量数据的,则使用的时候需要合理评估容量进行设置。如果缓存的是热点数据,当空间不足时会根据 LRU 进行淘汰数据。

容量评估

当你使用堆外缓存时,一个很重要的就是需要提前进行内存容量的评估。这里有个需要重点考虑的因素是除了业务数据本身的大小之外,你还要考虑序列化框架的序列化和缓存框架本身的数据结构造成的开销。比如:Kryo 可能需要存储对象的类名、属性类型、属性是否为空等各种标识,取决于你的对象的复杂程度,会造成额外的开销。另外,以 OHC 为例,由 OHC 的数据结构可知,OHC 造成的开销 = bucket number * 8 字节 + entry number * 64 个字节

当然最稳妥的方式是根据业务情况进行压测!

总结与展望

后续还可以关注以下问题:

  • 评测缓存数据量及更新频率对 GC 的影响,为选择堆内还是堆外缓存提供参考依据。
  • JNA + Jemalloc vs Unsafe vs ptmalloc 的性能评测。
  • 如何从框架层面简化多级缓存的使用。

以上便是本次分享的全部内容。

说明:文中涉及的 Ehcache 3.0、Cassandra 3.0 及 OHC 相关特性基于较早期版本,实际使用时请参考最新官方文档以确认兼容性与新特性。