Java是一門跨平臺的語言,其運行時通過Java虛擬機調用操作系統的相關系統函數,也就是說底層都是操作系統的相關程序。因此,我們在學習java I/O時需要對操作系統的I/O進行了解;由于大多時候Java應用程序都運行在Linux系統之上,我們以Linux為學習的基礎。
## 1.1 文件IO
在Linux系統中,所有的輸入輸出都會當做一個文件進行處理,Socket可以看做是一種特殊的文件。
### 基本I/O與標準I/O
類Unix系統中常用的I/O函數有read()/write()等,這些被稱為不帶緩沖的I/O;標準I/O在基本的I/O函數基礎上增加了流和緩沖的概念,常用的函數有fopen/getc()/putc()等,標準I/O為了提高讀寫效率和保護磁盤,使用了頁緩存機制。
讀文件調用getc()時,操作系統底層會使用read()函數,并從用戶空間切換到內核空間,執行系統調用。首先將文件頁從磁盤拷貝到頁緩存中,由于頁緩存處在內核空間,不能被用戶進程直接尋址,所以還需要將頁緩存中數據頁再次拷貝到內存對應的用戶空間中。這樣,經過兩次數據拷貝過程,進程才能獲取到文件內容。寫操作也是一樣,用戶態寫數據時,待發送數據所在的緩沖區處于內核空間,用戶態不能直接訪問,必須先拷貝至內核空間對應的主存,才能寫回磁盤中(延遲寫回),因此寫入也是需要兩次數據拷貝。
**mmap內存映射**
mmap是一種內存映射文件的方法,可以將一個文件或者其它對象映射到進程的虛擬地址空間,實現文件磁盤地址和進程虛擬地址空間中某一段地址的一一對映,這樣應用程序就可以通過訪問進程虛擬內存地地址直接訪問到文件或對象。
mmap在操作文件時,首先為用戶進程創建新的虛擬內存區域,之后建立文件磁盤地址和虛擬內存相關區域的映射,這期間沒有涉及任何的文件拷貝。當進程訪問數據時發現內存中并無數據而發起的缺頁異常處理,根據已經建立好的映射關系進行一次數據拷貝,將磁盤中的文件數據讀取到虛擬地址對應的內存中,供進程使用。
綜上所述,常規的文件操作需要通過兩次數據拷貝才能從磁盤讀取到用戶空間所在的內存,而使用mmap操作文件,只需要從磁盤到內存的一次數據拷貝過程。mmap的關鍵點是實現了用戶空間和內核空間的映射,并在此基礎上直接進行數據交互,避免了空間不同數據不通的繁瑣過程。因此mmap效率更高。但在《Unix網絡編程》的14.8 存儲映射I/O一節中,作者說明了Linux3.2.0和Solaris中,兩種方法的測試結果相反;作者認為是實現方式的差異造成的。
## 1.2 Linux I/O模型
Linux I/O的模型直接影響了java I/O模型,以下是幾種常見的IO模型,在APUE(Unix環境高級編程)中有講解。
**阻塞模型**
傳統的read()和write()會等待數據包到達且復制到應用進程的用戶空間緩沖區或發生錯誤時才會返回,在此期間會一直等待;等待期間進程一直處于空閑狀態。
**非阻塞模型**
非阻塞IO模型下,我們發出open/read/write這樣的IO操作時,這些操作不會永遠阻塞,而是立即返回。對于一個給定的文件描述符,有兩種指定非阻塞的方法:
1. 調用open獲得描述符時,可指定O_NONBLOCK標志
2. 對于一個已經打開的描述符,可調用fcntl,由該函數打開O_NONBLOCK狀態標志
非阻塞模型由于立即返回,后面需要輪詢不斷的查看讀寫是否已經就緒,然后才能進行I/O操作。
**異步IO**
關于文件描述符的狀態,系統并不會主動告訴我們任何信息,需要不斷進行查詢(select或poll)。Linux系統中的信號機制提供了一直通知某事情發生的方法。異步IO通知內核某個操作,并讓內核在整個操作完成后通知應用程序。
**多路復用**
當我們需要在一個描述符s1上讀,然后又在另一個描述符s2上寫時,可以連續使用read()和write(),但是我們在s1上進行阻塞讀時,會導致進程即使有數據也不能寫入到s2中。因此我們需要另一種技術來完成這類操作。
解決方法1:使用兩個進程,一個讀,一個寫,這樣會使程序變得復雜,當然也可以使用兩個線程,但需要進行同步操作。
解決方法2:仍然使用一個進程,但使用非阻塞IO,打開兩個文件時都設置為非阻塞,輪詢兩個文件描述符,這樣會造成CPU資源的浪費
解決方法3:異步IO。信號對每個進程而言只有一個(SIGPOLL或SIGIO),如果該信號對兩個描述符都起作用,進行在接到此信號時無法判別是哪個。
還有一種技術就是我們現在要介紹的:I/O多路復用。
## 1.3 多路復用
使用I/O多路復用時,先構造一張進程感興趣的文件描述符列表,然后調用一個函數,直到這些描述符中的一個IO已準備好時,該函數才返回。常用的IO多路復用實現有:select() poll()和epoll()
**select**
select可以指定感興趣的描述符集合,select函數會遍歷當前進程打開的描述符集合,查找就緒事件返回,具體的原理可見http://blog.csdn.net/vonzhoufz/article/details/44490675
**select程序**
```
fd_set set;
while(1) {
/*設置文件描述符集,先清空,再加入管道1,2*/
/*每次循環都要清空集合,否則不能檢測描述符變化 */
FD_ZERO(&set);
FD_SET(fd1,&set);
FD_SET(fd2,&set);
rfd=select(FD_SETSIZE,&set,NULL,NULL,&val);
switch(rfd) {
case -1: exit(-1);break;
case 0:break;
default:
if(FD_ISSET(fd1,&set)){ //測試fd1是否可讀,
read(fd1,buf2,sizeof(buf2));
printf("[1]Get msg!\n");
}
if(FD_ISSET(fd2,&set))
{
read(fd2,buf,sizeof(buf));
printf("[2]Get msg!\n");
}
}//switch
}//while
```
**select缺點**
* 每次調用select,需要把fd_set從用戶空間和內核空間之間進行拷貝,fd很多時開銷很大
* 每次調用select都要在內核遍歷進程的所有fd,fd很多時開銷很大,隨著fd增長而線性增長
* select支持的文件描述符有限,默認是1024,太小
**poll**
```
for ( ; ; ){
//獲取可用描述符的個數 80
nready = poll(clientfds,maxi+1,INFTIM);
}
```
poll的實現和select非常相似,只是文件描述符fd集合的方式不同,poll使用pollfd結構而不是select的fd_set結構; poll不是為每個條件(讀/寫/異常)構造一個描述符集,而是構造一個pollfd結構,每個數組元素指定一個描述符編號以及我們對該描述符感興趣的條件
```
struct pollfd{
int fd ;
short events; //感興趣的事件
short revents;//發生的事件
}
```
poll和select同樣存在一個缺點:包含大量文件描述符的數組被整體復制于用戶態和內核的地址空間自己,無論是否就緒,開銷隨著文件描述符數量增加而增加。
**epoll**
```
for ( ; ; ){
//獲取已經準備好的描述符事件
ret = epoll_wait(epollfd,events,EPOLLEVENTS,-1);
handle_events(epollfd,events,ret,listenfd,buf);
}
```
epoll既然是對select和poll的改進,就應該能避免上述的三個缺點。那epoll都是怎么解決的呢?在此之前,我們先看一下epoll和select和poll的調用接口上的不同,select和poll都只提供了一個函數——select或者poll函數。
而epoll提供了三個函數,epoll_create,epoll_ctl和epoll_wait:
* epoll_create是創建一個epoll句柄 epollfd;
* epoll_ctl是注冊要監聽的事件類型;
* epoll_wait則是等待事件的產生。
針對fd_set每次從用戶空間和內核空間之間進行拷貝的缺點,epoll在 epoll_ctl函數中,每次在注冊fd到epoll_create生成的epoll句柄時,把fd拷貝進內核,而不是在 epoll_wait的時候重復拷貝。因此,epoll保證每個fd只拷貝一次,在循環的epoll_wait不進行拷貝,而select和poll在循環中每次都需要拷貝。
針對select/poll在內核中遍歷所有fd,epoll不會每次都將current(用戶寫select的線程)輪流加入fd對應設備的等待隊列,而是在 epoll_ctl時把current掛一遍,并為每個fd指定一個回調函數,設備就緒喚醒等待隊列上的線程時,會調用回調函數;回調函數把就緒的fd放入一個就緒鏈表。
epoll_wait的工作實際上就是在這個就緒鏈表中查看有沒有就緒的fd( schedule_timeout() 實現睡一會判斷一會)
針對fd大小的限制,epoll沒有限制,支持的fd上限時最大可以打開文件的數目,一般遠大于2048,如1GB機器可打開的是10萬左右 cat /proc/sys/fs/file-max可查看
## 1.4 Java網絡I/O模型
**BIO 阻塞I/O**
服務器每次收到請求后,會啟動一個線程處理連接,但是每次建立連接后創建線程,容易造成系統資源耗盡,因為每個線程都需要耗費一定的內存資源,當連接過多時,內存會耗盡。
```
while(true){
Socket socket= serverSock.accept();
System.out.println("Accept from a client!");
BioHandler handler=new BioHandler(socket);
handler.start();
}
```
在上面的基礎上,我們可以引入線程池機制,每次接收到請求,封裝為一個任務,提交到線程池處理;如果線程池已滿,剩余的任務需要等待或者創建新的線程,這與連接池的配置有關。如果線程池創建新的線程,也會有資源耗盡的缺點;如果等待,意味著如果應答比較緩慢,或者被故障服務器阻塞,之后的請求會一直排隊,直到超時。消息的接收方處理緩慢時,不能及時從TCP緩沖區讀取數據,造成發送方的窗口大小不斷減小,直到為0后不能再發送消息。
```
pool=new CommonThreadPool<>();
while(true){
Socket socket= serverSock.accept();
System.out.println("Accept from a client!");
pool.execute(new BioHandler(socket));
}
```
**NIO**
NIO底層會根據操作系統選擇可用的多路復用機制,需要將Channel注冊到打開的selector中。
```
Selector selector=Selector.open();
ServerSocketChannel.open().register(selector, SelectionKey.OP_ACCEPT);
while(true){
int size=selector.select();//獲取連接
}
```
NIO的優勢在于:
1. 客戶端發起的連接是異步的,客戶端不會被同步阻塞
2. SocketChannel的讀寫是異步的,沒有可讀寫數據不會同步等待,而是直接返回,IO通信線程可以處理其他事情
3. epoll沒有連接句柄的限制(只受制于操作系統的最大句柄數或單個線程的最大句柄數),一個selector線程可以同時處理成千上萬個客戶端連接,性能不會線性下降。
**Reactor**
在實踐中,對于NIO與線程的使用,抽象成了一種Reactor模型。Reactor模型采用分而治之的思想,將服務器處理客戶端請求的事件分為兩類:I/O事件和非I/O事件;前者需要等待I/O準備就緒,后者可以立即執行,因此分開對著兩種事件進行處理。非I/O事件一般包括收到請求后的解碼/計算/編碼等操作。
Reactor模型分為兩個角色:
1.Reactor負責相應IO事件,一旦建立連接,發生給相應的Handler處理。
2.Handler負責非阻塞事件,Handler會將任務提交給業務邏輯線程池,處理具體業務。
Reactor與線程/線程池的組合可以有如下組合:
***單線程Reactor***
單個線程,所有連接注冊到該線程上,適合I/O密集,不適合CPU密集(業務邏輯大量計算)的應用;CPU資源緊張使用單線程可以減少線程切換。
***Reactor與線程池***
Reactor僅負責I/O,線程池負責其他業務邏輯
***雙Reactor與線程池***
mainReactor負責處理客戶端的連接請求,并將accept的連接注冊到subReactor的其中一個線程上;subReactor負責處理客戶端通道上的數據讀寫和業務邏輯;
這種方式的好處是,CPU資源緊張時,可通過調整subReactor數量調整線程大小;CPU密集任務時,可以在業務邏輯處理交給線程池處理。
**AIO**
NIO2.0引入了新的異步通道的概念,并提供了異步文件通道和異步套接字通道的實現。具體程序可見
https://github.com/ssj234/JavaStudy_IO/tree/master/IOResearch/src/net/ssj/aio
```
//創建一個異步的服務端通道
asynchronousServerSocketChannel=AsynchronousServerSocketChannel.open();
//監聽端口
asynchronousServerSocketChannel.bind(new InetSocketAddress(port));
//接受請求時,只需要注冊一個接受到請求后的處理器即可【CompletionHandler】
asynchronousServerSocketChannel.accept(this, new AcceptCompletionHandler());
//AcceptCompletionHandler 實現了CompletionHandler接口,對accept完成后事件進行處理,accept后需要讀取客戶端請求,并在讀完成后調用回掉函數
attachment.asynchronousServerSocketChannel.accept(attachment,this);
//分配一個緩沖區,讀取輸入,交給ReadCompletionHandler處理【CompletionHandler】
ByteBuffer buffer=ByteBuffer.allocate(1024);
result.read(buffer,buffer,new ReadCompletionHandler(result));
//ReadCompletionHandler在讀完后,向客戶發出寫請求,也是異步的。
```