OHCache介绍
缓存框架OHC基于Java语言实现,并以类库的形式供其他Java程序调用,是一种以单机模式运行的堆外缓存。
OHC简介
缓存的分类与实现机制多种多样,包括单机缓存与分布式缓存等等。具体到JVM应用,又可以分为堆内缓存和堆外缓存。
OHC 全称为 off-heap-cache
,即堆外缓存,是一款基于Java 的 key-value 堆外缓存框架。
OHC是2015年针对 Apache Cassandra 开发的缓存框架,后来从 Cassandra 项目中独立出来,成为单独的类库,其项目地址为:https://github.com/snazy/ohc
堆内和堆外
Java程序运行时,由Java虚拟机(JVM)管理的内存区域称为堆(heap)。垃圾收集器会扫描堆内空间,识别应用程序已经不再使用的对象,并释放其空间,这个过程称为GC。
堆内缓存,顾名思义,是指将数据缓存在堆内的机制,比如 HashMap
就可以用作简单的堆内缓存。由于垃圾收集器需要扫描堆,并且在扫描时需要暂停应用线程(stop-the-world,STW
),因此,缓存数据过多会导致GC开销增大,从而影响应用程序性能。
与堆内空间不同,堆外空间不影响GC,由应用程序自身负责分配与释放内存。因此,当缓存数据量较大(达到G以上级别)时,可以使用堆外缓存来提升性能。
OHC 的特性
相对于持久化数据库,可用的内存空间更少、速度也更快,因此通常将访问频繁的数据放入堆外内存进行缓存,并保证缓存的时效性。OHC主要具有以下特性来满足需求:
- 数据存储在堆外,不影响GC
- 支持为每个缓存项设置过期时间
- 支持配置
LRU
、W-TinyLFU
逐出策略 - 能够维护大量的缓存条目(百万量级以上)
- 支持异步加载缓存
- 读写速度在微秒级别
OHC具有低延迟、容量大、不影响GC的特性,并且支持使用方根据自身业务需求进行灵活配置。
使用
在java项目中使用OHC,示例如下:
pom文件:
<dependency>
<groupId>org.caffinitas.ohc</groupId>
<artifactId>ohc-core</artifactId>
<version>0.7.4</version>
</dependency>
<dependency>
<groupId>com.esotericsoftware</groupId>
<artifactId>kryo</artifactId>
<version>5.3.0</version>
</dependency>
字符串序列化,这里序列化使用的是Kryo,是一款性能非常优秀的序列化工具:
import com.esotericsoftware.kryo.Kryo;
import com.esotericsoftware.kryo.io.Input;
import com.esotericsoftware.kryo.io.Output;
import org.caffinitas.ohc.CacheSerializer;
import org.objenesis.strategy.StdInstantiatorStrategy;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
/**
* @author lifengdi
* @date 2022/9/19 17:58
*/
public class OhcStringSerializer implements CacheSerializer<String> {
private final ThreadLocal<Kryo> kryoThreadLocal = ThreadLocal.withInitial(() -> {
Kryo kryo = new Kryo();
kryo.setReferences(false);
// 设置是否注册全限定名,
kryo.setRegistrationRequired(false);
// 设置初始化策略,如果没有默认无参构造器,那么就需要设置此项,使用此策略构造一个无参构造器
kryo.setInstantiatorStrategy(new StdInstantiatorStrategy());
return kryo;
});
@Override
public void serialize(String value, ByteBuffer buf) {
Kryo kryo = kryoThreadLocal.get();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Output output = new Output(bos);
kryo.writeObjectOrNull(output, value, String.class);
output.close();
byte[] bytes = bos.toByteArray();
buf.put(bytes);
}
@Override
public String deserialize(ByteBuffer buf) {
Kryo kryo = kryoThreadLocal.get();
byte[] bytes = new byte[buf.remaining()];
buf.get(bytes);
ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
Input input = new Input(bis);
input.close();
return kryo.readObjectOrNull(input, String.class);
}
@Override
public int serializedSize(String value) {
Kryo kryo = kryoThreadLocal.get();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Output output = new Output(bos);
kryo.writeObject(output, value);
output.close();
return bos.size();
}
}
工具类封装:
import org.caffinitas.ohc.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* @author lifengdi
* @date 2022/9/19 18:07
*/
public class OffHeapCache {
OHCache<String, String> ohCache;
/**
* 过期时间
*/
private static final long EXPIRE_TIME = 24;
/**
* 时间单位
*/
private static final TimeUnit TIME_UNIT = TimeUnit.HOURS;
private OffHeapCache() {
ohCache = OHCacheBuilder.<String, String>newBuilder()
.keySerializer(new OhcStringSerializer())
.valueSerializer(new OhcStringSerializer())
.eviction(Eviction.LRU)
.capacity(1024 * 1024 * 1024)
.hashTableSize(16)
.timeouts(true)
.defaultTTLmillis(TIME_UNIT.toMillis(EXPIRE_TIME))
.build();
}
private static class SingletonHolder {
static final OffHeapCache instance = new OffHeapCache();
}
public static OffHeapCache getInstance() {
return OffHeapCache.SingletonHolder.instance;
}
public void put(String key, String value) {
ohCache.put(key, value);
}
public String get(String key) {
return ohCache.get(key);
}
/**
* 设置缓存
* @param key key
* @param value value
* @param expireTime 过期时间(毫秒)
*/
public void put(String key, String value, long expireTime) {
long expireAt = System.currentTimeMillis() + expireTime;
ohCache.put(key, value, expireAt);
}
/**
* 统计
* @return OHCacheStats
*/
public OHCacheStats stats() {
return ohCache.stats();
}
/**
* 获取hotKey
* @param n 数量
* @return list
*/
public List<String> hotKeyIterator(int n) {
CloseableIterator<String> hotKeyIterator = ohCache.hotKeyIterator(n);
List<String> hotKey = new ArrayList<>();
while (hotKeyIterator.hasNext()) {
hotKey.add(hotKeyIterator.next());
}
try {
hotKeyIterator.close();
} catch (IOException e) {
e.printStackTrace();
}
return hotKey;
}
}
用法示例:
import org.caffinitas.ohc.Eviction;
import org.caffinitas.ohc.OHCache;
import org.caffinitas.ohc.OHCacheBuilder;
public class OffHeapCacheExample {
public static void main(String[] args) {
OffHeapCache ohCache = OffHeapCache.getInstance();
ohCache.put("hello", "world");
System.out.println(ohCache.get("hello")); // world
}
}
OHC 的底层原理
整体架构
OHC 以 API 的方式供其他 Java 程序调用,其 org.caffinitas.ohc.OHCache
接口定义了可调用的方法。对于缓存来说,最常用的是 get 和 put 方法。针对不同的使用场景,OHC提供了两种OHCache的实现:
org.caffinitas.ohc.chunked.OHCacheChunkedImpl
org.caffinitas.ohc.linked.OHCacheLinkedImpl
以上两种实现均把所有条目缓存在堆外,堆内通过指向堆外的地址指针对缓存条目进行管理。
其中,linked 实现为每个键值对分别分配堆外内存,适合中大型键值对。chunked 实现为每个段分配堆外内存,适用于存储小型键值对。由于 chunked 实现仍然处于实验阶段,所以我们选择 linked 实现在线上使用,后续介绍也以linked 实现为例,其整体架构及内存分布如下图所示,下文将分别介绍其功能。
OHCacheLinkedImpl
OHCacheLinkedImpl
是堆外缓存的具体实现类,其主要成员包括:
段数组:OffHeapLinkedMap[]
序列化器与反序列化器:CacheSerializer
OHCacheLinkedImpl
中包含多个段,每个段用 OffHeapLinkedMap
来表示。同时,OHCacheLinkedImpl
将Java对象序列化成字节数组存储在堆外,在该过程中需要使用用户自定义的 CacheSerializer
。OHCacheLinkedImpl
的主要工作流程如下:
-
计算 key 的 hash值,根据 hash值 计算段号,确定其所处的
OffHeapLinkedMap
-
从
OffHeapLinkedMap
中获取该键值对的堆外内存指针 -
对于 get 操作,从指针所指向的堆外内存读取 byte[],把 byte[] 反序列化成对象
-
对于 put 操作,把对象序列化成 byte[],并写入指针所指向的堆外内存
段的实现:OffHeapLinkedMap
在OHC中,每个段用 OffHeapLinkedMap
来表示,段中包含多个分桶,每个桶是一个链表,链表中的元素即是缓存条目的堆外地址指针。OffHeapLinkedMap
的主要作用是根据 hash值 找到 键值对 的 堆外地址指针。在查找指针时,OffHeapLinkedMap
先根据 hash值 计算出 桶号,然后找到该桶的第一个元素,然后沿着第一个元素按顺序线性查找。
空间分配
OHC 的 linked 实现为每个键值对分别分配堆外内存,因此键值对实际是零散地分布在堆外。
OHC提供了JNANativeAllocator
和 UnsafeAllocator
这两个分配器,分别使用 Native.malloc(size)
和 Unsafe.allocateMemory(size)
分配堆外内存,用户可以通过配置来使用其中一种。
OHC 会把 key 和 value 序列化成 byte[] 存储到堆外,用户需要通过实现 CacheSerializer
来自定义类完成 序列化 和 反序列化。因此,占用的空间实际取决于用户自定义的序列化方法。
除了 key 和 value 本身占用的空间,OHC 还会对 key 进行 8位 对齐。比如用户计算出 key 占用 3个字节,OHC会将其对齐到8个字节。另外,对于每个键值对,OHC需要额外的64个字节来维护偏移量等元数据。因此,对于每个键值对占用的堆外空间为:
每个条目占用堆外内存 = key占用内存(8位对齐) + value占用内存 + 64字节