本文介紹操做系統I/O工做原理,Java I/O設計,基本使用,開源項目中實現高性能I/O常見方法和實現,完全搞懂高性能I/O之道html
在介紹I/O原理以前,先重溫幾個基礎概念:java
操做系統:管理計算機硬件與軟件資源的系統軟件 內核:操做系統的核心軟件,負責管理系統的進程、內存、設備驅動程序、文件和網絡系統等等,爲應用程序提供對計算機硬件的安全訪問服務git
爲了不用戶進程直接操做內核,保證內核安全,操做系統將內存尋址空間劃分爲兩部分: 內核空間(Kernel-space),供內核程序使用 用戶空間(User-space),供用戶進程使用 爲了安全,內核空間和用戶空間是隔離的,即便用戶的程序崩潰了,內核也不受影響github
計算機中的數據是基於隨着時間變換高低電壓信號傳輸的,這些數據信號接二連三,有着固定的傳輸方向,相似水管中水的流動,所以抽象數據流(I/O流)的概念:指一組有順序的、有起點和終點的字節集合,apache
抽象出數據流的做用:實現程序邏輯與底層硬件解耦,經過引入數據流做爲程序與硬件設備之間的抽象層,面向通用的數據流輸入輸出接口編程,而不是具體硬件特性,程序和底層硬件能夠獨立靈活替換和擴展編程
典型I/O讀寫磁盤工做原理以下:數組
tips: DMA:全稱叫直接內存存取(Direct Memory Access),是一種容許外圍設備(硬件子系統)直接訪問系統主內存的機制。基於 DMA 訪問方式,系統主內存與硬件設備的數據傳輸能夠省去CPU 的全程調度緩存
值得注意的是:安全
這裏先以最經典的阻塞式I/O模型介紹:性能優化
tips:recvfrom,經socket接收數據的函數
值得注意的是:
Java中對數據流進行具體化和實現,關於Java數據流通常關注如下幾個點:
(1) 流的方向 從外部到程序,稱爲輸入流;從程序到外部,稱爲輸出流
(2) 流的數據單位 程序以字節做爲最小讀寫數據單元,稱爲字節流,以字符做爲最小讀寫數據單元,稱爲字符流
(3) 流的功能角色
從/向一個特定的IO設備(如磁盤,網絡)或者存儲對象(如內存數組)讀/寫數據的流,稱爲節點流; 對一個已有流進行鏈接和封裝,經過封裝後的流來實現數據的讀/寫功能,稱爲處理流(或稱爲過濾流);
java.io包下有一堆I/O操做類,初學時看了容易搞不懂,其實仔細觀察其中仍是有規律: 這些I/O操做類都是在繼承4個基本抽象流的基礎上,要麼是節點流,要麼是處理流
java.io包中包含了流式I/O所須要的全部類,java.io包中有四個基本抽象流,分別處理字節流和字符流:
節點流I/O類名由節點流類型 + 抽象流類型組成,常見節點類型有:
節點流的建立一般是在構造函數傳入數據源,例如:
FileReader reader = new FileReader(new File("file.txt")); FileWriter writer = new FileWriter(new File("file.txt")); 複製代碼
處理流I/O類名由對已有流封裝的功能 + 抽象流類型組成,常見功能有:
處理流的應用了適配器/裝飾模式,轉換/擴展已有流,處理流的建立一般是在構造函數傳入已有的節點流或處理流:
FileOutputStream fileOutputStream = new FileOutputStream("file.txt"); // 擴展提供緩衝寫 BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(fileOutputStream); // 擴展提供提供基本數據類型寫 DataOutputStream out = new DataOutputStream(bufferedOutputStream); 複製代碼
Java NIO(New I/O)是一個能夠替代標準Java I/O API的IO API(從Java 1.4開始),Java NIO提供了與標準I/O不一樣的I/O工做方式,目的是爲了解決標準 I/O存在的如下問題:
標準I/O處理,完成一次完整的數據讀寫,至少須要從底層硬件讀到內核空間,再讀到用戶文件,又從用戶空間寫入內核空間,再寫入底層硬件
此外,底層經過write、read等函數進行I/O系統調用時,須要傳入數據所在緩衝區起始地址和長度 因爲JVM GC的存在,致使對象在堆中的位置每每會發生移動,移動後傳入系統函數的地址參數就不是真正的緩衝區地址了
可能致使讀寫出錯,爲了解決上面的問題,使用標準I/O進行系統調用時,還會額外致使一次數據拷貝:把數據從JVM的堆內拷貝到堆外的連續空間內存(堆外內存)
因此總共經歷6次數據拷貝,執行效率較低
傳統的網絡I/O處理中,因爲請求創建鏈接(connect),讀取網絡I/O數據(read),發送數據(send)等操做是線程阻塞的
// 等待鏈接 Socket socket = serverSocket.accept(); // 鏈接已創建,讀取請求消息 StringBuilder req = new StringBuilder(); byte[] recvByteBuf = new byte[1024]; int len; while ((len = socket.getInputStream().read(recvByteBuf)) != -1) { req.append(new String(recvByteBuf, 0, len, StandardCharsets.UTF_8)); } // 寫入返回消息 socket.getOutputStream().write(("server response msg".getBytes())); socket.shutdownOutput(); 複製代碼
以上面服務端程序爲例,當請求鏈接已創建,讀取請求消息,服務端調用read方法時,客戶端數據可能還沒就緒(例如客戶端數據還在寫入中或者傳輸中),線程須要在read方法阻塞等待直到數據就緒
爲了實現服務端併發響應,每一個鏈接須要獨立的線程單獨處理,當併發請求量大時爲了維護鏈接,內存、線程切換開銷過大
Java NIO核心三大核心組件是Buffer(緩衝區)、Channel(通道)、Selector
Buffer提供了經常使用於I/O操做的字節緩衝區,常見的緩存區有ByteBuffer, CharBuffer, DoubleBuffer, FloatBuffer, IntBuffer, LongBuffer, ShortBuffer,分別對應基本數據類型: byte, char, double, float, int, long, short,下面介紹主要以最經常使用的ByteBuffer爲例,Buffer底層支持Java堆內(HeapByteBuffer)或堆外內存(DirectByteBuffer)
堆外內存是指與堆內存相對應的,把內存對象分配在JVM堆之外的內存,這些內存直接受操做系統管理(而不是虛擬機,相比堆內內存,I/O操做中使用堆外內存的優點在於:
ByteBuffer底層堆外內存的分配和釋放基於malloc和free函數,對外allocateDirect方法能夠申請分配堆外內存,並返回繼承ByteBuffer類的DirectByteBuffer對象:
public static ByteBuffer allocateDirect(int capacity) { return new DirectByteBuffer(capacity); } 複製代碼
堆外內存的回收基於DirectByteBuffer的成員變量Cleaner類,提供clean方法能夠用於主動回收,Netty中大部分堆外內存經過記錄定位Cleaner的存在,主動調用clean方法來回收; 另外,當DirectByteBuffer對象被GC時,關聯的堆外內存也會被回收
tips: JVM參數不建議設置-XX:+DisableExplicitGC,由於部分依賴Java NIO的框架(例如Netty)在內存異常耗盡時,會主動調用System.gc(),觸發Full GC,回收DirectByteBuffer對象,做爲回收堆外內存的最後保障機制,設置該參數以後會致使在該狀況下堆外內存得不到清理
堆外內存基於基礎ByteBuffer類的DirectByteBuffer類成員變量:Cleaner對象,這個Cleaner對象會在合適的時候執行unsafe.freeMemory(address),從而回收這塊堆外內存
Buffer能夠見到理解爲一組基本數據類型,存儲地址連續的的數組,支持讀寫操做,對應讀模式和寫模式,經過幾個變量來保存這個數據的當前位置狀態:capacity、 position、 limit:
Channel(通道)的概念能夠類比I/O流對象,NIO中I/O操做主要基於Channel: 從Channel進行數據讀取 :建立一個緩衝區,而後請求Channel讀取數據 從Channel進行數據寫入 :建立一個緩衝區,填充數據,請求Channel寫入數據
Channel和流很是類似,主要有如下幾點區別:
Java NIO中最重要的幾個Channel的實現:
基於標準I/O中,咱們第一步可能要像下面這樣獲取輸入流,按字節把磁盤上的數據讀取到程序中,再進行下一步操做,而在NIO編程中,須要先獲取Channel,再進行讀寫
FileInputStream fileInputStream = new FileInputStream("test.txt"); FileChannel channel = fileInputStream.channel(); 複製代碼
tips: FileChannel僅能運行在阻塞模式下,文件異步處理的 I/O 是在JDK 1.7 才被加入的 java.nio.channels.AsynchronousFileChannel
// server socket channel: ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.bind(new InetSocketAddress(InetAddress.getLocalHost(), 9091)); while (true) { SocketChannel socketChannel = serverSocketChannel.accept(); ByteBuffer buffer = ByteBuffer.allocateDirect(1024); int readBytes = socketChannel.read(buffer); if (readBytes > 0) { // 從寫數據到buffer翻轉爲從buffer讀數據 buffer.flip(); byte[] bytes = new byte[buffer.remaining()]; buffer.get(bytes); String body = new String(bytes, StandardCharsets.UTF_8); System.out.println("server 收到:" + body); } } 複製代碼
Selector(選擇器) ,它是Java NIO核心組件中的一個,用於檢查一個或多個NIO Channel(通道)的狀態是否處於可讀、可寫。實現單線程管理多個Channel,也就是能夠管理多個網絡鏈接
Selector核心在於基於操做系統提供的I/O複用功能,單個線程能夠同時監視多個鏈接描述符,一旦某個鏈接就緒(通常是讀就緒或者寫就緒),可以通知程序進行相應的讀寫操做,常見有select、poll、epoll等不一樣實現
Java NIO Selector基本工做原理以下:
示例以下,完整可運行代碼已經上傳github(github.com/caison/cais…):
Selector selector = Selector.open(); ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.bind(new InetSocketAddress(9091)); // 配置通道爲非阻塞模式 serverSocketChannel.configureBlocking(false); // 註冊服務端的socket-accept事件 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { // selector.select()會一直阻塞,直到有channel相關操做就緒 selector.select(); // SelectionKey關聯的channel都有就緒事件 Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator(); while (keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); // 服務端socket-accept if (key.isAcceptable()) { // 獲取客戶端鏈接的channel SocketChannel clientSocketChannel = serverSocketChannel.accept(); // 設置爲非阻塞模式 clientSocketChannel.configureBlocking(false); // 註冊監聽該客戶端channel可讀事件,併爲channel關聯新分配的buffer clientSocketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocateDirect(1024)); } // channel可讀 if (key.isReadable()) { SocketChannel socketChannel = (SocketChannel) key.channel(); ByteBuffer buf = (ByteBuffer) key.attachment(); int bytesRead; StringBuilder reqMsg = new StringBuilder(); while ((bytesRead = socketChannel.read(buf)) > 0) { // 從buf寫模式切換爲讀模式 buf.flip(); int bufRemain = buf.remaining(); byte[] bytes = new byte[bufRemain]; buf.get(bytes, 0, bytesRead); // 這裏當數據包大於byteBuffer長度,有可能有粘包/拆包問題 reqMsg.append(new String(bytes, StandardCharsets.UTF_8)); buf.clear(); } System.out.println("服務端收到報文:" + reqMsg.toString()); if (bytesRead == -1) { byte[] bytes = "[這是服務回的報文的報文]".getBytes(StandardCharsets.UTF_8); int length; for (int offset = 0; offset < bytes.length; offset += length) { length = Math.min(buf.capacity(), bytes.length - offset); buf.clear(); buf.put(bytes, offset, length); buf.flip(); socketChannel.write(buf); } socketChannel.close(); } } // Selector不會本身從已selectedKeys中移除SelectionKey實例 // 必須在處理完通道時本身移除 下次該channel變成就緒時,Selector會再次將其放入selectedKeys中 keyIterator.remove(); } } 複製代碼
tips: Java NIO基於Selector實現高性能網絡I/O這塊使用起來比較繁瑣,使用不友好,通常業界使用基於Java NIO進行封裝優化,擴展豐富功能的Netty框架來優雅實現
下面結合業界熱門開源項目介紹高性能I/O的優化
零拷貝(zero copy)技術,用於在數據讀寫中減小甚至徹底避免沒必要要的CPU拷貝,減小內存帶寬的佔用,提升執行效率,零拷貝有幾種不一樣的實現原理,下面介紹常見開源項目中零拷貝實現
Kafka基於Linux 2.1內核提供,並在2.4 內核改進的的sendfile函數 + 硬件提供的DMA Gather Copy實現零拷貝,將文件經過socket傳送
函數經過一次系統調用完成了文件的傳送,減小了原來read/write方式的模式切換。同時減小了數據的copy, sendfile的詳細過程以下:
基本流程以下:
相比傳統的I/O方式,sendfile + DMA Gather Copy方式實現的零拷貝,數據拷貝次數從4次降爲2次,系統調用從2次降爲1次,用戶進程上下文切換次數從4次變成2次DMA Copy,大大提升處理效率
Kafka底層基於java.nio包下的FileChannel的transferTo:
public abstract long transferTo(long position, long count, WritableByteChannel target)
複製代碼
transferTo將FileChannel關聯的文件發送到指定channel,當Comsumer消費數據,Kafka Server基於FileChannel將文件中的消息數據發送到SocketChannel
RocketMQ基於mmap + write的方式實現零拷貝: mmap() 能夠將內核中緩衝區的地址與用戶空間的緩衝區進行映射,實現數據共享,省去了將數據從內核緩衝區拷貝到用戶緩衝區
tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);
複製代碼
mmap + write 實現零拷貝的基本流程以下:
RocketMQ中消息基於mmap實現存儲和加載的邏輯寫在org.apache.rocketmq.store.MappedFile中,內部實現基於nio提供的java.nio.MappedByteBuffer,基於FileChannel的map方法獲得mmap的緩衝區:
// 初始化 this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel(); this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize); 複製代碼
查詢CommitLog的消息時,基於mappedByteBuffer偏移量pos,數據大小size查詢:
public SelectMappedBufferResult selectMappedBuffer(int pos, int size) { int readPosition = getReadPosition(); // ...各類安全校驗 // 返回mappedByteBuffer視圖 ByteBuffer byteBuffer = this.mappedByteBuffer.slice(); byteBuffer.position(pos); ByteBuffer byteBufferNew = byteBuffer.slice(); byteBufferNew.limit(size); return new SelectMappedBufferResult(this.fileFromOffset + pos, byteBufferNew, size, this); } 複製代碼
tips: transientStorePoolEnable機制 Java NIO mmap的部份內存並非常駐內存,能夠被置換到交換內存(虛擬內存),RocketMQ爲了提升消息發送的性能,引入了內存鎖定機制,即將最近須要操做的CommitLog文件映射到內存,並提供內存鎖定功能,確保這些文件始終存在內存中,該機制的控制參數就是transientStorePoolEnable
所以,MappedFile數據保存CommitLog刷盤有2種方式:
RocketMQ 基於 mmap+write 實現零拷貝,適用於業務級消息這種小塊文件的數據持久化和傳輸 Kafka 基於 sendfile 這種零拷貝方式,適用於系統日誌消息這種高吞吐量的大塊文件的數據持久化和傳輸
tips: Kafka 的索引文件使用的是 mmap+write 方式,數據文件發送網絡使用的是 sendfile 方式
Netty 的零拷貝分爲兩種:
Netty中對Java NIO功能封裝優化以後,實現I/O多路複用代碼優雅了不少:
// 建立mainReactor NioEventLoopGroup boosGroup = new NioEventLoopGroup(); // 建立工做線程組 NioEventLoopGroup workerGroup = new NioEventLoopGroup(); final ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap // 組裝NioEventLoopGroup .group(boosGroup, workerGroup) // 設置channel類型爲NIO類型 .channel(NioServerSocketChannel.class) // 設置鏈接配置參數 .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.SO_KEEPALIVE, true) .childOption(ChannelOption.TCP_NODELAY, true) // 配置入站、出站事件handler .childHandler(new ChannelInitializer<NioSocketChannel>() { @Override protected void initChannel(NioSocketChannel ch) { // 配置入站、出站事件channel ch.pipeline().addLast(...); ch.pipeline().addLast(...); } }); // 綁定端口 int port = 8080; serverBootstrap.bind(port).addListener(future -> { if (future.isSuccess()) { System.out.println(new Date() + ": 端口[" + port + "]綁定成功!"); } else { System.err.println("端口[" + port + "]綁定失敗!"); } }); 複製代碼
頁緩存(PageCache)是操做系統對文件的緩存,用來減小對磁盤的 I/O 操做,以頁爲單位的,內容就是磁盤上的物理塊,頁緩存能幫助程序對文件進行順序讀寫的速度幾乎接近於內存的讀寫速度,主要緣由就是因爲OS使用PageCache機制對讀寫訪問操做進行了性能優化:
頁緩存讀取策略:當進程發起一個讀操做 (好比,進程發起一個 read() 系統調用),它首先會檢查須要的數據是否在頁緩存中:
頁緩存寫策略:當進程發起write系統調用寫數據到文件中,先寫到頁緩存,而後方法返回。此時數據尚未真正的保存到文件中去,Linux 僅僅將頁緩存中的這一頁數據標記爲「髒」,而且被加入到髒頁鏈表中
而後,由flusher 回寫線程週期性將髒頁鏈表中的頁寫到磁盤,讓磁盤中的數據和內存中保持一致,最後清理「髒」標識。在如下三種狀況下,髒頁會被寫回磁盤:
RocketMQ中,ConsumeQueue邏輯消費隊列存儲的數據較少,而且是順序讀取,在page cache機制的預讀取做用下,Consume Queue文件的讀性能幾乎接近讀內存,即便在有消息堆積狀況下也不會影響性能,提供了2種消息刷盤策略:
Kafka實現消息高性能讀寫也利用了頁緩存,這裏再也不展開
《深刻理解Linux內核 —— Daniel P.Bovet》
RocketMQ 消息存儲流程 —— Zhao Kun(趙坤)
更多精彩,歡迎關注公衆號 分佈式系統架構