使用缓存框架OHC操作堆外内存,减少GC

2023年 8月 1日 83.0k 0

简介

通过本文入门堆外内存,使用缓存框架OHC操作堆外内存,优化内存使用,减少GC。文章包含OHC源码的简单分析和本人在项目中对OHC的应用。

快速开始

堆(heap)是JVM运行时数据区中的一部分,咱们new出来的对象都存放在堆内存中,由JVM使用GC等手段帮大家管理。

JVM帮咱们管是省事了,但是如果堆中数据量很大,GC的频率就会增加,可能会影响系统的效率,这时咱们就可以考虑下堆外内存。堆内内存会受到GC的影响,但是堆外内存GC可管不着了。这样我们就可以根据业务需求或是存储对象的特点,选择不同的地方(堆内or堆外)存储对象。

下面咱们三步走战略快速开始OHC:引入依赖 -> 创建实例 -> 使用实例,把堆外内存用起来。

第一步,引入依赖


    org.caffinitas.ohc
    ohc-core
    0.7.4

第二步,创建OHC实例OHCache ohCache

OHCache ohCache = OHCacheBuilder.newBuilder()
                                .keySerializer(yourKeySerializer)
                                .valueSerializer(yourValueSerializer)
                                .build();

创建实例时需要设置yourKeySerializeryourValueSerializer两个参数,这两个玩意用于序列化OHC中存储的key和value,与key和value的类型有关。这两个参数是两个类,需要我们自己实现,咱先不管,现在只需要知道创建实例时,需要传入一些参数。

第三步,使用OHC实例OHCache ohCache

ohCache.put("hello", "world");
String value = ohCache.get("hello");

这么一看,缓存框架OHC用起来就像个Map。咱们就先把这东西当成个Map,只不过存储的位置不是咱们熟悉的JVM堆内存,而是堆外内存。

马蜂窝技术团队提供了一个使用OHC的Demo,有兴趣可以先试试运行起来:chebacca/ohc-example (github.com)

浅析源码

最关心的肯定是内存如何分配,其次是内存如何释放,咱们就依次看一下这两个部分的源码。

内存分配

探索下OHC是如何分配堆外内存的。

点开put方法,发现有两个实现类,咱们需要的的是OHCacheLinkedImpl,另一个实现类OHC的作者说现在还处于实验阶段。

一路点下去,发现这行代码:

oldValueAdr = Uns.allocate(oldValueLen, throwOOME);

再进去,发现这个:

long address = allocator.allocate(bytes);

这个allocator是个接口,点进去发现两个实现类,分别是UnsafeAllocatorJNANativeAllocator,这两个就是分配堆外内存的两种方法,OHC默认使用JNANativeAllocator,但是我们也可以通过配置,使用UnsafeAllocator分配堆外内存。下面分别看一下这两种分配堆外内存的方式。

UnsafeAllocator

看这个实现类的名字就能猜到,这东西跟unsafe类脱不了关系,点开一看果然。

先是经典反射拿到unsafe类,然后一行代码:

return unsafe.allocateMemory(size);

人家unsafe注释写的很明白,申请的是native memory:

Allocates a new block of native memory, of the given size in bytes.

JNANativeAllocator

点开JNANativeAllocator,一眼就看到这行:

return Native.malloc(size);

虽然以前没见过,看名字也能猜到,这东西是个本地方法(native method)。点开一看,果然:public static native long malloc(long var0);

还有一个问题,com.sun.jna.Native#malloc是在jna包下,JNANativeAllocator中也有jna,这jna到底是个什么东西。

说jna前先说jni,jni是Java Native Interface,sun.misc.Unsafe#allocateMemory就是jni的方式,而JNA是建立在JNI之上的一个高级库,简化了Java和本地代码之间的交互过程。

但是无论如何,两者底层都是调用了C语言中的malloc() 函数来分配本地内存。

内存释放

