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

现状

在互联网项目中,一般以堆内缓存的使用居多,无论是 Guava,Memcache,还是 JDK 自带的 HashMap,ConcurrentHashMap 等,都是在堆内内存中做数据计算操作。这样做的好处显而易见,用户完全不必在意数据的分配,溢出,回收等操作,全部交由 JVM 来进行处理。

由于 JVM 提供了诸多的垃圾回收算法,可以保证在不影响甚至微影响系统的前提下,做到堆内内存接近完美的管控。君不见,小如图书管理这样的系统,大如整个电商交易平台,都在 JVM 的加持下,服务于几个,十几个,乃至于上亿用户,而在这些系统中,堆内缓存组件所带来的收益可是居功至伟。在自下而上的互联网架构中,堆内缓存就像把卫这宫廷入口的剑士,神圣而庄严,真可谓谁敢横刀立马,唯我堆内缓存将军。

堆内缓存劣势

事物都是有两面性的,堆内缓存在JVM的管理下,纵然无可挑剔,但是在GC过程中产生的程序小停顿和程序大停顿,则像一把利剑一样,斩断了对构造出完美高并发系统的念想。简单的以HashMap这个JDK自带的缓存组件为例,benchmark结果如下:

Benchmark                                   Mode  Cnt          Score          Error             Units
localCacheBenchmark.testlocalCacheSet      thrpt   20      85056.759 ±   126702.544             ops/s

其插入速度最快为85056.759+126702.544=211759.303ops,最慢为0,也就是每秒插入速度最快为20w,最慢为0。之所以为0,是因为HashMap中的数据在快速的增长过程中,引起了频繁的GC操作,为了给当前HashMap腾出足够的空间进行插入操作,不得不释放一些对象。频繁的GC,势必对插入速度有不小的影响,造成应用的偶尔性暂停。所以这也能解释为啥最慢的时候,ops为0了。同时从benchmark数据,我们可以看到误差率为126702.544ops,比正常操作的85056.756要大很多,说明GC的影响,对HashMap的插入操作影响特别的大。

由于GC的存在,堆内缓存操作的ops会受到不小的影响,会造成原本小流量下10ms能够完成的内存计算,大流量下500ms还未完成。如果内存计算过于庞杂,则造成整体流程的ops吞吐量降低,也是极有可能的事儿。所以从这里可以看出,堆内缓存组件,在高并发的压力下,如果计算量巨大,尤其是写操作巨大,使其不会成为护城的利剑,反而成了性能的帮凶,何其可惧。

堆外缓存优势

为了缓解在高并发,高写入操作下,堆内缓存组件造成的频繁GC问题,堆外缓存应运而生。从前面的描述我们知道,堆内缓存是受JVM管控的,所以我们不必担心垃圾回收的问题。但是堆外缓存是不受JVM管控的,所以也不受GC的影响导致的应用暂停问题。但是由于堆外缓存的使用,是以byte数组来进行的,所以需要自己进行序列化反序列化操作。目前已知的知名开源项目中,netty4的buffer pool采用了堆外缓存实现,具体的比对信息截图如下:

在这里插入图片描述

带有Direct字眼的即为offheap堆外Buffer,x轴为分配的内存大小,Y轴为耗时。从上面可以看出,小块内存分配,JVM要稍微优秀一点;但是大块内存分配,明显的堆外缓存要优秀一些。由于堆外Buffer操作不受GC影响,实际上性能更好一些。但是需要的垃圾回收管控也需要自己去做,要麻烦很多

堆外缓存实现

说到堆外缓存实现原理,不可不提到sun.misc.Unsafe这个package包。此包提供了底层的Unsafe操作方法,让我们可以直接在堆外内存做数据分配操作。由于是底层包,所以用户层面很少用到,只是一些jdk里面的核心类库会用到。其实例的初始化方式如下:

public static Unsafe getUnsafe() {
    Class cc = sun.reflect.Reflection.getCallerClass(2);
    if (cc.getClassLoader() != null)
        throw new SecurityException("Unsafe");
    return theUnsafe;
}

可以看出是一个单例模式。让我们来尝试使用一下(下面代码是先分配了一个100bytes的空间,得到分配好的地址,然后在此地址里面放入1,最后将此地址里面的数据取出,打印出来):

