一. 为什么用缓存

  • 用户数和访问量越来越大
  • 并发量/吞吐量要求越来越高
  • 连接数或者文件读写存在瓶颈
  • 应用和数据库所做的计算也越来越多

如何能够有效利用有限的资源来提供尽可能大的吞吐量?一个有效的办法就是引入缓存

什么是缓存?

缓存(cache)最初用于CPU和主内存之间,指代访问速度比一般随机存取存储器(RAM)快的一种RAM。如今缓存的概念已被扩充,CPU与内存之间、内存和硬盘之间、硬盘与网络之间,都存在某种意义上的缓存。从广义上来说,凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为缓存。

举例来说,业务程序和数据库通常运行在不同的物理服务器上,并通过网络访问数据库。网络传输的耗时,自然会增加系统的响应时间。为了降低响应时间、提高系统性能,业务程序可以将从数据库中读取到的部分数据,缓存在本地服务器以供后续使用。

缓存分类:

缓存的分类与实现机制多种多样, 根据缓存与应用的藕合度, 分为 local cache (本地缓存/单机缓存)和 remote cache(分布式缓存):

本地缓存:在应用中的缓存组件

分布式缓存:与应用分离的缓存组件或服务

二. 为什么用本地缓存

本地缓存:在应用中的缓存组件

  • 优点:
  1. 应用和 cache是在同一个进程内部,请求缓存非常快速/性能提升,没有过多的网络开销等
  2. 集群节点扩容简单/快速
  3. 堆机器就能快速实现吞吐量提升
  • 缺点:
  1. 集群各节点都需要维护单独的缓存, 对内存等资源是一种浪费 (用空间换时间)
  2. 可能存在缓存数据和真实数据不一致
  3. 可能需要额外的数据同步机制

分布式缓存:与应用分离的缓存组件或服务

  • 优点:
  1. 缓存本身就是一个独立的应用,与本地应用隔离,多个应用可直接的共享缓存(占用资源少)
  2. 不存在数据一致性问题
  • 缺点:
  1. 有网络开销, 响应速度相对本地缓存较慢
  2. 集群节点扩容数据同步复杂
  3. 单点故障: 缓存组件挂掉, 整个应用不可用
  4. 存在连接数和访问上限

为什么选择本地缓存?

  1. 对响应速度要求比较高
  2. 需要支撑较高的吞吐量

哪些数据可以存储到本地缓存?

1.访问频繁的数据

2.静态基础数据(长时间内不变的数据)

3.相对静态数据(短时间内不变的数据)

主流的本地缓存框架:

Map

Guava Cache

Caffeine

EhCache

Ohcache

三. 为什么用堆外缓存

本地缓存具体到JVM应用,又可以分为堆内缓存(Heap)和堆外缓存(Heap-Off)

Java程序运行时,由Java虚拟机(JVM)管理的内存区域称为堆(heap)。

由于GC时需要扫描堆,并且在扫描时需要暂停应用线程(stop-the-world,STW),因此,缓存数据过多必然导致GC开销增大,从而影响应用程序性能。

与堆内空间不同,堆外空间不影响GC,由应用程序自身负责分配与释放内存。因此,当缓存数据量较大(达到G以上级别)时,可以使用堆外缓存来提升性能。

那么缓存数据进入老年代,出现堆积,为何会导致YGC时间过长呢?

  1. 在YGC阶段,涉及到垃圾标记的过程,从GCRoot开始标记。
  2. 因为YGC不涉及到老年代的回收,一旦从GCRoot扫描到引用了老年代对象时,就中断本次扫描。这样做可以减少扫描范围,加速YGC。
  3. 存在被老年代对象引用的年轻代对象,它们没有被GCRoot直接或者间接引用。
  4. YGC阶段中的old-gen scanning即用于扫描被老年代引用的年轻代对象。
  5. old-gen scanning扫描时间与老年代内存占用大小成正比。
  6. 得到结论,老年代内存占用增大会导致YGC时间变长。

总的来说,将缓存数据在JVM内存会对垃圾回收造成一定影响:

  1. 缓存数据最初缓存到年轻代,会增加YGC的频率。
  2. 缓存数据被提升到老年代,会增加FGC的频率。
  3. 老年代的缓存数据增长后,会延长old-gen scanning时间,从而增加YGC耗时。

堆内缓存: 是指将数据缓存在JVM进程堆内的机制

  1. 优点是直接在 heap区内读写,速度快
  2. 缺点是缓存的数据量非常有限
  3. 同时缓存时间受 GC影响
  4. 数据过多会导致GC开销增大,从而影响应用程序性能

