Java 使用堆外内存(off-heap memory) 作为缓存
我们通常用的 Java 缓存基本可认为是扩展了 HashMap 或 ConcurrentHashMap 的实现,它们各自实现自己的缓存策略,如时间与空间的控制,生命周期管理,是否支持分布式,溢出时能否转储到磁盘。关于 Java 本地缓存的存储分为内存与磁盘,内存多数情况下指的是堆内内存(on-heap), 而介于堆内内存与文件存储之间的就是堆外内存(off-heap)
我们查看一下当前 Spring 支持的缓存实现, Supported Cache Providers, 列有 Generic, JCache(JSR-107), EhCache 2.x, Hazelcast, Infinispan, Couchbase, Redis, Caffeine, Simple, 这其中无一支持堆外缓存,其中的 EhCache 要付费使用 EhCache 3(Big Memory) 才能支持 off-heap。
不过我们可以直接借助于 Java 内置的 ByteBuffer或存或选用第三方的缓存库来使用堆外缓存,主流的有
下面将介绍前两个库的使用
Maven 项目中引用依赖
它会依赖 JNA, Guava 等项目,因为它要由 JNA 去堆外分配内存的
OHC 在对象于堆内外间转储时需要序列/反序化,所以键和值都要实现 org.caffinitas.ohc.CacheSerializer 类,下面的例子键和值都为字符串
实际中键和值的序列化类可分开,例如可限定键的最大长度为 32767,可用两个字节存储一个 short 类型的字节表示。
使用堆外内存的代码
执行后可看到输出为
通过 OHCacheBuilder 可以设置缓存的策略,如键的失效时间
键在 60 分钟之后就会失效。
使用 OHCache 时所有的键与值都必须定义后相应的 CacheSerializer, 值的序列化实现有可能会影响到缓存的性能,键的话简单,都用字符串。值的话可用 JDK 或 JSON 的序列化。
更详细的内容请参考 堆外缓存OHCache使用总结
同时还要在 pom.xml 中指定 kotlin-stdlib 的版本,配置 kotlin.version 属性值为 1.9.25, 否则可能会采用更早的 kotlin-stdlib 库而造成运行时出错
测试代码
执行输出为
如果替换上面的
而通过调用 DB.HashMapMaker 的方法可以定制失效策略,缓存对象的数目及存储容量的控制,还有键和值的序列化实现,MapDB 内置了很多的序列化实现,默认为使用 JDK 的 Serializable(SerializerJava), 不像 OHC 要完全自己实现所有的序列化。

