好久沒上來更新文章,最近發(fā)生了很多事,有開心的也有不開心的,世間百態(tài),酸甜苦辣都有,生活總要繼續(xù),也需要做個總結。最近一段時間在做公司基礎組件的重構,正好做一個總結,一篇可能闡述不完,會有一個系列吧,歡迎關注。
本文主要幾個點:
- MMAP和NIO概述
- MMAP構造
- Buffer的讀寫
- 工程實際應用和需要注意的坑
- 概述
我們知道目前操作系統(tǒng)提供了一種內(nèi)存映射文件的方法MMAP,可以將文件或者其他對象映射到進程的地址空間,實現(xiàn)磁盤地址和進程虛擬地址的一一対映關系,這樣進程就可以內(nèi)存的操作方式來操作這個映射文件,系統(tǒng)會自動回寫臟頁面到對應的文件磁盤上,這樣對文件的操作不必再調(diào)用read,write等系統(tǒng)調(diào)用函數(shù)。相反,內(nèi)核空間對這段區(qū)域的修改也直接反映用戶空間。所以MMAP也是實現(xiàn)不同進程通信的一種方式。同時直接操作內(nèi)存少了普通IO需要的用戶態(tài)和內(nèi)核態(tài)之間的切換。而且由于有系統(tǒng)的自動回寫的機制,用MMAP可以很大程度上防丟失,比如重要數(shù)據(jù)或者日志等。MMAP的理論感興趣的可以再網(wǎng)上找資料深入了解下。
我們今天要討論的是Java中怎么使用MMAP呢?在這之前需要先大概了解下Java中的NIO。
普通IO是面向流的,Java IO面向流意味著每次從流中讀一個或多個字節(jié),直至讀取所有字節(jié),它們沒有被緩存在任何地方,也不能前后移動流中的數(shù)據(jù)。除非先將它緩存到一個緩沖區(qū)。 而Java NIO是面向緩沖區(qū)的,數(shù)據(jù)讀取到一個它稍后處理的緩沖區(qū),需要時可在緩沖區(qū)中前后移動。這就增加了處理過程中的靈活性。
NIO中有兩個比價重要的概念Channel和Buffer,所有的 IO 在NIO 中都從一個Channel 開始,所以Channel 有點象流。 數(shù)據(jù)可以從Channel讀到Buffer中,也可以從Buffer 寫到Channel中。

channel與buffer都有好幾種類型,Buffer覆蓋了能通過IO發(fā)送的基本數(shù)據(jù)類型:byte, short, int, long, float, double 和 char,對應ByteBuffer, ShortBuffer, IntBuffer, LongBuffer, FloatBuffer, DoubleBuffer, CharBuffer。另外一個特殊的buffer就是MappedByteBuffer,就是我們今天的主要MMAP對應的buffer。
Channel的一些主要實現(xiàn)有:FileChannel,DatagramChannel,SocketChannel和ServerSocketChannel,分別對應文件IO,UDP和TCP網(wǎng)絡IO。
基本的背景知識就介紹到這,感覺意猶未盡的小伙伴可以自行再查找其他資料補充。接下來我們就先從FileChannel開始介紹MMAP。
- MMAP構造
前面說過Java NIO從Channel開始,有點類似于傳統(tǒng)IO中的Stream。MMAP本質(zhì)上是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是文件映射的起始位置,不能為負數(shù),否則會拋IllegalArgumentException異常
size是本次映射的長度,不能為負數(shù)或者大于Integer.MAX_VALUE, 否則也會拋IllegalArgumentException異常
就上面這么兩個步驟就在Java中完成了MMAP的初始化了,是不是非常簡單,接下來就是怎么通過buffer來進行讀寫操作了。
其實buffer本質(zhì)上是一塊可以寫入數(shù)據(jù),然后可以從中讀取數(shù)據(jù)的內(nèi)存。這塊內(nèi)存被包裝成MappedByteBuffer對象,并提供了一組方法,用來方便的訪問該塊內(nèi)存,系統(tǒng)也會自動回寫內(nèi)容到映射文件中。
- Buffer讀寫
buffer的讀寫數(shù)據(jù)一般遵循下面四個步驟
a. 調(diào)用position方法移動到需要寫入的位置,默認初始化位置是0,然后調(diào)用putXXX方法寫入數(shù)據(jù)
b. 調(diào)用flip方法切換到讀模式
c. 從buffer中讀取數(shù)據(jù)
d. 調(diào)用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三個重要參數(shù)position/limit/capacity的解釋圖:
寫模式下,position就是下一個寫入的位置,position最大可為capacity – 1。limit和capacity一樣,buffer的容量;
讀模式下,position會被重置為0。當從Buffer的position處讀取數(shù)據(jù)時,position向后移動到下一個可讀的位置,limit是最多能讀到多少數(shù)據(jù)。就是寫模式下的position值。換句話說,能讀到之前寫入的所有數(shù)據(jù)(limit被設置成已寫數(shù)據(jù)的數(shù)量,這個值在寫模式下就是position)