堆外缓存: 是指将数据缓存在JVM进程堆外的机制

  1. 读写比堆内相对要慢
  2. 优点是堆外空间不受GC影响
  3. 缓存数据量较大(G以上级别)时, 且仍有较高的性能

主流堆内缓存:

  1. LinkedHashMap:Java自带类,内置LRU驱逐策略的实现(access-order);多线程访问需要自己实现同步。
  2. Guava Cache:Google Guava工具包中的缓存实现,支持LRU驱逐策略;支持多线程并发访问,支持按时间过期,但只有在访问时才清除过期数据。
  3. Ehcache:支持多种驱逐策略:LFU、LRU、FIFO,支持持久化和集群。性能跟Guava Cache比相当。
  4. Caffeine:支持W-TinyLFU驱逐策略,Benchmark测试读写性能是Guava Cache的6倍左右。

主流堆外缓存:

  1. OHCache:支持缓存驱逐和过期(Cassandra/HugeGraph使用的缓存库)
  2. ChronicleMap:支持Hash结构,性能好,不支持缓存驱逐
  3. MapDB:支持Tree结构,可顺序扫描,不支持缓存驱逐
  4. Ehcache3:BigMemory收费

四. 为什么选择OHCache

目前在市面上, 有诸多的堆外缓存框架, 比如mapdb,ohc,ehcache3等,

但是由于ehcache3收费,所以这里不做讨论,主要讨论mapdb和ohc这两个。

我们先通过benchmark来筛选一下二者的性能差异

从上面的结果可以看出,ohc性能性能十倍于mapdb。而且由于ohc本身支持entry过期,但是mapdb不支持。

所以这里综合一下,选择ohc作为我们的堆外缓存组件

  1. 性能卓越
  2. 支持容量大, GB级别
  3. 不影响GC
  4. API简单, 学习成本低, 能快速上手
  5. 适合于离线数据,更新周期比较长

五. OHCache怎么用

1. OHCache介绍

OHC全称为off-heap-cache,即堆外缓存,是一款基于Java的key-value堆外缓存框架。OHC是2015年针对Apache Cassandra开发的缓存框架,后来从Cassandra项目中独立出来,成为单独的类库,其项目地址为 https://github.com/snazy/ohc

