一.背景

性能优化是一场永无止境的旅程。

到家门店系统,作为到家核心基础服务之一,门店C端接口有着调用量高,性能要求高的特点。

C端服务经过演进,核心接口先查询本地缓存,如果本地缓存没有命中,再查询Redis。本地缓存命中率99%,服务性能比较平稳。

随着门店数据越来越多,本地缓存容量逐渐增大到3G左右。虽然对垃圾回收器和JVM参数都进行调整,由于本地缓存数据量越来越大,本地缓存数据对于应用GC的影响越来越明显,YGC平均耗时****100ms特别是大促期间调用方接口毛刺感知也越来越明显

由于本地缓存在每台机器上容量是固定的,即便是将机器扩容,对与GC毛刺也没有明显效果。

二.初识此物心已惊-OHC初识

本地缓存位于应用程序的内存中,读取和写入速度非常快,可以快速响应请求,无需额外的网络通信,但是一般本地缓存存在JVM内,数据量过多会影响GC,造成GC频率、耗时增加;如果用Redis的话有网络通信的开销。

框架          | 简介                                                                                                          | 特点                                                                                                                                                               | 堆外缓存 | 性能(一般情况) |

| ----------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | -------- |

| Guava Cache | Guava Cache是Google的本地缓存库,提供了基本的缓存功能。它简单易用、轻量级,并支持基本的缓存操作。                                                   | ·支持最大容量限制 ·支持两种过期删除策略(插入时间和访问时间) ·支持简单的统计功能 ·基于LRU算法实现                                                                                                           | 不支持  | 性能中等     |

| Caffeine    | Caffeine是一个高性能的本地缓存库,提供了丰富的功能和配置选项。它支持高并发性能、低延迟和一些高级功能,如缓存过期、异步刷新和缓存统计等。                                    | ·提供了丰富的功能和配置选项;高并发性能和低延迟;支持缓存过期、异步刷新和缓存统计等功能; ·基于java8实现的新一代缓存工具,缓存性能接近理论最优。 ·可以看作是Guava Cache的增强版,功能上两者类似,不同的是Caffeine采用了一种结合LRU、LFU优点的算法:W-TinyLFU,在性能上有明显的优越性 | 不支持  | 性能出色     |

| Ehcache     | Encache是一个纯Java的进程内缓存框架,具有快速、精干等特点,是Hibernate中默认的CacheProvider。同Caffeine和Guava Cache相比,Encache的功能更加丰富,扩展性更强 | ·支持多种缓存淘汰算法,包括LRU、LFU和FIFO ·缓存支持堆内存储、堆外存储、磁盘存储(支持持久化)三种 ·支持多种集群方案,解决数据共享问题                                                                                       | 支持   | 性能一般     |

| **OHC**     | OHC(Off-Heap Cache)是一个高性能的堆外缓存库,专为高并发和低延迟而设计。它使用堆外内存和自定义的数据结构来提供出色的性能                                       | ·针对高并发和低延迟进行了优化;使用自定义数据结构和无锁并发控制;较低的GC开销; ·在高并发和低延迟的缓存访问场景下表现出色                                                                                              | 支持   | 性能最佳     |

通过对本地缓存的调研,堆外缓存可以很好兼顾上面的问题。堆外缓存把数据放在JVM堆外的,缓存数据对GC影响较小,同时它是在机器内存中的,相对与Redis也没有网络开销,最终选择OHC。

三.习得技能心自安-OHC使用

talk is cheap, show me the code! OCH是骡子是马我们遛一遛。

1.引入POM

OHC 存储的是二进制数组,需要实现OHC序列化接口,将缓存数据与二进制数组之间序列化和反序列化

这里使用的是Protostuff,当然也可以使用kryo、Hession等,通过压测验证选择适合的序列化框架。