Buffer中還提供了一個remain()的api,其實就是limit和postion的差值,那么在寫模式下就是還可以寫入多少的數(shù)據(jù),讀模式下就是還有多少數(shù)據(jù)未讀取。
/**
* 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用在寫日志上,可以降低丟失率,同時寫內(nèi)存的方式也比普通的IO更高效,畢竟少了系統(tǒng)內(nèi)核態(tài)和用戶態(tài)之間的切換。那么接下來看下實際應用。
首先是初始化,這有幾個工程實際中需要考慮的點
- 如果初始化失敗怎么保證使用,也就是容錯
- mmap中可能有數(shù)據(jù)尚未回寫磁盤,怎么恢復數(shù)據(jù),避免數(shù)據(jù)被覆蓋
其中initMMAPBackBuffer是容錯機制,對1個點的解決,在mmap初始化失敗時開辟一個backbuffer做備用。mRemaining用來解決第二個問題,類似于Java Class文件的魔數(shù),這里用一個int 4字節(jié)來保存mmap中的有效數(shù)據(jù)長度,下一次啟動可以讀取,并將buffer 的寫入位置pos移動到mRemaining + 4位置,避免上一次數(shù)據(jù)被覆蓋。
// 有效長度
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,保證業(yè)務的使用。另外可以根據(jù)自己的實際情況做下mmap失敗的埋點上報,方便統(tǒng)計分析。
private void initMMAPBackBuffer(final String errMsg) {
if (!mMapSuccess) {
mBuffer = ByteBuffer.allocateDirect(Constants.BUFFER_SIZE);
ReporterManager.get().logMMAPFailed(errMsg);
}
}
接下來就是寫數(shù)據(jù)了,這個實踐中有幾個點需要注意
- buffer寫入數(shù)據(jù)之前需要先判斷remain是否夠大,否則會拋異常BufferOverflowException
- 如果單條日志超過整個buffer的capacity要怎么處理
- 需要更新文件頭信息,就是上面的mRemaining
- buffer滿了后需要怎么處理
- 對buffer的寫入需要加鎖,怎么減小synchronized的范圍,提高性能?
第二個點的處理可以自行處理,可以把bytes循環(huán)截斷寫入到buffer,知道buffer,滿了后先dump再繼續(xù)寫入。這里是簡單的直接丟棄掉,因為單條日志超過整個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的數(shù)據(jù)并且clear,供下一次寫入使用。
幾個點需要注意,dump需要深拷貝,為了內(nèi)存友好這里對臨時buffer做了內(nèi)存池優(yōu)化。然后mBuffer需要通過flip切到讀模式,如果是mmap成功情況下是有4個字節(jié)記錄有效長度的,這個不需要做dump,所以把mBuffer的position移動到BUFFER_OFFSET,對應長度也減掉這個偏移。數(shù)據(jù)讀取到臨時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失敗就不需要更新,因為純內(nèi)存的方式?jīng)]有恢復數(shù)據(jù)這一說。然后就是簡單的記錄當前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中的數(shù)據(jù),然后通過listener傳遞出去,這樣可以最快的速度讓mBuffer空出來可用,這里要注意listener中的操作不能是耗時操作,否則會占用當前線程。這個BufferListener具體做的事在后面再單獨說,這里先把寫入的主流程梳理完。
interface BufferListener {
void onFull(Buffer buffer);
}
最后就是第5點,鎖的范圍要盡量小,提高性能,比如字符串轉bytes,以及dump出buffer后BufferListener的調(diào)用,都不需要放到鎖范圍內(nèi)。
上面就是MMAP的初始化和在工程中使用踩過的坑,接下來補充BufferListener的處理。為了不占用當前線程的時間片,BufferListener通過異步線程來處理臨時buffer落到文件的IO操作。
這里是Android中具體使用,這里有幾個點需要注意
- 為什么使用HanderThread?因為需要攜帶buffer這個參數(shù),所以使用Handler比較方便
- 怎么充分利用這個IO線程呢?如果只是每次滿了后才喚醒做io操作有點浪費,這里的處理是在初始化或者每次full_dump后更新下當前時間mTime,然后初始化的時候發(fā)送一個PREPARE_DUMP_MSG,在這里判斷是否到了一定間隔時間,到了就先把buffer中的內(nèi)容做copy,然后再發(fā)送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和使用攻略,然后結合我在公司實際項目中的應用以及工程落地中需要注意的點,希望對大家有所幫助。后面會有個對日志性能優(yōu)化的總結,比如時間戳優(yōu)化、緩存、編碼優(yōu)化等,歡迎關注,今天就到這后會有期。