简介
通过本文入门堆外内存,使用缓存框架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();
创建实例时需要设置yourKeySerializer
和yourValueSerializer
两个参数,这两个玩意用于序列化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
是个接口,点进去发现两个实现类,分别是UnsafeAllocator
和JNANativeAllocator
,这两个就是分配堆外内存的两种方法,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
对象
静息时内存使用情况:
堆外内存使用时:used heap 1.171GB
堆内内存使用时:used heap 2.287GB
通过对比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
- 不过期,返回广告对象
然后我们只需要处理删除队列就行了。
回顾
回顾一下我们干了什么。开始时我们的数据存放在堆内内存的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)