背景
在前公司時(shí)參與了一個(gè)編碼競(jìng)賽,雖然只拿到一個(gè)中游成績(jī),但在參賽過程中學(xué)習(xí)到很多其他人優(yōu)秀的思考方式,也接受了前輩的指點(diǎn),尤其是在參賽時(shí)的一些知識(shí)面拓展對(duì)我?guī)椭恍 F渲幸恍┢匠:苌俳佑|到的知識(shí)對(duì)于之后的工作會(huì)有所幫助。
題目很簡(jiǎn)單,大概是這樣:
- 在4G內(nèi)存的機(jī)器上實(shí)現(xiàn)對(duì)大文件內(nèi)容的按行排序
- 文件每行為小寫字母組成的不重復(fù)的一段字符串,最長(zhǎng)為128字節(jié)
- 文件大小有1G/2G/5G/10G/20G多種,很明顯一部分文件是無法全部加載到內(nèi)存中的
具體過程及結(jié)果不細(xì)說,在這里簡(jiǎn)單介紹其中用到的部分NIO技術(shù),這些技術(shù)無論在各種框架如Netty等,以及各種中間件如RocketMQ等都有用到。
基本概念
堆外內(nèi)存
我們都知道,JVM需要申請(qǐng)一塊內(nèi)存用于進(jìn)程的使用,類、對(duì)象、方法等數(shù)據(jù)均保存在JVM堆棧也就是申請(qǐng)的這塊內(nèi)存之中,JVM也會(huì)負(fù)責(zé)幫我們管理和回收再利用這塊內(nèi)存。
相對(duì)的,堆外內(nèi)存就是直接調(diào)用系統(tǒng)malloc分配的內(nèi)存,這部分內(nèi)存不屬于JVM直接管理,也不受JVM最大內(nèi)存的限制,通過引用指向這段內(nèi)存。
用戶態(tài)和內(nèi)核態(tài)
應(yīng)用程序是不能直接訪問內(nèi)存、硬盤等資源,而是通過操作系統(tǒng)提供的接口調(diào)用。而操作系統(tǒng)為保證安全,將系統(tǒng)進(jìn)行權(quán)限分級(jí),分為權(quán)限高的內(nèi)核態(tài)和權(quán)限低的用戶態(tài),用戶態(tài)的很多操作需要借用內(nèi)核態(tài)代為進(jìn)行系統(tǒng)調(diào)度,即狀態(tài)轉(zhuǎn)換。
內(nèi)存映射
操作系統(tǒng)提供了將一段磁盤文件內(nèi)容映射到內(nèi)存的方法,對(duì)這段內(nèi)存數(shù)據(jù)的修改操作會(huì)直接由操作系統(tǒng)保證異步刷盤到硬盤的文件中;在內(nèi)存映射的過程中可以省略中間的很多IO環(huán)節(jié),而這個(gè)刷盤過程即使應(yīng)用程序崩潰也能夠完成,這就是內(nèi)存映射。
使用內(nèi)存映射文件處理存儲(chǔ)于磁盤上的文件時(shí),將不必再對(duì)文件執(zhí)行I/O操作,使得內(nèi)存映射文件在處理大數(shù)據(jù)量的文件時(shí)能起到相當(dāng)重要的作用。
——搜狗百科
ByteBuffer緩沖區(qū) FileChannel通道
ByteBuffer是一個(gè)緩沖區(qū),NIO中的所有數(shù)據(jù)都是經(jīng)過緩沖區(qū)處理的,其底層一般是一個(gè)byte array,可以說ByteBuffer是一個(gè)帶有多個(gè)游標(biāo)的array包裝類。簡(jiǎn)言之ByteBuffer是一塊邏輯上連續(xù)的內(nèi)存,用于NIO的讀寫中轉(zhuǎn),合理的設(shè)計(jì)可以實(shí)現(xiàn)數(shù)據(jù)零拷貝(Zero-Copy),也可以理解為減少不必要的數(shù)據(jù)復(fù)制過程。
Zero-Copy:
通常一次發(fā)送/復(fù)制文件讀寫處理需要經(jīng)過如下過程:
- 從磁盤復(fù)制到內(nèi)核態(tài)緩存(讀數(shù)據(jù))
- 從內(nèi)核態(tài)讀到應(yīng)用所在的用戶態(tài)緩存
- 從用戶態(tài)緩存復(fù)制到內(nèi)核態(tài)緩存(寫數(shù)據(jù))
- 從內(nèi)核態(tài)緩存復(fù)制到真正的寫入目標(biāo),如硬盤/網(wǎng)絡(luò)socket緩存
可以看到數(shù)據(jù)在流轉(zhuǎn)過程中讀/寫都復(fù)制了兩次,主要問題在于內(nèi)核態(tài)和用戶態(tài)緩存間的復(fù)制。而如果可以合理利用內(nèi)核提供的能力直接不經(jīng)過用戶態(tài)和內(nèi)核態(tài)的來回復(fù)制,直接從內(nèi)核態(tài)緩存復(fù)制到內(nèi)核態(tài)的目標(biāo)緩存位置,將會(huì)明顯減少不必要的復(fù)制過程,也就是所謂的Zero-Copy。
關(guān)鍵方法
方法名 | 描述 | 用途 |
---|---|---|
array | 獲取內(nèi)部array | 數(shù)據(jù)讀寫,對(duì)array操作等效于對(duì)ByteBuffer的操作 |
get系列方法 | 獲取本Buffer中的數(shù)據(jù) | 數(shù)據(jù)讀寫 |
put系列方法 | 數(shù)據(jù)寫入本Buffer | 數(shù)據(jù)讀寫 |
as系列方法 | 傳出至WritableByteChannel | 將ByteBuffer包裝成其他類型的Buffer |
put(ByteBuffer src) | 將src ByteBuffer的內(nèi)容寫入自身 | Channel間數(shù)據(jù)復(fù)制 |
一些不好理解的核心方法
為了復(fù)用Buffer實(shí)現(xiàn)零拷貝,Buffer內(nèi)置了很多游標(biāo),這些游標(biāo)的使用是Buffer最核心也是最不好理解的內(nèi)容:
- mark 用于標(biāo)記一個(gè)特定的位置
- position 當(dāng)前位置
- limit 范圍限制,即Buffer的可讀范圍在0~limit
- capacity 容量
方法名 | 描述 | 用途 |
---|---|---|
mark | 在當(dāng)前位置設(shè)置mark | mark=position; |
reset | 從當(dāng)前位置回退到mark處 | position=mark; |
rewind | 倒帶,即回到初始狀態(tài)(回到起點(diǎn))并清空mark,一般用于再次讀 | position=0; mark=-1; |
clear | 將整個(gè)Buffer游標(biāo)重置但不清理數(shù)據(jù),新數(shù)據(jù)直接覆蓋,一般用于再次寫入 | position=0; limit=capacity; mark=-1; |
flip | 特殊的“倒帶”,可用數(shù)據(jù)變?yōu)?~position并回退到起點(diǎn),通常在寫完Buffer后flip供讀取 | limit=position; position=0; mark=-1; |
remaining | 返回還剩多少數(shù)據(jù)用于讀/寫 | return limit-position; |
limit | 返回limit | return limit; |
capacity | 返回capacity | return capacity; |
這些操作并沒有真正區(qū)分讀/寫使用,一旦理解出現(xiàn)偏差將很難實(shí)現(xiàn)正確的處理邏輯,也許調(diào)一下午才能調(diào)通,血的教訓(xùn)
DirectByteBuffer 直接緩沖區(qū)
DirectByteBuffer是一個(gè)特殊的ByteBuffer,底層同樣需要一塊連續(xù)的內(nèi)存,操作模式與普通的ByteBuffer一致,但這塊內(nèi)存是調(diào)用unsafe的native方法分配的堆外內(nèi)存。
直接緩沖區(qū)的內(nèi)存釋放也是由unsafe的native方法完成的,DirectByteBuffer指向的內(nèi)存通過PhantomReference持有,由JVM自行回收。但如果DirectByteBuffer經(jīng)過數(shù)次GC后進(jìn)入老年代,就很可能由于Full GC間隔較長(zhǎng)而長(zhǎng)期存活,進(jìn)而導(dǎo)致指向的堆外內(nèi)存也無法回收。當(dāng)需要手動(dòng)回收時(shí),需要通過反射調(diào)用DirectByteBuffer內(nèi)部的Cleaner的clean私有方法。
為何要使用堆外內(nèi)存
Java應(yīng)用一般能夠操作的是JVM管理的堆內(nèi)內(nèi)存,一段數(shù)據(jù)從應(yīng)用中發(fā)送至網(wǎng)絡(luò)需要經(jīng)過多次復(fù)制:
- 從堆內(nèi)復(fù)制到堆外
- 從堆外復(fù)制到socket緩存
- socket緩存flush
考慮到Java內(nèi)存模型,可能還存在工作內(nèi)存/主內(nèi)存之間的復(fù)制;
考慮到GC,可能還存在堆內(nèi)內(nèi)存之間的復(fù)制;
而如果使用堆外內(nèi)存,則少了一步從堆內(nèi)到堆外的復(fù)制過程。
使用直接緩沖區(qū)的優(yōu)點(diǎn):
- 這塊緩沖區(qū)內(nèi)存不受JVM直接管理回收
- 大小不受JVM分配的最大內(nèi)存限制
- 一些IO操作可以避免堆外內(nèi)存和堆內(nèi)內(nèi)存間的復(fù)制,比如網(wǎng)絡(luò)傳輸
- 某些生命周期較長(zhǎng)的大對(duì)象可以保存在堆外內(nèi)存,減少對(duì)GC的影響
缺點(diǎn):
- 不受JVM直接管理,容易造成堆外內(nèi)存泄露
- 由于堆外內(nèi)存并不能保存復(fù)雜對(duì)象而只能保存基本類型的包裝類(底層都是byte array),因此要保存對(duì)象時(shí)需要序列化
必須先復(fù)制到堆外內(nèi)存的原因
參考資料指出在BIO中,native讀寫文件前會(huì)先在堆外分配一塊內(nèi)存將堆內(nèi)數(shù)據(jù)復(fù)制到堆外內(nèi)存中:
- 底層通過write、read、pwrite,pread函數(shù)進(jìn)行系統(tǒng)調(diào)用時(shí),需要傳入buffer的起始地址和buffer count作為參數(shù)。如果使用java heap的話,我們知道jvm中buffer往往以byte[] 的形式存在,這是一個(gè)特殊的對(duì)象,由于java heap GC的存在,這里對(duì)象在堆中的位置往往會(huì)發(fā)生移動(dòng),移動(dòng)后我們傳入系統(tǒng)函數(shù)的地址參數(shù)就不是真正的buffer地址了,這樣的話無論讀寫都會(huì)發(fā)生出錯(cuò)。而C Heap僅僅受Full GC的影響,相對(duì)來說地址穩(wěn)定。
- JVM規(guī)范中沒有要求Java的byte[]必須是連續(xù)的內(nèi)存空間,它往往受宿主語言的類型約束;而C Heap中我們分配的虛擬地址空間是可以連續(xù)的,而上述的系統(tǒng)調(diào)用要求我們使用連續(xù)的地址空間作為buffer。
MappedByteBuffer 內(nèi)存映射緩沖區(qū)
MappedByteBuffer與其他ByteBuffer一樣底層是一段連續(xù)內(nèi)存,區(qū)別在于這段內(nèi)存使用的是內(nèi)存映射的那段內(nèi)存,也就是說對(duì)于這塊緩沖區(qū)的數(shù)據(jù)修改會(huì)同步到對(duì)應(yīng)的文件中。
FileChannel
NIO的Channel類型是一個(gè)通道,本身不能訪問數(shù)據(jù),而是與Buffer交互。
Channel類的作用主要是操作數(shù)據(jù)、數(shù)據(jù)傳輸、實(shí)現(xiàn)內(nèi)存映射。
幾類Channel:
- FileChannel(文件)
- SocketChannel(客戶端TCP)
- ServerSocketChannel(服務(wù)端TCP)
- DatagramChannel(UDP)
關(guān)鍵方法
方法名 | 描述 | 用途 |
---|---|---|
transferFrom | 從ReadableByteChannel傳入 | Channel間數(shù)據(jù)復(fù)制 |
transferTo | 傳出至WritableByteChannel | Channel間數(shù)據(jù)復(fù)制 |
read | 寫到ByteBuffer中 | Channel與ByteBuffer間數(shù)據(jù)復(fù)制 |
write | 從ByteBuffer中讀 | Channel與ByteBuffer間數(shù)據(jù)復(fù)制 |
position | 游標(biāo)當(dāng)前位置 | |
size | Channel內(nèi)容長(zhǎng)度 | |
map | 映射出一個(gè)MappedByteBuffer | 從Channel映射出可操作的ByteBuffer |
為何使用Channel
- transferFrom和transferTo兩個(gè)方法底層依賴操作系統(tǒng)API實(shí)現(xiàn),由操作系統(tǒng)內(nèi)核負(fù)責(zé)數(shù)據(jù)復(fù)制,由于省去了內(nèi)核緩沖區(qū)向用戶緩沖區(qū)的來回復(fù)制以及上下文切換,Channel的transferFrom和transferTo方法效率會(huì)相當(dāng)高
- 讀寫使用ByteBuffer,減少復(fù)制次數(shù)
- MappedByteBuffer映射出的一塊內(nèi)存不需要阻塞等待刷盤完成,也不擔(dān)心應(yīng)用程序崩潰導(dǎo)致的數(shù)據(jù)丟失問題
FileChannel優(yōu)點(diǎn):
- 內(nèi)存映射的內(nèi)容可以防止程序甭崩潰(kill -9)導(dǎo)致的數(shù)據(jù)丟失,這個(gè)特性在很多中間件系統(tǒng)中作用很大(阿里某些中間件比賽有要求kill -9不丟失)
- 不用阻塞等待,效率高
- 減少復(fù)制次數(shù)
缺點(diǎn):
- 由于內(nèi)存映射需要指定映射文件大小,那么當(dāng)映射的文件大小比寫入的內(nèi)容大時(shí)會(huì)產(chǎn)生文件間隙,即文件EOF后還有一部分無內(nèi)容的填充,文件末尾亂碼之類的,這個(gè)在實(shí)際應(yīng)用中需要注意
- 映射后的內(nèi)存頁面需要等待被置換,導(dǎo)致系統(tǒng)的整體內(nèi)存管理相對(duì)復(fù)雜
一些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));
}
/**
* 使用堆外內(nèi)存
*/
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));
}
/**
* 內(nèi)存映射全量
*/
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));
}
/**
* 內(nèi)存映射部分
*/
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;
// 避免文件產(chǎn)生間隙
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,其實(shí)算是小文件,大文件暫時(shí)沒找到,效果會(huì)更明顯。
這段代碼在我的開發(fā)機(jī)器上輸出結(jié)果為:
streamCopy cost:2718
bufferCopy cost:2604
directBufferCopy cost:2420
mappedByteBufferCopy cost:541
mappedByteBufferCopyByPart cost:11232
channelCopy cost:330
- 以stream為基準(zhǔn)
- 使用ByteBuffer效率比基準(zhǔn)高一點(diǎn)
- 在文件復(fù)制上堆外內(nèi)存效率比堆內(nèi)內(nèi)存要高
- 內(nèi)存映射大段文件來操作會(huì)非常快,因?yàn)楣?jié)省了很多不必要的IO
- 內(nèi)存映射過小時(shí),由于頻繁的內(nèi)存置換,效率反而很低
- ZeroCopy,快,沒話說
參考資料
通過零拷貝實(shí)現(xiàn)有效數(shù)據(jù)傳輸 - IBM Developer
Java NIO direct buffer的優(yōu)勢(shì)在哪兒? - 知乎
本文搬自我的博客,歡迎參觀!