如何實現高效能的IO及其原理?
程式執行在記憶體以及IO的體現
首先普及一下常識,如圖所示:
1、在整個記憶體空間中,跑著各種各樣的程式,有Java程式、C程式,他們共用一塊記憶體空間。
2、對於Java程式,JVM會申請一塊堆空間,通過Xmx可以設定,其餘空間是堆外空間,其中每個執行緒有自己的執行緒棧,保證執行緒記憶體隔離,堆空間使用完以後,會觸發Full FC,堆外空間所有程序可共享使用,無限制。
3、所有系統執行的程式都必須通過作業系統核心進行IO操作,作業系統也是程式,也需要一定的記憶體空間。
一、使用Buffer代替基本IO
我們寫一個方法,此方法使用了FileWriter進行了檔案的寫操作,我們都知道不呼叫flush()可能會造成資料丟失,那麼為什麼呢,flush操作到底做了些什麼呢?
public void fileIO() throws Exception { File file = new File("/Volumes/work/temp/temp.txt"); if (file.exists()) { file.delete(); } file.createNewFile(); FileInputStream fileInputStream = new FileInputStream(file); FileWriter fileWriter = new FileWriter(file); fileWriter.write("hello"); fileWriter.write("world"); fileWriter.write("\nhello world"); Thread.sleep(99999); fileWriter.flush(); fileWriter.close(); }
我們知道我們在寫資料的時候不管是C還是Java都會有兩個緩衝區,一個是作業系統的緩衝區sys buffer,還有一個是程式的緩衝區program buffer。那麼剛剛的flush操作是把程式的緩衝區內容寫到了系統緩衝區,還是把系統緩衝區的內容刷到了硬碟呢?因此我們在呼叫flush()之前進行了sleep操作,檢查在flush之前,具體的內容並未寫到temp.txt檔案中,當我們睡眠時間結束後,可以看到呼叫flush方法後則把內容寫到了檔案中,如圖:
實際上FileWriter基本IO是沒有先寫程式快取的,那麼實際上FileWriter的每次write操作都發生了系統呼叫,直接寫到了核心的系統緩衝區,然後當呼叫flush操作時,系統緩衝區的內容再刷到了硬碟上。
因此IO效能提升第一步:無論是InputStream還是FileWriter,都是底層的IO,是直接呼叫核心的,因此寫入都是直接寫入到核心的系統buffer,因此在使用IO的時候不要使用這類底層IO,否則發生大量系統呼叫,降低系統性能,而是應該先寫到程式buffer然後再呼叫系統IO,當程式buffer滿了後才通過系統呼叫寫到系統buffer空間中,這樣減少了大量系統呼叫,提升了效能。
那麼什麼時候系統buffer中的資料才寫入到硬碟呢?2種情況:①.系統buffer滿了;②.執行了flush()操作,也就是發生了fsync的系統呼叫。
public void bufferedIO() throws Exception { BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(file), 1024); BufferedReader reader = new BufferedReader(new FileReader(file)); bufferedOutputStream.write("hello world\nhello world".getBytes()); bufferedOutputStream.flush(); bufferedOutputStream.close(); String line = reader.readLine(); System.out.println(line); }
還有另一種是直接寫入到記憶體的,如程式碼:
public void memoryIO() throws Exception { ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(1024); // 位元組陣列輸出流在記憶體中建立一個位元組陣列緩衝區,所有傳送到輸出流的資料儲存在該位元組陣列緩衝區中。可以通過toString()和toByteArray()獲取資料 byteArrayOutputStream.write("hello world".getBytes()); String string = byteArrayOutputStream.toString(); System.out.println(string); byte[] inData = byteArrayOutputStream.toByteArray(); ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(inData); byte[] data = new byte[1024]; byteArrayInputStream.read(data); System.out.println(new String(data)); byteArrayOutputStream.flush(); byteArrayOutputStream.close(); }
這樣就類似於Redis一樣,是對記憶體進行直接操作,因此這樣也能提高不少效率。
二、堆外記憶體mmap直接對映核心空間
如下圖:
1、如果資料在堆內,那麼在寫入磁碟時,會先序列化後拷貝到堆外,然後堆外再write到系統核心緩衝區,核心緩衝區通過系統呼叫fsync寫入到磁碟;
2、如果資料是在堆外記憶體,那麼也需要先拷貝到核心緩衝區,在fsync系統呼叫後也才寫入到磁碟;
3、通過系統呼叫mmap申請一塊虛擬的地址空間,這片空間使用者程式和系統核心都可以訪問到。
如下程式碼:
public void randomIO() throws Exception{ RandomAccessFile randomAccessFile = new RandomAccessFile(file,"rw"); randomAccessFile.write("hello world\nhello chicago\nhello ChengDu".getBytes()); FileChannel channel = randomAccessFile.getChannel(); /** * 堆外的資料如果想寫磁碟,通過系統呼叫,經歷資料從使用者空間拷貝到核心空間 * 堆外mapedBuffer的資料核心直接處理 */ // 分配在了堆上 heap空間 // ByteBuffer byteBuffer = ByteBuffer.allocate(1024); // 分配在了堆外 offheap空間 // ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024); //mmap 核心系統呼叫 堆外空間,直接對映 MappedByteBuffer byteBuffer = channel.map(FileChannel.MapMode.READ_WRITE,0,2018); byteBuffer.put("byteBuffer testing".getBytes()); randomAccessFile.seek(12); randomAccessFile.write("*****".getBytes()); }
可以看到通過FileChannel的map方法實現系統呼叫,申請mmap直接對映空間,資料無需由使用者空間拷貝到系統空間,節省了一次拷貝的時間損耗,提升了效能。
三、sendfile零拷貝
在Linux系統中。儲存在檔案中的資訊通過網路傳送給客戶這樣的簡單過程中,所涉及的操作。下面是其中的部分簡單程式碼:
read(file, tmp_buf, len); write(socket, tmp_buf, len);
其實過程中實現了多次拷貝,效能很低,如圖可知:
步驟一:系統呼叫read導致了從使用者空間到核心空間的上下文切換。DMA模組從磁碟中讀取檔案內容,並將其儲存在核心空間的緩衝區內,完成了第1次複製。
步驟二:資料從核心空間緩衝區複製到使用者空間緩衝區,完成了第2次複製,之後系統呼叫read返回,這導致了從核心空間向用戶空間的上下文切換。此時,需要的資料已存放在指定的使用者空間緩衝區內(引數tmp_buf),程式可以繼續下面的操作。
步驟三:系統呼叫write導致從使用者空間到核心空間的上下文切換。資料從使用者空間緩衝區被再次複製到核心空間緩衝區,完成了第3次複製。不過,這次資料存放在核心空間中與使用的socket相關的特定緩衝區中,而不是步驟一中的緩衝區。
步驟四:系統呼叫返回,導致了第4次上下文切換。第4次複製在DMA模組將資料從核心空間緩衝區傳遞至協議引擎的時候發生,這與我們的程式碼的執行是獨立且非同步發生的。你可能會疑惑:“為何要說是獨立、非同步?難道不是在write系統呼叫返回前資料已經被傳送了?write系統呼叫的返回,並不意味著傳輸成功——它甚至無法保證傳輸的開始。呼叫的返回,只是表明乙太網驅動程式在其傳輸佇列中有空位,並已經接受我們的資料用於傳輸。可能有眾多的資料排在我們的資料之前。除非驅動程式或硬體採用優先順序佇列的方法,各組資料是依照FIFO的次序被傳輸的(圖1中叉狀的DMA copy表明這最後一次複製可以被延後)。
因此就誕生了零拷貝:
sendfile(socket, file, len);
如圖:
步驟一:sendfile系統呼叫導致檔案內容通過DMA模組被複制到核心緩衝區中。
步驟二:記錄資料位置和長度的描述符被加入到socket緩衝區中,DMA模組將資料直接從核心緩衝區傳遞給協議引擎。
基於以上實現,最終實現了“零拷貝”。
高效能IO應用
在現實應用中,Kafka常用來進行日誌處理,存在著大量的IO,其高效能就是建立在IO上的優化,如圖: