大家好,我是大明哥,一个专注「死磕 Java」系列创造的硬核程序员。 本文已收录到我的小站:skjava.com


深化剖析堆外内存 DirectByteBuffer & MappedByteBuffer

关于 ByteBuffer 而言,有两个较为特别的类 DirectByteBuffer 和 MappedByteBuffer,这两个类的原理都是基于内存文件映射的。

ByteBuffer 分为两种,一种是直接的,另外一种是间接的。

  • 直接缓冲:直接运用内存映射,关于 Java 而言便是直接在 JVM 之外分配虚拟内存地址空间,Java 中运用 DirectByteBuffer 来完成,也便是堆外内存。
  • 间接缓冲:是在 JVM 堆上完成,Java 中运用 HeapByteBuffer 来完成,也便是堆内内存。

咱们这篇文章主要剖析直接缓冲,即 DirectByteBuffer。在了解 DirectByteBuffer 之前咱们需求先了解操作体系的内存办理方面的知识点。

虚拟内存与物理内存

咱们先了解几个根本概念。

  • MMC:Memory Management Unit,CPU的内存办理单元,用来办理虚拟存储器、物理存储器的控制线路,虚拟地址到物理地址的映射。
  • 物理内存:即内存条的内存空间,咱们能够理解为实在的内存。
  • 虚拟内存:计算机体系内存办理的一种技能。它使得运用程序以为它拥有接连的可用的内存(一个接连完好的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需求时进行数据交换。
  • 页面文件:操作体系反映构建并运用虚拟内存的硬盘空间巨细而创立的文件,在 windows 下,即 pagefile.sys 文件,其存在意味着物理内存被占满后,将暂时不必的数据移动到硬盘上。
  • 缺页中止:当程序试图拜访已映射在虚拟地址空间中但未被加载至物理内存的一个分页时,由 MMC 发出的中止。假如操作体系判别此次拜访是有用的,则测验将相关的页从虚拟内存文件中载入物理内存。

关于操作体系而言,为什么会有实在内存(物理内存)和虚拟内存之分呢?这是因为假如咱们只运用物理内存会有许多问题。

  1. 程序运转困难。为什么会困难,因为内存有限。关于 32 位的体系而言,每个进程在运转的时分都需求分配 4G(2^32) 的内存,关于一个体系而言,你有多少物理内存可供分配,而且在许多运用中咱们服务器的标配一般都是 2C4G,这只能运转一个进程了。

  2. 没有虚拟内存,咱们程序就直接拜访物理内存了,这就意味着一个程序能够任意拜访内存中的所有地址,假如有人搞破坏修正了其他程序在用的地址中的数据,这就或许导致其他程序崩溃。

    虚拟内存的出现解决了上面的问题,进程运转时都会分配 4G 的虚拟内存,进程以为它有了 4G 的内存空间了(仅仅它以为),但实际上,在虚拟内存对应的物理内存上或许只有一点点,实际用了多少内存就会对应多少物理内存。一起进程得到的 4G 虚拟内存是一个接连的内存空间(也仅仅它以为的),而实际上,它通常会被分割为多个物理内存碎片,而且或许有一部分内存还存储在磁盘上,在需求的时分进行数据交换。

对咱们常用的 Linux 操作体系而言,虚拟内存一般都是 4G,其中 1G 为体系内存,3G 为运用程序内存。

深化剖析堆外内存 DirectByteBuffer & MappedByteBuffer

进程运用的是虚拟内存,可是咱们数据仍是存储在物理内存上,那虚拟内存是怎样和物理内存对应起来的呢? 页表,虚拟内存和物理内存树立对应联系采用的是页表页映射的办法,如下:

深化剖析堆外内存 DirectByteBuffer & MappedByteBuffer

页表记录了虚拟内存每个页和物理内存之间的对应联系,如下图:

深化剖析堆外内存 DirectByteBuffer & MappedByteBuffer

它有两个栏位:有用位和途径

  • 有用位:有用位有两个值,0 和 1 ,其中 1 表明已经在物理内存上了,0 表明不在物理内存上
  • 途径:具体的物理页号编码或许磁盘地址

当 CPU 寻址时,它有三种状况:

  • 未分配:虚拟地址地点的那一页并未被分配,代表没有数据和他们关联,这部分也不会占用物理内存。
  • 未缓存:虚拟地址地点的那一页被分配了,但并不在物理内存中。
  • 已缓存:虚拟地址地点的那一页就在物理内存中。

CPU 拜访虚拟内存地址进程如下:

  1. 首要查看页表,判别该页的有用位是否为 1,假如为 1,则射中缓存,依据物理内存页号编码找到物理内存傍边的内容,回来。
  2. 假如有用位为 0,表明不在物理内存上,则参数缺页反常,调用体系内核缺页反常处理程序,操作体系会马上阻塞该进程,并将磁盘中对应的页加载到物理内存且有用位设置为 1,然后使该进行就绪。假如物理内存满了,则会经过页面置换算法选择一个页来覆盖即可。

深化剖析堆外内存 DirectByteBuffer & MappedByteBuffer

内存映射

下图是 Linux 进程的虚拟内存结构:

深化剖析堆外内存 DirectByteBuffer & MappedByteBuffer

留意其中一块区域“Memory mapped region for shared libraries”,这块区域便是内存映射文件的时分将某一段的虚拟地址和文件目标的某一部分树立映射联系,此时并没有仿制数据到内存中,而是当进程代码第一次引证这段代码内的虚拟地址时,触发了缺页反常,这时分 OS 依据映射联系直接将文件的相关部分数据仿制到进程的用户私有空间中去,当有操作第 N 页数据的时分重复这样的 OS 页面调度程序操作。这样就减少了文件仿制到内核空间,再仿制到用户空间功率比规范 IO 高。下面两张图片是规范 IO 操作和内存映射文件,小伙伴们能够认真比照下(图片来自:blog.csdn.net/linxdcn/art…)。

  • 规范 IO

深化剖析堆外内存 DirectByteBuffer & MappedByteBuffer

  • 内存文件映射

深化剖析堆外内存 DirectByteBuffer & MappedByteBuffer

MappedByteBuffer

先看 MappedByteBuffer 和 DirectByteBuffer 的类图:

深化剖析堆外内存 DirectByteBuffer & MappedByteBuffer

MappedByteBuffer 是一个抽象类,DirectByteBuffer 则是他的子类。

MappedByteBuffer 作为抽象类,其实它本身仍是非常简单的。界说如下

public abstract class MappedByteBuffer extends ByteBuffer {
    // 文件描述符
    private final FileDescriptor fd;
    // package 拜访等级
    // 只能经过子类 DirectByteBuffer 结构函数调用
    MappedByteBuffer(int mark, int pos, int lim, int cap, FileDescriptor fd) {
        super(mark, pos, lim, cap);
        this.fd = fd;
    }
    MappedByteBuffer(int mark, int pos, int lim, int cap) {
        super(mark, pos, lim, cap);
        this.fd = null;
    }
    // 省掉一些代码
  }

其实在父类 Buffer 中有一个非常重要的特点 address

// Used only by direct buffers
// NOTE: hoisted here for speed in JNI GetDirectBufferAddress
long address;

这个特点表明分配堆外内存的地址,是为了在 JNI 调用 GetDirectBufferAddress 时提升它调用的速率。这个特点咱们在后面会经常用到,到时分再剖析。

MappedByteBuffer 作为抽象类,咱们能够经过调用 FileChannel 的 map() 办法来创立,如下:

FileInputStream inputStream = new FileInputStream("/Users/chenssy/Downloads/test.txt");
FileChannel fileChannel = inputStream.getChannel();
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0,fileChannel.size());