<!--OHC相关-->
<dependency>
<groupId>org.caffinitas.ohc</groupId>
<artifactId>ohc-core</artifactId>
<version>0.7.4</version>
</dependency> <!--OHC 存储的是二进制数组,所以需要实现OHC序列化接口,将缓存数据与二进制数组之间序列化和反序列化-->
<!--这里使用的是protostuff,当然也可以使用kryo、Hession等,通过压测验证选择适合的-->
<dependency>
<groupId>io.protostuff</groupId>
<artifactId>protostuff-core</artifactId>
<version>1.6.0</version>
</dependency>
<dependency>
<groupId>io.protostuff</groupId>
<artifactId>protostuff-runtime</artifactId>
<version>1.6.0</version>
</dependency>

2.创建OHC缓存

OHC缓存创建

OHCache<String, XxxxInfo> basicStoreInfoCache = OHCacheBuilder.<String, XxxxInfo>newBuilder()
.keySerializer(new OhcStringSerializer()) //key的序列化器
.valueSerializer(new OhcProtostuffXxxxInfoSerializer()) //value的序列化器
.segmentCount(512) // 分段数量 默认=2*CPU核数
.hashTableSize(100000)// 哈希表大小 默认=8192
.capacity(1024 * 1024 * 1024) //缓存容量 单位B 默认64MB
.eviction(Eviction.LRU) // 淘汰策略 可选LRU\W_TINY_LFU\NONE
.timeouts(false) //不使用过期时间,根据业务自己选择
.build();

自定义序列化器,这里key-String 序列化器,这里直接复用OCH源码中测试用例的String序列化器

value-自定义对象序列化器,这里用Protostuff实现,也可以自己选择使用kryo、Hession等实现;

//key-String 序列化器,这里直接复用OCH源码中测试用例的String序列化器
public class OhcStringSerializer implements CacheSerializer<String> { @Override
public int serializedSize(String value) {
return writeUTFLen(value);
} @Override
public void serialize(String value, ByteBuffer buf) {
// 得到字符串对象UTF-8编码的字节数组
byte[] bytes = value.getBytes(Charsets.UTF_8);
buf.put((byte) ((bytes.length >>> 8) & 0xFF));
buf.put((byte) ((bytes.length >>> 0) & 0xFF));
buf.put(bytes);
} @Override
public String deserialize(ByteBuffer buf) {
int length = (((buf.get() & 0xff) << 8) + ((buf.get() & 0xff) << 0));
byte[] bytes = new byte[length];
buf.get(bytes);
return new String(bytes, Charsets.UTF_8);
}
static int writeUTFLen(String str) {
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;
}
} if (utflen > 65535) {
throw new RuntimeException("encoded string too long: " + utflen + " bytes");
}
return utflen + 2;
}
} //value-自定义对象序列化器,这里用Protostuff实现,可以自己选择使用kryo、Hession等实现
public class OhcProtostuffXxxxInfoSerializer implements CacheSerializer<XxxxInfo> { /**
* 将缓存数据序列化到 ByteBuffer 中,ByteBuffer是OHC管理的堆外内存区域的映射。
*/
@Override
public void serialize(XxxxInfo t, ByteBuffer byteBuffer) {
byteBuffer.put(ProtostuffUtils.serialize(t));
}
/**
* 对堆外缓存的数据进行反序列化
*/
@Override
public XxxxInfo deserialize(ByteBuffer byteBuffer) {
byte[] bytes = new byte[byteBuffer.remaining()];
byteBuffer.get(bytes);
return ProtostuffUtils.deserialize(bytes, XxxxInfo.class);
} /**
* 计算字序列化后占用的空间
*/
@Override
public int serializedSize(XxxxInfo t) {
return ProtostuffUtils.serialize(t).length;
}
}

为了方便调用和序列化封装为工具类,同时对代码通过FastThreadLocal进行优化,提升性能

public class ProtostuffUtils {

