MMAP以及工程实战

作者: juexingzhe | 来源:发表于2021-07-24 16:58 被阅读0次

好久没上来更新文章,最近发生了很多事,有开心的也有不开心的,世间百态,酸甜苦辣都有,生活总要继续,也需要做个总结。最近一段时间在做公司基础组件的重构,正好做一个总结,一篇可能阐述不完,会有一个系列吧,欢迎关注。

本文主要几个点:

  • MMAP和NIO概述
  • MMAP构造
  • Buffer的读写
  • 工程实际应用和需要注意的坑
  1. 概述

我们知道目前操作系统提供了一种内存映射文件的方法MMAP,可以将文件或者其他对象映射到进程的地址空间,实现磁盘地址和进程虚拟地址的一一対映关系,这样进程就可以内存的操作方式来操作这个映射文件,系统会自动回写脏页面到对应的文件磁盘上,这样对文件的操作不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间。所以MMAP也是实现不同进程通信的一种方式。同时直接操作内存少了普通IO需要的用户态和内核态之间的切换。而且由于有系统的自动回写的机制,用MMAP可以很大程度上防丢失,比如重要数据或者日志等。MMAP的理论感兴趣的可以再网上找资料深入了解下。

我们今天要讨论的是Java中怎么使用MMAP呢?在这之前需要先大概了解下Java中的NIO。

普通IO是面向流的,Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方,也不能前后移动流中的数据。除非先将它缓存到一个缓冲区。 而Java NIO是面向缓冲区的,数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。

NIO中有两个比价重要的概念Channel和Buffer,所有的 IO 在NIO 中都从一个Channel 开始,所以Channel 有点象流。 数据可以从Channel读到Buffer中,也可以从Buffer 写到Channel中。

channel&buffer

channel与buffer都有好几种类型,Buffer覆盖了能通过IO发送的基本数据类型:byte, short, int, long, float, double 和 char,对应ByteBuffer, ShortBuffer, IntBuffer, LongBuffer, FloatBuffer, DoubleBuffer, CharBuffer。另外一个特殊的buffer就是MappedByteBuffer,就是我们今天的主要MMAP对应的buffer。

Channel的一些主要实现有:FileChannel,DatagramChannel,SocketChannel和ServerSocketChannel,分别对应文件IO,UDP和TCP网络IO。

基本的背景知识就介绍到这,感觉意犹未尽的小伙伴可以自行再查找其他资料补充。接下来我们就先从FileChannel开始介绍MMAP。

  1. MMAP构造

前面说过Java NIO从Channel开始,有点类似于传统IO中的Stream。MMAP本质上是IO操作,对应的Channel在NIO包里面是FileChannel。

首先是打开FileChannel,无法直接打开一个FileChannel,需要通过使用一个InputStream、OutputStream或RandomAccessFile来获取一个FileChannel实例。下面是通过RandomAccessFile打开FileChannel的示例:

RandomAccessFile file = new RandomAccessFile("mmap.txt", "rw");
FileChannel inChannel = file.getChannel();

有了Channel,就需要一个Buffer与Channel进行交互,MMAP对应的buffer是MappedByteBuffer。可以通过下面代码获取到:

MappedByteBuffer  buffer = channel.map(MapMode mode,long position, long size);

其中:
MapMode是一个文件映射方式的枚举,分别有只读、读写、copy-on-write三种文件属性定义,下面是源码:

    public static class MapMode {

        /**
         * Mode for a read-only mapping.
         */
        public static final MapMode READ_ONLY
            = new MapMode("READ_ONLY");

        /**
         * Mode for a read/write mapping.
         */
        public static final MapMode READ_WRITE
            = new MapMode("READ_WRITE");

        /**
         * Mode for a private (copy-on-write) mapping.
         */
        public static final MapMode PRIVATE
            = new MapMode("PRIVATE");

        private final String name;

        private MapMode(String name) {
            this.name = name;
        }

        /**
         * Returns a string describing this file-mapping mode.
         *
         * @return  A descriptive string
         */
        public String toString() {
            return name;
        }

    }

position是文件映射的起始位置,不能为负数,否则会抛IllegalArgumentException异常

size是本次映射的长度,不能为负数或者大于Integer.MAX_VALUE, 否则也会抛IllegalArgumentException异常

就上面这么两个步骤就在Java中完成了MMAP的初始化了,是不是非常简单,接下来就是怎么通过buffer来进行读写操作了。

其实buffer本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成MappedByteBuffer对象,并提供了一组方法,用来方便的访问该块内存,系统也会自动回写内容到映射文件中。

  1. Buffer读写

buffer的读写数据一般遵循下面四个步骤

a. 调用position方法移动到需要写入的位置,默认初始化位置是0,然后调用putXXX方法写入数据
b. 调用flip方法切换到读模式
c. 从buffer中读取数据
d. 调用clear()方法或者compact()方法