long address = unsafe.allocateMemory(100);
unsafe.putLong(address,1);
System.out.println(unsafe.getLong(address));

遇到了以下错误

java.lang.SecurityException: Unsafe
    at sun.misc.Unsafe.getUnsafe(Unsafe.java:90)
    at UnsafeTest.testUnsafe(UnsafeTest.java:18)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    .......

Process finished with exit code -1

可以看出,由于安全性的原因,我们是无法直接使用Unsafe的实例来进行数据操作的,主要原因是因为cc.getClassLoader()对theUnsafe实例做了过滤限制。但是我们可以直接用theUnsafe来实现,由于是private修饰,我们可以用反射来将private修饰改成public修饰,让其暴露出来供我们使用:

Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);
long address = unsafe.allocateMemory(100);
unsafe.putLong(address,1);
System.out.println(unsafe.getLong(address));

这样就可以了,能够正确的获取运行结果。从这里我们可以看出,堆外内存必须自己分配地址空间,那么对应的,自己需要控制好地址边界,如果控制不好,经典的OOM Exception将会出现。这也是比堆内内存使用麻烦的地方。

上面的代码展示,其实已经说明了Unsafe方法的基本使用方式。如果想查看更多的Unsafe实现方式,个人推荐可以看看Cassandra源码中的中的Object mapper - Caffinitas里面关于Unsafe的实现。此类的名称为Uns.java,由于类精简,个人认为很值得一看,我贴出部分代码来:

static{
    try
    {
        Field field = Unsafe.class.getDeclaredField("theUnsafe");
        field.setAccessible(true);
        unsafe = (Unsafe) field.get(null);
        if (unsafe.addressSize() > 8)
            throw new RuntimeException("Address size " + unsafe.addressSize() + " not supported yet (max 8 bytes)");

        if (__DEBUG_OFF_HEAP_MEMORY_ACCESS)
            LOGGER.warn("Degraded performance due to off-heap memory allocations and access guarded by debug code enabled via system property " + OHCacheBuilder.SYSTEM_PROPERTY_PREFIX + "debugOffHeapAccess=true");

        IAllocator alloc;
        String allocType = __ALLOCATOR != null ? __ALLOCATOR : "jna";
        switch (allocType)
        {
            case "unsafe":
                alloc = new UnsafeAllocator();
                LOGGER.info("OHC using sun.misc.Unsafe memory allocation");
                break;
            case "jna":
            default:
                alloc = new JNANativeAllocator();
                LOGGER.info("OHC using JNA OS native malloc/free");
        }

        allocator = alloc;
    }
    catch (Exception e)
    {
        throw new AssertionError(e);
    }
}

。。。。。。
static long getLongFromByteArray(byte[] array, int offset)
{
    if (offset < 0 || offset + 8 > array.length)
        throw new ArrayIndexOutOfBoundsException();
    return unsafe.getLong(array, (long) Unsafe.ARRAY_BYTE_BASE_OFFSET + offset);
}

static int getIntFromByteArray(byte[] array, int offset)
{
    if (offset < 0 || offset + 4 > array.length)
        throw new ArrayIndexOutOfBoundsException();
    return unsafe.getInt(array, (long) Unsafe.ARRAY_BYTE_BASE_OFFSET + offset);
}

static short getShortFromByteArray(byte[] array, int offset)
{
    if (offset < 0 || offset + 2 > array.length)
        throw new ArrayIndexOutOfBoundsException();
    return unsafe.getShort(array, (long) Unsafe.ARRAY_BYTE_BASE_OFFSET + offset);
}

堆外缓存进阶

写到这里,原理什么的大概都懂了,我们准备进阶一下,写个基于Off-heap堆外缓存的Int数组,由于On-heap Array的空间请求分配到了堆上,所以这里自然而然的就把空间分配到了堆外。代码如下:

public class OffheapIntArray {

    /**
     * 此list分配的地址
     */
    private long address;

    /**
     * 默认分配空间大小
     */
    private static final int defaultSize = 1024;

    /**
     * 带参构造
     * 由于Integer类型在java中占用4个字节,所以在分配地址的时候,一个integer,需要分配 4*8 = 32 bytes的空间
     * @param size
     * @throws NoSuchFieldException
     * @throws IllegalAccessException
     */
    public OffheapIntArray(Integer size) throws NoSuchFieldException, IllegalAccessException {
        if (size == null) {
            address = alloc(defaultSize * 4 * 8);
        } else {
            address = alloc(size * 4 * 8);
        }
    }

