Java NIO中的堆外內存、內存映射與Zero Copy

在前公司時參與了一個編碼競賽,雖然只拿到一箇中遊成績,但在參賽過程中學習到很多其他人優秀的思考方式,也接受了前輩的指點,尤其是在參賽時的一些知識面拓展對我幫助不小。其中一些平常很少接觸到的知識對於之後的工作會有所幫助。

題目很簡單,大概是這樣:

  • 在4G內存的機器上實現對大文件內容的按行排序
  • 文件每行為小寫字母組成的不重複的一段字符串,最長為128字節
  • 文件大小有1G/2G/5G/10G/20G多種,很明顯一部分文件是無法全部加載到內存中的

具體過程及結果不細說,在這裡簡單介紹其中用到的部分NIO技術,這些技術無論在各種框架如Netty等,以及各種中間件如RocketMQ等都有用到。

基本概念

堆外內存

我們都知道,JVM需要申請一塊內存用於進程的使用,類、對象、方法等數據均保存在JVM堆棧也就是申請的這塊內存之中,JVM也會負責幫我們管理和回收再利用這塊內存。

相對的,堆外內存就是直接調用系統malloc分配的內存,這部分內存不屬於JVM直接管理,也不受JVM最大內存的限制,通過引用指向這段內存。

用戶態和內核態

應用程序是不能直接訪問內存、硬盤等資源,而是通過操作系統提供的接口調用。而操作系統為保證安全,將系統進行權限分級,分為權限高的內核態和權限低的用戶態,用戶態的很多操作需要借用內核態代為進行系統調度,即狀態轉換。

Java NIO中的堆外內存、內存映射與Zero Copy

Unix系統架構

內存映射

操作系統提供了將一段磁盤文件內容映射到內存的方法,對這段內存數據的修改操作會直接由操作系統保證異步刷盤到硬盤的文件中;在內存映射的過程中可以省略中間的很多IO環節,而這個刷盤過程即使應用程序崩潰也能夠完成,這就是內存映射。

使用內存映射文件處理存儲於磁盤上的文件時,將不必再對文件執行I/O操作,使得內存映射文件在處理大數據量的文件時能起到相當重要的作用。 ——搜狗百科

ByteBuffer緩衝區 FileChannel通道

ByteBuffer是一個緩衝區,NIO中的所有數據都是經過緩衝區處理的,其底層一般是一個byte array,可以說ByteBuffer是一個帶有多個遊標的array包裝類。簡言之ByteBuffer是一塊邏輯上連續的內存,用於NIO的讀寫中轉,合理的設計可以實現數據 零拷貝(Zero-Copy) ,也可以理解為減少不必要的數據複製過程。

Zero-Copy: 通常一次發送/複製文件讀寫處理需要經過如下過程:

  1. 從磁盤複製到內核態緩存(讀數據)
  2. 從內核態讀到應用所在的用戶態緩存
  3. 從用戶態緩存複製到內核態緩存(寫數據)
  4. 從內核態緩存複製到真正的寫入目標,如硬盤/網絡socket緩存

可以看到數據在流轉過程中讀/寫都複製了兩次,主要問題在於內核態和用戶態緩存間的複製。而如果可以合理利用內核提供的能力直接不經過用戶態和內核態的來回複製,直接從內核態緩存複製到內核態的目標緩存位置,將會明顯減少不必要的複製過程,也就是所謂的Zero-Copy。

關鍵方法

方法名描述用途array獲取內部array數據讀寫,對array操作等效於對ByteBuffer的操作get系列方法獲取本Buffer中的數據數據讀寫put系列方法數據寫入本Buffer數據讀寫as系列方法傳出至WritableByteChannel將ByteBuffer包裝成其他類型的Bufferput(ByteBuffer src)將src ByteBuffer的內容寫入自身Channel間數據複製

一些不好理解的核心方法