释放就很好说了,一个用的是sun.misc.Unsafe#freeMemory,一个用的是com.sun.jna.Native#free,但是可以肯定的是,都需要我们手动释放。如果写C或者C++可能觉得这是天经地义的,毕竟你向操作系统借了一块内存,那肯定要还的。但是还内存这一步,Java程序员平时都是全权交由JVM管理的。

其他方法

抛开上面提到的两种操作堆外内存的方式,可能java.nio.ByteBuffer#allocateDirect这个方法会听的更多。

简单说,这是对sun.misc.Unsafe#allocateMemory的封装,让咱们可以在较安全的情况下操作堆外内存。只需一行代码,如下:

ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1 * 1024 * 1024);

ByteBuffer.allocateDirect可以通过Cleaner(虚引用)+显示GC(system.gc())+unsafe.freeMemory的方式可以自动释放内存。这部分就不展开讲了,感兴趣可以了解下。

项目实战

优化前

我先介绍下业务场景:在广告检索系统中,我使用ConcurrentHashMap构建hash索引。Map中key为广告id,value为广告对象,这样可以构造广告id -> 广告对象这样一个映射,达到广告id快速拿取广告对象的目的。

注意这是一个索引,意味着全量广告数据存储在内存中,那么数据量一大,咱们的堆内内存就必然不够用了(后面有测试)。

简单来说,使用OHC优化前,咱们的索引长这样:

private static final Map MAP;
static {
    MAP = new ConcurrentHashMap();
}

public long size(){
    return MAP.size();
}
@Override
public CreativeObject get(Long key) {
    return MAP.get(key);
}
@Override
public void add(Long key, CreativeObject value) {
    MAP.put(key, value);
}

通过索引取数据就是MAP.get(key),添加索引数据就是MAP.put(key, value)

前面咱们也提到了,主要就是内存空间的问题,才想着大费周章操作堆外内存。以我使用的64位JVM为例,估算下每个广告id -> 广告对象映射,到底需要多少内存空间。

引入依赖,用这个能知道某个对象总共占用了多少内存空间:


    org.openjdk.jol
    jol-core
    0.9

测试代码:

CreativeObject obj = new CreativeObject();
obj.setId(2067558814203501L);
obj.setName("掘金主页");
obj.setHeight(720);
obj.setWidth(1080);
obj.setMaterialFormat(1);
obj.setMaterialType(1);
obj.setStatus(1);
obj.setUrl("https://juejin.cn/user/2067558814203501/posts");
//查看对象内部信息
log.info(ClassLayout.parseInstance(obj).toPrintable());
//查看对象外部信息
log.info(GraphLayout.parseInstance(obj).toPrintable());
//获取对象总大小
log.info("size : " + GraphLayout.parseInstance(obj).totalSize());
System.out.println("-----------------");
Long id = 2067558814203501L;
//查看对象内部信息
log.info(ClassLayout.parseInstance(id).toPrintable());
//查看对象外部信息
log.info(GraphLayout.parseInstance(id).toPrintable());
//获取对象总大小
log.info("size : " + GraphLayout.parseInstance(id).totalSize());

测试结果:

CreativeObject对象总大小为304byte,Long对象总大小为24byte,一组映射占用对内存大小为328byte(根据存储数据不同,映射大小也会不同)。我的测试数据有350万条广告,如果全部存储到内存中,大约需要1.069GB的空间。这个数对不对呢,先记住这个数时1GB多一点,咱们后面会验证。

数据记录