    public int get(int index) throws NoSuchFieldException, IllegalAccessException {
        return getUnsafe().getInt(address + index * 4 * 8);
    }

    public void set(int index, int value) throws NoSuchFieldException, IllegalAccessException {
        getUnsafe().putInt(address + index * 4 * 8, value);
    }

    private Unsafe getUnsafe() throws IllegalAccessException, NoSuchFieldException {
        Field f = Unsafe.class.getDeclaredField("theUnsafe");
        f.setAccessible(true);
        return (Unsafe) f.get(null);
    }

    private long alloc(int size) throws NoSuchFieldException, IllegalAccessException {
        long address = getUnsafe().allocateMemory(size);
        return address;
    }

    public void free() throws NoSuchFieldException, IllegalAccessException {
        if (address == 0) {
            return;
        }
        getUnsafe().freeMemory(address);
    }
}

测试

@Test
public void testOffheap() throws NoSuchFieldException, IllegalAccessException {
    OffheapIntArray offheapArray = new OffheapIntArray(10);
    offheapArray.set(0,11111);
    offheapArray.set(1,1112);
    offheapArray.set(2,1113);
    offheapArray.set(3,1114);
    System.out.println(offheapArray.get(0));
    System.out.println(offheapArray.get(1));
    System.out.println(offheapArray.get(2));
    System.out.println(offheapArray.get(3));
    offheapArray.free();
}

可以看到得到了正确的输出结果。当然我这里只是简单的模拟使用。具体的使用方式,推荐如下两篇文章,可以对堆外内存的使用有更近一步的认识:

  • Guide to sun.misc.Unsafe:https://www.baeldung.com/java-unsafe
  • Java Magic. Part 4: sun.misc.Unsafe:http://mishadoff.com/blog/java-magic-part-4-sun-dot-misc-dot-unsafe/

堆外缓存组件选择

知道了堆外缓存的简单使用后,这里我们要更近一步,使用现有的堆外缓存组件到项目中。

目前在市面上,有诸多的缓存组件,比如mapdb,ohc,ehcache3等,但是由于ehcache3收费,所以这里不做讨论,主要讨论mapdb和ohc这两个。我们先通过benchmark来筛选一下二者的性能差异,由于这两个缓存组件提供的都是基于key-value模型的数据存储,所以benchmark的指标有9个,分别是get,set方法,hget,hset方法(value存储的是hashmap),sadd,smember方法(value存储的是set),zadd,zrange方法(value存储的是treeset)。

benchmark结果如下:

在这里插入图片描述

从上面的结果可以看出,ohc属于性能怪兽类型,性能十倍于mapdb。而且由于ohc本身支持entry过期,但是mapdb不支持。所以这里综合一下,选择ohc作为我们的堆外缓存组件。需要说明一下的是,在我进行benchmark测试过程中,堆外缓存中会进行大量的数据读写操作,但是这些读写ops整体非常平稳,从error和score的对比就可以看出。不会出现应用暂停的情况。说明GC对堆外缓存的影响是非常小的。

OHC 简介

缓存的分类与实现机制多种多样,包括单机缓存与分布式缓存等等。具体到JVM应用,又可以分为堆内缓存和堆外缓存。

OHC 全称为 off-heap-cache,即堆外缓存,是一款基于Java 的 key-value 堆外缓存框架

OHC是2015年针对 Apache Cassandra 开发的缓存框架,后来从 Cassandra 项目中独立出来,成为单独的类库,其项目地址为:https://github.com/snazy/ohc

OHC 特性

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

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

OHC具有低延迟、容量大、不影响GC的特性,并且支持使用方根据自身业务需求进行灵活配置。

OHC 使用示例

1、引入依赖

<dependency>
    <groupId>org.caffinitas.ohc</groupId>
    <artifactId>ohc-core</artifactId>
    <version>0.7.0</version>
</dependency>

2、OHC是将Java对象序列化后存储在堆外,因此用户需要实现 org.caffinitas.ohc.CacheSerializer 类,OHC会运用其实现类来序列化和反序列化对象。例如,以下例子是对 string 进行的序列化实现:

public class StringSerializer implements CacheSerializer<String> {
 
    /**
     * 计算字符串序列化后占用的空间
     *
     * @param value 需要序列化存储的字符串
     * @return 序列化后的字节数
     */
    @Override
    public int serializedSize(String value) {
        byte[] bytes = value.getBytes(Charsets.UTF_8);
 
        // 设置字符串长度限制,2^16 = 65536
        if (bytes.length > 65536)
            throw new RuntimeException("encoded string too long: " + bytes.length + " bytes");
        // 设置字符串长度限制,2^16 = 65536
        return bytes.length + 2;
    }
 
    /**
     * 将字符串对象序列化到 ByteBuffer 中,ByteBuffer是OHC管理的堆外内存区域的映射。
     *
     * @param value 需要序列化的对象
     * @param buf   序列化后的存储空间
     */
    @Override
    public 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 字符串对象.
     */
    @Override
    public 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);
    }
}

3、将CacheSerializer的实现类作为参数,传递给OHCache的构造函数来创建OHCache

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
    }
}

工具类

在这里插入图片描述

public class OhcCacheStrategy implements CacheStrategy {

    /**
     * 日志
     */
    private static Logger logger = LoggerFactory.getLogger(OhcCacheStrategy.class);

    /**
     * 缓存组件
     */
    public OHCache<byte[], byte[]> dataCache;

    /**
     * 过期时间组件
     */
    public OHCache<byte[], byte[]> expireCache;

    /**
     * 缓存table最大容量
     */
    private long level2cacheMax = 1024000L;

    /**
     * 锁
     */
    private final Object lock = new Object();

    /**
     * 键过期回调
     */
    public ExpirekeyAction expirekeyAction;

    /**
     * db引擎初始化
     */
    @PostConstruct
    public void initOhcEngine() {
        try {
            dataCache = OHCacheBuilder.<byte[], byte[]>newBuilder()
                    .keySerializer(new OhcSerializer())
                    .valueSerializer(new OhcSerializer())
                    .segmentCount(2 * 4)
                    .hashTableSize((int) level2cacheMax / 102400)
                    .capacity(2 * 1024 * 1024 * 1024L)
                    .defaultTTLmillis(OffheapCacheConst.EXPIRE_DEFAULT_SECONDS * 1000)
                    .timeouts(true)
                    .timeoutsSlots(64)
                    .timeoutsPrecision(512)
                    .eviction(Eviction.LRU)
                    .build();
            logger.error("ohc data cache init ok...");
            expireCache = OHCacheBuilder.<byte[], byte[]>newBuilder()
                    .keySerializer(new OhcSerializer())
                    .valueSerializer(new OhcSerializer())
                    .segmentCount(1)
                    .hashTableSize((int) level2cacheMax / 102400)
                    .capacity(2 * 1024 * 1024 * 1024L)
                    .defaultTTLmillis(OffheapCacheConst.EXPIRE_DEFAULT_SECONDS * 1000)
                    .timeouts(true)
                    .timeoutsSlots(64)
                    .timeoutsPrecision(512)
                    .eviction(Eviction.NONE)
                    .build();
            logger.error("ohc expire cache init ok...");
        } catch (Exception ex) {
            logger.error(OffheapCacheConst.PACKAGE_CONTAINER_OHC + OffheapCacheConst.ENGINE_INIT_FAIL, ex);
            AlarmUtil.alarm(OffheapCacheConst.PACKAGE_CONTAINER_OHC + OffheapCacheConst.ENGINE_INIT_FAIL, ex.getMessage());
            throw ex;
        }
    }

    @Override
    public <T> boolean putEntry(String key, T entry, long expireAt) {
        synchronized (lock) {
            byte[] entryKey = SerializationUtils.serialize(key);
            byte[] entryVal = SerializationUtils.serialize((Serializable) entry);
            //缓存数据入库
            if (dataCache.put(entryKey, entryVal, expireAt)) {
                //过期时间入库
                putExpire(key, expireAt);
                //返回执行结果
                return true;
            }
            return false;
        }
    }

    @Override
    public <T> T queryEntry(String key) {
        byte[] result = dataCache.get(SerializationUtils.serialize(key));
        if (result == null) {
            return null;
        }
        return SerializationUtils.deserialize(result);
    }

