JAVA堆外内存的简介和使用

内存分析

最近看了一篇文章《蚂蚁消息中间件 (MsgBroker) 在 YGC 优化上的探索》

文章涉及JVM的垃圾回收,主要讲的是通过使用「堆外内存」对Young GC进行优化。

文章中介绍,MsgBroker消息中间件会对消息进行缓存,JVM需要为被缓存的消息分配内存,首先会被分配到年轻代。

当缓存中的消息由于各种原因,一直投递不成功,这些消息会进入老年代。

最终呈现的问题是YGC时间太长。

随着新特性的开发和消息量的增长,我们发现 MsgBroker 的 YGC 平均耗时已缓慢增长至 50ms~60ms,甚至部分机房的 YGC 平均耗时已高达 120ms。

有一个疑问,消息进入老年代,出现堆积,为何会导致YGC时间过长呢?

按着文章中的叙述,回答这个问题。

  1. 在YGC阶段,涉及到垃圾标记的过程,从GCRoot开始标记。
  2. 因为YGC不涉及到老年代的回收,一旦从GCRoot扫描到引用了老年代对象时,就中断本次扫描。这样做可以减少扫描范围,加速YGC。
  3. 存在被老年代对象引用的年轻代对象,它们没有被GCRoot直接或者间接引用。
  4. YGC阶段中的old-gen scanning即用于扫描被老年代引用的年轻代对象。
  5. old-gen scanning扫描时间与老年代内存占用大小成正比。
  6. 得到结论,老年代内存占用增大会导致YGC时间变长。

总的来说,将消息缓存在JVM内存会对垃圾回收造成一定影响:

  1. 消息最初缓存到年轻代,会增加YGC的频率。
  2. 消息被提升到老年代,会增加FGC的频率。
  3. 老年代的消息增长后,会延长old-gen scanning时间,从而增加YGC耗时。

文章使用「堆外内存」减少了消息对JVM内存的占用,并使用基于Netty的网络层框架,达到了理想的YGC时间。

注:Netty中也使用了堆外内存。

通过引入自适应投递限流,在实验室测试环境下,MsgBroker 在异常场景下的 YGC 耗时进一步从 83ms 降低到 40ms,恢复了正常的水平。


一:堆外内存是什么?

在JAVA中,JVM内存指的是堆内存。

机器内存中,不属于堆内存的部分即为堆外内存。

堆外内存也被称为直接内存。

堆内存和堆外内存

堆外内存并不神秘,在C语言中,分配的就是机器内存,和本文中的堆外内存是相似的概念。

在JAVA中,可以通过Unsafe和NIO包下的ByteBuffer来操作堆外内存。

Unsafe类操作堆外内存

sun.misc.Unsafe提供了一组方法来进行堆外内存的分配,重新分配,以及释放。

  1. public native long allocateMemory(long size); —— 分配一块内存空间。
  2. public native long reallocateMemory(long address, long size); —— 重新分配一块内存,把数据从address指向的缓存中拷贝到新的内存块。
  3. public native void freeMemory(long address); —— 释放内存。

参考:Unsafe类操作JAVA内存

一顿操作猛如虎,直接psvm走起。

public static void main(String[] args) {
    Unsafe unsafe = new Unsafe();
    unsafe.allocateMemory(1024);
}

然而Unsafe类的构造器是私有的,报错。

而且,allocateMemory方法也不是静态的,不能通过Unsafe.allocateMemory调用。

幸运的是可以通过Unsafe.getUnsafe()取得Unsafe的实例。

public class UnsafeTest {

    public static void main(String[] args) {
        Unsafe unsafe = Unsafe.getUnsafe();
        unsafe.allocateMemory(1024);
        unsafe.reallocateMemory(1024, 1024);
        unsafe.freeMemory(1024);
    }
}

此外,也可以通过反射获取unsafe对象实例

参考:危险代码:如何使用Unsafe操作内存中的Java类和对象

NIO类操作堆外内存

用NIO包下的ByteBuffer分配直接内存则相对简单。

public class TestDirectByteBuffer {

    public static void main(String[] args) throws Exception {
        ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024);
    }
}

然而运行时报错了。

java(51146,0x7000023ed000) malloc: *** error for object 0x400: pointer being realloc'd was not allocated
*** set a breakpoint in malloc_error_break to debug

错误信息

参考:JAVA堆外内存

然而在小伙伴的电脑上跑这段的代码是可以成功运行的。


二:堆外内存垃圾回收

对于内存,除了关注怎么分配,还需要关注如何释放。

从JAVA出发,习惯性思维是堆外内存是否有垃圾回收机制。

考虑堆外内存的垃圾回收机制,需要了解以下两个问题:

  1. 堆外内存会溢出么?
  2. 什么时候会触发堆外内存回收?

问题一

通过修改JVM参数:-XX:MaxDirectMemorySize=40M,将最大堆外内存设置为40M。

既然堆外内存有限,则必然会发生内存溢出。

为模拟内存溢出,可以设置JVM参数:-XX:+DisableExplicitGC,禁止代码中显式调用System.gc()。

可以看到出现OOM。

得到的结论是,堆外内存会溢出,并且其垃圾回收依赖于代码显式调用System.gc()。

参考:JAVA堆外内存

问题二

关于堆外内存垃圾回收的时机,首先考虑堆外内存的分配过程。

JVM在堆内只保存堆外内存的引用,用DirectByteBuffer对象来表示。

每个DirectByteBuffer对象在初始化时,都会创建一个对应的Cleaner对象。

这个Cleaner对象会在合适的时候执行unsafe.freeMemory(address),从而回收这块堆外内存。