map() 办法界说如下:

public abstract MappedByteBuffer map(MapMode mode, long position, long size)
    throws IOException;

该办法能够把文件的从 position 开端的 size 巨细的区域映射为 MappedByteBuffer,mode 界说了可拜访该内存映射文件的拜访办法,共有三种

  • MapMode.READ_ONLY(只读): 试图修正得到的缓冲区将导致抛出 ReadOnlyBufferException。
  • MapMode.READ_WRITE(读/写): 对得到的缓冲区的更改最终将写入文件;但该更改对映射到同一文件的其他程序纷歧定是可见的。
  • MapMode.PRIVATE(专用): 可读可写,可是修正的内容不会写入文件,仅仅buffer自身的改变,这种才能称之为”copy on write”

MappedByteBuffer 作为 ByteBuffer 的子类,它一起也是一个抽象类,比较 ByteBuffer ,它新增了三个办法:

  • isLoaded():假如缓冲区的内容在物理内存中,则回来真,不然回来。
  • load():将缓冲区的内容载入内存,并回来该缓冲区的引证。
  • force():缓冲区是READ_WRITE模式下,此办法对缓冲区内容的修正强行写入文件。

与传统 IO 功能比照

比较传统 IO, MappedByteBuffer 就一个字,!!!,它快就在于它采用了 direct buffer(内存映射) 的办法来读取文件内容。这种办法是直接调动体系底层的缓存,没有 JVM,少了内核空间和用户空间之间的仿制操作,所以功率大大提高了。那它比较传统 IO 它快了多少呢?下面咱们来做个小实验。

  • 首要咱们写一个程序用来生成文件。