    @Override
    public long queryExpireTime(String key) {
        byte[] entryKey = SerializationUtils.serialize(key);
        return expireCache.get(entryKey) == null ? 0 : SerializationUtils.deserialize(expireCache.get(entryKey));
    }

    @Override
    public boolean removeEntry(String key) {
        byte[] entryKey = SerializationUtils.serialize(key);
        if (dataCache.remove(entryKey)) {
            removeExpire(key);
            return true;
        }
        return false;
    }

    @Override
    public boolean removeAll() {
        Iterable<byte[]> dataKey = () -> dataCache.keyIterator();
        dataCache.removeAll(dataKey);

        Iterable<byte[]> expireKey = () -> expireCache.keyIterator();
        expireCache.removeAll(expireKey);

        return true;
    }

    @Override
    public List<String> queryKeys() {
        List<String> list = new ArrayList<>();
        Iterator<byte[]> iterator = expireCache.keyIterator();
        while (iterator.hasNext()) {
            list.add(SerializationUtils.deserialize(iterator.next()));
        }
        return list;
    }

    /**
     * key过期时间同步入库
     *
     * @param key
     * @param expireAt
     */
    private void putExpire(String key, long expireAt) {
        try {
            expireCache.put(SerializationUtils.serialize(key), SerializationUtils.serialize(expireAt));
        } catch (Exception ex) {
            logger.error("key[" + key + "]过期时间入库失败...");
        }
    }

    /**
     * 同步清理过期键
     *
     * @param key
     */
    private void removeExpire(String key) {
        try {
            if (expireCache.remove(SerializationUtils.serialize(key))) {
                if (expirekeyAction != null) {
                    expirekeyAction.keyExpiredNotification(key);
                }
            }
        } catch (Exception ex) {
            logger.error("key[" + key + "]过期时间清除失败...");
        }
    }
}

这个类是堆外缓存的核心策略类。所有其他的数据模型读写操作都可以依据此类来扩展,比如类似redis的sortedset,value可以存储一个Treeset即可。需要说明一下,上面代码中,dataCache主要用于存储数据部分,expireCache主要用于存储键过期时间。以便于可以实现键主动过期和被动过期功能。用户添加删除键的时候,会同步删除expireCache中的键,以便于二者能够统一。由于ohc本身并未实现keyExpireCallback,所以这里我实现了这个功能,只要有键被移除(主动删除还是被动删除,都会触发通知)

@PostConstruct
public void Init() {
    ohcCacheTemplate.registerExpireKeyAction(key -> {
        logger.error("key " + key + " expired...");
    });
}

键被动过期功能,模仿了redis的键被动驱逐方式

public class OffheapCacheWorker {

    /**
     * 带参注入
     *
     * @param cacheStrategy
     */
    public OffheapCacheWorker(CacheStrategy cacheStrategy) {
        this.cacheStrategy = cacheStrategy;
        this.offheapCacheHelper = new OffheapCacheHelper();
    }

    /**
     * 日志
     */
    private static Logger logger = LoggerFactory.getLogger(OffheapCacheWorker.class);

    /**
     * 缓存帮助类
     */
    private OffheapCacheHelper offheapCacheHelper;

    /**
     * 缓存构建器
     */
    private CacheStrategy cacheStrategy;

    /**
     * 过期key检测线程
     */
    private Thread expireCheckThread;

    /**
     * 线程状态
     */
    private volatile boolean started;

    /**
     * 线程开启
     *
     * @throws IOException
     */
    public synchronized void start() {
        if (started) {
            return;
        }
        expireCheckThread = new Thread("expire key check thread") {
            @Override
            public void run() {

                logger.error("expire key check thread start...");

                while (!Thread.currentThread().isInterrupted()) {
                    try {
                        processLoop();
                    } catch (RuntimeException suppress) {
                        logger.error("Thread `" + getName() + "` occured a error, suppressed.", suppress);
                        throw suppress;
                    } catch (Exception exception) {
                        logger.error("Thread `" + getName() + "` occured a error, exception.", exception);
                    }
                }
                logger.info("Thread `{}` was stopped normally.", getName());
            }
        };
        expireCheckThread.start();
        started = true;
    }