Main
v如果停留在
链接:
[版权声明]
本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。
- 堆内存储(on-heap): 操作最快,无需序列化,但大量数据时会影响到 GC 的效率
- 堆外存储(off-heap): 存储在 Java 进程内存但非 JVM 堆内(不在 -Xmx 指定的内存范围内),使用或保存时需进行序列化/反序列化过程(在堆内与堆外转换),但不受 GC 影响,有助于提它来 GC 的效率
- 文件存储:不仅存在序列化与反序列化过程,还带 IO 操作,所以最慢,唯一优点就是大
我们查看一下当前 Spring 支持的缓存实现, Supported Cache Providers, 列有 Generic, JCache(JSR-107), EhCache 2.x, Hazelcast, Infinispan, Couchbase, Redis, Caffeine, Simple, 这其中无一支持堆外缓存,其中的 EhCache 要付费使用 EhCache 3(Big Memory) 才能支持 off-heap。
不过我们可以直接借助于 Java 内置的 ByteBuffer或存或选用第三方的缓存库来使用堆外缓存,主流的有
- OHCache
- MapDB
- ChronicleMap
- Ehcache3: BigMemory 收费(估计在选型时得跳过)
下面将介绍前两个库的使用
OHCache 使用
OHC(off-heap-cache), 它是从 Cassandra 中独立出的项目, 项目地址为 https://github.com/snazy/ohc,似乎有好多年没维护了,距离当前(2024-08-19), 实际的代码变更还在四年前。Maven 项目中引用依赖
1<dependency>
2 <groupId>org.caffinitas.ohc</groupId>
3 <artifactId>ohc-core</artifactId>
4 <version>0.7.4</version>
5</dependency>它会依赖 JNA, Guava 等项目,因为它要由 JNA 去堆外分配内存的
OHC 在对象于堆内外间转储时需要序列/反序化,所以键和值都要实现 org.caffinitas.ohc.CacheSerializer 类,下面的例子键和值都为字符串
1package blog.yanbin;
2
3import com.google.common.base.Charsets;
4import org.caffinitas.ohc.CacheSerializer;
5
6import java.nio.ByteBuffer;
7
8public class StringSerializer implements CacheSerializer<String> {
9
10 @Override
11 public int serializedSize(String value) {
12 byte[] bytes = value.getBytes(Charsets.UTF_8);
13 return bytes.length + 4; // 4 bytes to record length
14 }
15
16 @Override
17 public void serialize(String value, ByteBuffer buf) {
18 byte[] bytes = value.getBytes(Charsets.UTF_8);
19 // get length
20 buf.putInt(bytes.length);
21 buf.put(bytes);
22 }
23
24 @Override
25 public String deserialize(ByteBuffer buf) {
26 int length = buf.getInt();
27 byte[] bytes = new byte[length];
28 buf.get(bytes);
29 return new String(bytes, Charsets.UTF_8);
30 }
31}实际中键和值的序列化类可分开,例如可限定键的最大长度为 32767,可用两个字节存储一个 short 类型的字节表示。
使用堆外内存的代码
1package blog.yanbin;
2
3import org.caffinitas.ohc.Eviction;
4import org.caffinitas.ohc.OHCache;
5import org.caffinitas.ohc.OHCacheBuilder;
6
7public class Main {
8
9 public static void main(String[] args) {
10 OHCache<String, String> ohCache = OHCacheBuilder.<String, String>newBuilder()
11 .keySerializer(new StringSerializer())
12 .valueSerializer(new StringSerializer())
13 .eviction(Eviction.LRU)
14 .build();
15
16 ohCache.put("hello", "world");
17 System.out.println(ohCache.get("hello")); // world
18 }
19}执行后可看到输出为
14:14:25.507 [main] INFO org.caffinitas.ohc.linked.Uns - OHC using JNA OS native malloc/free前两行的日志看到 OHC 用了 JNA 来分配释放内存。
14:14:25.905 [main] DEBUG org.caffinitas.ohc.linked.OHCacheLinkedImpl - OHC linked instance with 32 segments and capacity of 67108864 created.
world
通过 OHCacheBuilder 可以设置缓存的策略,如键的失效时间
1.eviction(Eviction.LRU)
2.timeouts(true)
3.defaultTTLmillis(60*60*1000L)键在 60 分钟之后就会失效。
使用 OHCache 时所有的键与值都必须定义后相应的 CacheSerializer, 值的序列化实现有可能会影响到缓存的性能,键的话简单,都用字符串。值的话可用 JDK 或 JSON 的序列化。
更详细的内容请参考 堆外缓存OHCache使用总结
MapDB 的使用
Map<D,B> - MapDB, 官网的介绍是MapDB provides Java Maps, Sets, Lists, Queues and other collections backed by off-heap or on-disk storage. It is a hybrid between java collection framework and embedded database engine(MapDB 基于堆外和磁盘存储提供了 Java 的 Map, Set, List, Queue 等集合,它混合了 Java 集合框架与内嵌式数据库引擎)现在来体验它的堆外存储作缓存的功能(这似乎只是一个较小的功能),在 Maven 项目中引入依赖
1<dependency>
2 <groupId>org.mapdb</groupId>
3 <artifactId>mapdb</artifactId>
4 <version>3.1.0</version>
5</dependency>同时还要在 pom.xml 中指定 kotlin-stdlib 的版本,配置 kotlin.version 属性值为 1.9.25, 否则可能会采用更早的 kotlin-stdlib 库而造成运行时出错
1<kotlin.version>1.9.25</kotlin.version>java.lang.NoClassDefFoundError: kotlin/enums/EnumEntriesKt先创建一个 User 类
at org.mapdb.DBMaker$StoreType.<clinit>(DBMaker.kt:35)
at org.mapdb.DBMaker.memoryDB(DBMaker.kt:71)
1package blog.yanbin;
2
3import java.io.Serializable;
4
5public class User implements Serializable {
6 public String name;
7 public User(String name) {
8 this.name = name;
9 }
10}测试代码
1import org.mapdb.DB;
2import org.mapdb.DBMaker;
3import org.mapdb.HTreeMap;
4
5public class Main {
6
7 public static void main(String[] args) {
8 DB db = DBMaker.memoryDB().make();
9 DB.HashMapMaker<?, ?> mapMaker = db.hashMap("map");
10 HTreeMap<String, User> map = (HTreeMap<String, User>) mapMaker.createOrOpen();
11
12 map.put("u", new User("Scott"));
13
14 System.out.println(map.get("u").name);
15 }
16}执行输出为
Scott何以见得数据是存到了堆外的呢?我们可以用 JVisualVM 来查看,或是不让
User 实现 Serializable 接口,那么上面的代码就会报错java.io.NotSerializableException: blog.yanbin.User间接的说明 User 需要经过序列化,而在堆内存储序列化是不必要的。
如果替换上面的
DB db = DBMaker.memoryDB().make();为
DB db = DBMaker.heapDB().make();那么 User 就无需实现
Serializable 接口了,因为 heapDB() 表示用堆内存储,除这两个以后,MapDB 还能更多的存储选择,DBMaker 有以下方法实现 1fileDB(file: java.io.File);
2fileDB(file: kotlin.String);
3heapDB();
4heapShardedHashMap(concurrency: kotlin.Int);
5heapShardedHashSet(concurrency: kotlin.Int);
6memoryDB();
7memoryDirectDB();
8memoryShardedHashMap(concurrency: kotlin.Int);
9memoryShardedHashSet(concurrency: kotlin.Int);
10tempFileDB();
11volumeDB(volume: org.mapdb.volume.Volume, volumeExists: kotlin.Boolean); 而通过调用 DB.HashMapMaker 的方法可以定制失效策略,缓存对象的数目及存储容量的控制,还有键和值的序列化实现,MapDB 内置了很多的序列化实现,默认为使用 JDK 的 Serializable(SerializerJava), 不像 OHC 要完全自己实现所有的序列化。