為了複用Buffer實現零拷貝,Buffer內置了很多遊標,這些遊標的使用是Buffer最核心也是最不好理解的內容:

  • mark 用於標記一個特定的位置
  • position 當前位置
  • limit 範圍限制,即Buffer的可讀範圍在0~limit
  • capacity 容量

方法名描述用途mark在當前位置設置markmark=position;reset從當前位置回退到mark處position=mark;rewind倒帶,即回到初始狀態(回到起點)並清空mark,一般用於再次讀position=0; mark=-1;clear將整個Buffer遊標重置但不清理數據,新數據直接覆蓋,一般用於再次寫入position=0; limit=capacity; mark=-1;flip特殊的“倒帶”,可用數據變為0~position並回退到起點,通常在寫完Buffer後flip供讀取limit=position; position=0; mark=-1;remaining返回還剩多少數據用於讀/寫return limit-position;limit返回limitreturn limit;capacity返回capacityreturn capacity;

這些操作並沒有真正區分讀/寫使用,一旦理解出現偏差將很難實現正確的處理邏輯,也許調一下午才能調通,血的教訓

DirectByteBuffer 直接緩衝區

DirectByteBuffer是一個特殊的ByteBuffer,底層同樣需要一塊連續的內存,操作模式與普通的ByteBuffer一致,但這塊內存是調用unsafe的native方法分配的堆外內存。

直接緩衝區的內存釋放也是由unsafe的native方法完成的,DirectByteBuffer指向的內存通過PhantomReference持有,由JVM自行回收。但如果DirectByteBuffer經過數次GC後進入老年代,就很可能由於Full GC間隔較長而長期存活,進而導致指向的堆外內存也無法回收。當需要手動回收時,需要通過反射調用DirectByteBuffer內部的Cleaner的clean私有方法。

為何要使用堆外內存

Java應用一般能夠操作的是JVM管理的堆內內存,一段數據從應用中發送至網絡需要經過多次複製:

  1. 從堆內複製到堆外
  2. 從堆外複製到socket緩存
  3. socket緩存flush

考慮到Java內存模型,可能還存在工作內存/主內存之間的複製;

考慮到GC,可能還存在堆內內存之間的複製;

而如果使用堆外內存,則少了一步從堆內到堆外的複製過程。

使用直接緩衝區的優點:

  • 這塊緩衝區內存不受JVM直接管理回收
  • 大小不受JVM分配的最大內存限制
  • 一些IO操作可以避免堆外內存和堆內內存間的複製,比如網絡傳輸
  • 某些生命週期較長的大對象可以保存在堆外內存,減少對GC的影響

缺點:

  • 不受JVM直接管理,容易造成堆外內存洩露
  • 由於堆外內存並不能保存複雜對象而只能保存基本類型的包裝類(底層都是byte array),因此要保存對象時需要序列化

必須先複製到堆外內存的原因

  1. 底層通過write、read、pwrite,pread函數進行系統調用時,需要傳入buffer的起始地址和buffer count作為參數。如果使用java heap的話,我們知道jvm中buffer往往以byte[] 的形式存在,這是一個特殊的對象,由於java heap GC的存在,這裡對象在堆中的位置往往會發生移動,移動後我們傳入系統函數的地址參數就不是真正的buffer地址了,這樣的話無論讀寫都會發生出錯。而C Heap僅僅受Full GC的影響,相對來說地址穩定。
  2. JVM規範中沒有要求Java的byte[]必須是連續的內存空間,它往往受宿主語言的類型約束;而C Heap中我們分配的虛擬地址空間是可以連續的,而上述的系統調用要求我們使用連續的地址空間作為buffer。

MappedByteBuffer 內存映射緩衝區

MappedByteBuffer與其他ByteBuffer一樣底層是一段連續內存,區別在於這段內存使用的是內存映射的那段內存,也就是說對於這塊緩衝區的數據修改會同步到對應的文件中。

FileChannel

NIO的Channel類型是一個通道,本身不能訪問數據,而是與Buffer交互。

Channel類的作用主要是操作數據、數據傳輸、實現內存映射。