    /**
     * 线程停止
     *
     * @throws IOException
     */
    public synchronized void stop() throws IOException {
        started = false;
        if (expireCheckThread != null) {
            expireCheckThread.interrupt();
        }
    }

    /**
     * 过期键驱逐
     * 模仿的redis键过期机制
     */
    private void processLoop() throws InterruptedException {

        //每次采集样本数
        int sampleCheckNumber = 20;

        //过期key计数
        int sampleExpiredCount = 0;

        //抽样次数迭代
        int sampleCheckIteration = 0;

        //缓存的key
        List<String> keys = cacheStrategy.queryKeys();

        //抽样开始时间
        long start = System.currentTimeMillis();

        //循环开始
        do {

            //键数量
            long expireContainerSize = keys.size();

            //默认为键数量
            long loopCheckNumber = expireContainerSize;

            //每次检查的键数量,如果超过样本数,则以样本数为准
            if (loopCheckNumber > sampleCheckNumber) {
                loopCheckNumber = sampleCheckNumber;
            }

            //开始检测
            while (loopCheckNumber-- > 0) {
                //取随机下标
                int rndNum = offheapCacheHelper.getRandomNumber(toIntExact(expireContainerSize) + 1);
                //取随机键
                String rndKey = keys.get(rndNum);
                //获取过期时间
                long expireTime = cacheStrategy.queryExpireTime(rndKey);
                //过期时间比对
                if (expireTime <= System.currentTimeMillis()) {
                    //键驱逐
                    boolean result = cacheStrategy.removeEntry(rndKey);
                    if (result) {
                        expireContainerSize--;
                        sampleExpiredCount++;
                    }
                }
            }
            //抽样次数递增
            sampleCheckIteration++;

            //抽样达到16次(16的倍数,&0xf都为0)且本批次耗时超过0.5秒,将退出,避免阻塞正常业务操作
            if ((sampleCheckIteration % 16) == 0 && (System.currentTimeMillis() - start) > 300) {
                logger.error("清理数据库过期键操作耗时过长,退出,预备重新开始...");
                return;
            }
        } while (sampleExpiredCount > sampleCheckNumber / 4);
        Thread.sleep(1500);
    }
}

键被动驱逐,会随机抽取20个key检测,如果过期键小于5个,则直接进行下一次抽样。否则将进行键驱逐操作。一旦抽样次数达到限定次数且键驱逐耗时过长,为了不影响业务,将会退出本次循环,继续下一次循环操作。此worker在后台运行,实测6W个过期key一起过期,cpu占用控制在10%,60w个过期key基本上一起过期,cpu占用控制在60%左右。达到预期效果。在大量的读写操作过程中,可以看到堆内内存几乎没有变化。

在这里插入图片描述

OHC 底层原理

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

在这里插入图片描述

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[],并写入指针所指向的堆外内存

3、段的实现:OffHeapLinkedMap

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

4、空间分配

OHC 的 linked 实现为每个键值对分别分配堆外内存,因此键值对实际是零散地分布在堆外。

OHC提供了JNANativeAllocator 和 UnsafeAllocator 这两个分配器,分别使用 Native.malloc(size) 和 Unsafe.allocateMemory(size) 分配堆外内存,用户可以通过配置来使用其中一种。

OHC 会把 key 和 value 序列化成 byte[] 存储到堆外,如2.3所述,用户需要通过实现 CacheSerializer 来自定义类完成 序列化 和 反序列化。因此,占用的空间实际取决于用户自定义的序列化方法。

除了 key 和 value 本身占用的空间,OHC 还会对 key 进行 8位 对齐。比如用户计算出 key 占用 3个字节,OHC会将其对齐到8个字节。另外,对于每个键值对,OHC需要额外的64个字节来维护偏移量等元数据。因此,对于每个键值对占用的堆外空间为:

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

OHC使用场景

针对我们线上常用的缓存:Redis 集群、OHC 和 Guava 来进行线上数据存储,这三种存储方式的特性分别如下:
在这里插入图片描述

因为不同存储方式的特性差别较大,我们会根据具体场景来从中选择。

特征组装与排序引擎所需的数据主要分为“离线数据” 和 “实时数据”,均使用Redis作为主库。

