jvm堆外内存--DirectByteBuffer

jvm的堆外内存本来是高贵而神秘的东西,只在一些缓存方案实战级别才会出现。但自从用了Netty等高并发IO框架后,就变成了天天与堆外内存打交道,在mina,netty等nio框架中屡见不鲜。堆外内存的优点是能减少IO过程的内存复制,不需要 "堆内存Buffer拷贝一份到直接内存中,然后才写入Socket";而且没有烦人的GC。

广义的堆外内存
堆外内存是相对于jvm堆内内存的一个概念。堆内内存是jvm所管控的Java进程内存,我们平时在Java中创建的对象都处于堆内内存中,并且它们遵循jvm的内存管理机制,jvm会采用GC回收机制统一管理堆内。堆外内存就是存在于jvm管控之外的一块内存区域,因此它是不受jvm的管控。技术上,我们在jvm参数里通常设置-Xmx来指定我们堆内的最大值,不过这还不是我们理解的Java堆内,-Xmx的值是新生代和老生代的和的最大值,我们在jvm参数里通常还会加一个参数-XX:MaxPermSize来指定持久代的最大值,那么我们认识的Java堆的最大值其实是-Xmx和-XX:MaxPermSize的总和, 在分代算法下,新生代,老生代和持久代是连续的虚拟地址,因为它们是一起分配的 ,那么剩下的都可以认为是堆外内存(广义的)了,这些包括了jvm本身在运行过程中分配的内存,codecache,jni里分配的内存,DirectByteBuffer、HeapByteBuffer分配的内存等等。这部分内存区域直接被操作系统管理.

堆外内存(狭义)
而作为java层开发者,我们常说的堆外内存溢出了,其实是狭义的堆外内存,这个主要是指java.nio.DirectByteBuffer、HeapByteBuffer在创建的时候分配的内存,这就是本文主要讲的内容,因为它和我们平时碰到的问题比较密切。至于剩下的一些没有讲解到的堆外内存,主要是jvm层设计者使用,所以不是开发者重点考虑的问题。因此通常说的堆外内存也被叫直接内存。堆外内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存在现在的jvm体系里面,有时会被频繁地使用,而且也可能导致OOM 异常。
在JDK 1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用Native 函数库直接分配堆外内存,然后通过一个存储在Java 堆里面的DirectByteBuffer 对象作为这块内存的引用进行操作,这样能在一些场景中显著提高性能,因为避免了在Java 堆和Native 堆中来回复制数据。
java内存模型主要讲解的是jvm堆内内存,所以在jvm模型里面,是找不到堆外内存的,下图是jvm内存模型图:
堆外这部分内存区域直接被操作系统管理.

DirectByteBuffer ———— 直接缓冲
DirectByteBuffer是Java用于实现堆外内存的一个重要类,我们可以通过该类实现堆外内存的创建、使用和销毁。

DirectByteBuffer该类本身还是位于Java内存模型的堆中,被jvm直接管控、操纵。
而DirectByteBuffer中的unsafe.allocateMemory(size);是个一个native方法,这个方法分配的是堆外内存,通过操作系统的malloc来进行分配的。分配的内存是系统本地的内存,并不在Java的内存中,不属于jvm管控范围,那么在DirectByteBuffer一定会存在某种方式来操纵堆外内存。其中DirectByteBuffer中有一个long address,这个值即指向被分配的堆外内存地址。

堆内与堆外的数据交互
jvm中的内存和OS中的堆外内存是各自管理和释放的,因此如果存在数据之间交互,比如我们要完成一个从文件中读数据到堆内内存的操作,完成这个操作通常有2种方法,一种即FileChannelImpl.read(HeapByteBuffer)。这里实际上File I/O会将数据读到堆外内存中,然后堆外内存再将数据拷贝到堆内内存,这样我们就读到了文件中的内存。拷贝过程中,jvm和OS都能保证数据安全,不被错误释放。这样就存在2次考虑,性能较低。另外一种方法就是直接使用堆外内存,如DirectByteBuffer:这种方式是直接在堆外分配一个内存(即,native memory)来存储数据,程序通过JNI直接将数据读/写到堆外内存中。因为数据直接写入到了堆外内存中,所以这种方式就不会再在jvm管控的堆内再分配内存来存储数据了,也就不存在堆内内存和堆外内存数据拷贝的操作了。这样在进行I/O操作时,只需要将这个堆外内存地址传给JNI的I/O的函数就好了。
从这里可以看到,堆外内存的优势:
  1 减少了jvm垃圾回收的工作,因为垃圾回收会暂停其他的工作(可能使用多线程或者时间片的方式,根本感觉不到)
  2 加快了复制的速度,降低IO吞吐(这是大部分情况下使用堆外内存的最主要优势之一)。因为堆内在flush到远程时,会先复制到直接内存(非堆内存),然后在发送;而堆外内存相当于省略掉了这个工作。
  同时,它的弊处有:
  1 堆外内存难以控制,如果内存泄漏,那么很难排查
  2 堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象或者扁平化的比较适合。

堆外内存创建
DirectByteBuffer(int cap) 即分配堆外内存大小,在这个方法中,调用了一个重要的方法来检测系统当前堆外内存情况:Bits.reserveMemory(size, cap) 方法,该方法用于在系统中保存总分配内存(按页分配)的大小和实际内存的大小。其中,如果系统中内存( 即,堆外内存 )不够的话, reserveMemory中的jlra.tryHandlePendingReference()会触发一次非堵塞的Reference#tryHandlePending(false)。该方法会将已经被jvm垃圾回收的DirectBuffer对象的堆外内存释放。如果在进行一次堆外内存资源回收后,还不够进行本次堆外内存分配的话,则调用System.gc(),触发一个full gc,当然前提是你没有显示的设置-XX:+DisableExplicitGC来禁用显式GC。并且你需要知道,调用System.gc()并不能够保证full gc马上就能被执行。full gc过程最差情况下会进行最多9次尝试,看是否有足够的可用堆外内存来分配堆外内存。并且每次尝试之前,都对延迟等待时间,已给jvm足够的时间去完成full gc操作。如果9次尝试后依旧没有足够的可用堆外内存来分配本次堆外内存,则抛出OutOfMemoryError("Direct buffer memory”)异常。

这里之所以用使用full gc的很重要的一个原因是:System.gc()会对新生代的老生代都会进行内存回收,这样会比较彻底地回收DirectByteBuffer对象以及他们关联的堆外内存.DirectByteBuffer对象本身其实是很小的,但是它后面可能关联了一个非常大的堆外内存,因此我们通常称之为冰山对象. 我们做ygc的时候会将新生代里的不可达的DirectByteBuffer对象及其堆外内存回收了,但是无法对old里的DirectByteBuffer对象及其堆外内存进行回收,这也是我们通常碰到的最大的问题。( 并且堆外内存多用于生命期中等或较长的对象 )如果有大量的DirectByteBuffer对象移到了old,但是又一直没有做cms gc或者full gc,而只进行ygc,那么我们的物理内存可能被慢慢耗光,但是我们还不知道发生了什么,因为heap明明剩余的内存还很多(前提是我们禁用了System.gc – jvm参数DisableExplicitGC)。

这个过程步骤简单概括为:
 Bits.reserveMemory(size, cap)方法在可用堆外内存不足以分配给当前要创建的堆外内存大小时,会实现以下的步骤来尝试完成本次堆外内存的创建:
① 触发一次非堵塞的Reference#tryHandlePending(false)。该方法会将已经被jvm垃圾回收的DirectBuffer对象的堆外内存释放。

② 如果进行一次堆外内存资源回收后,还不够进行本次堆外内存分配的话,则进行 System.gc()。System.gc()会触发一个full gc,但你需要知道,调用System.gc()并不能够保证full gc马上就能被执行。所以在后面打代码中,会进行最多9次尝试,看是否有足够的可用堆外内存来分配堆外内存。并且每次尝试之前,都对延迟等待时间,已给jvm足够的时间去完成full gc操作。
注意,如果你设置了-XX:+DisableExplicitGC,将会禁用显示GC,这会使System.gc()调用无效。