幾類Channel:

  • FileChannel(文件)
  • SocketChannel(客戶端TCP)
  • ServerSocketChannel(服務端TCP)
  • DatagramChannel(UDP)

關鍵方法

方法名描述用途transferFrom從ReadableByteChannel傳入Channel間數據複製transferTo傳出至WritableByteChannelChannel間數據複製read寫到ByteBuffer中Channel與ByteBuffer間數據複製write從ByteBuffer中讀Channel與ByteBuffer間數據複製position遊標當前位置sizeChannel內容長度map映射出一個MappedByteBuffer從Channel映射出可操作的ByteBuffer

為何使用Channel

  • transferFrom和transferTo兩個方法底層依賴操作系統API實現,由操作系統內核負責數據複製,由於省去了內核緩衝區向用戶緩衝區的來回複製以及上下文切換,Channel的transferFrom和transferTo方法效率會相當高
  • 讀寫使用ByteBuffer,減少複製次數
  • MappedByteBuffer映射出的一塊內存不需要阻塞等待刷盤完成,也不擔心應用程序崩潰導致的數據丟失問題

FileChannel優點:

  • 內存映射的內容可以防止程序甭崩潰(kill -9)導致的數據丟失,這個特性在很多中間件系統中作用很大(阿里某些中間件比賽有要求kill -9不丟失)
  • 不用阻塞等待,效率高
  • 減少複製次數

缺點:

  • 由於內存映射需要指定映射文件大小,那麼當映射的文件大小比寫入的內容大時會產生文件間隙,即文件EOF後還有一部分無內容的填充,文件末尾亂碼之類的,這個在實際應用中需要注意
  • 映射後的內存頁面需要等待被置換,導致系統的整體內存管理相對複雜

一些Channel可以使用讀/讀寫等模式操作

效率比較