int size = 1024 * 10;
File file = new File("/Users/chenssy/Downloads/fileTest.txt");
byte[] bytes = new byte[size];
for (int i = 0 ; i < size ; i++) {
    bytes[i] = new Integer(i).byteValue();
}
FileUtil.writeBytes(bytes,file);

经过更改 size 的数字,咱们能够生成 10k,1M,10M,100M,1G 五个文件,咱们就这两个文件来比照 MappedByteBuffer 和 传统 IO 读取文件内容的功能。

  • 传统 IO 读取文件
File file = new File("/Users/chenssy/Downloads/fileTest.txt");
FileInputStream in = new FileInputStream(file);
FileChannel channel = in.getChannel();
ByteBuffer buff = ByteBuffer.allocate(1024);
long begin = System.currentTimeMillis();
while (channel.read(buff) != -1) {
    buff.flip();
    buff.clear();
}
long end = System.currentTimeMillis();
System.out.println("time is:" + (end - begin));
  • MappedByteBuffer 读取文件
int BUFFER_SIZE = 1024;
File file = new File("/Users/chenssy/Downloads/fileTest.txt");
FileChannel fileChannel = new FileInputStream(file).getChannel();
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0,fileChannel.size());
byte[] b = new byte[1024];
int length = (int) file.length();
long begin = System.currentTimeMillis();
for (int i = 0 ; i < length ; i += 1024) {
    if (length - i > BUFFER_SIZE) {
        mappedByteBuffer.get(b);
    } else {
        mappedByteBuffer.get(new byte[length - i]);
    }
}
long end = System.currentTimeMillis();
System.out.println("time is:" + (end - begin));

大明哥电脑是 32GB 的 MacBook Pro, 对 10k,1M,10M,100M,1G 五个文件的测验结果如下:

深化剖析堆外内存 DirectByteBuffer & MappedByteBuffer

绿色是传统 IO 读取文件的,蓝色是运用 MappedByteBuffer 来读取文件的,从图中咱们能够看出,文件越大,两者读取速度距离越大,所以 MappedByteBuffer 一般适用于大文件的读取。

DirectByteBuffer

父类 MappedByteBuffer 做了根本的介绍,且与传统 IO 做了一个比照,这儿就不对 DirectByteBuffer 做介绍了,咱们直接撸源码,撸了源码后我相信你对堆外内存会有更加深化的了解。

DirectByteBuffer 是包拜访等级,其界说如下:

class DirectByteBuffer extends MappedByteBuffer implements DirectBuffer {
   // ....
}