③ 如果9次尝试后依旧没有足够的可用堆外内存来分配本次堆外内存,则抛出OutOfMemoryError("Direct buffer memory”)异常。

那么可用堆外内存到底是多少了?,即默认堆外存内存有多大:
① 如果我们没有通过-XX:MaxDirectMemorySize来指定最大的堆外内存。则②
② 如果我们没通过-Dsun.nio.MaxDirectMemorySize指定了这个属性,且它不等于-1。则③
③ 那么最大堆外内存的值来自于:
directMemory = Runtime.getRuntime().maxMemory(),这是一个native方法,其中在我们使用CMS GC的情况下也就是我们设置的-Xmx的值里除去一个survivor的大小就是默认的堆外内存的大小了。

堆外内存回收
Cleaner是PhantomReference的子类,并通过自身的next和prev字段维护的一个双向链表。PhantomReference的作用在于跟踪垃圾回收过程,并不会对对象的垃圾回收过程造成任何的影响。所以cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); 用于对当前构造的DirectByteBuffer对象的垃圾回收过程进行跟踪。当DirectByteBuffer对象从pending状态 ——> enqueue状态时,会触发Cleaner的clean(),而Cleaner的clean()的方法会实现通过unsafe对堆外内存的释放。这里会涉及到gc与cleaner如何关联:
gc有一种PhantomReference变量管理方式,Phantom是幻影的意思,Cleaner就是PhantomReference的子类。
当GC时发现它除了PhantomReference外已不可达(持有它的DirectByteBuffer失效了),就会把它放进 Reference类pending list静态变量里。然后另有一条ReferenceHandler线程,名字叫 "Reference Handler"的,关注着这个pending list,如果看到有对象类型是Cleaner,就会执行它的clean(),其他类型就放入应用构造Reference时传入的ReferenceQueue中,这样应用的代码可以从Queue里拖出这些理论上已死的对象,做爱做的事情——这是一种比finalizer更轻量更好的机制。
Cleaner的clean()方法会调用当期cleaner中的remove(this),即将当前Cleaner从Cleaner链表中移除,这样当clean()执行完后,Cleaner就是一个无引用指向的对象了,也就是可被GC回收的对象。
实际中,我们通常会加上配置参数的方式来辅助回收堆外内存:  我们可以通过-XX:MaxDirectMemorySize来指定最大的堆外内存大小,当使用达到了阈值的时候将调用System.gc()来做一次full gc,以此来回收掉没有被使用的堆外内存。

什么情况下使用堆外内存
  • 堆外内存适用于生命周期中等或较长的对象。( 如果是生命周期较短的对象,在YGC的时候就被回收了,就不存在大内存且生命周期较长的对象在FGC对应用造成的性能影响 )。
  • 直接的文件拷贝操作,或者I/O操作。直接使用堆外内存就能少去内存从用户内存拷贝到系统内存的操作,因为I/O操作是系统内核内存和设备间的通信,而不是通过程序直接和外设通信的。
  • 同时,还可以使用 池+堆外内存 的组合方式,来对生命周期较短,但涉及到I/O操作的对象进行堆外内存的再使用。( Netty中就使用了该方式 )

堆外内存 VS 内存池
  • 内存池:主要用于两类对象:
        ①生命周期较短,且结构简单的对象,在内存池中重复利用这些对象能增加CPU缓存的命中率,从而提高性能;
        ②加载含有大量重复对象的大片数据,此时使用内存池能减少垃圾回收的时间。
  • 堆外内存:它和内存池一样,也能缩短垃圾回收时间,但是它适用的对象和内存池完全相反。内存池往往适用于生命期较短的可变对象,而生命期中等或较长的对象,正是堆外内存要解决的。

堆外内存的特点
  • 对于大内存有良好的伸缩性
  • 对垃圾回收停顿的改善可以明显感觉到
  • 在进程间可以共享,减少虚拟机间的复制
  • 内存管理复杂

堆外内存的一些问题
  • 堆外内存回收问题,以及堆外内存的泄漏问题。
  • 堆外内存的数据结构问题:堆外内存最大的问题就是数据结构不那么直观,如果数据结构比较复杂,就要对它进行串行化(serialization),而串行化本身也会影响性能。另一个问题是由于你可以使用更大的内存,你可能开始担心虚拟内存(即硬盘)的速度对你的影响。

为什么不能大面积使用堆外内存
如果我们大面积使用堆外内存并且没有限制,那迟早会导致内存溢出,毕竟程序是跑在一台资源受限的机器上,因为这块内存的回收不是你直接能控制的,当然你可以通过别的一些途径,比如反射,直接使用Unsafe接口等,但是这些务必给你带来了一些烦恼,Java与生俱来的优势被你完全抛弃了—开发不需要关注内存的回收,由gc算法自动去实现。另外上面的gc机制与堆外内存的关系也说了,如果一直触发不了cms gc或者full gc,那么后果可能很严重。

参考:

猜你喜欢

转载自blog.csdn.net/meiliangdeng1990/article/details/80718066