jvm 堆外内存_从使用NIO读写文件说起——拆解堆外内存分配过程

b1e80fb65fe75f00a99b5c28afb331ff.png

点击上方蓝字关注我吧!

本篇文章大概5800字,阅读时间大约10分钟

本文拆解了NIO的堆外内存分配过程以及回收原理,为后续梳理Netty的内存池设计以及堆外内存使用和回收打下基础。

52876caf4dc284e1edc9c5131ad0973d.gif

所谓堆外内存,也叫直接内存(Direct Memory),这是Java里或者说依赖了虚拟机的编程语言特有的一个概念。《深入理解java虚拟机》里说到:

它并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也可以被用户调度使用,而且也可能导致OutOfMemoryError。

《深入理解Java虚拟机》

通俗的说,堆外内存是JDK的NIO引入的,依赖了JNI,调用了native库函数,也就是说使用了C/C++的库函数去分配了Java虚拟机管控范围之外的内存块儿。之所以使用堆外内存,它的好处之一就是可以加快I/O的速度,JDK使用一种特殊方式为用户分配堆外内存缓冲区,JDK文档中的描述为:给定一个直接字节缓冲区(堆外内存也叫直接内存或者直接缓冲区),Java虚拟机将尽最大努力直接对它执行本机I/O操作。也就是说Java语言传统的I/O交互过程,它会在每一次调用操作系统的I/O操作之前(或之后),尝试将JVM堆内的数据内容拷贝到一个临时的中间缓冲区中(也是在用户空间),再将这份数据从中间缓冲区拷贝到内核的Socket缓冲区;或者反过来,从内核的Socket缓冲区里拷贝数据到一个临时的中间缓冲区中,再将这份数据从中间缓冲区里拷贝到JVM堆内。如果使用堆外内存分配,那么就少了一次数据拷贝,即Java会将数据直接放入分配的这块直接缓冲区里,后续直接拷贝到内核,或者从内核里拷贝到直接缓冲区,这是用户空间的内存拷贝过程的优化。

下面看如何使用直接缓冲区。要分配直接缓冲区需要用户显式的调用静态的allocateDirect()方法,而不在是allocate()方法,使用方式与普通缓冲区并无区别,如下面的拷贝文件的demo:

06336826aa521721738e4d689b06e735.png

以上在黄色区域的代码,分配了一块儿大小为512字节的直接缓冲区内存,即所谓的堆外内存分配:

ByteBuffer.allocateDirect(512)

进入这个allocateDirect方法,如下:

2b87b8ac7e10fec58fd99d1b2f96cfa6.png

实际上,它底层也会new一个对象——DirectByteBuffer(capacity) 对象,这和堆内内存的分配流程如出一辙,这个DirectByteBuffer对象就是代表的直接缓冲区。如下:

f3811201fe3917c360ecc5b70a98194b.png

以ByteBuffer为例,如下是它简化的类图,其实Buffer还有很多子类,但是我们不关心这么多,只抓要害,且只详细画了ByteBuffer的类图。

caa174027909c3adfd51f42f514b9972.png

下面进入这个DirectByteBuffer类的构造器:

e7efe695dbf88affe56eecd195f29069.png

发现它用到了很多JDK并没有开源的代码,比如sun包下的一些代码,即它会调用到JDK的底层的一些API。可以大概看下,主要用到了JDK的unsafe工具类操作了一些事情,还有就是一个VM类,都是Java业务代码里几乎一辈子也看不到的,下面只分析它分配堆外内存的核心流程,一些涉及到了什么内存页对齐的流程不看了,抓主要矛盾,不是重点。

看上图第二个红色划线处——unsafe调用的allocateMemory方法,最终调用到本地方法,需要用到JNI调用,该类(Unsafe)也不是开源的,只能反编译查看。

70327e0eadcae5e7790276c621f1c851.png

认识Java底层类——Unsafe,Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问操作系统的内存资源、自主管理内存资源等,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。注意它不是Netty里设计的那个Unsafe组件,只不过它们的命名理念是一致的,可以参考:Netty为何在Channel里设计Unsafe,且针对不同类型的Channel设计了两大类实现?

由于Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中不正确,过度的使用Unsafe类会使得程序出错的概率变大,使得Java这种安全的语言变得不再“安全”,因此对Unsafe的使用一定要慎重。

不过很多开源组件,包括Netty,就艺高人胆大,大量利用了Unsafe来提高内存读写性能(这也是Netty的一个可选项,如果Netty的系统参数io.netty.noUnsafe为true且JDK支持的话,才能打开这些特性)。Unsafe不能直接使用,假如代码中直接使用会抛出异常——java.lang.SecurityException:Unsafe,所以必须通过反射来得到它的一个实例。例如Netty的io.netty.util.internal.PlatformDependent0类中的源码就写的非常规范:

03bff5fdd4223125fbca8fe5d60405bd.png

后面还有一大堆,实现逻辑比较长,这里不看了,具体获取并使用unsafe的方式,可以直接看这个类的源码进行模仿。

下面简单介绍介绍,深入的话没必要,一个是大部分应用软件的RD都用不到,没必要花费太多时间在上面,第二是本文篇幅有限。下面反编译其源码,注意该类不开源。

291dcef0bd999d3a7c29037ce88eb46f.png

发现它是单例类,提供静态方法getUnsafe()获取Unsafe实例,当且仅当调用getUnsafe方法的类为引导类加载器所加载时才合法,否则抛出SecurityException异常,所以Netty就必须用反射的方式,修改该类的属性访问的权限。当然还有一个方式是修改classpath,但是不通用,移植性差。

一些API的用法具体可以自行网上搜索相关文档。

3740399e9dbe04cd17e5fc010cf9b9c0.png

如下,继续看NIO的Buffer的堆外内存分配过程,它最终会调用native方法——allocateMemory0(long size)分配堆外内存。它的C++实现如下:

e47ebdb5b3e6344d65eb8ed69b7ac5ce.png

以上,本质就是在Java虚拟机外面调用了C语言的库函数malloc分配了一块连续的内存区域,因为malloc分配的是一块连续的内存,并且这些内存都是虚拟内存,即还没有在内核中被实际的分配,所以这块儿内存是位于用户空间,也就是说JDK的堆外内存还是在用户空间分配的,这其中又涉及到了内存的段页式使用方法,不细究了,只需要知道malloc分配内存是没有真正分配物理地址的,malloc调用后只是分配了内存的逻辑地址,没有分配实际的物理内存,只有当开始涉及到程序或者数据分段的载入内存时,引发了缺页中断,才会通过段表,页表进行重定位——建立内存的物理页和程序的逻辑地址的映射关系,这涉及到了操作系统的内存管理的内容。这里知道这个结论即可。

学过C语言的都知道,malloc函数最好和free函数搭配使用,否则容易导致OOM。虽然程序停止后,它使用的资源会被自动释放,有些是在main函数结束时释放,有些是操作系统统一释放,但指望程序停止再释放内存,很可能程序会活不到正常退出的那一刻,因为可能会有大量程序,或者一个程序大量的分配内存,导致OOM而使得程序提前崩溃。。。所以需要谁用谁申请,用完了及时调用free函数释放内存。也就是说Java的堆外内存仍然可能会OOM,所以Java也一样实现了配套的释放API,后续单说。

继续看这个C++函数:

e47ebdb5b3e6344d65eb8ed69b7ac5ce.png