分配内存

DirectByteBuffer 能够经过 ByteBuffer.allocateDirect(int capacity) 进行结构。

public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

调用 DirectByteBuffer 结构函数:

DirectByteBuffer(int cap) {
      super(-1, 0, cap, cap);
      boolean pa = VM.isDirectMemoryPageAligned();
      int ps = Bits.pageSize();
      long size = Math.max(1L, (long)cap + (pa ? ps : 0));
      Bits.reserveMemory(size, cap);// ①
      long base = 0;
      try {
          base = unsafe.allocateMemory(size); // ②
      } catch (OutOfMemoryError x) {
          Bits.unreserveMemory(size, cap);
          throw x;
      }
      unsafe.setMemory(base, size, (byte) 0);
      if (pa && (base % ps != 0)) {
          // Round up to page boundary
          address = base + ps - (base & (ps - 1));
      } else {
          address = base;
      }
      cleaner = Cleaner.create(this, new Deallocator(base, size, cap));  // ③
      att = null;
  }

这段代码中有三个办法非常重要:

  1. Bits.reserveMemory(size, cap)
  2. base = unsafe.allocateMemory(size)
  3. cleaner = Cleaner.create(this, new Deallocator(base, size, cap));

下面就这三段代码来逐个剖析。

  • Bits.reserveMemory(size, cap)

这段代码有两个效果

  1. 总分配内存(按页分配)的巨细和实际内存的巨细
  2. 判别堆外内存是否足够,不可进行 GC 操作
static void reserveMemory(long size, int cap) {
    if (!memoryLimitSet && VM.isBooted()) {
        // 获取最大堆外可分配内存
        maxMemory = VM.maxDirectMemory();
        memoryLimitSet = true;
    }
    // 判别是否够分配堆外内存
    if (tryReserveMemory(size, cap)) {
        return;
    }
    final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
    // retry while helping enqueue pending Reference objects
    // which includes executing pending Cleaner(s) which includes
    // Cleaner(s) that free direct buffer memory
    while (jlra.tryHandlePendingReference()) {
        if (tryReserveMemory(size, cap)) {
            return;
        }
    }
    // trigger VM's Reference processing
    System.gc();
    // a retry loop with exponential back-off delays
    // (this gives VM some time to do it's job)
    boolean interrupted = false;
    try {
        long sleepTime = 1;
        int sleeps = 0;
        while (true) {
            if (tryReserveMemory(size, cap)) {
                return;
            }
            if (sleeps >= MAX_SLEEPS) {
                break;
            }
            if (!jlra.tryHandlePendingReference()) {
                try {
                    Thread.sleep(sleepTime);
                    sleepTime <<= 1;
                    sleeps++;
                } catch (InterruptedException e) {
                    interrupted = true;
                }
            }
        }
        // no luck
        throw new OutOfMemoryError("Direct buffer memory");
    } finally {
        if (interrupted) {
            // don't swallow interrupts
            Thread.currentThread().interrupt();
        }
    }
}

maxMemory = VM.maxDirectMemory(),获取 JVM 允许请求的最大 DirectByteBuffer 的巨细,该参数可经过 XX:MaxDirectMemorySize 来设置。这儿需求留意的是 -XX:MaxDirectMemorySize限制的是总 cap,而不是实在的内存运用量,因为在页对齐的情况下,实在内存运用量和总 cap 是不同的。

tryReserveMemory()能够计算 DirectByteBuffer 占用总内存的巨细,假如发现堆外内存无法再次分配 DirectByteBuffer 则会回来 false,这个时分会调用 jlra.tryHandlePendingReference() 来进行会触发一次非阻塞的 Reference#tryHandlePending(false),经过注释咱们了解了该办法主要仍是协助 ReferenceHandler 内部线程进行下一次 pending 的处理,内部主要是希望遇到 Cleaner,然后调用 Cleaner#clean() 进行堆外内存的开释。

