虎牙二面:說(shuō)說(shuō)你對(duì) Java “零拷貝”的理解?
來(lái)源:https://juejin.im/post/6844903815913668615
# 前言
從字面意思理解就是數(shù)據(jù)不需要來(lái)回的拷貝,大大提升了系統(tǒng)的性能;這個(gè)詞我們也經(jīng)常在java nio,netty,kafka,RocketMQ等框架中聽到,經(jīng)常作為其提升性能的一大亮點(diǎn);下面從I/O的幾個(gè)概念開始,進(jìn)而在分析零拷貝。
# I/O概念
1.緩沖區(qū)
緩沖區(qū)是所有I/O的基礎(chǔ),I/O講的無(wú)非就是把數(shù)據(jù)移進(jìn)或移出緩沖區(qū);進(jìn)程執(zhí)行I/O操作,就是向操作系統(tǒng)發(fā)出請(qǐng)求,讓它要么把緩沖區(qū)的數(shù)據(jù)排干(寫),要么填充緩沖區(qū)(讀);下面看一個(gè)java進(jìn)程發(fā)起read請(qǐng)求加載數(shù)據(jù)大致的流程圖:

進(jìn)程發(fā)起read請(qǐng)求之后,內(nèi)核接收到read請(qǐng)求之后,會(huì)先檢查內(nèi)核空間中是否已經(jīng)存在進(jìn)程所需要的數(shù)據(jù),如果已經(jīng)存在,則直接把數(shù)據(jù)copy給進(jìn)程的緩沖區(qū);如果沒有內(nèi)核隨即向磁盤控制器發(fā)出命令,要求從磁盤讀取數(shù)據(jù),磁盤控制器把數(shù)據(jù)直接寫入內(nèi)核read緩沖區(qū),這一步通過DMA完成;接下來(lái)就是內(nèi)核將數(shù)據(jù)copy到進(jìn)程的緩沖區(qū);如果進(jìn)程發(fā)起write請(qǐng)求,同樣需要把用戶緩沖區(qū)里面的數(shù)據(jù)copy到內(nèi)核的socket緩沖區(qū)里面,然后再通過DMA把數(shù)據(jù)copy到網(wǎng)卡中,發(fā)送出去;你可能覺得這樣挺浪費(fèi)空間的,每次都需要把內(nèi)核空間的數(shù)據(jù)拷貝到用戶空間中,所以零拷貝的出現(xiàn)就是為了解決這種問題的;關(guān)于零拷貝提供了兩種方式分別是:mmap+write方式,sendfile方式;
2.虛擬內(nèi)存
所有現(xiàn)代操作系統(tǒng)都使用虛擬內(nèi)存,使用虛擬的地址取代物理地址,這樣做的好處是:
1.一個(gè)以上的虛擬地址可以指向同一個(gè)物理內(nèi)存地址,
2.虛擬內(nèi)存空間可大于實(shí)際可用的物理地址;
利用第一條特性可以把內(nèi)核空間地址和用戶空間的虛擬地址映射到同一個(gè)物理地址,這樣DMA就可以填充對(duì)內(nèi)核和用戶空間進(jìn)程同時(shí)可見的緩沖區(qū)了,大致如下圖所示:

省去了內(nèi)核與用戶空間的往來(lái)拷貝,java也利用操作系統(tǒng)的此特性來(lái)提升性能,下面重點(diǎn)看看java對(duì)零拷貝都有哪些支持。
3.mmap+write方式
使用mmap+write方式代替原來(lái)的read+write方式,mmap是一種內(nèi)存映射文件的方法,即將一個(gè)文件或者其它對(duì)象映射到進(jìn)程的地址空間,實(shí)現(xiàn)文件磁盤地址和進(jìn)程虛擬地址空間中一段虛擬地址的一一對(duì)映關(guān)系;這樣就可以省掉原來(lái)內(nèi)核read緩沖區(qū)copy數(shù)據(jù)到用戶緩沖區(qū),但是還是需要內(nèi)核read緩沖區(qū)將數(shù)據(jù)copy到內(nèi)核socket緩沖區(qū),大致如下圖所示:

4.sendfile方式

# Java零拷貝
1.MappedByteBuffer
public class MappedByteBufferTest {public static void main(String[] args) throws Exception {File file = new File("D://db.txt");long len = file.length();byte[] ds = new byte[(int) len];MappedByteBuffer mappedByteBuffer = new FileInputStream(file).getChannel().map(FileChannel.MapMode.READ_ONLY, 0,len);for (int offset = 0; offset < len; offset++) {byte b = mappedByteBuffer.get();ds[offset] = b;}Scanner scan = new Scanner(new ByteArrayInputStream(ds)).useDelimiter(" ");while (scan.hasNext()) {System.out.print(scan.next() + " ");}}}
主要通過FileChannel提供的map()來(lái)實(shí)現(xiàn)映射,map()方法如下:
public abstract MappedByteBuffer map(MapMode mode,long position, long size)throws IOException;
public MappedByteBuffer map(MapMode mode, long position, long size)throws IOException{...省略...int pagePosition = (int)(position % allocationGranularity);long mapPosition = position - pagePosition;long mapSize = size + pagePosition;try {// If no exception was thrown from map0, the address is validaddr = map0(imode, mapPosition, mapSize);} catch (OutOfMemoryError x) {// An OutOfMemoryError may indicate that we've exhausted memory// so force gc and re-attempt mapSystem.gc();try {Thread.sleep(100);} catch (InterruptedException y) {Thread.currentThread().interrupt();}try {addr = map0(imode, mapPosition, mapSize);} catch (OutOfMemoryError y) {// After a second OOME, failthrow new IOException("Map failed", y);}}// On Windows, and potentially other platforms, we need an open// file descriptor for some mapping operations.FileDescriptor mfd;try {mfd = nd.duplicateForMapping(fd);} catch (IOException ioe) {unmap0(addr, mapSize);throw ioe;}assert (IOStatus.checkAll(addr));assert (addr % allocationGranularity == 0);int isize = (int)size;Unmapper um = new Unmapper(addr, mapSize, isize, mfd);if ((!writable) || (imode == MAP_RO)) {return Util.newMappedByteBufferR(isize,addr + pagePosition,mfd,um);} else {return Util.newMappedByteBuffer(isize,addr + pagePosition,mfd,um);}}
大致意思就是通過native方法獲取內(nèi)存映射的地址,如果失敗,手動(dòng)gc再次映射;最后通過內(nèi)存映射的地址實(shí)例化出MappedByteBuffer,MappedByteBuffer本身是一個(gè)抽象類,其實(shí)這里真正實(shí)例話出來(lái)的是DirectByteBuffer;
2.DirectByteBuffer
DirectByteBuffer繼承于MappedByteBuffer,從名字就可以猜測(cè)出開辟了一段直接的內(nèi)存,并不會(huì)占用jvm的內(nèi)存空間;上一節(jié)中通過Filechannel映射出的MappedByteBuffer其實(shí)際也是DirectByteBuffer,當(dāng)然除了這種方式,也可以手動(dòng)開辟一段空間:
ByteBuffer directByteBuffer = ByteBuffer.allocateDirect(100);如上開辟了100字節(jié)的直接內(nèi)存空間;
3.Channel-to-Channel傳輸
經(jīng)常需要從一個(gè)位置將文件傳輸?shù)搅硗庖粋€(gè)位置,F(xiàn)ileChannel提供了transferTo()方法用來(lái)提高傳輸?shù)男?,首先看一個(gè)簡(jiǎn)單的實(shí)例:
public class ChannelTransfer {public static void main(String[] argv) throws Exception {String files[]=new String[1];files[0]="D://db.txt";catFiles(Channels.newChannel(System.out), files);}private static void catFiles(WritableByteChannel target, String[] files)throws Exception {for (int i = 0; i < files.length; i++) {FileInputStream fis = new FileInputStream(files[i]);FileChannel channel = fis.getChannel();channel.transferTo(0, channel.size(), target);channel.close();fis.close();}}}
通過FileChannel的transferTo()方法將文件數(shù)據(jù)傳輸?shù)絊ystem.out通道,接口定義如下:
public abstract long transferTo(long position, long count,WritableByteChannel target)throws IOException;
幾個(gè)參數(shù)也比較好理解,分別是開始傳輸?shù)奈恢茫瑐鬏數(shù)淖止?jié)數(shù),以及目標(biāo)通道;transferTo()允許將一個(gè)通道交叉連接到另一個(gè)通道,而不需要一個(gè)中間緩沖區(qū)來(lái)傳遞數(shù)據(jù);注:這里不需要中間緩沖區(qū)有兩層意思:第一層不需要用戶空間緩沖區(qū)來(lái)拷貝內(nèi)核緩沖區(qū),另外一層兩個(gè)通道都有自己的內(nèi)核緩沖區(qū),兩個(gè)內(nèi)核緩沖區(qū)也可以做到無(wú)需拷貝數(shù)據(jù);
# Netty零拷貝
netty提供了零拷貝的buffer,在傳輸數(shù)據(jù)時(shí),最終處理的數(shù)據(jù)會(huì)需要對(duì)單個(gè)傳輸?shù)膱?bào)文,進(jìn)行組合和拆分,Nio原生的ByteBuffer無(wú)法做到,netty通過提供的Composite(組合)和Slice(拆分)兩種buffer來(lái)實(shí)現(xiàn)零拷貝;看下面一張圖會(huì)比較清晰:

TCP層HTTP報(bào)文被分成了兩個(gè)ChannelBuffer,這兩個(gè)Buffer對(duì)我們上層的邏輯(HTTP處理)是沒有意義的。但是兩個(gè)ChannelBuffer被組合起來(lái),就成為了一個(gè)有意義的HTTP報(bào)文,這個(gè)報(bào)文對(duì)應(yīng)的ChannelBuffer,才是能稱之為”Message”的東西,這里用到了一個(gè)詞”Virtual Buffer”??梢钥匆幌耼etty提供的CompositeChannelBuffer源碼:
public class CompositeChannelBuffer extends AbstractChannelBuffer {private final ByteOrder order;private ChannelBuffer[] components;private int[] indices;private int lastAccessedComponentId;private final boolean gathering;public byte getByte(int index) {int componentId = componentId(index);return components[componentId].getByte(index - indices[componentId]);}...省略...
components用來(lái)保存的就是所有接收到的buffer,indices記錄每個(gè)buffer的起始位置,lastAccessedComponentId記錄上一次訪問的ComponentId;CompositeChannelBuffer并不會(huì)開辟新的內(nèi)存并直接復(fù)制所有ChannelBuffer內(nèi)容,而是直接保存了所有ChannelBuffer的引用,并在子ChannelBuffer里進(jìn)行讀寫,實(shí)現(xiàn)了零拷貝。
# 其他零拷貝
RocketMQ的消息采用順序?qū)懙絚ommitlog文件,然后利用consume queue文件作為索引;RocketMQ采用零拷貝mmap+write的方式來(lái)回應(yīng)Consumer的請(qǐng)求;同樣kafka中存在大量的網(wǎng)絡(luò)數(shù)據(jù)持久化到磁盤和磁盤文件通過網(wǎng)絡(luò)發(fā)送的過程,kafka使用了sendfile零拷貝方式;
# 總結(jié)
零拷貝如果簡(jiǎn)單用java里面對(duì)象的概率來(lái)理解的話,其實(shí)就是使用的都是對(duì)象的引用,每個(gè)引用對(duì)象的地方對(duì)其改變就都能改變此對(duì)象,永遠(yuǎn)只存在一份對(duì)象。
正文結(jié)束
1.不認(rèn)命,從10年流水線工人,到谷歌上班的程序媛,一位湖南妹子的勵(lì)志故事
3.從零開始搭建創(chuàng)業(yè)公司后臺(tái)技術(shù)棧
5.37歲程序員被裁,120天沒找到工作,無(wú)奈去小公司,結(jié)果懵了...
一個(gè)人學(xué)習(xí)、工作很迷茫?
點(diǎn)擊「閱讀原文」加入我們的小圈子!