如果堆外内存分配成功,那么返回一个指针,该指针指向了这块儿被分配的内存空间的起始地址;否则,返回空指针——NULL。所以上面的malloc函数返回了一个void *,在C语言里表示未确定类型的指针。C/C++规定,void *类型可以强转为任何其他类型的的指针。接着看malloc的参数,即Java层传入了一个long的参数,它会作为malloc的参数,意思是分配的内存大小至少为参数所指定的字节数。并且需要澄清的是,malloc分配了连续的内存空间,本质是在逻辑上是连续的,而在物理内存上可以不连续,这涉及到了操作系统的内存管理机制,即程序分段,内存分页的管理方式,期间需要逻辑地址到物理地址的重定位等策略,这些都是操作系统私下处理的,这里不多总结了,还是那句话,看源码要抓主要矛盾。

接着,Java将分配的这块连续内存,使用了addr_to_java(void* p)函数进行了转换:

bb250576cf2c5232164262290c1e0230.png

很简单,即返回了一个uintptr_t类型的指针。在64位的机器上uintptr_t是unsigned long int的别名;在32位的机器上uintptr_t是unsigned int的别名。这里使用了typedef定义了别名。目的是提高程序的可移植性(在32位和64位的机器上都是一套)。

以上,得知Java调用了C语言的库函数malloc分配了所谓的堆外内存。当然这样说不是很准确,确切的说之前的HeapByteBuffer是完全在JVM堆内分配的,而new的DirectByteBuffer,既然new出来,肯定也在JVM堆内分配了内存,只不过这块堆内内存只是一个指针(是malloc函数返回的内存的首地址),它指向了堆外的一块内存,这块内存才是真正分配给用户使用的,和JVM没有关系。

那new出来的DirectByteBuffer对象,是如何和堆外内存关联的呢?结合前面的JVM的C++代码知道,分配的堆外内存返回了一个指向该内存区域首地址的一个指针,在看下面的Java的源码,发现在抽象类Buffer里,有一个long类型的属性叫address:

db913d11b7f3b5d236953348e0b7e00b.png

这个long类似的address就保存了堆外内存的那个指针的值,它只会被DirectXXXBuffer使用,下面可以画个图,如下,DirectByteBuffer的内存分配由两部分组成:

a51a9ea7c1c2779e64f6d1ff18a63772.png

一句话:DirectByteBuffer对象本身依然被JVM的GC管理,只是它关联的堆外内存不受JVM的GC算法的控制,所以这也是Java的堆外内存的另一个优势,即不受限GC管理,无需担心频繁的GC导致的应用暂停问题。在高并发,高写入背景下,效果明显。

这里再次回答或者重申一个问题——JDK为什么给NIO设计了堆外内存?

还是以ByteBuffer为例,DirectByteBuffer中不保存字节数组,它直接将数据放到了堆外内存,后续直接用堆外内存和操作系统的I/O函数交互,少去了一次用户空间的数据拷贝过程,否则用HeapByteBuffer的话,每次I/O需要先把JVM堆内的字节数组的内容拷贝到一块儿临时的堆外内存区域,而且直接使用堆外内存和I/O操作交互,不需要担心忽然发生的GC导致的内存变化而产生的错误逻辑,比如使用堆内内存和操作系统I/O交互时,需要使用JNI接口调度操作系统的I/O系统调用,假设GC同时也发生了,其GC算法可能会压缩堆内的内存空间,比如内存碎片整理等。这可能导致正在和操作系统I/O交互的数据乱套,所以此时不能进行GC,但是不GC,进行I/O操作时JVM比较容易有概率发生OOM。所有最佳方案就是直接使用堆外内存,一方面堆外内存不受频繁的GC影响,其实严格来说也有影响,它会被GC清理,只不过GC的各种清理算法管不到它的头上(后续单说),一方面堆外内存直接和操作系统I/O函数交互,少了一次无意义的JVM堆到堆外的缓冲区的数据拷贝,速度也快一些。

前面说了堆外内存分破,下面看堆外内存的释放过程。首先,用户可以通过get方法拿到堆外内存的首地址address:

93fbcf6d69764850ec27b63bf866ddf7.png