附使用 ByteBuffer 的例子
User 类 1package blog.yanbin;
2
3import java.io.Serializable;
4
5public class User implements Serializable {
6 public String name;
7 public User(String name) {
8 this.name = name;
9 }
10}Main
1package blog.yanbin;
2
3import java.io.ByteArrayInputStream;
4import java.io.ByteArrayOutputStream;
5import java.io.IOException;
6import java.io.ObjectInputStream;
7import java.io.ObjectOutputStream;
8import java.nio.ByteBuffer;
9import java.nio.ByteOrder;
10
11public class Main {
12 public static void main(String[] args) throws Exception {
13 ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024*1024).order(ByteOrder.nativeOrder());
14 saveCache(byteBuffer);
15
16 System.gc();
17 System.out.println("out saveCache");
18 System.in.read();
19
20 readCache(byteBuffer);
21 }
22
23 static void saveCache(ByteBuffer buffer) throws IOException {
24 ByteArrayOutputStream baos = new ByteArrayOutputStream();
25 ObjectOutputStream oos = new ObjectOutputStream(baos);
26 oos.writeObject(new User("Scott"));
27
28 System.out.println("in saveCache");
29 System.in.read();
30
31 buffer.position(0);
32 buffer.putInt(123);
33 buffer.put(baos.toByteArray());
34 }
35
36 static void readCache(ByteBuffer buffer) throws Exception {
37 buffer.position(0);
38 int i = buffer.getInt();
39 System.out.println("integer: " + i);
40 byte[] bytes = new byte[buffer.remaining()];
41 buffer.get(bytes);
42 ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
43 ObjectInputStream ois = new ObjectInputStream(bais);
44 User user = (User)ois.readObject();
45 System.out.println("user name: " + user.name);
46 }
47}v如果停留在
in saveCache,用 JVisualVM 可以看到内存中的 blog.yanbin.User 实例,而执行到了 out saveCache 后在 JVM 中的 blog.yanbin.User 实例不见了,最后输出是integer: 123如果 blog.yanbin.User 类没有实现 Serializable 接口就会出错
user name: Scott
java.io.NotSerializableException: blog.yanbin.User如果是换成往一个 Map 中存入一个 User 对象,只要 Map 的实例还在,其中的 user 对象也就在堆内存中。
总结
- 当在缓存对象多,占用空间大的情况(如 GB 级),GC 会影响性能时,可考虑用堆外缓存。毕竟堆内外间需频繁的进行序列化与反序列化,但堆外缓存比进程外缓存如(Redis) 效率又要高一些。
- 像 ByteBuffer 在堆外分配内存时需预先设定内存大小,使用 OHC 和 MapDB 要简单些
- 当堆外内存(进程内存)越过系统能分配的空间,进程崩溃,但 JVM 将一无所知
- 使用 OHC 需自己实现所有的键值的序列化/反序列化,而 MapDB 内置了许多的相应实现
- 几个层次的缓存:堆内(on-heap), 堆外(off-heap), 本地磁盘,进程外。进程外可能是本地的进程或远端的进程,本地磁盘与远端缓存性能差异因磁盘介质,网络介质而不同,不能一概认为本地磁盘就比存取远端数据要快。
链接:
- 堆外缓存OHCache使用总结
- https://www.cnblogs.com/thisiswhy/p/17095006.html
- 高性能Java架构之堆外缓存与磁盘缓存解决方案:MapDB
- MapDB使用入门
[版权声明]
本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。