RandomAccessFile file = new RandomAccessFile("data/nio-data.txt", "rw");
FileChannel channel = file.getChannel();

MappedByteBuffer  buffer = channel.map(MapMode mode,long position, long size);

int bytesRead = channel.read(buf); //read into buffer.
while (bytesRead != -1) {

  buf.flip();  //make buffer ready for read

  while(buf.hasRemaining()){
      System.out.print((char) buf.get()); // read 1 byte at a time
  }

  buf.clear(); //make buffer ready for writing
  bytesRead = channel.read(buf);
}
channel.close();

下面是buffer三个重要参数position/limit/capacity的解释图:
写模式下,position就是下一个写入的位置,position最大可为capacity – 1。limit和capacity一样,buffer的容量;

读模式下,position会被重置为0。当从Buffer的position处读取数据时,position向后移动到下一个可读的位置,limit是最多能读到多少数据。就是写模式下的position值。换句话说,能读到之前写入的所有数据(limit被设置成已写数据的数量,这个值在写模式下就是position)


read&write.png

Buffer中还提供了一个remain()的api,其实就是limit和postion的差值,那么在写模式下就是还可以写入多少的数据,读模式下就是还有多少数据未读取。

    /**
     * Returns the number of elements between the current position and the
     * limit.
     *
     * @return  The number of elements remaining in this buffer
     */
    public final int remaining() {
        return limit - position;
    }

通过上面基本了解了MMAP在Java中的使用方法,下面说下我在实际工程中的应用。

  1. 实际应用

在公司的项目中,把mmap用在写日志上,可以降低丢失率,同时写内存的方式也比普通的IO更高效,毕竟少了系统内核态和用户态之间的切换。那么接下来看下实际应用。

首先是初始化,这有几个工程实际中需要考虑的点

  1. 如果初始化失败怎么保证使用,也就是容错
  2. mmap中可能有数据尚未回写磁盘,怎么恢复数据,避免数据被覆盖

其中initMMAPBackBuffer是容错机制,对1个点的解决,在mmap初始化失败时开辟一个backbuffer做备用。mRemaining用来解决第二个问题,类似于Java Class文件的魔数,这里用一个int 4字节来保存mmap中的有效数据长度,下一次启动可以读取,并将buffer 的写入位置pos移动到mRemaining + 4位置,避免上一次数据被覆盖。

  // 有效长度
  private volatile int mRemaining;
  private void init() {
    FileChannel channel;
    try {
      RandomAccessFile accessFile = new RandomAccessFile(mFile, "rw");
      channel = accessFile.getChannel();
    } catch (IOException e) {
      mMapSuccess = false;
      initMMAPBackBuffer(e.getMessage());
      Log.e(TAG, "create accessFile Failed", e);
      return;
    }

    try {
      mBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, Constants.BUFFER_SIZE);
      mRemaining = mBuffer.getInt();
      if (mRemaining > Constants.BUFFER_SIZE || mRemaining <= 0) {
        mRemaining = 0;
        writeHead(0);
      }
      mBuffer.position(mRemaining + 4);
    } catch (IOException e) {
      mMapSuccess = false;
      initMMAPBackBuffer(e.getMessage());
      Log.e(TAG, "map Failed", e);
    }
  }

看下容错的处理,其实就是直接开辟一个ByteBuffer,保证业务的使用。另外可以根据自己的实际情况做下mmap失败的埋点上报,方便统计分析。

  private void initMMAPBackBuffer(final String errMsg) {
    if (!mMapSuccess) {
      mBuffer = ByteBuffer.allocateDirect(Constants.BUFFER_SIZE);
      ReporterManager.get().logMMAPFailed(errMsg);
    }
  }

接下来就是写数据了,这个实践中有几个点需要注意

  1. buffer写入数据之前需要先判断remain是否够大,否则会抛异常BufferOverflowException
  2. 如果单条日志超过整个buffer的capacity要怎么处理
  3. 需要更新文件头信息,就是上面的mRemaining
  4. buffer满了后需要怎么处理
  5. 对buffer的写入需要加锁,怎么减小synchronized的范围,提高性能?

第二个点的处理可以自行处理,可以把bytes循环截断写入到buffer,知道buffer,满了后先dump再继续写入。这里是简单的直接丢弃掉,因为单条日志超过整个buffer的情况明显不合理。其他的点接下来仔细分析。

  public void add(final LogInfo trace) {
    byte[] bytes = data; // data to write
    int bytesLength = bytes.length;

    Buffer byteBuffer = null;
    synchronized (this) {
      if (mBuffer.remaining() < bytesLength) {
        byteBuffer = getBytesAndClear();
      }

      if (mBuffer.remaining() < bytesLength) {
        // data is too large over buffer capacity
        return;
      }
      mBuffer.put(bytes, 0, bytesLength);
      writeHead(bytesLength);
    }
    if (mBufferListener != null && byteBuffer != null) {
      mBufferListener.onFull(byteBuffer);
    }
  }