离线数据由定时算法任务生成后写入HDFS,一般按照小时级或者天级进行更新,并通过 XXL Job 和 DataX 定时从 HDFS 同步到 Redis 供使用。实时数据则根据用户行为进行在线更新,通常使用 Flink 任务实时计算后直接写入 Redis。

在这里插入图片描述

对于离线数据,其更新周期比较长,非常适合使用OHC缓存到服务所在服务器本地。比如,在进行排序时,item的历史点击率是非常重要的特征数据,特别是其最近几天的点击率。这种以天为单位更新的离线特征,如果使用OHC缓存到本地,则可以避免读取Redis的网络开销,节省排序阶段耗时。

对于实时数据,其更新受用户实时行为影响,下次更新时间是不确定的。比如用户对某个目的地的偏好程度,这种数据随着用户在App端不断进行点击而更新。这种实时数据不会使用OHC缓存到本地,否则可能会导致 本地缓存 和 主库的数据不一致。即使进行缓存,也应该设置较小的过期时间(比如秒级或者分钟级),尽量保证数据的实时性和准确性。

其他数据,比如站内的高热数据 和 兜底数据,其数据量较小且可能频繁使用,这种数据我们使用 Guava 缓存到堆内,以便于快速读取。

生产配置优化

OHC支持大量配置选项,供使用方根据自身业务场景进行选择,这里介绍下在我们业务中相关参数的配置。

在这里插入图片描述

总容量

最开始使用OHC时,我们设置的上限为4G左右。随着业务的发展和数据量的增长,逐渐增大到10G,基本可以覆盖热点数据。

段数量

一方面,OHC使用了分段锁,多个线程访问同一个段时会导致竞争,所以段数量不宜设置过小。同时,当段内条目数量达到一定负载时 OHC 会自动 rehash,段数量过小则会允许段内存储的条目数量增加,从而可能导致段内频繁进行rehash,影响性能。另一方面,段的元数据是存储在堆内的,过大的段数量会占用堆内空间。因此,应该在尽量减少rehash的次数的前提下,结合业务的QPS等参数,将段数量设置为较小的值。

哈希算法

通过压测,我们发现使用 CRC32、CRC32C 和 MURMUR3 时,键值对的分布都比较均匀,而 CRC32C 的 CPU使用率相对较低,因此使用 CRC32C 作为哈希算法。

逐出算法

选用10G的总容量,基本已经覆盖了大部分热点数据,并且很少出现偶发性或者周期性的批量操作,因此选用了LRU。

优化

1、异步移除过期数据

在 OffHeapLinkedMap 的原始实现中,读取键值对 时 会判断其是否过期,如果过期则立即将其移除。移除键值对是相对比较 “昂贵” 的操作,可能会阻塞当前读取线程,因此我们对其进行了异步改造。读取键值对时,如果发现其已经过期,则会将其存入一个队列。同时,在后台加入了一个清理线程,定期从队列里面读取过期内容并进行移除。

2、加锁方式优化

OHC本身是线程安全的,因为每个段都有自己的锁,在读取 和 写入时都会加锁。其源代码中使用的是 CAS锁(compare-and-set),在更新失败时尝试挂起线程并重试:
在这里插入图片描述

每个线程都有自己的缓存,当变量标记为脏时线程会更新缓存。但是,无论是否成功设置该值,CAS锁在每次调用变量时都会将其标记为脏数据,这会导致在线程竞争激烈时性能下降。使用 CASC(compare-and-set-compare)锁可以尽量减少 CAS 的次数,从而提高性能:

在这里插入图片描述

3、参数配置

-XX:MaxDirectMemorySize=9g
-Dorg.caffinitas.ohc.capacity=9126805504   # 5G=5368709120 7G=7516192768 8.5G=9126805504 
-Dorg.caffinitas.ohc.maxEntrySize=2621440  # 10M
-Dorg.caffinitas.ohc.segmentCount=1024
-Dorg.caffinitas.ohc.hashAlgorighm=CRC32C
-Dorg.caffinitas.ohc.eviction=W_TINY_LFU
-Dorg.caffinitas.ohc.edenSize=0.1

MaxDirectMemorySize

-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()

OHC适合将离线数据进行本地缓存,从而节省访问远程数据库的时间。

本文归总转载
OHC使用
你所不知道的堆外内存

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 11
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

白鸽呀

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值