假如还不可的话那就只能调用 System.gc(); 了,可是咱们需求留意的是,调用 System.gc(); 并不能马上就能够履行 full gc,所以就有了下面的代码,下面代码的核心意思是,测验 9 次,假如仍然没有足够的堆外内存来进行分配的话,则会抛出反常 OutOfMemoryError("Direct buffer memory")。每次测验之前都会 Thread.sleep(sleepTime),给体系足够的时间来进行 full gc。

总体来说 Bits.reserveMemory(size, cap) 便是用来计算体系中 DirectByteBuffer 究竟占用了多少,一起经过进行 GC 操作来确保有足够的内存空间来创立本次的 DirectByteBuffer 目标。所以关于堆外内存 DirectByteBuffer 咱们仍然能够不需求手动去开释内存,直接交给体系就能够了。还有一点需求留意的是,别设置 -XX:+DisableExplicitGC,不然 System.gc()就无效了。

  • base = unsafe.allocateMemory(size)

到了这段代码咱们就知道了,咱们有足够的空间来创立 DirectByteBuffer 目标了。unsafe.allocateMemory(size)是一个 native 办法,它是在堆外内存(C_HEAP)中分配一块内存空间,并回来堆外内存的基地址。

inline char* AllocateHeap( size_t size, MEMFLAGS flags, address pc = 0, AllocFailType alloc_failmode = AllocFailStrategy::EXIT_OOM){
   // ... 省掉 
  char*p=(char*)os::malloc(size, flags, pc); 
  // 分配在 C_HEAP 上并回来指向内存区域的指针 
  // ... 省掉 
  return p; 
}
  • cleaner = Cleaner.create(this, new Deallocator(base, size, cap));

这段代码其实便是创立一个 Cleaner 目标,该目标用于对 DirectByteBuffer 占用对堆外内存进行整理,调用 create() 来创立 Cleaner 目标,该目标接受两个参数

  1. Object referent:引证目标
  2. Runnable thunk:整理线程

调用 Cleaner#clean() 进行整理,该办法其实便是调用 thunk#run(),也便是 Deallocator#run()

public void run() {
    if (address == 0) {
        // Paranoia
        return;
    }
    unsafe.freeMemory(address);
    address = 0;
    Bits.unreserveMemory(size, capacity);
}

办法很简单便是调用 unsafe.freeMemory() 开释掉指定堆外内存地址的内存空间,然后重新计算体系中的 DirectByteBuffer 的巨细情况。

Cleaner 是 PhantomReference 的子类,PhantomReference 是虚引证,了解 JVM 的小伙伴应该知道虚引证的效果是盯梢废物收回器搜集目标的活动,当该目标被搜集器收回时收到一个体系通知,所以 Cleaner 的效果便是能够确保 JVM 在收回 DirectByteBuffer 目标时,能够确保相对应的堆外内存也开释。

开释内存

在创立 DirectByteBuffer 目标的时分,会 new 一个 Cleaner 目标,该目标是 PhantomReference 的子类,PhantomReference 为虚引证,它的效果在于盯梢废物收回进程,并不会对目标的废物收回进程造成任何的影响。

当 DirectByteBuffer 目标从 pending 状况 —> enqueue 状况,他会触发 Cleaner#clean()

public void clean() {
    if (!remove(this))
        return;
    try {
        thunk.run();
    } catch (final Throwable x) {
        // ...
    }
}

clean() 办法中其实便是调用 thunk.run(),该办法有 DirectByteBuffer 的内部类 Deallocator 来完成:

public void run() {
    if (address == 0) {
        // Paranoia
        return;
    }
    // 开释内存
    unsafe.freeMemory(address);
    address = 0;
    Bits.unreserveMemory(size, capacity);
}

直接用 unsafe.freeMemory() 开释堆外内存了,这个 address 便是分配堆外内存的内存地址。

关于堆外内存 DirectByteBuffer 就介绍到这儿,我相信小伙伴们一定有所收获。下面大明哥介绍堆内内存:HeapByteBuffer。

参阅