    /**
* 避免每次序列化都重新申请Buffer空间,提升性能
*/
private static final FastThreadLocal<LinkedBuffer> bufferPool = new FastThreadLocal<LinkedBuffer>() {
@Override
protected LinkedBuffer initialValue() throws Exception {
return LinkedBuffer.allocate(4 * 2 * LinkedBuffer.DEFAULT_BUFFER_SIZE);
}
}; /**
* 缓存Schema
*/
private static Map<Class<?>, Schema<?>> schemaCache = new ConcurrentHashMap<>(); /**
* 序列化方法,把指定对象序列化成字节数组
*/
@SuppressWarnings("unchecked")
public static <T> byte[] serialize(T obj) {
Class<T> clazz = (Class<T>) obj.getClass();
Schema<T> schema = getSchema(clazz);
byte[] data;
LinkedBuffer linkedBuffer = null;
try {
linkedBuffer = bufferPool.get();
data = ProtostuffIOUtil.toByteArray(obj, schema, linkedBuffer);
} finally {
if (Objects.nonNull(linkedBuffer)) {
linkedBuffer.clear();
}
} return data;
} /**
* 反序列化方法,将字节数组反序列化成指定Class类型
*/
public static <T> T deserialize(byte[] data, Class<T> clazz) {
Schema<T> schema = getSchema(clazz);
T obj = schema.newMessage();
ProtostuffIOUtil.mergeFrom(data, obj, schema);
return obj;
} @SuppressWarnings("unchecked")
private static <T> Schema<T> getSchema(Class<T> clazz) {
Schema<T> schema = (Schema<T>) schemaCache.get(clazz);
if (Objects.isNull(schema)) {
schema = RuntimeSchema.getSchema(clazz);
if (Objects.nonNull(schema)) {
schemaCache.put(clazz, schema);
}
} return schema;
}
}

3.压测及参数调整