可以手动获取JDK的unsafe工具,然后清理堆外内存。只不过很少用,因为JDK为每一个堆外内存都设计了一套清理机制,依靠的是DirectByteBuffer一个静态内部类——Deallocator,它本质是一个Runnable,故会被一个清理线程单独驱动,如下,本质依然是使用unsafe类清理堆外内存,更本质的是调用了C语言的free函数:

4521c059ab61149ad7bd925b016cdfd4.png

以上,这个unsafe.freeMemory(long address)方法内部调用了native方法,最终调用了C语言库函数free:

47c944045a7d125771d6f23ebc7173c9.png

言归正传,这个自动清理堆外内存的task——Deallocator类,会被DirectByteBuffer的cleaner对象封装,如下DirectByteBuffer构造器黄色区域代码:

0fb97a4eeafec843707f36e20d205651.png

这个Cleaner类,本质是一个继承了Java虚引用的工具,目的就是用来在GC时通过调用Cleaner的clean方法回收堆外内存,即每个DirectByteBuffer对象都关联了一个Cleaner对象,而Cleaner里聚合了前面说的Deallocator任务,后续会通过这个关系拿到堆外内存的地址,大小等信息,从而实现清理:

72916b5b23b84bf47257064965580e14.png

以上,Cleaner继承了JDK的虚引用类——PhantomReference,虚引用也称为幽灵引用或者幻影引用,是所有引用类型中最弱的一个,Java有四大类型的引用——强,软,弱,虚,它们的设计目的主要是为了更好的进行内存管理,简单说就是对象在不同的引用包装下,对象被垃圾回收的力度不同。如下官方解释:

bc65486fb03de6ff86f80eb693a83568.png

虚引用的作用在于跟踪GC过程,它与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当GC准备收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列ReferenceQueue中。如下是ReferenceQueue的入队方法,加入的元素是一个继承了Reference的类的对象:

438cb89ddeda5eefce61f7400c93ccfd.png

如下该队列的注释写到。当检测到一个对象可达性被更改后,GC会将已经注册的引用对象追加到这个引用队列里。

fb042b0c88f25fa39c0107d9f4b0aad9.png

然后ReferenceQueue里有一个专门的线程ReferenceHandler,如下

9c035bdd68c1bf79fb9038ed3b60af94.png

在ReferenceQueue初始化时就被启动了,如下:

ca445d4aff8c47c43a5e1a93e04ed5a8.png

这个线程最终会执行到队列里的Cleaner对象的clean方法(当然也有其它对象,不关注),如下是ReferenceHandler的run方法里的片段:

21eddc0a48b338950bf3f61a336f77e1.png

而前面我总结了Java为每个DirectByteBuffer关联了一个Cleaner对象,这个对象的clean方法里会调用native函数——free去根据基地址address释放堆外内存。

以上,就一句话:使用虚引用的目的就是为了得知对象被GC的时机,所以可以利用虚引用来进行销毁前的一些操作,比如说资源释放等。这个虚引用对于对象而言完全是无感的,有没有完全一样,但是对于虚引用的使用者而言,就像是待观察的对象的线索,可以通过它来观察对象是否已经被回收,从而进行相应的处理。所以虚引用一个很重要的用途就是用来做堆外内存释放,比如DirectByteBuffer通过虚引用来实现堆外内存释放。

而且注意到这个Cleaner对象本身继承了虚引用类型,属于虚引用对象:

38a5822e0c088731cc3d5a225ad5b540.png

它内部属性如下,会在初始化时显示的创建一个类共享级别的ReferenceQueue:

a6e5810fafd71a2578179c876d0a5c5d.png

后续所有的Cleaner对象(和DirectByteBuffer一对一)在GC时都会被放入这个ReferenceQueue。而Cleaner内部是一个双向链表的数据结构,如下它有next和prev指针,还有一个first指针,该静态类型的first指针会指向第一个Cleaner对象:

