前言
前言部分是科普,讀者可自行選擇是否閱讀這部分內容。
為什麼我們需要關心 NIO?我想很多業務猿都會有這個疑問。
我在工作的前兩年對這個問題也很不解,因為那個時候我認為自己已經非常熟悉 IO 操作了,讀寫文件什麼的都非常溜了,IO 包無非就是 File、RandomAccessFile、字節流、字符流這些,感覺沒什麼好糾結的。最混亂的當屬 InputStream/OutputStream 一大堆的類不知道誰是誰,不過了解了裝飾者模式以後,也都輕鬆破解了。
在 Java 領域,一般性的文件操作確實只需要和 java.io 包打交道就可以了,尤其對於寫業務代碼的程式設計師來說。不過,當你寫了兩三年代碼後,你的業務代碼可能已經寫得很溜了,蒙著眼睛也能寫增刪改查了。這個時候,也許你會想要開始了解更多的底層內容,包括並發、JVM、分布式系統、各個開源框架源碼實現等,處於這個階段的程式設計師會開始認識到 NIO 的用處,因為系統間通訊無處不在。
可能很多人不知道 Netty 或 Mina 有什麼用?和 Tomcat 有什麼區別?為什麼我用 HTTP 請求就可以解決應用間調用的問題卻要使用 Netty?
當然,這些問題的答案很簡單,就是為了提升性能。那意思是 Tomcat 性能不好?當然不是,它們的使用場景就不一樣。當初我也不知道 Nginx 擺在 Tomcat 前面有什麼用,也是經過實踐慢慢領悟到了那麼些意思。
Nginx 是 web 伺服器,Tomcat/Jetty 是應用伺服器,Netty 是通訊工具。
也許你現在還不知道 NIO 有什麼用,但是一定不要放棄學習它。
緩衝區操作
緩衝區是 NIO 操作的核心,本質上 NIO 操作就是緩衝區操作。
寫操作是將緩衝區的數據排乾,如將數據從緩衝區持久化到磁碟中。
讀操作是將數據填充到緩衝區中,以便應用程式後續使用數據。
當然,我們這裡說的緩衝區是指用戶空間的緩衝區。
.
簡單分析下上圖。應用程式發出讀操作後,內核向磁碟控制器發送命令,要求磁碟返回相應數據,磁碟控制器通過 DMA 直接將數據發送到內核緩衝區。一旦內核緩衝區滿了,內核即把數據拷貝到請求數據的進程指定的緩衝區中。
DMA: Direct Memory AccessWikipedia:直接內存訪問是計算機科學中的一種內存訪問技術。它允許某些電腦內部的硬體子系統(電腦外設),可以獨立地直接讀寫系統內存,而不需中央處理器(CPU)介入處理 。在同等程度的處理器負擔下,DMA 是一種快速的數據傳送方式。很多硬體的系統會使用 DMA,包含硬碟控制器、繪圖顯卡、網卡和音效卡。也就是說,磁碟控制器可以在不用 CPU 的幫助下就將數據從磁碟寫到內存中,畢竟讓 CPU 等待 IO 操作完成是一種浪費
很容易看出來,數據先到內核,然後再從內核複製到用戶空間緩衝區的做法並不高效,下面簡單說說為什麼需要這麼設計。
首先,用戶空間運行的代碼是不可以直接訪問硬體的,需要由內核空間來負責和硬體通訊,內核空間由作業系統控制。其次,磁碟存儲的是固定大小的數據塊,磁碟按照扇區來組織數據,而用戶進程請求的一般都是任意大小的數據塊,所以需要由內核來負責協調,內核會負責組裝、拆解數據。
內核空間會對數據進行緩存和預讀取,所以,如果用戶進程需要的數據剛好在內核空間中,直接拷貝過來就可以了。如果內核空間沒有用戶進程需要的數據的話,需要掛起用戶進程,等待數據準備好。
虛擬內存
這個概念大家都懂,這裡就繼續囉嗦一下了,虛擬內存是計算機系統內存管理的一種技術。前面說的緩存區操作看似簡單,但是具體到底層細節,還是蠻複雜的。
下面的描述,我儘量保證準確,但是不會展開得太具體,因為虛擬內存還是蠻複雜的,要完全介紹清楚,恐怕需要很大的篇幅,如果讀者對這方面的內容感興趣的話,建議讀者尋找更加專業全面的介紹資料,如《深入理解計算機系統》。
物理內存被組織成一個很大的數組,每個單元是一個字節大小,然後每個字節都有一個唯一的物理地址,這應該很好理解。
虛擬內存是對物理內存的抽象,它使得應用程式認為它自己擁有連續可用的內存(一個連續完整的地址空間),而實際上,應用程式得到的全部內存其實是一個假象,它通常會被分隔成多個物理內存碎片(後面說的頁),還有部分暫時存儲在外部磁碟存儲器上,在需要時進行換入換出。
舉個例子,在 32 位系統中,每個應用程式能訪問到的內存是 4G(32 位系統的最大尋址空間 2^32),這裡的 4G 就是虛擬內存,每個程序都以為自己擁有連續的 4G 空間的內存,即使我們的計算機只有 2G 的物理內存。也就是說,對於機器上同時運行的多個應用程式,每個程序都以為自己能得到連續的 4G 的內存。這中間就是使用了虛擬內存。
我們從概念上看,虛擬內存也被組織成一個很大的數組,每個單元也是一個字節大小,每個字節都有唯一的虛擬地址。它被存儲於磁碟上,物理內存是它的緩存。
物理內存作為虛擬內存的緩存,當然不是以字節為單位進行組織的,那樣效率太低了,它們之間是以頁(page)進行緩存的。虛擬內存被分割為一個個虛擬頁,物理內存也被分割為一個個物理頁,這兩個頁的大小應該是一致的,通常是 4KB - 2MB。
舉個例子,看下圖:
.
進程 1 現在有 8 個虛擬頁,其中有 2 個虛擬頁緩存在主存中,6 個還在磁碟上,需要的時候再讀入主存中;進程 2 有 7 個虛擬頁,其中 4 個緩存在主存中,3 個還在磁碟上。
在 CPU 讀取內存數據的時候,給出的是虛擬地址,將一個虛擬地址轉換為物理地址的任務我們稱之為地址翻譯。在主存中的查詢表存放了虛擬地址到物理地址的映射關係,表的內容由作業系統維護。CPU 需要訪問內存時,CPU 上有一個叫做內存管理單元的硬體會先去查詢真實的物理地址,然後再到指定的物理地址讀取數據。
上面說的那個查詢表,我們稱之為頁表,虛擬內存系統通過頁表來判斷一個虛擬頁是否已經緩存在了主存中。如果是,頁表會負責到物理頁的映射;如果不命中,也就是我們經常會見到的概念缺頁,對應的英文是 page fault,系統首先判斷這個虛擬頁存放在磁碟的哪個位置,然後在物理內存中選擇一個犧牲頁,並將虛擬頁從磁碟複製到內存中,替換這個犧牲頁。
在磁碟和內存之間傳送頁的活動叫做交換(swapping)或者頁面調度(paging)。
下面,簡單介紹下虛擬內存帶來的好處。
SRAM緩存:表示位於 CPU 和主存之間的 L1、L2 和 L3 高速緩存。
DRAM緩存:表示虛擬內存系統的緩存,緩存虛擬頁到主存中。
物理內存訪問速度比高速緩存要慢 10 倍左右,而磁碟要比物理內存慢大約 100000 倍。所以,DRAM 的緩存不命中比 SRAM 緩存不命中代價要大得多,因為 DRAM 緩存一旦不命中,就需要到磁碟加載虛擬頁。而 SRAM 緩存不命中,通常由 DRAM 的主存來服務。而從磁碟的一個扇區讀取第一個字節的時間開銷比起讀這個扇區中連續的字節要慢大約 100000 倍。
了解 Kafka 的讀者應該知道,消息在磁碟中的順序存儲對於 Kafka 的性能至關重要。
結論就是,IO 的性能主要是由 DRAM 的緩存是否命中決定的。
內存映射文件
英文名是 Memory Mapped Files,相信大家也都聽過這個概念,在許多對 IO 性能要求比較高的 java 應用中會使用到,它是作業系統提供的支持,後面我們在介紹 NIO Buffer 的時候會碰到的 MappedByteBuffer 就是用來支持這一特性的。
是什麼:
我們可以認為內存映射文件是一類特殊的文件,我們的 Java 程序可以直接從內存中讀取到文件的內容。它是通過將整個文件或文件的部分內容映射到內存頁中實現的,作業系統會負責加載需要的頁,所以它的速度是非常快的。
優勢:
一旦我們將數據寫入到了內存映射文件,即使我們的 JVM 掛掉了,作業系統依然會幫助我們將這部分內存數據持久化到磁碟上。當然了,如果是斷電的話,還是有可能會丟失數據的。另外,它比較適合於處理大文件,因為作業系統只會在我們需要的頁不在內存中時才會去加載頁數據,而用其處理大量的小文件反而可能會造成頻繁的缺頁。另一個重要的優勢就是內存共享。我們可以在多個進程中同時使用同一個內存映射文件,也算是一種進程間協作的方式吧。想像下進程間的數據通訊平時我們一般採用 Socket 來請求,而內存共享至少可以帶來 10 倍以上的性能提升。
我們還沒有接觸到 NIO 的 Buffer,下面就簡單地示意一下:
import java.io.RandomAccessFile;import java.nio.MappedByteBuffer;import java.nio.channels.FileChannel;public class MemoryMappedFileInJava { private static int count = 10485760; //10 MB public static void main(String[] args) throws Exception { RandomAccessFile memoryMappedFile = new RandomAccessFile("largeFile.txt", "rw"); // 將文件映射到內存中,map 方法 MappedByteBuffer out = memoryMappedFile.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, count); // 這一步的寫操作其實是寫到內存中,並不直接操作文件 for (int i = 0; i < count; i++) { out.put((byte) 'A'); } System.out.println("Writing to Memory Mapped File is completed"); // 這一步的讀操作讀的是內存 for (int i = 0; i < 10 ; i++) { System.out.print((char) out.get(i)); } System.out.println("Reading from Memory Mapped File is completed"); }}
我們需要注意的一點就是,用於加載內存映射文件的內存是堆外內存。
參考資料:Why use Memory Mapped File or MapppedByteBuffer in Java
分散/聚集 IO
scatter/gather IO,個人認為這個看上去很酷炫,實踐中比較難使用到。
分散/聚集 IO(另一種說法是 vectored I/O 也就是向量 IO)是一種可以在單次操作中對多個緩衝區進行輸入輸出的方法,可以把多個緩衝區的數據寫到單個數據流,也可以把單個數據流讀到多個緩衝區中。
.
.
這個功能是作業系統提供的支持,Java NIO 包中已經給我們提供了操作接口 。這種操作可以提高一定的性能,因為一次操作相當於多次的線性操作,同時這也帶來了原子性的支持,因為如果用多線程來操作的話,可能存在對同一文件的操作競爭。
非阻塞 IO
相信讀者在很多地方都看到過說 NIO 其實不是代表 New IO,而是 Non-Blocking IO,我們這裡不糾結這個。我想之所以會有這個說法,是因為在 Java 1.4 第一次推出 NIO 的時候,提供了 Non-Blocking IO 的支持。
在理解非阻塞 IO 前,我們首先要明白,它的對立面 阻塞模式為什麼不好。
比如說 InputStream.read 這個方法,一旦某個線程調用這個方法,那麼就將一直阻塞在這裡,直到數據傳輸完畢,返回 -1,或者由於其他錯誤拋出了異常。
我們再拿 web 伺服器來說,阻塞模式的話,每個網絡連接進來,我們都需要開啟一個線程來讀取請求數據,然後到後端進行處理,處理結束後將數據寫回網絡連接,這整個流程需要一個獨立的線程來做這件事。那就意味著,一旦請求數量多了以後,需要創建大量的線程,大量的線程必然帶來創建線程、切換線程的開銷,更重要的是,要給每個線程都分配一部分內存,會使得內存迅速被消耗殆盡。我們說多線程是性能利器,但是這就是過多的線程導致系統完全消化不了了。
通常,我們可以將 IO 分為兩類:面向數據塊(block-oriented)的 IO 和面向流(stream-oriented)的 IO。比如文件的讀寫就是面向數據塊的,讀取鍵盤輸入或往網絡中寫入數據就是面向流的。
注意,這節混著用了流和通道這兩個詞,提出來這點是希望不會對讀者產生困擾。
面向流的 IO 往往是比較慢的,如網絡速度比較慢、需要一直等待用戶新的輸入等。
這個時候,我們可以用一個線程來處理多個流,讓這個線程負責一直輪詢這些流的狀態,當有的流有數據到來後,進行相應處理,也可以將數據交給其他子線程來處理,這個線程繼續輪詢。
問題來了,不斷地輪詢也會帶來資源浪費呀,尤其是當一個線程需要輪詢很多的數據流的時候。
現代作業系統提供了一個叫做 readiness selection 的功能,我們讓作業系統來監控一個集合中的所有的通道,當有的通道數據準備好了以後,就可以直接到這個通道獲取數據。當然,作業系統不會通知我們,但是我們去問作業系統的時候,它會知道告訴我們通道 N 已經準備好了,而不需要自己去輪詢(後面我們會看到,還要自己輪詢的 select 和 poll)。
後面我們在介紹 Java NIO 的時候會說到 Selector,對應類 java.nio.channels.Selector,這個就是 java 對 readiness selection 的支持。這樣一來,我們的一個線程就可以更加高效地管理多個通道了。
.
上面這張圖我想大家也都可能看過,就是用一個 Selector 來管理多個 Channel,實現了一個線程管理多個連接。說到底,其實就是解決了我們前面說的阻塞模式下線程創建過多的問題。
在 Java 中,繼承自 SelectableChannel 的子類就是實現了非阻塞 IO 的,我們可以看到主要有 socket IO 中的 DatagramChannel 和 SocketChannel,而 FileChannel 並沒有繼承它。所以,文件 IO 是不支持非阻塞模式的。
在系統實現上,POSIX 提供了 select 和 poll 兩種方式。它們兩個最大的區別在於持有句柄的數量上,select 最多只支持到 FD_SETSIZE(一般常見的是 1024),顯然很多場景都會超過這個數量。而 poll 我們想創建多少就創建多少。它們都有一個共同的缺點,那就是當有任務完成後,我們只能知道有幾個任務完成了,而不知道具體是哪幾個句柄,所以還需要進行一次掃描。
正是由於 select 和 poll 的不足,所以催生了以下幾個實現。BSD& OS X 中的 kqueue,Solaris 中的 /dev/poll,還有 Linux 中的 epoll。
Windows 沒有提供額外的實現,只能使用 select。
在不同的作業系統上,JDK 分別選擇相應的系統支持的非阻塞實現方式。
異步 IO
我們知道 Java 1.4 引入了 New IO,從 Java 7 開始,就不再是 New IO 了,而是 More New IO 來臨了,我們也稱之為 NIO2。
Java7 在 NIO 上帶來的最大的變化應該就屬引入了 Asynchronous IO(異步 IO)。本來吧,異步 IO 早就提上日程了,可是大佬們沒有時間完成,所以才一直拖到了 java 7 的。廢話不多說,簡單來看看異步 IO 是什麼。
要說異步 IO 是什麼,當然還得從 Non-Blocking IO 沒有解決的問題入手。非阻塞 IO 很好用,它解決了阻塞式 IO 的等待問題,但是它的缺點是需要我們去輪詢才能得到結果。
而異步 IO 可以解決這個問題,線程只需要初始化一下,提供一個回調方法,然後就可以幹其他的事情了。當數據準備好以後,系統會負責調用回調方法。
異步 IO 最主要的特點就是回調,其實回調在我們日常的代碼中也是非常常見的。
最簡單的方法就是設計一個線程池,池中的線程負責完成一個個阻塞式的操作,一旦一個操作完成,那麼就調用回調方法。比如 web 伺服器中,我們前面已經說過不能每來一個請求就新開一個線程,我們可以設計一個線程池,在線程池外用一個線程來接收請求,然後將要完成的任務交給線程池中的線程並提供一個回調方法,這樣這個線程就可以去幹其他的事情了,如繼續處理其他的請求。等任務完成後,池中的線程就可以調用回調方法進行通知了。
另外一種方式就是自己不設計線程池,讓作業系統幫我們實現。流程也是基本一樣的,提供給作業系統回調方法,然後就可以幹其他事情了,等操作完成後,作業系統會負責回調。這種方式的缺點就是依賴於作業系統的具體實現,不過也有它的一些優勢。
首先,我們自己設計處理任務的線程池的話,我們需要掌握好線程池的大小,不能太大,也不能太小,這往往需要憑我們的經驗;其次,讓作業系統來做這件事情的話,作業系統可以在一些場景中幫助我們優化性能,如文件 IO 過程中幫助更快找到需要的數據。
作業系統對異步 IO 的實現也有很多種方式,主要有以下 3 中:
Linux AIO:由 Linux 內核提供支持POSIX AIO:Linux,Mac OS X(現在該叫 Mac OS 了),BSD,solaris 等都支持,在 Linux 中是通過 glibc 來提供支持的。Windows:提供了一個叫做 completion ports 的機制。
這篇文章 asynchronous disk I/O 的作者表示,在類 unix 的幾個系統實現中,限制太多,實現的質量太差,還不如自己用線程池進行管理異步操作。
而 Windows 系統下提供的異步 IO 的實現方式有點不一樣。它首先讓線程池中的線程去自旋調用 GetQueuedCompletionStatus.aspx) 方法,判斷是否就緒。然後,讓任務跑起來,但是需要提供特定的參數來告訴執行任務的線程,讓線程執行完成後將結果通知到線程池中。一旦任務完成,作業系統會將線程池中阻塞在 GetQueuedCompletionStatus 方法的線程喚醒,讓其進行後續的結果處理。
Windows 智能地喚醒那些執行 GetQueuedCompletionStatus 方法的線程,以讓線程池中活躍的線程數始終保持在合理的水平。這樣就不至於創建太多的線程,降低線程切換的開銷。
Java 7 在異步 IO 的實現上,如果是 Linux 或者其他類 Unix 系統上,是採用自建線程池實現的,如果是 Windows 系統上,是採用系統提供的 completion ports 來實現的。
所以,在非阻塞 IO 和異步 IO 之間,我們應該怎麼選擇呢?
如果是文件 IO,我們沒得選,只能選擇異步 IO。
如果是 Socket IO,在類 unix 系統下我們應該選擇使用非阻塞 IO,Netty 是基於非阻塞模式的;在 Windows 中我們應該使用異步 IO。
當然了,Java 的存在就是為了實現平臺無關化,所以,其實不需要我們選擇,了解這些權當讓自己漲點知識吧。
總結
和其他幾篇文章一樣,也沒什麼好總結的,要說的都在文中了,希望讀者能學到點東西吧。
如果哪裡說得不對了,我想也是正常的,我這些年寫的都是 Java,對於底層了解得愈發的少了,所以如果讀者發現有什麼不合理的內容,非常希望讀者可以提出來。