已同步更新到微信公眾號(hào)欺缘,手機(jī)閱讀更舒適~
零拷貝機(jī)制原理分析之前,我們先來看下傳統(tǒng)IO在數(shù)據(jù)拷貝的基本原理,從數(shù)據(jù)拷貝(I/O拷貝)的次數(shù)以及上下文切換的次數(shù)進(jìn)行對(duì)比分析。
傳統(tǒng)IO:
1氏仗、JVM進(jìn)程內(nèi)發(fā)起read()系統(tǒng)調(diào)用,操作系統(tǒng)由用戶態(tài)空間切換到內(nèi)核態(tài)空間(第一次上下文切換)
2夺鲜、通過DMA引擎建數(shù)據(jù)從磁盤拷貝到內(nèi)核態(tài)空間的輸入的socket緩沖區(qū)中(第一次拷貝)
3皆尔、將內(nèi)核態(tài)空間緩沖區(qū)的數(shù)據(jù)原封不動(dòng)的拷貝到用戶態(tài)空間的緩存區(qū)中(第二次拷貝),同時(shí)內(nèi)核態(tài)空間切換到用戶態(tài)空間(第二次上下文切換)币励,read()系統(tǒng)調(diào)用結(jié)束
4慷蠕、JVM進(jìn)程內(nèi)業(yè)務(wù)邏輯代碼執(zhí)行
5、JVM進(jìn)程內(nèi)發(fā)起write()系統(tǒng)調(diào)用
6食呻、操作系統(tǒng)由用戶態(tài)空間切換到內(nèi)核態(tài)空間(第三次上下文切換)流炕,將用戶態(tài)空間的緩存區(qū)數(shù)據(jù)原封不動(dòng)的拷貝到內(nèi)核態(tài)空間輸出的socket緩存區(qū)中(第三次拷貝)
7澎现、write()系統(tǒng)調(diào)用返回,操作系統(tǒng)由內(nèi)核態(tài)空間切換到用戶態(tài)空間(第四次上下文切換)每辟,通過DMA引擎將數(shù)據(jù)從內(nèi)核態(tài)空間的socket緩存區(qū)數(shù)據(jù)拷貝到協(xié)議引擎中(第四次拷貝)
傳統(tǒng)IO方式剑辫,一共在用戶態(tài)空間與內(nèi)核態(tài)空間之間發(fā)生了4次上下文的切換,4次數(shù)據(jù)的拷貝過程影兽,其中包括2次DMA拷貝和2次I/O拷貝(內(nèi)核態(tài)與用戶應(yīng)用程序之間發(fā)生的拷貝)揭斧。
內(nèi)核空間緩沖區(qū)的一大用處是為了減少磁盤I/O操作,因?yàn)樗鼤?huì)從磁盤中預(yù)讀更多的數(shù)據(jù)到緩沖區(qū)中峻堰。而使用BufferedInputStream的用處是減少“系統(tǒng)調(diào)用”讹开。
DMA:
DMA(Direct Memory Access) —直接內(nèi)存訪問 :DMA是允許外設(shè)組件將I/O數(shù)據(jù)直接傳送到主存儲(chǔ)器中并且傳輸不需要CPU的參與,以此將CPU解放出來去完成其他的事情捐名。
sendfile數(shù)據(jù)零拷貝:
顯然旦万,在傳統(tǒng)IO中,用戶態(tài)空間與內(nèi)核態(tài)空間之間的復(fù)制是完全不必要的镶蹋,因?yàn)橛脩魬B(tài)空間僅僅起到了一種數(shù)據(jù)轉(zhuǎn)存媒介的作用成艘,除此之外沒有做任何事情。
Linux 提供了sendfile()用來減少我們前面提到的數(shù)據(jù)拷貝和的上下文切換次數(shù)
1贺归、發(fā)起sendfile()系統(tǒng)調(diào)用淆两,操作系統(tǒng)由用戶態(tài)空間切換到內(nèi)核態(tài)空間(第一次上下文切換)
2、通過DMA引擎建數(shù)據(jù)從磁盤拷貝到內(nèi)核態(tài)空間的輸入的socket緩沖區(qū)中(第一次拷貝)
3拂酣、將數(shù)據(jù)從內(nèi)核空間拷貝到與之關(guān)聯(lián)的socket緩沖區(qū)(第二次拷貝)
4秋冰、將socket緩沖區(qū)的數(shù)據(jù)拷貝到協(xié)議引擎中(第三次拷貝)
5、sendfile()系統(tǒng)調(diào)用結(jié)束婶熬,操作系統(tǒng)由用戶態(tài)空間切換到內(nèi)核態(tài)空間(第二次上下文切換)
根據(jù)以上過程剑勾,一共有2次的上下文切換,3次的I/O拷貝赵颅。我們看到從用戶空間到內(nèi)核空間并沒有出現(xiàn)數(shù)據(jù)拷貝虽另,從操作系統(tǒng)角度來看,這個(gè)就是零拷貝饺谬。內(nèi)核空間出現(xiàn)了復(fù)制的原因: 通常的硬件在通過DMA訪問時(shí)期望的是連續(xù)的內(nèi)存空間捂刺。
支持scatter-gather特性的sendfile數(shù)據(jù)零拷貝
這次相比sendfile()數(shù)據(jù)零拷貝,減少了一次從內(nèi)核空間到與之相關(guān)的socket緩沖區(qū)的數(shù)據(jù)拷貝商蕴。
1叠萍、發(fā)起sendfile()系統(tǒng)調(diào)用,操作系統(tǒng)由用戶態(tài)空間切換到內(nèi)核態(tài)空間(第一次上下文切換)
2绪商、通過DMA引擎建數(shù)據(jù)從磁盤拷貝到內(nèi)核態(tài)空間的輸入的socket緩沖區(qū)中(第一次拷貝)
3苛谷、將描述符信息會(huì)拷貝到相應(yīng)的socket緩沖區(qū)當(dāng)中,該描述符包含了兩方面的信息:a)kernel buffer的內(nèi)存地址格郁;b)kernel buffer的偏移量腹殿。
4独悴、DMA gather copy根據(jù)socket緩沖區(qū)中描述符提供的位置和偏移量信息直接將內(nèi)核空間緩沖區(qū)中的數(shù)據(jù)拷貝到協(xié)議引擎上(第二次拷貝),這樣就避免了最后一次I/O數(shù)據(jù)拷貝锣尉。
5刻炒、sendfile()系統(tǒng)調(diào)用結(jié)束,操作系統(tǒng)由用戶態(tài)空間切換到內(nèi)核態(tài)空間(第二次上下文切換)
下面這個(gè)圖更進(jìn)一步理解:
Linux/Unix操作系統(tǒng)下可以通過下面命令查看是否支持scatter-gather特性自沧。
# ethtool -k eth0 | grep scatter-gather
scatter-gather: on
tx-scatter-gather: on
tx-scatter-gather-fraglist: on
許多的web server都已經(jīng)支持了零拷貝技術(shù)坟奥,比如Apache、Tomcat拇厢。
sendfile零拷貝消除了所有內(nèi)核空間緩沖區(qū)與用戶空間緩沖區(qū)之間的數(shù)據(jù)拷貝過程爱谁,因此sendfile零拷貝I/O的實(shí)現(xiàn)是完成在內(nèi)核空間中完成的,這對(duì)于應(yīng)用程序來說就無法對(duì)數(shù)據(jù)進(jìn)行操作了孝偎。
如果需要對(duì)數(shù)據(jù)做操作访敌,Linux提供了mmap零拷貝來實(shí)現(xiàn)。
mmap零拷貝:
通過上圖看到衣盾,一共發(fā)生了4次的上下文切換寺旺,3次的I/O拷貝,包括2次DMA拷貝和1次的I/O拷貝势决,相比于傳統(tǒng)IO減少了一次I/O拷貝阻塑。使用mmap()讀取文件時(shí),只會(huì)發(fā)生第一次從磁盤數(shù)據(jù)拷貝到OS文件系統(tǒng)緩沖區(qū)的操作果复。
1)在什么場(chǎng)景下使用mmap()去訪問文件會(huì)更高效叮姑?
對(duì)文件執(zhí)行隨機(jī)訪問時(shí),如果使用read()或write()据悔,則意味著較低的 cache 命中率。這種情況下使用mmap()通常將更高效耘沼。
多個(gè)進(jìn)程同時(shí)訪問同一個(gè)文件時(shí)(無論是順序訪問還是隨機(jī)訪問)极颓,如果使用mmap(),那么操作系統(tǒng)緩沖區(qū)的文件內(nèi)容可以在多個(gè)進(jìn)程之間共享群嗤,從操作系統(tǒng)角度來看菠隆,使用mmap()可以大大節(jié)省內(nèi)存。
2)什么場(chǎng)景下沒有使用mmap()的必要狂秘?
訪問小文件時(shí)骇径,直接使用read()或write()將更加高效。
單個(gè)進(jìn)程對(duì)文件執(zhí)行順序訪問時(shí)(sequential access)者春,使用mmap()幾乎不會(huì)帶來性能上的提升破衔。譬如說,使用read()順序讀取文件時(shí)钱烟,文件系統(tǒng)會(huì)使用 read-ahead 的方式提前將文件內(nèi)容緩存到文件系統(tǒng)的緩沖區(qū)晰筛,因此使用read()將很大程度上可以命中緩存嫡丙。
下面我們通過代碼示例來對(duì)比下傳統(tǒng)IO與使用了零拷貝技術(shù)的NIO之間的差異。
我們通過服務(wù)端開啟socket監(jiān)聽读第,然后客戶端連接的服務(wù)端進(jìn)行數(shù)據(jù)的傳輸曙博,數(shù)據(jù)傳輸文件大小為237M。
1怜瞒、構(gòu)建傳統(tǒng)IO的socket服務(wù)端父泳,監(jiān)聽8898端口。
public class OldIOServer {
public static void main(String[] args) throws Exception {
try (ServerSocket serverSocket = new ServerSocket(8898)) {
while (true) {
Socket socket = serverSocket.accept();
DataInputStream inputStream = new DataInputStream(socket.getInputStream());
byte[] bytes = new byte[4096];
// 從socket中讀取字節(jié)數(shù)據(jù)
while (true) {
// 讀取的字節(jié)數(shù)大小吴汪,-1則表示數(shù)據(jù)已被讀完
int readCount = inputStream.read(bytes, 0, bytes.length);
if (-1 == readCount) {
break;
}
}
}
}
}
}
2惠窄、構(gòu)建傳統(tǒng)IO的客戶端,連接服務(wù)端的8898端口浇坐,并從磁盤讀取237M的數(shù)據(jù)文件向服務(wù)端socket中發(fā)起寫請(qǐng)求睬捶。
public class OldIOClient {
public static void main(String[] args) throws Exception {
Socket socket = new Socket();
socket.connect(new InetSocketAddress("localhost", 8898)); // 連接服務(wù)端socket 8899端口
// 設(shè)置一個(gè)大的文件, 237M
try (FileInputStream fileInputStream = new FileInputStream(new File("/Users/david/Downloads/jdk-8u144-macosx-x64.dmg"));
// 定義一個(gè)輸出流
DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());) {
// 讀取文件數(shù)據(jù)
// 定義byte緩存
byte[] buffer = new byte[4096];
int readCount; // 每一次讀取的字節(jié)數(shù)
int total = 0; // 讀取的總字節(jié)數(shù)
long startTime = System.currentTimeMillis();
while ((readCount = fileInputStream.read(buffer)) > 0) {
total += readCount; //累加字節(jié)數(shù)
dataOutputStream.write(buffer); // 寫入到輸出流中
}
System.out.println("發(fā)送的總字節(jié)數(shù):" + total + ", 耗時(shí):" + (System.currentTimeMillis() - startTime));
}
}
}
運(yùn)行結(jié)果:發(fā)送的總字節(jié)數(shù):237607747, 耗時(shí):450 (400~600毫秒之間)
接下來,我們通過使用JDK提供的NIO的方式實(shí)現(xiàn)數(shù)據(jù)傳輸與上述傳統(tǒng)IO做對(duì)比近刘。
1擒贸、構(gòu)建基于NIO的服務(wù)端,監(jiān)聽8899端口觉渴。
public class NewIOServer {
public static void main(String[] args) throws Exception {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8899));
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false); // 這里設(shè)置為阻塞模式
int readCount = socketChannel.read(byteBuffer);
while (-1 != readCount) {
readCount = socketChannel.read(byteBuffer);
// 這里一定要調(diào)用下rewind方法介劫,將position重置為0開始位置
byteBuffer.rewind();
}
}
}
}
2、構(gòu)建基于NIO的客戶端案淋,連接NIO的服務(wù)端8899端口座韵,通過FileChannel.transferTo傳輸237M的數(shù)據(jù)文件虱疏。
public class NewIOClient {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 8899));
socketChannel.configureBlocking(true);
String fileName = "/Users/david/Downloads/jdk-8u144-macosx-x64.dmg";
FileInputStream fileInputStream = new FileInputStream(fileName);
FileChannel fileChannel = fileInputStream.getChannel();
long startTime = System.currentTimeMillis();
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel); // 目標(biāo)channel
System.out.println("發(fā)送的總字節(jié)數(shù):" + transferCount + ",耗時(shí):" + (System.currentTimeMillis() - startTime));
fileChannel.close();
}
}
運(yùn)行結(jié)果:發(fā)送的總字節(jié)數(shù):237607747,耗時(shí):161(100到300毫秒之間)
結(jié)合運(yùn)行結(jié)果藐唠,基于NIO零拷貝技術(shù)要比傳統(tǒng)IO傳輸效率高3倍多。所以舌缤,后續(xù)當(dāng)設(shè)計(jì)大文件數(shù)據(jù)傳輸時(shí)可以優(yōu)先采用類似NIO的方式實(shí)現(xiàn)瓣距。
這里我們使用了FileChannel黔帕,其中調(diào)用的transferTo()方法將數(shù)據(jù)從FileChannel傳輸?shù)狡渌腸hannel中,如果操作系統(tǒng)底層支持的話transferTo蹈丸、transferFrom會(huì)使用相關(guān)的零拷貝技術(shù)來實(shí)現(xiàn)數(shù)據(jù)的傳輸成黄。所以,這里是否使用零拷貝必須依賴于底層的系統(tǒng)實(shí)現(xiàn)逻杖。
FileChannel.transferTo方法
public abstract long transferTo(long position,
long count,
WritableByteChannel target) throws IOException
將字節(jié)從此通道的文件傳輸?shù)浇o定的可寫入字節(jié)通道奋岁。
試圖讀取從此通道的文件中給定 position 處開始的 count 個(gè)字節(jié),并將其寫入目標(biāo)通道荸百。此方法的調(diào)用不一定傳輸所有請(qǐng)求的字節(jié)闻伶;是否傳輸取決于通道的性質(zhì)和狀態(tài)。如果此通道的文件從給定的 position 處開始所包含的字節(jié)數(shù)小于 count 個(gè)字節(jié)管搪,或者如果目標(biāo)通道是非阻塞的并且其輸出緩沖區(qū)中的自由空間少于 count 個(gè)字節(jié)虾攻,則所傳輸?shù)淖止?jié)數(shù)要小于請(qǐng)求的字節(jié)數(shù)铡买。
此方法不修改此通道的位置。如果給定的位置大于該文件的當(dāng)前大小霎箍,則不傳輸任何字節(jié)奇钞。如果目標(biāo)通道中有該位置,則從該位置開始寫入各字節(jié)漂坏,然后將該位置增加寫入的字節(jié)數(shù)景埃。
與從此通道讀取并將內(nèi)容寫入目標(biāo)通道的簡單循環(huán)語句相比,此方法可能高效得多顶别。很多操作系統(tǒng)可將字節(jié)直接從文件系統(tǒng)緩存?zhèn)鬏數(shù)侥繕?biāo)通道谷徙,而無需實(shí)際復(fù)制各字節(jié)。
參數(shù):
position - 文件中的位置驯绎,從此位置開始傳輸完慧;必須為非負(fù)數(shù)
count - 要傳輸?shù)淖畲笞止?jié)數(shù);必須為非負(fù)數(shù)
target - 目標(biāo)通道
返回:
實(shí)際已傳輸?shù)淖止?jié)數(shù)剩失,可能為零
FileChannel.transferFrom方法
public abstract long transferFrom(ReadableByteChannel src,
long position,
long count) throws IOException
將字節(jié)從給定的可讀取字節(jié)通道傳輸?shù)酱送ǖ赖奈募小?br> 試著從源通道中最多讀取 count 個(gè)字節(jié)屈尼,并將其寫入到此通道的文件中從給定 position 處開始的位置。此方法的調(diào)用不一定傳輸所有請(qǐng)求的字節(jié)拴孤;是否傳輸取決于通道的性質(zhì)和狀態(tài)脾歧。如果源通道的剩余空間小于 count 個(gè)字節(jié),或者如果源通道是非阻塞的并且其輸入緩沖區(qū)中直接可用的空間小于 count 個(gè)字節(jié)演熟,則所傳輸?shù)淖止?jié)數(shù)要小于請(qǐng)求的字節(jié)數(shù)鞭执。
此方法不修改此通道的位置。如果給定的位置大于該文件的當(dāng)前大小芒粹,則不傳輸任何字節(jié)兄纺。如果該位置在源通道中,則從該位置開始讀取各字節(jié)化漆,然后將該位置增加讀取的字節(jié)數(shù)囤热。
與從源通道讀取并將內(nèi)容寫入此通道的簡單循環(huán)語句相比,此方法可能高效得多获三。很多操作系統(tǒng)可將字節(jié)直接從源通道傳輸?shù)轿募到y(tǒng)緩存,而無需實(shí)際復(fù)制各字節(jié)锨苏。
參數(shù):
src - 源通道
position - 文件中的位置疙教,從此位置開始傳輸;必須為非負(fù)數(shù)
count - 要傳輸?shù)淖畲笞止?jié)數(shù)伞租;必須為非負(fù)數(shù)
返回:
實(shí)際已傳輸?shù)淖止?jié)數(shù)贞谓,可能為零
發(fā)生相應(yīng)的異常的情況:
異常拋出:
IllegalArgumentException - 如果關(guān)于參數(shù)的前提不成立
NonReadableChannelException - 如果不允許從此通道進(jìn)行讀取操作
NonWritableChannelException - 如果目標(biāo)通道不允許進(jìn)行寫入操作
ClosedChannelException - 如果此通道或目標(biāo)通道已關(guān)閉
AsynchronousCloseException - 如果正在進(jìn)行傳輸時(shí)另一個(gè)線程關(guān)閉了任一通道
ClosedByInterruptException - 如果正在進(jìn)行傳輸時(shí)另一個(gè)線程中斷了當(dāng)前線程,因此關(guān)閉了兩個(gè)通道并將當(dāng)前線程設(shè)置為中斷
IOException - 如果發(fā)生其他 I/O 錯(cuò)誤
參考資料:
http://xcorpion.tech/2016/09/10/It-s-all-about-buffers-zero-copy-mmap-and-Java-NIO/
http://www.reibang.com/p/e76e3580e356
http://www.linuxjournal.com/node/6345
http://senlinzhan.github.io/2017/03/25/%E7%BD%91%E7%BB%9C%E7%BC%96%E7%A8%8B%E4%B8%AD%E7%9A%84zerocpoy%E6%8A%80%E6%9C%AF/
jdk官方文檔