大家好,我是伟伟。在之前的《3 招将吞吐量提升了 100%,现在它是我的了》文章中,我对OHC堆外缓存做了一个眼睛:这次就回收这只眼睛,给大家展示一下OHC。上一篇文章提到的场景是什么,我们先简单回顾一下。即使一个服务的各个JVM配置合理,其GC情况依然不容乐观。然后dump了一把内存,经过分析,发现有2个对象特别巨大,占了总存活堆内存的76.8%。其中,第一大对象是本地缓存,GC后仍然存活,无法杀死。怎么做?将缓存的对象移出堆。由于堆外内存不在GC的工作范围内,避免了缓存过多对GC的影响。堆外内存不受堆内内存大小的限制,只受服务器物理内存大小的限制。三者的关系是这样的:物理内存=堆外内存+堆内内存。对于堆外内存的使用,有一个现成的开源项目叫OHC,开箱即用,比别人香。当时只是简单介绍,没有深入分析。个人还是对这个OHC比较感兴趣。应用场景。你可以启动你的watch(假装)act(force)可以使用的应用场景有哪些?也就是你的本地缓存对象太多了,你的“堆”快满了,如此频繁的GC,时间长了,会影响服务的正常运行。这时一群人说:我要求调整JVM参数,增加堆内存。还有一群人说:在我看来,这个本地缓存根本不应该被滥用。没必要,不用缓存,减少内存占用。还有一群人说:不是不能用吗?当大家都在争论得面红耳赤时,你淡淡的说:我觉得这个问题可以用堆外内存来解决。比如有一个开源项目叫OHC,比较好。你可以调查一下。你好。所以为了以后更好的安装这个force,我打算在这篇文章中复制一份,但是首先声明一下,这篇文章不会带你去源码,只是让你知道它的存在这个框架,做一个简单的指南就可以了。Demo的老规矩是,对于不懂的技术,先简单使用,再深入理解。所以还是要做个Demo,直接去它的github上找Quickstart搞定。它的Quickstart就是这么一行代码:https://github.com/snazy/ohc第一次看到的时候觉得太粗糙了。在我的想象中,一个好的Quickstart可以自己粘贴直接运行。显然,这是行不通的。对此高级妓女表示强烈谴责和极度愤怒。不过没办法,还是先坚持吧。对了,记得先导入maven依赖:org.caffinitas.ohcohc-core0.7.4粘贴之后,发现这是一道填空题:key和value的序列化方法没有提供给我们,需要自定义,在它的README中也有提到:上面写着即key和value的Serialization需要实现CacheSerializer接口,该接口有三个方法,分别是序列化后对象的长度、序列化和反序列化方法。我需要自己实现一个序列化方法。瞬间脑子里冒出几个关键词:Protobuf、Thrift、kryo、hessian等等。但是太麻烦了,还得自己码。我只是想做一个演示来品尝一下。要是能直接从别处借一个就好了。于是,我拉下了OHC的源码,因为直觉告诉我,它的测试用例中肯定有现成的序列化方案。果然有很多测试用例,我发现了这个:org.caffinitas.ohc.linked.TestUtils这种序列化方式在测试用例中被广泛使用:现在有了序列化方式,整个完整代码是这样的是的,我也会给你做一个舒服的Quickstart,你粘贴后就可以使用了:keySerializer(OhcDemo.stringSerializer).valueSerializer(OhcDemo.stringSerializer).build();ohCache.put("你好","为什么");System.out.println("ohCache.get(hello)="+ohCache.get("hello"));}publicstaticfinalCacheSerializerstringSerializer=newCacheSerializer(){publicvoidserialize(Strings,ByteBufferbuf){//获取字符串对象的UTF-8编码字节数组[]bytes=s.getBytes(字符集.UTF_8);//用前16位记录数组长度buf.put((byte)((bytes.length>>>8)&0xFF));buf.put((byte)((bytes.length)&0xFF));buf.put(字节);}publicStringdeserialize(ByteBufferbuf){//获取字节数组的长度intlength=(((buf.get()&0xff)<<8)+((buf.get()&0xff)));byte[]bytes=newbyte[length];//读取字节数组buf.get(bytes);//返回字符串对象returnnewString(bytes,Charsets.UTF_8);}publicintserializedSize(Strings){byte[]bytes=s.getBytes(Charsets.UTF_8);//设置字符串长度限制,2^16=65536if(bytes.length>65535)thrownewRuntimeException("encodedstringtoolong:"+bytes.length+"bytes");返回bytes.length+2;从上面的demo也可以看出OHCache和Map类似,基本上put的方法也是一样的,而get只是put的对象,也就是缓存的对象,由用户决定——定义的序列化方法。比如上面那个只能缓存string类型。如果你想在里面放一个自定义对象,你必须为自定义对象实现一个序列化方法。这很简单。上网搜索一下,有很多。现在我们有一个可以运行的演示。运行后,输出是这样的。没有什么不妥:demo正在运行,我们找到了“动手”的地方。接下来就是对其进行分析,结合我们的实际业务,沉淀出一套“可移植、可复用”的组合拳,为自己“赋能”。对比为了让大家更直观的看出堆外内存和堆内内存的区别,我给大家跑两个程序。首先是我们堆内存的代表选手,HashMap:publicstaticvoidmain(String[]args)throwsInterruptedException{hashMapOOM();}privatestaticvoidhashMapOOM()throwsInterruptedException{//准备时间,便于观察TimeUnit.SECONDS.sleep(10);整数=0;while(true){//转到地图Stringbig=newString(newbyte[1024*1024]);HASHMAP.put(num+"",大);数++;}}}通过JVM参数控制堆内存大小为100m,然后在Map中连续存储1M字符串,那么这个程序很快就会出现OOM:visualvm中对应的内存趋势图是这样的:程序基本属于一个开机,然后内存满了,然后马上降温。属于老二,被老二杀死。但是,当我们使用同样的逻辑,使用堆外内存时,情况就不一样了:时间,便于观察TimeUnit.SECONDS.sleep(10);OHCacheohCache=OHCacheBuilder.newBuilder().keySerializer(stringSerializer).valueSerializer(stringSerializer).build();整数=0;while(true){Stringbig=newString(newbyte[1024*1024]);ohCache.put(num+"",大);数++;}}publicstaticfinalCacheSerializerstringSerializer=newCacheSerializer(){//之前写过,此处略去};}关于上面程序中的stringSerializer,需要注意的是我取消了这个大小限制,当我正在测试。目的是为了测试使用和HashMap一样大小的1M。String:Thisisthememorytrendgraphaftertheprogramhasrunningfor3minutes:这个图怎么说?丑是有点丑,但我们是说至少没有秒,程序不崩溃。这两张内存图对比的时候,是不是感觉有点不一样呢?但是又出现了一个问题:如何查看OHCache占用的内存呢?前面说了,属于堆外内存。在JVM的堆之外,那是我机器的内存。打开任务管理器,切换到内存趋势图。正常情况下,趋势图是这样的,很稳定:从上面的截图可以看出,我的机器内存大小是16G,还有9.9G的内存可以使用。也就是说,在截图的这一刻,我可以使用的堆外内存量是9.9G。那我就先用它的6G,程序一启动,趋势图就变成这样:而程序一关闭,内存占用马上释放:可能你没注意到,我前面说了“useitfor6G”,这个6G怎么控制?因为我在程序中加了这么一行代码:如果不加,默认只会使用64M的堆外内存,而且看不到曲线。如果你想自己玩,亲眼看看这个趋势图,记得加上这行代码。具体值可以根据自己机器的情况给出。我个人的建议是先把作品存下来,最好是意思够用,不要给太多的价值,万一电脑坏了你来找我,我不但不赔钱,还笑话你。那么,除了可以定制的“6G”之外,还有很多参数可以定制。名单如下。文字很容易理解。自己看了就知道,主要逻辑写的很流畅,不需要做太多的源码分析。我最多只能在这里指路。我看源码是从put方法开始的,但是put方法有两个实现类:关于这两个实现类,github有介绍:linked实现方法:为每个需要缓存的对象分别分配堆外内存,Works最适合中型和大型条目。Chunked实现方式:为每个hash段整体分配堆外内存,相当于预分配的意思,适用于小条目。不过这里只需要看链接的实现即可。为什么?别问,问题是作者提出来的。github的README中有这样一条NOTE:注意:chunkedimplementation仍然应该被认为是实验性的。翻译过来就是:目前chunked的实现应该算是实验性的。experimental,放在句末,你就知道是形容词,什么意思?4级词汇,不会的就赶紧背,这就是考试。作者说,chunked的实现还在实验阶段,肯定有一些“暗坑”在里面。不踩坑的最好办法就是不用。然后,再看的时候,你会发现这个数据结构类似于ConcurrentHashMap。是的,有segment,有bucket,有entry,所以不要怀疑自己,真的很像。然后,当你看源码的时候,Debug方法肯定效率更高。当你debug的对象是put方法的时候,点几下就可以看到这个地方:这个地方是申请堆外内存的操作,对应IAllocator接口:接口中有三个方法:allocate:申请内存free:释放内存getTotalAllocated:获取申请的内存(空方法,未实现)。主要关注前两种方法,因为前面说了,这是堆外内存,需要自己管理内存。管理分为申请和发布,对应这两种方式。因此,这可以说是整个OHC框架的核心。随身携带这部分。操作堆外内存其实你一定接触过堆外内存,只不过一般都是框架封装起来的,自己悄悄使用,只是你没有察觉罢了。一般我们在申请堆外内存的时候都会这样写:这个方法最终会调用Unsafe中的native方法allocateMemory,相当于C++的malloc函数:这个方法会在操作系统中分配一块内存为了我们。内存的大小是供我们使用的。这块内存称为堆外内存,不受JVM控制,即不在gc管理范围内。该方法的返回值是一个long类型的值,即申请内存对应的首地址。不过需要注意的是,JVM有个配置叫做-XX:MaxDirectMemorySize(最大堆外内存)。如果使用ByteBuffer.allocateDirect申请堆外内存,大小会被这个配置限制,因为这个方法会调用:OHC来使用堆外内存,还必须向操作系统申请一部分内存通过某种方法,那么申请内存的方法也是allocateMemory吗?对于这个问题,作者在github上给出了三个连续的否定:不仅告诉你没用过,还告诉你为什么没用过:首先是开头的“TL;DR”只是让我觉得自己很愚蠢。后来查了一下,原来是“Toolong;Don’tread”的缩写。直译的意思是:太长了,读不下去。但我觉得结合语境分析,作者的意思应该类似于“短篇小说”。这句话一般用在文章开头,先给干货。你看,我又学到了一个小知识。那我给大家解释一下这段英文在说什么。作者表示,绕过ByteBuffer.allocateDirect方法,直接分配堆外内存,对于GC来说更稳定,因为我们可以清楚地控制内存分配,更重要的是,我们完全可以自己控制内存的释放。如果您使用ByteBuffer.allocateDirect方法,堆外内存可能会在垃圾回收期间被释放。这句话对应代码中的this,在OHC中不需要这样的操作。OHC希望框架自己完全控制什么时候应该释放:那么作者接着说:另外,如果在分配内存时没有更多可用的堆外内存,可能会触发FullGC,如果多个线程请求内存同时遇到这种情况是有问题的,因为这意味着连续发生大量的FullGC。这句话对应的代码在这里:如果堆外内存不足,就会触发FullGC。可以想象,在机器内存吃紧的情况下,程序还在不停地申请堆外内存,进而导致FullGC的频繁出现。这是怎样的“灾难性”后果?基本上,服务处于不可用状态。OHC需要防止这种情况发生。除了这两个原因,作者还说:进一步,stock实现使用全局同步链表来跟踪堆外内存分配。在ByteBuffer.allocateDirect方法的实现中,也使用了一个全局的、同步的链表List是一种跟踪堆外内存分配的数据结构。这里我也不清楚它说的“链表”到底对应的是什么,就不乱解释了。想知道的可以在评论区点我,我会好好学习的。综上,作者最后一句话说:这就是为什么OHC直接分配堆外内存的原因。这就是为什么OHC直接分配堆外内存的原因。然后他也提出了一个建议:并建议在Linux系统上预加载jemalloc,以提高内存管理性能。建议在Linux系统上预加载jemalloc以提高内存管理性能。言外之意就是用它来代替glibc的malloc,jemalloc基本碾压malloc。网上有很多关于jemalloc和malloc的相关文章。如果你有兴趣,你也可以找他们。这里我就不展开了。现在我们知道OHC并没有使用常规的ByteBuffer.allocateDirect方法来完成堆外内存的申请,那么它是如何实现这个“sao操作”的呢?在UnsafeAllocator实现类中,是这样写的:org.caffinitas.ohc.alloc.UnsafeAllocator直接通过反射获取Unsafe,运行时没有任何冗余代码。在JNANativeAllocator实现类中,使用JNA方式操作内存:OHC框架默认采用JNA方式,也可以通过代码或者日志输出验证:关于Unsafe和JNA,操作堆外内存的方式有哪些比较好,在网上找到了这个链接:https://mail.openjdk.org/pipe...这封邮件是AlekseyShipilev对一个叫Robert的网友提出的问题的回复。事情是这样的,Robert他对Native.malloc()和Unsafe.allocateMemory()进行了基准测试,发现前者的执行速度是后者的三倍。想知道为什么:然后AlekseyShipilev解析了这个问题。这个家伙是谁?他是benchmarking之父:所以他的回答还是比较权威的,但是需要注意的是,他并没有正面说明这两种方法更好,只是解释了为什么使用JMH时性能差了3倍。另外,我还要说一点,通过反射把Unsafe的代码拿来是一件好事。推荐阅读,理解,掌握:是不是OHC中很好的例子,虽然有现成的方法,但是和我的场景不太匹配,我不'不需要一些限制性的判断,我只是想简单的要求一块堆外内存来使用。那我就绕过中间人,自己直接调用Unsafe中的方法。如何获得不安全?还是之前的代码,通过反思,可以在其他开源框架中看到很多相似或者相同的代码片段。只要记住它,你就完成了。好了,文章就这些了,如果对你有帮助,请给我一个免费的点赞,是不是过分了?