广义的堆外内存
说到堆外内存,那大家肯定想到堆内内存,这也是我们大家接触最多的,我们在jvm参数里通常设置-Xmx来指定我们的堆的最大值,不过这还不是我们理解的Java堆,-Xmx的值是新生代和老生代的和的最大值,我们在jvm参数里通常还会加一个参数-XX:MaxPermSize来指定持久代的最大值,那么我们认识的Java堆的最大值其实是-Xmx和-XX:MaxPermSize的总和,在分代算法下,新生代,老生代和持久代是连续的虚拟地址,因为它们是一起分配的,那么剩下的都可以认为是堆外内存(广义的)了,这些包括了jvm本身在运行过程中分配的内存,codecache,jni里分配的内存,DirectByteBuffer分配的内存等等狭义的堆外内存
而作为java开发者,我们常说的堆外内存溢出了,其实是狭义的堆外内存,这个主要是指java.nio.DirectByteBuffer在创建的时候分配内存,我们这篇文章里也主要是讲狭义的堆外内存,因为它和我们平时碰到的问题比较密切
JDK/JVM里DirectByteBuffer的实现
DirectByteBuffer通常用在通信过程中做缓冲池,在mina,netty等nio框架中屡见不鲜
通过上面的代码我们知道可以通过-XX:MaxDirectMemorySize来指定最大的堆外内存
DirectByteBuffer在创建的时候会通过Unsafe的native方法来直接使用malloc分配一块内存,这块内存是heap 之外的,那么自然也不会对gc造成什么影响(System.gc除外),因为gc耗时的操作主要是操作heap之内的对象,对这块内存的操作也是直接通过 Unsafe的native方法来操作的,相当于DirectByteBuffer仅仅是一个壳,还有我们通信过程中如果数据是在Heap里的,最终也还是会copy一份到堆外,然后再进行发送,所以为什么不直接使用堆外内存呢。对于需要频繁操作的内存,并且仅仅是临时存在一会的,都建议使用堆外内存,并且做成缓冲池,不断循环利用这块内存。
如果我们大面积使用堆外内存并且没有限制,那迟早会导致内存溢出,毕竟程序是跑在一台资源受限的机器上,因为这块内存的回收不是你直接能控制的。
正常情况下,JVM创建一个缓冲区的时候,实际上做了如下几件事:
- JVM确保Heap区域内的空间足够,如果不够则使用触发GC在内的方法获得空间;
- 获得空间之后会找一组堆内的连续地址分配数组, 这里需要注意的是,在物理内存上,这些字节是不一定连续的;
对于不涉及到IO的操作,这样的处理没有任何问题,但是当进行IO操作的时候就会出现一点性能问题.
所有的IO操作都需要操作系统进入内核态才行,而JVM进程属于用户态进程, 当JVM需要把一个缓冲区写到某个Channel或Socket的时候,需要切换到内核态.
而内核态由于并不知道JVM里面这个缓冲区存储在物理内存的什么地址,并且这些物理地址并不一定是连续的(或者说不一定是IO操作需要的块结构),所以在切换之前JVM需要把缓冲区复制到物理内存一块连续的内存上, 然后由内核去读取这块物理内存,整合成连续的、分块的内存.
为了解决这个问题, Java的某些版本会把物理区域分配好的部分内存做缓存就不用每次都开辟一块空间,但效果还不够好,毕竟复制的部分是少不了的.
JDK1.4之后引入了NIO, 提供了一种内存映射技术, 让我们可以直接从Java代码中创建DirectBuffer,这种Buffer在创建的时候直接就在物理内存中分配一块连续内存,当需要使用的时候不再需要复制,内核直接调用即可. 但缺点也是显而易见的,就是每次分配都比较昂贵一点,同时由于分配的内存不在Java Heap中,所以也不会受用户设置的堆大小的限制.
通常情况下,大量使用IO操作的时候使用内存映射是非常值得的