第一个点的处理就是代码中的第一个if语句,就是需要dump出当前buffer的数据并且clear,供下一次写入使用。
几个点需要注意,dump需要深拷贝,为了内存友好这里对临时buffer做了内存池优化。然后mBuffer需要通过flip切到读模式,如果是mmap成功情况下是有4个字节记录有效长度的,这个不需要做dump,所以把mBuffer的position移动到BUFFER_OFFSET,对应长度也减掉这个偏移。数据读取到临时buffer后需要做clear操作,主要是重置有效长度mReaning和mBuffer的position。

  private BufferPool.Buffer getBytesAndClear() {
    mBuffer.flip();
    Buffer byteBuffer = BufferPool.getInstance().getBuffer();
    byteBuffer.mLength = mBuffer.remaining();

    if (mMapSuccess) {
      mBuffer.position(BUFFER_OFFSET);
      byteBuffer.mLength -= BUFFER_OFFSET;
    }
    mBuffer.get(byteBuffer.mBytes, 0, byteBuffer.mLength);
    clear();
    return byteBuffer;
  }

  public void clear() {
    mBuffer.clear();
    mRemaining = 0;
    if (mMapSuccess) {
      mBuffer.putInt(0);
      mBuffer.position(BUFFER_OFFSET);
    }
  }

对于第3点更新文件头长度,如果mmap失败就不需要更新,因为纯内存的方式没有恢复数据这一说。然后就是简单的记录当前mBuffer的position,更新头长度后再恢复,然后就是把mRemaining写到0起始位置。

  private void writeHead(final int delta) {
    mRemaining += delta;
    if (!mMapSuccess) {
      return;
    }
    final int curPos = mBuffer.position();
    mBuffer.position(0);
    mBuffer.putInt(mRemaining);
    mBuffer.position(curPos);
  }

第4点mBuffer满了后,起始主要是第一个点的处理一样,需要dump出mBuffer中的数据,然后通过listener传递出去,这样可以最快的速度让mBuffer空出来可用,这里要注意listener中的操作不能是耗时操作,否则会占用当前线程。这个BufferListener具体做的事在后面再单独说,这里先把写入的主流程梳理完。

  interface BufferListener {
    void onFull(Buffer buffer);
  }

最后就是第5点,锁的范围要尽量小,提高性能,比如字符串转bytes,以及dump出buffer后BufferListener的调用,都不需要放到锁范围内。

上面就是MMAP的初始化和在工程中使用踩过的坑,接下来补充BufferListener的处理。为了不占用当前线程的时间片,BufferListener通过异步线程来处理临时buffer落到文件的IO操作。

这里是Android中具体使用,这里有几个点需要注意

  1. 为什么使用HanderThread?因为需要携带buffer这个参数,所以使用Handler比较方便
  2. 怎么充分利用这个IO线程呢?如果只是每次满了后才唤醒做io操作有点浪费,这里的处理是在初始化或者每次full_dump后更新下当前时间mTime,然后初始化的时候发送一个PREPARE_DUMP_MSG,在这里判断是否到了一定间隔时间,到了就先把buffer中的内容做copy,然后再发送PREPARE_DUMP_MSG,起到定时器的作用。这样可以最大程度保证写日志的mBuffer少遇到满的情况。
  @Override
  public void onFull(Buffer buffer) {
    if (buffer == null || buffer.mLength == 0) {
      return;
    }
    Message msg = Message.obtain(mHandler, FULL_DUMP_MSG, buffer);
    mHandler.sendMessage(msg);
  }

  @Override
  public boolean handleMessage(Message msg) {
    switch (msg.what) {
      case FULL_DUMP_MSG:
        mTime = SystemClock.elapsedRealtime();
        write((Buffer) msg.obj);
        break;
      case PREPARE_DUMP_MSG:
        if (isNextTime()) {
          write(bufferCut());
        }
        mHandler.sendEmptyMessageDelayed(PREPARE_DUMP_MSG, mConfig.getFlushInterval());
        break;
      default:
        break;
    }
    return true;
  }

  private boolean isNextTime() {
    return (SystemClock.elapsedRealtime() - mTime) >= mConfig.getFlushInterval();
  }
  1. 总结

能看到这里的都是对技术比较认真的小伙伴了,本文先概述了Java中的MMAP和使用攻略,然后结合我在公司实际项目中的应用以及工程落地中需要注意的点,希望对大家有所帮助。后面会有个对日志性能优化的总结,比如时间戳优化、缓存、编码优化等,欢迎关注,今天就到这后会有期。

相关文章

网友评论

    本文标题:MMAP以及工程实战

    本文链接:https://www.haomeiwen.com/subject/jewmmltx.html