数据量:350万CreativeObject对象

  • 静息时内存使用情况:
    image-20230510204915857.png

  • 堆外内存使用时:used heap 1.171GB
    image-20230510204651131.png

  • 堆内内存使用时:used heap 2.287GB
    image-20230510205003300.png

  • 通过对比2和3的堆内存使用情况(used heap),OHC确实起到了减少堆内内存的使用。

    使用OHC改进

    直接上代码:

    private static final OHCache OHC;
    static {
        OHC = OHCacheBuilder.newBuilder()
                .keySerializer(new LongSerializer())
                .valueSerializer(new CreativeObjectSerializer())
                .build();
    }
    
    public long size(){
        return OHC.size();
    }
    @Override
    public CreativeObject get(Long key) {
        return OHC.get(key);
    }
    @Override
    public void add(Long key, CreativeObject value) {
        OHC.put(key, value);
    }
    

    类比Map来看OHC的操作,发现也就是get()set()这些方法。

    但是咱们还有个小问题需要解决,就是keySerializer、valueSerializer这两个参数。其实也简单,我给出Long类型的序列化类,CreativeObject的序列化类太长了就不给了,你只需要根据自己需要存储的类型,写序列化类就OK了。

    public class LongSerializer implements CacheSerializer {
        @Override
        public void serialize(Long key, ByteBuffer buf) {
            buf.putLong(key);
        }
    
        @Override
        public Long deserialize(ByteBuffer buf) {
            return buf.getLong();
        }
    
        @Override
        public int serializedSize(Long key) {
            return Long.BYTES;
        }
    }
    

    必须考虑的问题

    申请堆外内存就必须考虑释放内存,就像使用ReentrantLock必须考虑unlock,就像使用threadlocal必须考虑remove,就像吸气必须呼气。

    结合业务场景(广告检索中的一个hash索引),让我们想想什么时候删除堆外内存中的存储的广告数据?应该是广告数据过期的时候。

    基于此,我们使用惰性删除,即使用索引时再判断数据是否过期,那么问题来了,此时应该直接将数据从堆外内存中删除么?可别忘了,堆外内存也是个共享数据啊,操作一切共享数据,都要考虑并发问题。

    为了并发安全的删除堆外内存,我将使用一个删除队列异步删除堆外内存,设计如下:

  • 选择广告id(这一步是业务相关,就当是从广告池中随机选了一个就行)
  • 判断是否在删除队列中
    • 在,重新选择广告id
    • 不在,继续下一步
  • 根据广告id从OHC中拿到广告对象
  • 判断是否过期
    • 过期,加入删除队列,重新选择广告id
    • 不过期,返回广告对象
  • 然后我们只需要处理删除队列就行了。

    回顾

    回顾一下我们干了什么。开始时我们的数据存放在堆内内存的Map里,为了降低GC频率,我们使用OHC把这些数据从堆内内存挪到了堆外内存。没了,我们其实就干了这一件事。

    其他

    原理知识

    • JVM相关:应该知道一些JVM相关的知识,掌握程度为《深入理解JVM虚拟机》中第二章、第三章的内容,即Java运行时数据区和GC。

    • OS相关:了解操作系统内存管理,掌握程度为4.1 为什么要有虚拟内存? | 小林coding (xiaolincoding.com)中内存管理部分的知识。

    JNA

    java-native-access/jna: Java Native Access (github.com)

    JNA(Java Native Access)是一个开源的Java框架,是Sun公司推出的一种调用本地方法的技术,是建立在经典的JNI基础之上的一个框架。之所以说它是JNI的替代者,是因为JNA大大简化了调用本地方法的过程,使用很方便,基本上不需要脱离Java环境就可以完成。

    JNI(Java Native Interface)即Java本地接口,它建立了Java与其他编程语言的桥梁,允许Java程序调用其他语言(尤其是C/C++)编写的程序或者代码库。并且,JDK本身的实现也大量用到JNI技术来调用本地C程序库。

    本文灵感

    Java堆外缓存OHC在马蜂窝推荐引擎的应用 (qq.com)

    参考资料

    snazy/ohc: Java large off heap cache (github.com)

    “堆外缓存”这玩意是真不错,我要写进简历了。 - 掘金 (juejin.cn)

    一文搞懂堆外内存(模拟内存泄漏) - 掘金 (juejin.cn)

    关于JVM堆外内存的一切 - 掘金 (juejin.cn)

    Java 内存之直接内存(堆外内存) · 日常学习 · 看云 (kancloud.cn)

    Java 堆内内存与堆外内存 - 腾讯云开发者社区-腾讯云 (tencent.com)

    10 双刃剑:合理管理 Netty 堆外内存.md (lianglianglee.com)

    JVM源码分析之堆外内存完全解读-阿里云开发者社区 (aliyun.com)

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论