09c9f2e9a94728c2b6e28dc4455415f3.png

如下是最新版的《深入理解Java虚拟机》里,对Java的GC机制的解释,Java的GC使用的是对象Root可达来判断是否需要回收它:

2b8e88d2095753ceed5b4474fdf86c66.png

其中提到了类静态属性引用的对象,对比在Cleaner对象,它会被一个静态的first指针引用,注释也写到:

Doubly-linked list of live cleaners, which prevents the cleaners themselves from being GC'd before their referents

活动的cleaners们组成的一个双向链表,可以用来防止cleaners本身在其关联的DirectByteBuffer对象不可达之前被回收。这里需要补充一点,在GC回收DirectByteBuffer对象时,会回调其关联的Cleaner的clean方法,里面除了调用free函数清理堆外内存外,还会将这个关联的Cleaner对象从其双向链表里删除,这样Cleaner就可以在下次GC时被彻底回收。

回到Netty,Netty的ByteBuf(即Netty独立设计的缓冲区组件)设计里有引用计数的机制,以及手动/自动释放堆外内存的API或者机制,当然这又细分为了池化内存回收和非池化内存回收过程,后续专题总结。下面简单看下Netty是如何实现的主动堆外内存回收,下面的demo是一个非池化的堆外内存分配和回收流程:

6367bce8ab60fd372c92f252f6e904a2.png

最终bytebuf的release方法调用到Netty如下的deallocate方法:

90862adea8241072b3f5c677bd98a636.png

这个方法最终会调用到Netty自己实现的一套安全获取JDK的Unsafe以及自定义的一系列配套工具的PlatformDependent0类的freeMemory方法释放堆外内存:

c679af955d100f81dbfff48dc75c8f88.png

最终调用到如下的unsafe的freeMemory方法,去调用C语言的free函数释放内存。

3c6b5791b350218b722a2695f47d6dbe.png

即Netty是为了避免堆外内存泄露,额外设计了手动释放堆外内存的API。毕竟正常的程序的GC是在新生代满了后,进行youngGC,如果此时DirectByteBuffer对象仍然可达,那么最终会被赶到老年代,而老年代满了才会触发FullGC,其频率是非常低的,等到那时候依赖的Cleaner机制可能顶不住,所以Netty提供了API,让用户也可以及时的手动清理堆外内存。

小结:

1、DirectByteBuffer——堆外内存优势和缺陷:

  • 不受限GC的管理(严格说Java设计了通过GC去清理堆外内存的机制,只不过不耽误这种优势),无全局停顿的问题,因为这块儿内存在堆外,GC的各种算法管不到它的头上,GC唯一能做的就是free它而已。

  • 减少了用户空间数据的拷贝次数——避免了数据在Java堆和Native堆中来回复制数据。注意它和操作系统的“零拷贝”是两码事

  • 依然会OOM,需要格外小心的使用,虽然它不会受到Java堆大小的限制,但是既然是内存,肯定还是会受到本机总内存大小以及处理器寻址空间的限制,而且一般服务器配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉堆外内存的配置,从而在依赖了使用了堆外内存的框架时,高并发场景下容易出现堆外的OutOfMemoryError异常

  • 堆外内存分配过程相比堆内存分配,比较损耗性能,所以Netty为其实现了内存池,以减少分配和回收堆外内存的次数,当然这个池子是一个通用池,堆内内存也能使用。

2、堆外内存的分配本质就是C语言的malloc函数,分配的是一段连续的虚拟内存,是在用户态分配,这块儿内容需要操作系统基础来理解,不多说。

3、需要知道堆外内存的回收机制——虚引用+单独的清理线程,当然用户也可以手动释放,本质都是调用了C语言的free函数。

END

点亮在看,你最好看

点击此处写留言~

bc80a77d18d32c951ae2e0cee524d2a8.png 4b3cffd3b84e607955e16128ac235665.gif

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值