好久没上来更新文章,最近发生了很多事,有开心的也有不开心的,世间百态,酸甜苦辣都有,生活总要继续,也需要做个总结。最近一段时间在做公司基础组件的重构,正好做一个总结,一篇可能阐述不完,会有一个系列吧,欢迎关注。
本文主要几个点:
- MMAP和NIO概述
- MMAP构造
- Buffer的读写
- 工程实际应用和需要注意的坑
- 概述
我们知道目前操作系统提供了一种内存映射文件的方法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&bufferchannel与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。
- 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对象,并提供了一组方法,用来方便的访问该块内存,系统也会自动回写内容到映射文件中。
- 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中的使用方法,下面说下我在实际工程中的应用。
- 实际应用
在公司的项目中,把mmap用在写日志上,可以降低丢失率,同时写内存的方式也比普通的IO更高效,毕竟少了系统内核态和用户态之间的切换。那么接下来看下实际应用。
首先是初始化,这有几个工程实际中需要考虑的点
- 如果初始化失败怎么保证使用,也就是容错
- 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);
}
}
接下来就是写数据了,这个实践中有几个点需要注意
- buffer写入数据之前需要先判断remain是否够大,否则会抛异常BufferOverflowException
- 如果单条日志超过整个buffer的capacity要怎么处理
- 需要更新文件头信息,就是上面的mRemaining
- buffer满了后需要怎么处理
- 对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中具体使用,这里有几个点需要注意
- 为什么使用HanderThread?因为需要携带buffer这个参数,所以使用Handler比较方便
- 怎么充分利用这个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();
}
- 总结
能看到这里的都是对技术比较认真的小伙伴了,本文先概述了Java中的MMAP和使用攻略,然后结合我在公司实际项目中的应用以及工程落地中需要注意的点,希望对大家有所帮助。后面会有个对日志性能优化的总结,比如时间戳优化、缓存、编码优化等,欢迎关注,今天就到这后会有期。
网友评论