当DirectByteBuffer对象在某次YGC中被回收,只有Cleaner对象知道堆外内存的地址。

当下一次FGC执行时,Cleaner对象会将自身Cleaner链表上删除,并触发clean方法清理堆外内存。

此时,堆外内存将被回收,Cleaner对象也将在下次YGC时被回收。

如果JVM一直没有执行FGC的话,无法触发Cleaner对象执行clean方法,从而堆外内存也一直得不到释放。

其实,在ByteBuffer.allocateDirect方式中,会主动调用System.gc()强制执行FGC。

JVM觉得有需要时,就会真正执行GC操作。

显式调用

参考:堆外内存的回收机制分析—占小狼

三:为什么用堆外内存?

堆外内存的使用场景非常巧妙。

第三方堆外缓存管理包ohc(off-heap-cache)给出了详细的解释。

摘了其中一段。

When using a very huge number of objects in a very large heap, Virtual machines will suffer from increased GC pressure since it basically has to inspect each and every object whether it can be collected and has to access all memory pages. A cache shall keep a hot set of objects accessible for fast access (e.g. omit disk or network roundtrips). The only solution is to use native memory - and there you will end up with the choice either to use some native code (C/C++) via JNI or use direct memory access.

大概的意思如下:

考虑使用缓存时,本地缓存是最快速的,但会给虚拟机带来GC压力。

使用硬盘或者分布式缓存的响应时间会比较长,这时候「堆外缓存」会是一个比较好的选择。

参考:OHC - An off-heap-cache — Github

四:如何用堆外内存?

在第一章中介绍了两种分配堆外内存的方法,Unsafe和NIO。

对于两种方法只是停留在分配和回收的阶段,距离真正使用的目标还很遥远。

在第三章中提到堆外内存的使用场景之一是缓存。

那是否有一个包,支持分配堆外内存,又支持KV操作,还无需关心GC。

答案当然是有的。

有一个很知名的包,Ehcache

Ehcache被广泛用于Spring,Hibernate缓存,并且支持堆内缓存,堆外缓存,磁盘缓存,分布式缓存。

此外,Ehcache还支持多种缓存策略。

其仓库坐标如下:

<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>3.4.0</version>
</dependency>

接下来就是写代码进行验证:

public class HelloHeapServiceImpl implements HelloHeapService {

    private static Map<String, InHeapClass> inHeapCache = Maps.newHashMap();

    private static Cache<String, OffHeapClass> offHeapCache;

    static {
        ResourcePools resourcePools = ResourcePoolsBuilder.newResourcePoolsBuilder()
                .offheap(1, MemoryUnit.MB)
                .build();

        CacheConfiguration<String, OffHeapClass> configuration = CacheConfigurationBuilder
                .newCacheConfigurationBuilder(String.class, OffHeapClass.class, resourcePools)
                .build();

        offHeapCache = CacheManagerBuilder.newCacheManagerBuilder()
                .withCache("cacher", configuration)
                .build(true)
                .getCache("cacher", String.class, OffHeapClass.class);


        for (int i = 1; i < 10001; i++) {
            inHeapCache.put("InHeapKey" + i, new InHeapClass("InHeapKey" + i, "InHeapValue" + i));
            offHeapCache.put("OffHeapKey" + i, new OffHeapClass("OffHeapKey" + i, "OffHeapValue" + i));
        }
    }

    @Data
    @AllArgsConstructor
    private static class InHeapClass implements Serializable {
        private String key;
        private String value;
    }

    @Data
    @AllArgsConstructor
    private static class OffHeapClass implements Serializable {
        private String key;
        private String value;
    }

    @Override
    public void helloHeap() {
        System.out.println(JSON.toJSONString(inHeapCache.get("InHeapKey1")));
        System.out.println(JSON.toJSONString(offHeapCache.get("OffHeapKey1")));
        Iterator iterator = offHeapCache.iterator();
        int sum = 0;
        while (iterator.hasNext()) {
            System.out.println(JSON.toJSONString(iterator.next()));
            sum++;
        }
        System.out.println(sum);
    }
}

其中.offheap(1, MemoryUnit.MB)表示分配的是堆外缓存。

Demo很简单,主要做了以下几步操作:

  1. 新建了一个Map,作为堆内缓存。
  2. 用Ehcache新建了一个堆外缓存,缓存大小为1MB。
  3. 在两种缓存中,都放入10000个对象。
  4. helloHeap方法做get测试,并统计堆外内存数量,验证先插入的对象是否被淘汰。

使用Java VisualVM工具Dump一个内存镜像。

Java VisualVM是JDK自带的工具。

工具位置如下:

/Library/Java/JavaVirtualMachines/jdk1.7.0_71.jdk/Contents/Home/bin/jvisualvm

也可以使用JProfiler工具。

打开镜像,堆里有10000个InHeapClass,却没有OffHeapClass,表示堆外缓存中的对象的确没有占用JVM内存。

内存镜像

接着测试helloHeap方法。

输出:

{"key":"InHeapKey1","value":"InHeapValue1"}
null
……(此处有大量输出)
5887

输出表示堆外内存启用了淘汰机制,插入10000个对象,最后只剩下5887个对象。

如果堆外缓存总量不超过最大限制,则可以顺利get到缓存内容。

总体而言,使用堆外内存可以减少GC的压力,从而减少GC对业务的影响。


参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,290评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,399评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,021评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,034评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,412评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,651评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,902评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,605评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,339评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,586评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,076评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,400评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,060评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,851评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,685评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,595评论 2 270

推荐阅读更多精彩内容