Cassandra是一套开源分布式NoSQL数据库系统。它最初由Facebook开发,用于储存收件箱等简单格式数据,集GoogleBigTable的数据模型与Amazon Dynamo的完全分布式的架构于一身Facebook于2008将 Cassandra 开源,此后,由于Cassandra良好的可扩展性,被Digg、Twitter等知名[Web 2.0](https://baike.baidu.com/item/Web 2.0)网站所采纳,成为了一种流行的分布式结构化数据存储方案。[百度百科]

2. OHCache特性

相对于持久化数据库,可用的内存空间更少、速度也更快,因此通常将访问频繁的数据放入堆外内存进行缓存,并保证缓存的时效性。OHC主要具有以下特性来满足需求:

1、数据存储在堆外,不影响GC
2、支持为每个缓存项设置过期时间
3、支持配置LRU、W-TinyLFU逐出策略
4、能够维护大量的缓存条目(百万量级以上)
5、支持异步加载缓存
6、读写速度在微秒级别

3. OHC使用示例

OHC以键值对的形式缓存数据,这里以key和value都是String类型为例,

  1. 首先需要在项目pom中加入OHC依赖。
<dependency><groupId>org.caffinitas.ohc</groupId>    <artifactId>ohc-core</artifactId><version>0.7.4</version>
</dependency>
  1. OHC是将Java对象序列化后存储在堆外,因此用户需要实现 org.caffinitas.ohc.CacheSerializer 类,OHC会运用其实现类来序列化和反序列化对象。例如,以下例子是对 string 进行的序列化实现
public class StringSerializer implements CacheSerializer<String> {/*** 计算字符串序列化后占用的空间** @param value 需要序列化存储的字符串* @return 序列化后的字节数*/@Overridepublic int serializedSize(String value) {byte[] bytes = value.getBytes(Charsets.UTF_8);// 设置字符串长度限制,2^16 = 65536if (bytes.length > 65536)throw new RuntimeException("encoded string too long: " + bytes.length + " bytes");// 设置字符串长度限制,2^16 = 65536return bytes.length + 2;}/*** 将字符串对象序列化到 ByteBuffer 中,ByteBuffer是OHC管理的堆外内存区域的映射。** @param value 需要序列化的对象* @param buf   序列化后的存储空间*/@Overridepublic void serialize(String value, ByteBuffer buf) {// 得到字符串对象UTF-8编码的字节数组byte[] bytes = value.getBytes(Charsets.UTF_8);// 用前16位记录数组长度buf.put((byte) ((bytes.length >>> 8) & 0xFF));buf.put((byte) ((bytes.length) & 0xFF));buf.put(bytes);}/*** 对堆外缓存的字符串进行反序列化** @param buf 字节数组所在的 ByteBuffer* @return 字符串对象.*/@Overridepublic String deserialize(ByteBuffer buf) {// 判断字节数组的长度int length = (((buf.get() & 0xff) << 8) + ((buf.get() & 0xff)));byte[] bytes = new byte[length];// 读取字节数组buf.get(bytes);// 返回字符串对象return new String(bytes, Charsets.UTF_8);}
}
  1. 将CacheSerializer的实现类作为参数,传递给OHCache的构造函数来创建OHCache
import org.caffinitas.ohc.Eviction;
import org.caffinitas.ohc.OHCache;
import org.caffinitas.ohc.OHCacheBuilder;public class OffHeapCacheExample {public static void main(String[] args) {OHCache<String, String> ohCache = OHCacheBuilder.<String, String>newBuilder().keySerializer(new StringSerializer()).valueSerializer(new StringSerializer()).eviction(Eviction.LRU).build();ohCache.put("hello", "world");System.out.println(ohCache.get("hello")); // world}
}

4. OHCache原理

4.1 整体架构

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 实现为例,其整体架构及内存分布如下图所示,下文将分别介绍其功能。

4.2 OHCacheLinkedImpl

OHCacheLinkedImpl是堆外缓存的具体实现类,其主要成员包括:

段数组:OffHeapLinkedMap[]

序列化器与反序列化器:CacheSerializer

OHCacheLinkedImpl 中包含多个段,每个段用 OffHeapLinkedMap 来表示。同时,OHCacheLinkedImpl 将Java对象序列化成字节数组存储在堆外,在该过程中需要使用用户自定义的 CacheSerializer。OHCacheLinkedImpl 的主要工作流程如下:

1、计算 key 的 hash值,根据 hash值 计算段号,确定其所处的 OffHeapLinkedMap

2、从 OffHeapLinkedMap 中获取该键值对的堆外内存指针

3、对于 get 操作,从指针所指向的堆外内存读取 byte[],把 byte[] 反序列化成对象

4、对于 put 操作,把对象序列化成 byte[],并写入指针所指向的堆外内存

5、对于 remove 操作,直接释放内存

4.3 OffHeapLinkedMap

在OHC中,每个段用 OffHeapLinkedMap 来表示,段中包含多个分桶,每个桶是一个链表,链表中的元素即是缓存条目的堆外地址指针。OffHeapLinkedMap 的主要作用是根据 hash值 找到 键值对 的 堆外地址指针。在查找指针时,OffHeapLinkedMap 先根据 hash值 计算出 桶号,然后找到该桶的第一个元素,然后沿着第一个元素按顺序线性查找。

举个例子,OffHeapLinkedMap中包含两个分桶,分桶1中有两个键值对:

  • 元素1:name:Jack,堆外地址为1024
  • 元素2:age:20,堆外地址为8192

分桶2中也有两个键值对:

  • 元素1:animal:cat,堆外地址为2048
  • 元素2:color:black,堆外地址为4096

同时,所有分桶第一个元素的地址,会存在一个连续的内存空间。这里我们假设该空间从12000出开始,那么12000出将存储1024(分桶1首元素的地址)和2048(分桶2首元素的地址)。上述示例的数据在堆外分布如下图所示。需要注意的是,上述数据均保存在堆外,在堆内只需要保存一个地址指针(12000)即可。当我们要查找color对应的值时,

  1. 先计算color的hash值
  2. 根据hash值计算桶号,这里是2号分桶
  3. 从堆外12000出,获取2号分桶对应的起始地址,这里是2048
  4. 访问2048,发现key是animal, 和 color不匹配,得到下一个地址4096
  5. 访问4096,发现命中color,返回

  • 确定segmentIndex/tableIndex

OHC中包含多个段,每个段又包含多个桶,在读取和写入时,OHC会根据hash值自动计算段号和桶号

int i = Util.bitNum((long)segments) - 1;
this.segmentShift = 64 - i;
this.segmentMask = (long)segments - 1L << this.segmentShift;private CheckSegment segment(long hash){int seg = (int) ((hash & segmentMask) >>> segmentShift);return maps[seg];
}
this.mask = hashTableSize - 1;
private int bucketIndexForHash(long hash) {return (int)(hash & (long)this.mask);
}
  • add/replace
  1. 先计算并分配内存, 如果分配失败则remove

  2. 写入key和value到堆外内存, 计算hash, 初始化header

  3. 计算seg索引(hash & this.segmentMask) >>> this.segmentShift

  4. 计算bucket索引(mask&hash)拿到首节点, 遍历链表

    4.1 如果比较key是否已存在, 如果存在且未过期, 则将value进行替换, 如果过期先将老的移除(包括从TimeOut保证有enoughCapaticy(不够先回收expire的, 再不够按evict策略回收)

  5. 检查是否需要rehash (rehash后直接替换整个table)

  6. 直接add到table对应的bucket的head节点, 如果expireAt>0添加到timeOut中, 更新LRU链表

  7. 更新freeCapacity

  • get
  1. 计算hash和segmentIndex和tableIndex

  2. 遍历table的bucket判断该node是否存在

    2.1 如果存在且过期先删除老的node, 如果未过期

    2.2 如果存在未过期更新LRU和refrenceCount++, 返回addr

  3. 根据offset获取value序列化后返回

  • remove

同步删除, 访问时先检查若过期先删除

  1. 计算hash和segmentIndex和tableIndex
  2. 遍历table对应bucket判断该node是否存在, 如果不存在直接返回
  3. 存在则重新链接bucket中链表, 在timeout中删除并更新, 从LRU删除并更新

实际测试各操作耗时:

add/put 操作约为<=100us

remove/query 操作约为20~30us

4.4 Entry空间分布

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个字节来维护偏移量等元数据。因此,对于每个键值对占用的堆外空间为:

每个条目占用堆外内存 = 64字节 + key占用内存(8位对齐) + value占用内存

其中64个字节的元数据及偏移量如下:

// offset of LRU replacement strategy next pointer (8 bytes, long)
static final long ENTRY_OFF_LRU_NEXT = 0;
// offset of LRU replacement strategy previous pointer (8 bytes, long)
static final long ENTRY_OFF_LRU_PREV = 8;
// offset of next hash entry in a hash bucket (8 bytes, long)
static final long ENTRY_OFF_NEXT = 16;
// offset of entry reference counter (4 bytes, int)
static final long ENTRY_OFF_REFCOUNT = 24;
// offset of entry sentinel (4 bytes, int)
static final long ENTRY_OFF_SENTINEL = 28;
// slot in which the entry resides (8 bytes, long)
static final long ENTRY_OFF_EXPIRE_AT = 32;
// LRU generation (4 bytes, int, only 2 distinct values)
static final long ENTRY_OFF_GENERATION = 40;
// bytes 44..47 unused
// offset of serialized hash value (8 bytes, long)
static final long ENTRY_OFF_HASH = 48;
// offset of serialized value length (4 bytes, int)
static final long ENTRY_OFF_VALUE_LENGTH = 56;
// offset of serialized hash key length (4 bytes, int)
static final long ENTRY_OFF_KEY_LENGTH = 60;
// offset of data in first block
static final long ENTRY_OFF_DATA = 64;

4.5 过期时间实现

OHC保留了64个字节存储键值对的元数据,其中包含用户设置的过期时间。

  • 过期键管理结构

采用类似hashTable的结构, 根据expireAt计算slot, slot结构位于堆外连续内存空间, 单个entry占用16个字节(hashEntryAdr+expireAt)

final class Timeouts
{private final long slotBitmask;private final int precisionShift;private final int slotCount;private final Slot[] slots;private final Ticker ticker;
private final class Slot {// minimum number of entries in a slotprivate static final int MIN_LEN = 16;private static final int ENTRY_SIZE = 8 + 8;// Each entry in a slot consists of two 8-byte values:// 1. pointer to hashEntryAdr// 2. expireAtprivate long addr;private int allocLen;private int len;private int used;private int min0;
}
  • 过期机制开启

builde时设置timeout, 且defaultTTLmillis>0即可

如果不用默认的timeoutsSlots和timeoutsPrecision, 需显式设置timeoutsSlots/timeoutsPrecision

private static OHCache<String, String> cache = OHCacheBuilder.<String,String>newBuilder().keySerializer(StringCacheSerializer.INSTANCE).valueSerializer(StringCacheSerializer.INSTANCE).eviction(Eviction.LRU).capacity(2*1024*1024).hashTableSize(16).segmentCount(2).timeouts(true).defaultTTLmillis(1000L).build();
  • 实现随机过期时间

builder的构建的ttl是统一的默认的超时时间, 如果想实现随机的超时时间

调用Cache对应的重载方法

public boolean put(K k, V v, long expireAt);
boolean addOrReplace(K key, V old, V value, long expireAt);
boolean putIfAbsent(K key, V value, long expireAt);
  • 可能存在的问题

OHC过期键删除使用的是“惰性删除”的策略, 即操作Key前先检查是否过期, 过期则删除

可能存在的问题, 如果遇到瓶颈可做优化:

  1. 大量失效键, 内存利用率不高(当内存不够时, 会evict)
  2. 过期键同步删除: 可以添加到队列异步删除
  3. 若不需要过期功能, 则不要配置从而节省空间&提高性能

4.6 异步回源实现

如果回源比较耗时, 可以选择异步回源的方式.

此时CacheBuilder构建时必须通过executorService显式配置线程池

其原理是先在堆外创建一个没有value中间状态的Entry, 将load的操作提交到配置的executorService, 等value获取load成功后替换成完整的Entry

public interface OHCache<K, V> extends Closeable{Future<V> getWithLoaderAsync(K key, CacheLoader<K, V> loader);Future<V> getWithLoaderAsync(K key, CacheLoader<K, V> loader, long expireAt);
} public interface CacheLoader<K, V>{V load(K key) throws PermanentLoadException, Exception;
}

4.7 OHCacheBuilder配置参数

OHCache构建配置参数

Field Meaning Default
keySerializer Serializer implementation used for keys Must be configured
valueSerializer Serializer implementation used for values Must be configured
executorService Executor service required for get operations using a cache loader. E.g. OHCache.getWithLoaderAsync(Object, CacheLoader) (Not configured by default meaning get operations with cache loader not supported by default)
segmentCount Number of segments 2 * number of CPUs (java.lang.Runtime.availableProcessors())
hashTableSize Initial size of each segment’s hash table 8192
loadFactor Hash table load factor. I.e. determines when rehashing occurs. .75f
capacity Capacity of the cache in bytes 16 MB * number of CPUs (java.lang.Runtime.availableProcessors()), minimum 64 MB
chunkSize If set and positive, the chunked implementation will be used and each segment will be divided into this amount of chunks. 0 - i.e. linked implementation will be used
fixedEntrySize If set and positive, the chunked implementation with fixed sized entries will be used. The parameter chunkSize must be set for fixed-sized entries. 0 - i.e. linked implementation will be used, if chunkSize is also 0
maxEntrySize Maximum size of a hash entry (including header, serialized key + serialized value) (not set, defaults to capacity divided by number of segments)
throwOOME Throw OutOfMemoryError if off-heap allocation fails false
hashAlgorighm Hash algorithm to use internally. Valid options are: XX for xx-hash, MURMUR3 or CRC32 Note: this setting does may only help to improve throughput in rare situations - i.e. if the key is very long and you’ve proven that it really improves performace MURMUR3
unlocked If set to true, implementations will not perform any locking. The calling code has to take care of synchronized access. In order to create an instance for a thread-per-core implementation, set segmentCount=1, too. false
defaultTTLmillis If set to a value > 0, implementations supporting TTLs will tag all entries with the given TTL in milliseconds. 0
timeoutsSlots The number of timeouts slots for each segment - compare with hashed wheel timer. 64
timeoutsPrecision The amount of time in milliseconds for each timeouts-slot. 128
ticker Indirection for current time - used for unit tests. Default ticker using System.nanoTime() and System.currentTimeMillis()
eviction Choose the eviction algorithm to use. Available are:LRU: Plain LRU - least used entry is subject to evictionW-WinyLFU: Enable use of Window Tiny-LFU. The size of the frequency sketch (“admission filter”) is set to the value of hashTableSize. See this article for a description.None: No entries will be evicted - this effectively provides a capacity-bounded off-heap map. LRU
frequencySketchSize Size of the frequency sketch used by W-WinyLFU Defaults to hashTableSize.
edenSize Size of the eden generation used by W-WinyLFU relative to a segment’s size 0.2

几个重要的配置参数

配置参数 注意点
capacity 根据机器实际内存和实际业务量
keySerializer 尽量选用占用空间小的序列化方法, 如Kryo
valueSerializer 尽量选用占用空间小的序列化方法, 如Kryo
segmentCount OHC使用了分段锁, 多个线程访问同一个段时会导致竞争,所以段数量不宜设置过小。同时,当段内条目数量达到一定负载时OHC会自动rehash,段数量过小则会允许段内存储的条目数量增加,从而可能导致段内频繁进行rehash,影响性能。另一方面,段的元数据是存储在堆内的,过大的段数量会占用堆内空间。因此,应该在尽量减少rehash的次数的前提下,结合业务的QPS等参数,将段数量设置为较小的值
hashTableSize 结合segmentCount和实际数据量(热数据量)评估, 尽量避免rehash(同步)影响性能
eviction 如果有周期性或偶发性的批量操作可以选择Tiny-LFU,一般选用LRU即可
hashAlgorighm 区别不大, 如果使用JDK11及以上使用CRC32C ( CPU使用率相对较低)

5. 实践优化

5.1 序列化的选择

如上文所说,OHC 是一款 key-value 形式的缓存框架,并且对 key 和 value 都提供了泛型支持。因此,使用方在创建 OHC对象时就需要确定 key 和 value 的类型。

一般使用场景中,使用OHC时 key 设置为 String 类型,value 则设置为 Object类型,从而可以存储各种类型的对象。由于 OHC 需要把 key 和 value 序列化成字节数组存储到堆外,因此需要选择合适的序列化工具。

对于String类型的key,其序列化过程比较简单,可以直接转换成UTF-8格式的字节数组来表示。对于Object类型的 value,则选用了开源的 Kyro 作为序列化工具。需要注意的是,由于Kyro不是线程安全的,可以搭配ThreadLocal一起使用。

在使用OHC时,通常有两个地方用到序列化。在存储每个键值对时,会调用 CacheSerializer#serializedSize 计算序列化后的内存空间占用,从而申请堆外内存。另外,在真正写入堆外时,会调用 CacheSerializer#serialize 真正进行序列化。因此,务必在这两个方法中使用相同的序列化方法。

public interface CacheSerializer<T> {void serialize(T var1, ByteBuffer var2);T deserialize(ByteBuffer var1);int serializedSize(T var1);
}

基本类型的包装类型length是确定, 实现比较简单. String类型可以将length和value一起写入.

public class LongCacheSerializer implements CacheSerializer<Long> {public static final LongCacheSerializer INSTANCE = new LongCacheSerializer();private LongCacheSerializer(){}@Overridepublic void serialize(Long value, ByteBuffer buf) {buf.putLong(value);}@Overridepublic Long deserialize(ByteBuffer buf) {return buf.getLong();}@Overridepublic int serializedSize(Long value) {return Longs.toByteArray(value).length;}
}
public class StringCacheSerializer implements CacheSerializer<String> {public static final StringCacheSerializer INSTANCE = new StringCacheSerializer();private StringCacheSerializer(){}/*** 计算字符串序列化后占用的空间** @param value 需要序列化存储的字符串* @return 序列化后的字节数*/@Overridepublic int serializedSize(String value) {byte[] bytes = value.getBytes(StandardCharsets.UTF_8);// 设置字符串长度限制,2^16 = 65536if (bytes.length > 65536)throw new RuntimeException("encoded string too long: " + bytes.length + " bytes");// 设置字符串长度限制,2^16 = 65536return bytes.length + 2;}/*** 将字符串对象序列化到 ByteBuffer 中,ByteBuffer是OHC管理的堆外内存区域的映射。** @param value 需要序列化的对象* @param buf   序列化后的存储空间*/@Overridepublic void serialize(String value, ByteBuffer buf) {// 得到字符串对象UTF-8编码的字节数组byte[] bytes = value.getBytes(StandardCharsets.UTF_8);// 用前16位记录数组长度buf.put((byte) ((bytes.length >>> 8) & 0xFF));buf.put((byte) ((bytes.length) & 0xFF));buf.put(bytes);}/*** 对堆外缓存的字符串进行反序列化** @param buf 字节数组所在的 ByteBuffer* @return 字符串对象.*/@Overridepublic String deserialize(ByteBuffer buf) {// 判断字节数组的长度int length = (((buf.get() & 0xff) << 8) + ((buf.get() & 0xff)));byte[] bytes = new byte[length];// 读取字节数组buf.get(bytes);// 返回字符串对象return new String(bytes, StandardCharsets.UTF_8);}
}
public class OHCCacheSerializer<T> implements CacheSerializer<T> {private final ThreadLocal<ByteBufferOutput> outputLocal = new ThreadLocal<>();private final ThreadLocal<Input> inputLocal = new ThreadLocal<>();private static final ThreadLocal<Kryo> kryoLocal = ThreadLocal.withInitial(() -> {Kryo kryo = new Kryo();kryo.setReferences(true);kryo.setRegistrationRequired(false);((DefaultInstantiatorStrategy) kryo.getInstantiatorStrategy()).setFallbackInstantiatorStrategy(new StdInstantiatorStrategy());return kryo;});public OHCCacheSerializer(Class<T> clazz){this.clazz = clazz;}private Class<T> clazz;@Overridepublic void serialize(T value, ByteBuffer buf) {Kryo kryo = getKryo();Output output = getOutput(buf);kryo.writeObjectOrNull(output, value, this.clazz);output.flush();}@Overridepublic T deserialize(ByteBuffer buf) {Kryo kryo = getKryo();Input input = getInput(buf);return kryo.readObjectOrNull(input,this.clazz);}@Overridepublic int serializedSize(T value) {Kryo kryo = getKryo();return getKryoSerializerLength(value, kryo);}private int getKryoSerializerLength(Object value, Kryo kryo) {ByteArrayOutputStream outputStream = new ByteArrayOutputStream();Output output = new Output(outputStream);kryo.writeClassAndObject(output, value);output.flush();output.close();return outputStream.size();}private Kryo getKryo() {return kryoLocal.get();}private Output getOutput(ByteBuffer buffer) {ByteBufferOutput output;if ((output = outputLocal.get()) == null) {output = new ByteBufferOutput();outputLocal.set(output);}if (buffer != null) {output.setBuffer(buffer);}return output;}private Input getInput(ByteBuffer buffer) {Input input;if ((input = inputLocal.get()) == null) {input = new Input();inputLocal.set(input);}if (buffer != null) {byte[] bytes = new byte[buffer.capacity()];buffer.get(bytes);input.setBuffer(bytes);}return input;}
}

5.2 监控或配置告警

定时打印缓存使用统计信息或配置告警, 统计信息包含:

public final class OHCacheStats
{private final long hitCount;private final long missCount;private final long evictionCount;private final long expireCount;private final long[] segmentSizes;private final long capacity;private final long free;private final long size;private final long rehashCount;private final long putAddCount;private final long putReplaceCount;private final long putFailCount;private final long removeCount;private final long totalAllocated;private final long lruCompactions;
}

如果需要严格控制堆外内存大小, 可以在启动参数里添加-XX:MaxDirectMemorySize=size 用于设置 New I/O(java.nio) direct-buffer allocations 的最大大小,size的单位可以使用 k/K、m/M、g/G;

如果没有设置该参数则默认值为0,意味着JVM自己自动给NIO direct-buffer allocations选择最大大小;

从代码java.base/jdk/internal/misc/VM.java中可以看到默认是取的Runtime.getRuntime().maxMemory(

注意: 不要忽略其他使用堆外内存的场景

5.3 缓存预热

实例刚启动时, 为了避免大量请求导致缓存穿透&响应较慢

同时上云后有CPU抑制现象, 首次请求耗时明显

可以在实例启动时, 提前将部分数据或全部数据加载到缓存里

5.4 减少缓存延迟

对准确性/实时性敏感的场景, 如何最大程度保证数据的实时性

  1. expire设置较小 => 接受该级别的数据延迟, 但回源频繁
  2. expire设置较大 => 数据延迟明显,需手动维护数据的一致性

数据更新时, 主动更新缓存

基于zk的watch/通知

基于RPC广播调用(注意漏机器问题)

基于mq广播消息

5.5 避免存放大对象

需要注意的是,get 和 put 的速度 和 缓存的键值对大小呈正相关趋势,因此不建议缓存过大的内容。可以通过maxEntrySize 配置项,来限制存储的最大键值对,OHC发现单个条目超过该值时不会将其放入堆外缓存。

5.6 线上表现

  • 场景

数据量: 10W个item* 2个限时折扣活动*30天 = 600W

需根据itemId和offsetMin来查询最近可参加的限时折扣信息

  • 性能

原来用的caffine, 常驻内存大造成GC频繁, 尖刺比较多, 响应时间增长明显,偶发超时

用OHC后, 全量数据不过期, 占用内存 <= 600M

单机压到多少3K, 查询TP99 <10ms, 批量查询TP99<20ms

六. 参考文档

https://www.cnblogs.com/liang1101/p/13499781.html

https://cloud.tencent.com/developer/article/1903413

https://my.oschina.net/bianxin/blog/4917416

https://zhuanlan.zhihu.com/p/345071202

https://www.cnblogs.com/scy251147/p/9634766.html

https://blog.csdn.net/qq_16046891/article/details/109194240

https://blog.csdn.net/javeme/article/details/104488028/

https://javajgs.com/archives/8310

https://github.com/snazy/ohc

https://gitee.com/mirrors_snazy/ohc

https://javadoc.io/doc/org.caffinitas.ohc/ohc-core/latest/index.html

堆外缓存OHCache使用总结相关推荐

  1. 堆外缓存是什么? OHC 堆外缓存使用简介

    现状 在互联网项目中,一般以堆内缓存的使用居多,无论是 Guava,Memcache,还是 JDK 自带的 HashMap,ConcurrentHashMap 等,都是在堆内内存中做数据计算操作.这样 ...

  2. Java堆外缓存OHC在马蜂窝推荐引擎的应用

    点击上方"马蜂窝技术",关注订阅更多优质内容 在推荐系统中,通常由推荐引擎提供线上推荐服务.推荐引擎的工作流程主要包括召回.排序等阶段,每个阶段都需要大量的数据支撑,快速读取这些数 ...

  3. 一文探讨堆外内存的监控与回收

    引子 记得那是一个风和日丽的周末,太阳红彤彤,花儿五颜六色,96 年的普哥微信找到我,描述了一个诡异的线上问题:线上程序使用了 NIO FileChannel 的 堆内内存作为缓冲区,读写文件,逻辑可 ...

  4. 堆外内存与堆内内存详解

    堆外内存一直是Java业务开发人员难以企及的隐藏领域,究竟他是干什么的,以及如何更好的使用呢?那就请跟着我进入这个世界吧. 一.什么是堆外内存 1.堆内内存(on-heap memory)回顾 堆外内 ...

  5. 一文深入了解史上最强的Java堆内缓存框架Caffeine

    它提供了一个近乎最佳的命中率.从性能上秒杀其他一堆进程内缓存框架,Spring5更是为了它放弃了使用多年的GuavaCache 缓存,在我们的日常开发中用的非常多,是我们应对各种性能问题支持高并发的一 ...

  6. minecraft_MineCraft和堆外内存

    minecraft 总览 MineCraft是一个很好的例子,说明何时堆外内存确实可以提供帮助. 关键要求是: 保留的数据大部分是一个简单的数据结构(在Minecraft的情况下,其很多字节[]) 堆 ...

  7. JVM初探——使用堆外内存减少Full GC

    问题: 大部分主流互联网企业线上Server JVM选用了CMS收集器(如Taobao.LinkedIn.Vdian), 虽然CMS可与用户线程并发GC以降低STW时间, 但它也并非十分完美, 尤其是 ...

  8. java堆外内存6_Java堆外内存排查小结

    简介 JVM堆外内存难排查但经常会出现问题,这可能是目前最全的JVM堆外内存排查思路.之前的文章排版太乱,现在整理重发一下,内容是一样的. 通过本文,你应该了解: pmap 命令 gdb 命令 per ...

  9. Java堆外内存:堆外内存溢出问题排查

    一.堆外内存组成 通常JVM的参数我们会配置 -Xms 堆初始内存  -Xmx 堆最大内存  -XX:+UseG1GC/CMS 垃圾回收器  -XX:+DisableExplicitGC 禁止显示GC ...

最新文章

  1. Spring MVC的异步模式DefferedResult
  2. SpringBoot 如何统一后端返回格式?老鸟们都是这样玩的!
  3. php scandir遍历,php使用scandir()函数扫描指定目录下所有文件示例
  4. linux系统键盘响应时间,怎样提高使用Linux键盘的效率
  5. log4j.xml示例_log4j.xml示例配置
  6. 商务高端、CPU要求高-笔记本选型
  7. Tp5.1 图片处理:缩略图+水印(换行显示)
  8. 2021-09-0723. 合并K个升序链表
  9. leetcode题目总结
  10. 黑客攻防实战入门读书笔记
  11. 二维码生成代码(转载)
  12. 研究称纯电动汽车起火几率更低,但更难被扑灭
  13. 人可以活很多次,但是七年就是一辈子
  14. payjs插件php,基于payjs的discuz支付插件制作
  15. mesothelioma-弥漫性间皮瘤
  16. 统计|如何理解多元线性回归的F检验的作用与目的
  17. 使用74LS160设计六进制计数器
  18. python定义字符串变量有两种常用方式_Python 1基础语法二(标识符、关键字、变量和字符串)...
  19. 小米公交卡服务器维护,小米公交卡如何退费 小米公交卡快速退费教程
  20. 专访-与 Adobe 面对面

热门文章

  1. python PIL图片拼接
  2. 酷派COOL20s什么时候发布 酷派COOL20s配置如何
  3. Elasticsearch 索引别名应用
  4. 第16节 最好的实践(16.1~16.5)
  5. 网页版简易计算器(仅加减乘除)
  6. js实现图片裁剪效果
  7. HDU-1014 线性同余法
  8. java c des ecb_PHP、Java的Des加密(ECB mode)
  9. 获取美元人民币实时汇率-Python版
  10. linux防火墙reject,Iptables 扩展动作 Reject Mark