public class UnitTest1 {

private static final String prefix = "~/path/to/";

public static void main( String[] args ) throws Exception

{

streamCopy( "input", "output1" );

bufferCopy( "input", "output2" );

directBufferCopy( "input", "output3" );

mappedByteBufferCopy( "input", "output4" );

mappedByteBufferCopyByPart( "input", "output5" );

channelCopy( "input", "output6" );

}

/**

* 使用stream

*/

private static void streamCopy( String from, String to ) throws IOException

{

long startTime = System.currentTimeMillis();

File inputFile = new File( prefix + from );

File outputFile = new File( prefix + to );

FileInputStream fis = new FileInputStream( inputFile );

FileOutputStream fos = new FileOutputStream( outputFile );

byte[] bytes = new byte[1024];

int len;

while ( (len = fis.read( bytes ) ) != -1 )

{

fos.write( bytes, 0, len );

}

fos.flush();

fis.close();

fos.close();

long endTime = System.currentTimeMillis();

System.out.println( "streamCopy cost:" + (endTime - startTime) );

}

/**

* 使用buffer

*/

private static void bufferCopy( String from, String to ) throws IOException

{

long startTime = System.currentTimeMillis();

RandomAccessFile inputFile = new RandomAccessFile( prefix + from, "r" );

RandomAccessFile outputFile = new RandomAccessFile( prefix + to, "rw" );

FileChannel inputChannel = inputFile.getChannel();

FileChannel outputChannel = outputFile.getChannel();

ByteBuffer byteBuffer = ByteBuffer.allocate( 1024 );

while ( inputChannel.read( byteBuffer ) != -1 )

{

byteBuffer.flip();

outputChannel.write( byteBuffer );

byteBuffer.clear();

}

inputChannel.close();

outputChannel.close();

long endTime = System.currentTimeMillis();

System.out.println( "bufferCopy cost:" + (endTime - startTime) );

}

/**

* 使用堆外內存

*/

private static void directBufferCopy( String from, String to ) throws IOException

{

long startTime = System.currentTimeMillis();

RandomAccessFile inputFile = new RandomAccessFile( prefix + from, "r" );

RandomAccessFile outputFile = new RandomAccessFile( prefix + to, "rw" );

FileChannel inputChannel = inputFile.getChannel();

FileChannel outputChannel = outputFile.getChannel();

ByteBuffer byteBuffer = ByteBuffer.allocateDirect( 1024 );

while ( inputChannel.read( byteBuffer ) != -1 )

{

byteBuffer.flip();

outputChannel.write( byteBuffer );

byteBuffer.clear();

}

inputChannel.close();

outputChannel.close();

long endTime = System.currentTimeMillis();

System.out.println( "directBufferCopy cost:" + (endTime - startTime) );

}

/**

* 內存映射全量

*/

private static void mappedByteBufferCopy( String from, String to ) throws IOException

{

long startTime = System.currentTimeMillis();

RandomAccessFile inputFile = new RandomAccessFile( prefix + from, "r" );

RandomAccessFile outputFile = new RandomAccessFile( prefix + to, "rw" );

FileChannel inputChannel = inputFile.getChannel();

FileChannel outputChannel = outputFile.getChannel();

MappedByteBuffer iBuffer = inputChannel.map( MapMode.READ_ONLY, 0, inputFile.length() );

MappedByteBuffer oBuffer = outputChannel.map( MapMode.READ_WRITE, 0, inputFile.length() );

/* 直接操作buffer,沒有其他IO操作 */

oBuffer.put( iBuffer );

inputChannel.close();

outputChannel.close();

long endTime = System.currentTimeMillis();

System.out.println( "mappedByteBufferCopy cost:" + (endTime - startTime) );

}

/**

* 內存映射部分

*/

private static void mappedByteBufferCopyByPart( String from, String to ) throws IOException

{

long startTime = System.currentTimeMillis();

RandomAccessFile inputFile = new RandomAccessFile( prefix + from, "r" );

RandomAccessFile outputFile = new RandomAccessFile( prefix + to, "rw" );

FileChannel inputChannel = inputFile.getChannel();

FileChannel outputChannel = outputFile.getChannel();

for ( long i = 0; i < inputFile.length(); i += 1024 )

{

long size = 1024;

/* 避免文件產生間隙 */

if ( i + size > inputFile.length() )

{

size = inputFile.length() - i;

}

MappedByteBuffer iBuffer = inputChannel.map( MapMode.READ_ONLY, i, size );

MappedByteBuffer oBuffer = outputChannel.map( MapMode.READ_WRITE, i, size );

oBuffer.put( iBuffer );

}

inputChannel.close();

outputChannel.close();

long endTime = System.currentTimeMillis();

System.out.println( "mappedByteBufferCopyByPart cost:" + (endTime - startTime) );

}

/**

* zero copy

*/

private static void channelCopy( String from, String to ) throws IOException

{

long startTime = System.currentTimeMillis();

RandomAccessFile inputFile = new RandomAccessFile( prefix + from, "r" );

RandomAccessFile outputFile = new RandomAccessFile( prefix + to, "rw" );

FileChannel inputChannel = inputFile.getChannel();

FileChannel outputChannel = outputFile.getChannel();

inputChannel.transferTo( 0, inputFile.length(), outputChannel );

inputChannel.close();

outputChannel.close();

long endTime = System.currentTimeMillis();

System.out.println( "channelCopy cost:" + (endTime - startTime) );

}

}

input文件大小為360MB,其實算是小文件,大文件暫時沒找到,效果會更明顯。

這段代碼在我的開發機器上輸出結果為:

streamCopy cost : 2718

bufferCopy cost : 2604

directBufferCopy cost : 2420

mappedByteBufferCopy cost : 541

mappedByteBufferCopyByPart cost : 11232

channelCopy cost : 330

  • 以stream為基準
  • 使用ByteBuffer效率比基準高一點
  • 在文件複製上堆外內存效率比堆內內存要高
  • 內存映射大段文件來操作會非常快,因為節省了很多不必要的IO
  • 內存映射過小時,由於頻繁的內存置換,效率反而很低
  • ZeroCopy,快,沒話說


分享到:


相關文章: