堆外内存初探

时间:2021-12-29 15:11:13
摘要: 使用Java语言的同学们都知道, Java的虚拟机对内存的管理大部分情况下就是指堆内存的管理, GC的也是对堆内存的清理和回收. 下面就看一下堆外内存的对JVM的意义.

使用Java语言的同学们都知道, Java的虚拟机对内存的管理大部分情况下就是指堆内存的管理, GC的也是对堆内存的清理和回收. 下面就看一下堆外内存的对JVM的意义.

第一次了解到堆外内存的使用场景是在使用netty, 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)。

JVM GC-Invisible Heap
淘宝基于OpenJDK HotSpot VM,定制且开源的服务器版Java虚拟机. GC-Invisible Heap,简称GCIH,是一种将Java对象从Java堆内移动到堆外,并且可以在JVM间共享这些对象的技术。

  • 没有序列化和反序列化
  • GC性能更高
  • 和直接操作JVM对象完全一样

如何从JVM移入堆外内存
遍历所有对象, 从根对象开始, 根据对象的引用关系递归调用moveIn(将该对象在GCIH的地址encode到刻对象的header上, 并设置header的最低2位为01).

如何阻止GC扫描
正常的GC过程是, 从root对象出发, 标记活的对象的同时, 将这个对象push到queue, 以便后续的GC线程处理 GCIH更改了GC过程, 当某个对象的地址在GCIH地址空间内时, 直接返回, 不将对象放到queue中