通过压测并逐步调整OHC配置常见参数(segmentCount、hashTableSize、eviction,参数含义见附录

MAX对比降低10倍

GC时间对比降低10倍

优化前

优化后

4.OHC缓存状态监控

OHC缓存的命中次数、内存使用状态等存储在OHCacheStats中,可以通过OHCache.stats()获取

OHCacheStates信息:

hitCount :缓存命中次数,表示从缓存中成功获取数据的次数。 missCount :缓存未命中次数,表示尝试从缓存中获取数据但未找到的次数。 evictionCount :缓存驱逐次数,表示因为缓存空间不足而从缓存中移除的数据项数量。 expireCount :缓存过期次数,表示因为缓存数据过期而从缓存中移除的数据项数量。 size :缓存当前存储的数据项数量。 capacity :缓存的最大容量,表示缓存可以存储的最大数据项数量。 free :缓存剩余空闲容量,表示缓存中未使用的可用空间。 rehashCount :重新哈希次数,表示进行哈希表重新分配的次数。 put(add/replace/fail) :数据项添加/替换/失败的次数。 removeCount :缓存移除次数,表示从缓存中移除数据项的次数。 segmentSizes(#/min/max/avg) :段大小统计信息,包括段的数量、最小大小、最大大小和平均大小。 totalAllocated :已分配的总内存大小,表示为负数时表示未知。 lruCompactions :LRU 压缩次数,表示进行 LRU 压缩的次数。

通过定期采集OHCacheStates信息,来监控本地缓存数据、命中率=[命中次数 / (命中次数 + 未命中次数)]等,并添加相关报警。同时通过缓存状态信息,来判断过期策略、段数、容量等设置是否合理,命中率是否符合预期等。

四.剖析根源见真谛-OHC原理

堆外缓存框架(Off-Heap Cache)是将缓存数据存储在 JVM 堆外的内存区域,而不是存储在 JVM 堆中。在 OHC(Off-Heap Cache)中,数据也是存储在堆外的内存区域。

具体来说,OHC 使用 DirectByteBuffer 来分配堆外内存,并将缓存数据存储在这些 DirectByteBuffer 中。

DirectByteBuffer 在 JVM 堆外的内存区域中分配一块连续的内存空间,缓存数据被存储在这个内存区域中。这使得 OHC 在处理大量数据时具有更高的性能和效率,因为它可以避免 JVM 堆的垃圾回收和堆内存限制

OHC 核心OHCache接口提供了两种实现:

  • OHCacheLinkedImpl: 实现为每个条目单独分配堆外内存,最适合中型和大型条目。
  • OHCacheChunkedImpl:实现为每个散列段作为一个整体分配堆外内存,并且适用于小条目。(实验性的,不推荐,不做关注)

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

OHCacheLinkedImpl的主要工作流程如下:

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

  2. 从OffHeapLinkedMap中获取该键值对的堆外内存地址(指针)

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

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

可以将OHC理解为一个key-value结果的map,只不过这个map数据存储是指向堆外内存的内存指针。

指针在堆内,指针指向的缓存数据存储在堆外。那么OHC最核心的其实就是对堆外内存的地址引用的put和get以及发生在其中内存空间的操作了。

对OHCacheLinkedImpl的put、get本地调试

1.put

put核心操作就是

1.申请堆外内存

2.将申请地址存入map;

3.异常时释放内存

第2步其实就是map数据更新、扩容等的一些实现这里不在关注,我们重点关注怎么申请和释放内存的

1.申请内存

通过深入代码发现是调用的IAllocator接口的JNANativeAllocator实现类,最后调用的是com.sun.jna.Native#malloc实现

2.释放内存

通过上面可知释放内存操作的代码

2.get

3.Q&A

在put操作时,上面看到IAllocator有两个实现类,JNANativeAllocator和UnsafeAllocator两个实现类,他们有什么区别?为什么使用JNANativeAllocator?

区别:UnsafeAllocator对内存操作使用的是Unsafe

为什么使用JNANativeAllocator:Native比Unsafe性能更好,差3倍左右,OHC默认使用JNANativeAllocator

在日常我们知道通过ByteBuffer#allocateDirect(int capacity)也可以直接申请堆外内存,通过ByteBuffer源码可以看到内部使用的就是Unsafe类

可以看到,同时DirectByteBuffer内部会调用 Bits.reserveMemory(size, cap);

Bits.reserveMemory方法中,当内存不足时可能会触发fullgc,多个申请内存的线程同时遇到这种情况时,对于服务来说是不能接受的,所以这也是OHC自己进行堆外内存管理的原因。

如果自己进行实现堆外缓存框架,要考虑上面这种情况。

五.总结

1.OHC使用建议

  1. 对于OHC的参数配置、序列化器的选择,没有固定的推荐。可以通过压测逐步调整到最优

  2. 由于OHC需要把key和value序列化成字节数组存储到堆外,因此需要选择合适的序列化工具

  3. 在存储每个键值对时,会调用CacheSerializer#serializedSize计算序列化后的内存空间占用,从而申请堆外内存。另外,在真正写入堆外时,会调用CacheSerializer#serialize真正进行序列化。因此,务必在这两个方法中使用相同的序列化方法,防止序列化的大小与计算出来的大小不一致,导致内存存不下或者多申请,浪费内存空间

2.缓存优化建议

  1. 当本地缓存影响GC时,可以考虑使用OHC减少本地缓存对GC的影响

  2. 区分热点数据,对缓存数据进行多级拆分,如堆内->堆外->分布式缓存(Reids )等;

  3. 将较大缓存对象拆分或者按照业务维度将不同热点数据缓存到不同介质中,减少单一存储介质压力;

  4. 减小缓存对象大小,如缓存JSON字符,可对字段名进行缩写 ,减少存储数据量,降低传输数据量,同时也能保证数据一定的私密性。

对象:{"paramID":1,"paramName":"John Doe"} 正常JSON字符串:{"paramID":1,"paramName":"John Doe"} 压缩字段名JSON字符串:

Hold hold , One more thing....

在使用Guava时,存储25w个缓存对象数据占用空间485M

使用OHCache时,储存60w个缓存对象数据占用数据387M

为什么存储空间差别那么多吶?

Guava 存储的对象是在堆内存中的,对象在 JVM 堆中存储时,它们会占用一定的内存空间,并且会包含对象头、实例数据和对齐填充等信息。对象的大小取决于其成员变量的类型和数量,以及可能存在的对齐需求。同时当对象被频繁创建和销毁时,可能会产生内存碎片。

而 OHC 它将对象存储在 JVM 堆外的直接内存中。由于堆外内存不受 Java 堆内存大小限制,OHC 可以更有效地管理和利用内存。此外,OHC 底层存储字节数组,存储字节数组相对于直接存储对象,可以减少对象的创建和销毁,在一些场景下,直接操作字节数组可能比操作对象更高效,因为它避免了对象的额外开销,如对象头和引用,减少额外的开销。同时将对象序列化为二进制数组存储,内存更加紧凑,减少内存碎片的产生

综上所述,OHC 在存储大量对象时能够更有效地利用内存空间,相对于 Guava 在内存占用方面具有优势。

另外一个原因,不同序列化框架性能不同,将对象序列化后的占用空间的大小也不同

参考及附录

1.OHC常见参数

name 默认值 描述
keySerializer 需要开发者实现 Key序列化实现
valueSerializer 需要开发者实现 Value序列化实现
capacity 64MB 缓存容量单位B
segmentCount 2倍CPU核心数 分段数量
hashTableSize 8192 哈希表的大小
loadFactor 0.75 负载因子
maxEntrySize capacity/segmentCount 缓存项最大字节限制
throwOOME false 内存不足是否抛出OOM
hashAlgorighm MURMUR3 hash算法,可选性MURMUR3、 CRC32, CRC32C (Jdk9以上支持)
unlocked false 读写数据是否加锁,默认是加锁
eviction LRU 驱逐策略,可选项:LRU、W_TINY_LFU、NONE
frequencySketchSize hashTableSize数量 W_TINY_ LFU frequency sketch 的大小
edenSize 0.2 W_TINY_LFU 驱逐策略下使用

2.JNI faster than Unsafe?

https://mail.openjdk.org/pipermail/hotspot-dev/2015-February/017089.html

3.OHC源码

https://github.com/snazy/ohc

4.参考文档

序列化框架对比

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

“堆外缓存”这玩意是真不错,我要写进简历了。

如何使用OHC提升系统吞吐量

作者:京东零售 赵雪召

来源:京东云开发者社区 转载请注明来源

一招MAX降低10倍,现在它是我的了的更多相关文章

  1. 使用Apache Spark 对 mysql 调优 查询速度提升10倍以上

    在这篇文章中我们将讨论如何利用 Apache Spark 来提升 MySQL 的查询性能. 介绍 在我的前一篇文章Apache Spark with MySQL 中介绍了如何利用 Apache Spa ...

  2. 程序员需要经纪人吗?10x 最好的程序员其生产力相当于同行的 10 倍~

    原文地址 10x 起源于技术界一个流行的说法,即最好的程序员是超级明星,其生产力相当于同行的 10 倍: Google 园区以好玩的设施闻名:小憩舱.球坑.按摩.干洗.随便吃到饱的自助餐.(为了拍人才 ...

  3. Hadoop3.0新特性介绍,比Spark快10倍的Hadoop3.0新特性

    Hadoop3.0新特性介绍,比Spark快10倍的Hadoop3.0新特性 Apache hadoop 项目组最新消息,hadoop3.x以后将会调整方案架构,将Mapreduce 基于内存+io+ ...

  4. Web 应用性能提升 10 倍的 10 个建议

    转载自http://blog.jobbole.com/94962/ 提升 Web 应用的性能变得越来越重要.线上经济活动的份额持续增长,当前发达世界中 5 % 的经济发生在互联网上(查看下面资源的统计 ...

  5. Databricks缓存提升Spark性能--为什么NVMe固态硬盘能够提升10倍缓存性能(原创)

    我们兴奋的宣布Databricks缓存的通用可用性,作为统一分析平台一部分的 Databricks 运行时特性,它可以将Spark工作负载的扫描速度提升10倍,并且这种改变无需任何代码修改. 1.在本 ...

  6. 一次 Spark SQL 性能提升10倍的经历(转载)

    1. 遇到了啥问题 是酱紫的,简单来说:并发执行 spark job 的时候,并发的提速很不明显. 嗯,且听我慢慢道来,啰嗦点说,类似于我们内部有一个系统给分析师用,他们写一些 sql,在我们的 sp ...

  7. Nginx:承受3万并发连接数,胜过Apache 10倍

    编者按:Nginx是目前比较重要的开源性负载均衡技术,新浪.网易.六间房等很多网站都将Nginx部署进自己的网站系统架构,并解决部分问题.本文是作者长期的实战经验,很有参考价值. 本文是我撰写的关于搭 ...

  8. 伯克利开源 Confluo,吞吐量是 Kafka 的 4 到 10 倍

    近日伯克利 RISE Lab 开源了一个多数据流实时分布式分析系统 Confluo,它既是一个网络监控和诊断框架,也可以作为时序数据库和发布订阅消息系统. 源码地址:https://github.co ...

  9. fasttext和cnn的比较,使用keras imdb看效果——cnn要慢10倍。

    fasttext: '''This example demonstrates the use of fasttext for text classification Based on Joulin e ...

  10. 搜索 比MySQL快10倍?这可能是目前AWS Aurora最详解读!

    作者介绍 朱阅岸,中国人民大学博士,现供职于腾讯云数据库团队.研究方向主要为数据库系统理论与实现.新硬件平台下的数据库系统以及TP+AP型混合系统.   编者按 Aurora作为AWS云上的关系数据库 ...

随机推荐

  1. 在Windows下用VScode构造shell脚本的IDE

    在linux系统中,大家可以很轻松的开发.调试shell脚本.但是,对于不熟悉linux系统 的小白或者想在Windows下开发shell脚本的人来说,这就有点不友好了.本篇文章就 教大家,在Wind ...

  2. Kali-Linux-配置开发环境

    本文主要讲解JDK.SDK.eclipse-adt.android studio.cpu模式TensorFlow 的安装配置.update:2019-08-30 03:31:46 JDK 当前系统jd ...

  3. mpi转以太网连接200plc通信不上实际问题和解决方法

    西门子S7200plc通信不上实际问题和解决方法 现场通信的同学在现场调试的时候,现在特别是做项目改造的项目,西门子S7200plc通信面临淘汰,但是在改造的项目中还能经常看到他们的身影,下面我们就来 ...

  4. 历时一个月,《穿透Laravel》全书完成!

    近几年来Laravel在PHP领域大放异彩,逐渐成为PHP开发框架中的中流砥柱. 这个系列的文章, 会带你一起探知Laravel框架底层的实现细节.与其他框架相比,Laravel的设计理念确实更为先进 ...

  5. destoon上做纯js实现html指定页面导出word

    因为最近做了范文网站需要,所以要下载为word文档,如果php进行处理,很吃后台服务器,所以想用前端进行实现.查询github发现,确实有这方面的插件. js导出word文档所需要的两个插件: 1 2 ...

  6. 物理standby database的日常维护【转】

    1.停止Standby select process, status from v$managed_standby; --查看备库是否在应用日志进行恢复 alter database recover ...

  7. Cloud Bursting解决方案,Serverless容器降本增效极致体验

    本文分享自华为云社区<DTSE Tech Talk | 第42期:Cloud Bursting解决方案,Serverless容器降本增效极致体验>,作者:华为云社区精选. 线下IDC自建K ...

  8. CF451B

    题目简化和分析: 这题就是判断将一段翻转后是否能变为升序的数组. 我的方法是保存原数组每一个数出现的位置(相同任意一个),让后另外用一个数组存储排好序后的原数组,逐一进行比较. 若同,则跳到下一个元素 ...

  9. Unity csc.rsp文件

    试验版本 Unity2020.3 编译符号,就是#if UNITY_ANDROID这个东西,在处理多平台多分支的情况下挺有用的,但是在ProjectSettings里面设置比较麻烦,不主动代码调用保存 ...

  10. 子组件emit 父组件方法,成功后回调执行子组件方法

    场景: 父组件 update方法 子组件 确定按钮  getlist 刷新列表 子组件点击确定按钮,调用父组件新增接口,新增成功以后,子组件列表刷新 子组件: emit("confirmPa ...