堆外内存定义
创建Java.nio.DirectByteBuffer时分配的内存。
堆外内存优缺点
优点: 提升了IO效率(避免了数据从用户态向内核态的拷贝);减少了GC次数(节约了大量的堆内内存)。
缺点:分配和回收堆外内存比分配和回收堆内存耗时;(解决方案:通过对象池避免频繁地创建和销毁堆外内存)
为什么堆外内存能够提升IO效率?
堆内内存由JVM管理,属于“用户态”;而堆外内存由OS管理,属于“内核态”。如果从堆内向磁盘写数据时,数据会被先复制到堆外内存,即内核缓冲区,然后再由OS写入磁盘,使用堆外内存避免了数据从用户内向内核态的拷贝。
堆外内存申请
JDK的ByteBuffer类提供了一个接口allocateDirect(int capacity)进行堆外内存的申请,底层通过unsafe.allocateMemory(size)实现。Netty、Mina等框架提供的接口也是基于ByteBuffer封装的。
堆外内存释放
JDK中使用DirectByteBuffer对象来表示堆外内存,每个DirectByteBuffer对象在初始化时,都会创建一个对应的Cleaner对象,用于保存堆外内存的元信息(开始地址、大小和容量等),当DirectByteBuffer被GC回收后,Cleaner对象被放入ReferenceQueue中,然后由ReferenceHandler守护线程调用unsafe.freeMemory(address),回收堆外内存。
主动回收(推荐): 对于Sun的JDK,只要从DirectByteBuffer里取出那个sun.misc.Cleaner,然后调用它的clean()就行;
基于 GC 回收:堆内的DirectByteBuffer对象被GC时,会调用cleaner回收其引用的堆外内存。问题是YGC只会将将新生代里的不可达的DirectByteBuffer对象及其堆外内存回收,如果有大量的DirectByteBuffer对象移到了old区,但是又一直没有做CMS GC或者FGC,而只进行YGC,物理内存会被慢慢耗光,触发OOM;
堆外内存溢出
Java.nio.DirectByteBuffer所需的内存超过了物理分配的堆外内存,出现”java.lang.OutOfMemoryError: Direct buffer memory”。
堆外内存使用注意
java.nio.DirectByteBuffer对象在创建过程中会先通过Unsafe接口直接通过os::malloc来分配内存,然后将内存的起始地址和大小存到java.nio.DirectByteBuffer对象里,这样就可以直接操作这些内存。这些内存只有在DirectByteBuffer回收掉之后才有机会被回收,因此如果这些对象大部分都移到了old,但是一直没有触发CMS GC或者Full GC,那么悲剧将会发生,因为你的物理内存被他们耗尽了,因此为了避免这种悲剧的发生,通过-XX:MaxDirectMemorySize来指定最大的堆外内存大小,当使用达到了阈值的时候将调用System.gc来做一次full gc,以此来回收掉没有被使用的堆外内存。
堆外内存默认大小
堆外内存默认值: (-Xmx值) - (1个survivor大小)
为什么Cleaner对象能够被放入ReferenceQueue中?
Cleaner对象关联了一个PhantomReference引用,如果GC过程中某个对象除了只有PhantomReference引用它之外,并没有其他地方引用它了,那将会把这个引用放到java.lang.ref.Reference.pending队列里,在GC完毕的时候通知ReferenceHandler这个守护线程去执行一些后置处理,在最终的处理里会通过Unsafe的free接口来释放DirectByteBuffer对应的堆外内存块。
Java的虚拟机对内存的管理大部分情况下就是指堆内存的管理, GC的也是对堆内存的清理和回收.
netty中提到的一个概念, "零拷贝", 也是netty高性能的原因之一. 零拷贝, 主要体现在三个方面:
Netty的接收和发送ByteBuffer采用DIRECT BUFFERS,使用堆外(直接)内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行Socket读写,JVM会将堆内Buffer拷贝一份到直接内存中,然后才写入Socket中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
Netty提供了组合Buffer对象,可以聚合多个ByteBuffer对象,用户可以像操作一个Buffer那样方便的对组合Buffer进行操作,避免了传统通过内存拷贝的方式将几个小Buffer合并成一个大的Buffer。
Netty的文件传输采用了transferTo方法,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题。
在使用堆外内存的同时也带来了新的问题, 相比较堆内存, 堆外内存的分配和回收要更耗时, 所以netty提供了基于内存池的缓冲区重用机制.
将本地缓存和堆外内存联系到一起, 是有一次调试线上频繁FULL GC然后OOM的问题, 当时的情况是, 线上频繁的报警有FULLGC, 怀疑有内存泄露,, 通过dump堆内存快照, 分析后发现有一个特别大的HashMap, 原因是打点日志引起的, 应用默认集成了日志系统会自动记录用户的所有行为, 应用这边合并日志后发送到日志接收端, 日志接收端挂掉了, 导致一直在重试, 重试的过程中不停的有新的日志加进来, 最后导致FULLGC. 当时我就在想, 如果将这种本地缓存移到堆外是不是就可以不用参与GC, 也可以使用更大的内存.
堆外内存有以下特点:
对于大内存有良好的伸缩性, 堆外内存突破JVM的内存限制
对垃圾回收停顿的改善可以明显感觉到
在进程间可以共享,减少虚拟机间的复制
堆外内存更适合生命周期中等或长期的对象
关于堆外内存的回收
堆外内存的回收其实依赖于我们的GC机制(堆外内存不会对GC造成什么影响)
首先我们要知道在java层面和我们在堆外分配的这块内存关联的只有与之关联的DirectByteBuffer对象了,它记录了这块内存的基地址以及大小,那么既然和GC也有关,那就是GC能通过操作DirectByteBuffer对象来间接操作对应的堆外内存了。
DirectByteBuffer对象在创建的时候关联了一个PhantomReference,说到PhantomReference它其实主要是用来跟踪对象何时被回收的,它不能影响GC决策.
GC过程中如果发现某个对象除了只有PhantomReference引用它之外,并没有其他的地方引用它了,那将会把这个引用放到java.lang.ref.Reference.pending队列里,在GC完毕的时候通知ReferenceHandler这个守护线程去执行一些后置处理, 而DirectByteBuffer关联的PhantomReference是PhantomReference的一个子类,在最终的处理里会通过Unsafe的free接口来释放DirectByteBuffer对应的堆外内存块
什么是基地址 这里就提出了段的概念, 将1G的数据划分为n个段, 每一个段是64K, 每一个段也就是每一个64K就是一个基地址 段内的数据的地址就是当前基地址的偏移地址, 此时 段地址+偏移地址就能够找到真正的内存数据了.
为什么要主动调用System.gc
System.gc()会对新生代的老生代都会进行内存回收,这样会比较彻底地回收DirectByteBuffer对象以及他们关联的堆外内存.
DirectByteBuffer对象本身其实是很小的,但是它后面可能关联了一个非常大的堆外内存,因此我们通常称之为*冰山对象.
我们做ygc的时候会将新生代里的不可达的DirectByteBuffer对象及其堆外内存回收了,但是无法对old里的DirectByteBuffer对象及其堆外内存进行回收,这也是我们通常碰到的最大的问题.
如果有大量的DirectByteBuffer对象移到了old,但是又一直没有做cms gc或者full gc,而只进行ygc,那么我们的物理内存可能被慢慢耗光,但是我们还不知道发生了什么,因为heap明明剩余的内存还很多(前提是我们禁用了System.gc -- JVM参数DisableExplicitGC)。