JavaIO演進之路
IO基礎入門
Linux 網(wǎng)絡IO模型簡介
linux內核把所有的外部設備都看做一個文件箭券,對一個文件的讀寫會調用內核提供的系統(tǒng)命令,返回一個file descripter(fd九妈,文件描述符)泰演,對一個Socket的讀寫也會有相應的描述符飒房,稱為sockerfd(socket描述符)
根據(jù)UNIX對IO模型的分類搁凸,UNIX提供了5種I/O模型,分別如下:
阻塞IO模型狠毯,最常用的IO模型就是阻塞IO模型坪仇,缺省情況下所有的文件操作都是阻塞的,系統(tǒng)調用直到數(shù)據(jù)包到達且被復制到應用進程的緩沖區(qū)中或者發(fā)生錯誤時才會返回垃你,在此期間會一直等待
非阻塞IO模型椅文,如果緩沖區(qū)沒有數(shù)據(jù)就直接返回一個錯誤喂很,一般都對非阻塞IO輪訓這個狀態(tài),看是不是有數(shù)據(jù)到來
I/O復用模型:Linux提供select/poll皆刺,進程通過一個或多個fd傳遞給select/poll調用少辣,阻塞在select狀態(tài)上,這樣select/poll就可以幫我們偵測多個fd是否處于就緒狀態(tài)羡蛾。select/poll是輪詢掃描fd是否就緒漓帅。linux還提供一個epoll系統(tǒng)調用,epoll使用事件驅動的方式代替掃描痴怨,因此效率更高忙干。當有fd就緒就立刻回調callback
信號驅動I/O,首先開啟套接口信號驅動I/O功能浪藻,并通過系統(tǒng)調用sigaction執(zhí)行一個信號處理函數(shù) (此系統(tǒng)調用立即返回捐迫,進程立即工作,它是非阻塞的)爱葵。當數(shù)據(jù)準備就緒時施戴,就為該進程準備一個SIGIO信號内舟,回調應用程序讀取數(shù)據(jù)并且通知主循環(huán)處理數(shù)據(jù)
異步I/O:告知內核啟動某個操作和悦,并讓內核在整個操作完成后通知我們
I/O多路復用技術
在I/O編程過程中需要處理多個客戶端接入請求,可以利用多線程或者I/O多路復用技術來處理郎楼,I/O多路復用通過把多個I/O的阻塞復用到同一個select的阻塞上辆雾,進而使系統(tǒng)在單線程的情況下可以處理多個客戶端請求肪笋,與傳統(tǒng)的線程或者多線程相比,I/O多路復用最大的優(yōu)勢使系統(tǒng)開銷小度迂,系統(tǒng)不需要創(chuàng)建新的額外進程或者線程藤乙,也不需要維護這些進程或者線程的運行,降低了系統(tǒng)的維護工作量英岭,節(jié)省了系統(tǒng)資源,I/O多路復用的主要應用場景:
- 服務器需要同時處理多個處于監(jiān)聽狀態(tài)或者多個連接狀態(tài)的套接字
- 服務器需要同時處理多種網(wǎng)絡協(xié)議的套接字
目前支持I/O多路復用的系統(tǒng)調用有select湿右、pselect诅妹、poll、epoll毅人,在Linux網(wǎng)絡編程過程中吭狡,很長一段時間都使用select做輪詢和網(wǎng)絡事件通知,然而select的一些固有缺陷導致了他的應用受到了很大的限制丈莺,最終Linux不得不在新的內核版本中尋找select的替代方案划煮,最終選擇了epoll。epoll和select的原理比較類似缔俄,為了克服select的缺點弛秋,epoll做出了很多重大改進器躏,現(xiàn)總結如下:
- 支持一個進程打開的socket描述符fd不受限制,僅受限于操作系統(tǒng)的最大文件句柄數(shù)
select最大的缺陷就是單個進程打開的fd是有一定限制的蟹略,它由fd——setsize設置登失,默認是1024,對于那些需喲啊支持上網(wǎng)額TCP連接的大型服務器顯然太少了挖炬,可以選擇修改這個宏然后重新編譯內核揽浙,不過這會帶來網(wǎng)絡效率的下降。我們也可以選擇多進程的方案(傳統(tǒng)的apache)來解決這個問題意敛,不過雖然在linux上創(chuàng)建進程的代價比較小馅巷,但是也是不可忽視的。另外草姻,進程之間的數(shù)據(jù)交換非常麻煩钓猬,對于Java來說,由于沒有共享內存碴倾,需要通過Socket通信或者其他方式進行數(shù)據(jù)同步逗噩,這帶來了額外的性能損耗,增加了程序復雜度跌榔,所以也不是一種完美的解決方案异雁,值得慶幸的是,epoll并沒有這個限制僧须,它所支持的FD上限是操作系統(tǒng)的最大文件句柄數(shù)纲刀,這個數(shù)字遠遠大于1024.例如,在1G 內存的機器上大約是10萬個句柄左右担平,具體的值可以通過cat/proc/sys/fs/file-max查看示绊,通常情況下跟內存的關系較大
I/O效率不會隨著fd數(shù)目的增加而線性下降
傳統(tǒng)select/poll的另一個致命缺點就是當你有一個很大的socket集合時,由于網(wǎng)絡延遲或者鏈路空閑暂论,任意時刻只有少部分的socker是活躍的面褐,但是select/poll每次調用都會線性掃描全部的集合,導致效率呈現(xiàn)線性下降取胎,epoll不存在這個問題展哭,它只會對活躍的socket進行操作,這是因為在內核實現(xiàn)中epoll是根據(jù)每個fd上面的callback函數(shù)實現(xiàn)的闻蛀。那么匪傍,只有活躍的socket才會去主動調用callback函數(shù),其他idle狀態(tài)的socket則不會觉痛。在這一點上役衡,epoll實現(xiàn)了一個偽AIO。針對epoll和select的benchmark測試表明薪棒,如果所有的socket都處于活躍態(tài)--李儒一個告訴LAN環(huán)境手蝎,epoll并不比select效率高很多榕莺,相反如果過多使用epoll_ctl,效率還有稍微降低柑船,但是一點使用idle connection模擬WAN環(huán)境帽撑,epoll的效率救援在select/poll之上了使用mapp加速內核和用戶空間之間的消息傳遞
epoll的API更簡單
包括創(chuàng)建一個epoll描述符,添加監(jiān)聽事件鞍时,阻塞等待的監(jiān)聽事件的發(fā)生亏拉、關閉epoll描述符等等
epoll是Linux系統(tǒng)下用來克服select/poll缺點的方法
JavaNIO歷史
Java1.4 NIO1.0 Java1.7NIO2.0
1.0的主要問題是:
- 沒有統(tǒng)一的文件屬性
- API能力比較弱不能實現(xiàn)目錄的級聯(lián)創(chuàng)建和遞歸遍歷
- 底層存儲的一些高級API不能使用
- 所有文件的操作都是同步阻塞調用,不支持異步文件讀寫
NIO入門
傳統(tǒng)的BIO編程
通常有一個獨立的Accepter線程負責監(jiān)聽客戶端的連接逆巍,它接受到客戶端連接之后偉每一個客戶端創(chuàng)建一個新的線程來進行鏈路處理及塘。處理完成后通過輸出六返回應答給客戶端
問題就是缺乏彈性伸縮的能力,當客戶端并發(fā)訪問量增加之后锐极,服務端的線程個數(shù)喝客戶端并發(fā)數(shù)成一比一的正比關系笙僚,線程數(shù)膨脹后,性能將急劇下降
public class TimeServerHandler implements Runnable{
private Socket socket;
public TimeServerHandler(Socket socket){
this.socket = socket;
}
@Override
public void run(){
BufferdReader in = null;
PrintWriter out = null;
try{
in = new BufferedReader(new InputStreamReader(this.socket.getInputStream());
out = new PrintWriter(this.socket.getOutputStream, true);
String currentTime = null;
String body = null;
while(true){
body = in.readLine();
if(body == null) break;
System.out.println("The time server receive order : " + body);
currentTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ? new Date()
}
}catch (Exception e){
if(in != null){
try{
in.close();
}catch(IOException e1){
e1.printStackTrace();
}
}
if(out != null){
out.close();
out = null;
}
if(this.socket != null){
try{
this.socket.close();
}catch(IOException e1){
e1.printStackTrace();
}
this.socket = null;
}
}
}
}
NIO類庫介紹
NIO彌補了原來同步阻塞I/O的不足灵再,他在標準Java代碼中提供了高速的肋层,面向塊的I/O。通過定義包含數(shù)據(jù)的類翎迁,以及通過以塊的形式處理這些數(shù)據(jù)栋猖,NIO不喲就那個使用本機代碼就可以利用第幾優(yōu)化,這是原來的I/O包無法做到的汪榔。
緩沖區(qū)Buffer
Buffer是一個對象蒲拉,它包含一些要寫入或者要讀出的數(shù)據(jù)。在NIO類庫中痴腌,所有數(shù)據(jù)都使用緩沖區(qū)來處理的雌团。在讀取數(shù)據(jù)時,它是直接讀到緩沖區(qū)中的士聪,在寫入數(shù)據(jù)時锦援,寫入到緩沖區(qū)中。任何時候訪問NIO中的數(shù)據(jù)剥悟,都是用過緩沖區(qū)進行操作灵寺。
緩沖區(qū)實質上是一個數(shù)組。通常他是一個字節(jié)數(shù)組ByteBuffer懦胞,也可以使用其他種類的數(shù)組替久。但是一個緩沖區(qū)不僅僅是一個數(shù)組凉泄,緩沖區(qū)提供了對數(shù)據(jù)的結構化訪問以及維護讀寫位置等信息躏尉。
最常用的緩沖區(qū)是ByteBuffer,除了ByteBuffer還有其他的一些緩沖區(qū)后众。實際上胀糜,每一種Java基本類型(除了Boolean)都對應一種緩沖區(qū):
ByteBuffer颅拦、CharBuffer、IntBuffer...
每一個Buffer類都是Buffer接口的一個子實例教藻。除了ByteBuffer距帅,每一個Buffer類都有完全一樣的操作,大部分標準I/O操作都使用ByteBuffer括堤,所以他在具有一般緩沖區(qū)的操作之外還提供了一些特有的操作碌秸,以方便網(wǎng)絡讀寫。
通道Channel
Channel是一個通道悄窃,它就像自來水管一樣讥电,網(wǎng)絡數(shù)據(jù)通過Channel讀取和寫入。通道與流的不同之處在于通道是雙向的轧抗,流只是在一個方向上流動(一個流必須是InputSteram或者OutputStream的子類)而通道可以用于讀寫恩敌、或者二者同時進行
因為Channel是全雙工的,所以它可以比流更好的映射底層操作系統(tǒng)的API横媚。特別是在UNIX網(wǎng)絡編程模型中纠炮,底層系統(tǒng)的通道都是全雙工的,同時支持讀寫操作灯蝴。
Channel可以分為兩大類:用于網(wǎng)絡讀寫的SelectableChannel和用于文件操作的FileChannel
本書涉及的ServerSocketChannel 和 SocketChannel的子類
多路復用 Selector
多路復用器Select是NIO編程的基礎恢口。多路復用器提供選擇已經(jīng)就緒任務的能力。簡單來講绽乔,selector會不斷輪詢注冊在其上的Channel弧蝇,如果某個Channel上面發(fā)生讀或者寫時間,這個Channel就處于就緒狀態(tài)折砸,會被Selector輪詢出來看疗,然后通過SelectionKey可以獲取就緒Channel的集合,進行后續(xù)的I/O操作睦授。
一個多路復用器可以同時輪詢多個Channel两芳,由于JDK使用了epoll()代替?zhèn)鹘y(tǒng)的select實現(xiàn),所以他沒有最大連接數(shù)的限制去枷,這也就意味著只需要一個線程負責Selector的輪詢怖辆,就可以接入成千上萬的客戶端,這確實是個非常大的進步删顶。
NIO服務端步驟
- 打開ServerSocketChannel竖螃,用于監(jiān)聽客戶端連接,是所有客戶端連接的父管道
ServerSocketChannel acceptorSvr = ServerSocketChannel.open();
- 綁定過監(jiān)聽端口逗余,設置連接為非阻塞模式
acceptorSvr.socket().bind(new InetSocketAddress(InetAddress.getByName("IP"), port));
acceptorSvr.configureBlocking(false);
- 創(chuàng)建Reactor線程特咆,創(chuàng)建多路復用器并且啟動線程
Selector selector = Selector.open();
New Thread(new ReactorTask()).start());
- 將ServerSocketChannel注冊到Reactor線程的多路復用器Selector上,監(jiān)聽ACCEPT事件:
SelectionKey key = accptorSvr.register( selector, SelectionKey.OP_ACCEPT, iohandler);
- 多路復用器在線程run方法的無限循環(huán)體內輪詢準備就緒的key:
int num = selector.select();
Set sekectedKeys = selector.selectedKeys();
Iterator it = selectedKeys.iterator();
while(it.hasNext()){
SelectionKey key = (SekectionKey)it.next();
//...deal with I/O event...
}
- 多路復用器監(jiān)聽到有新的客戶端接入录粱,處理新的接入請求腻格,完成TCP三次握手画拾,建立物理鏈路
SocketChannel channel = svrChannel.accept();
- 設置客戶端鏈路為非阻塞模式
channel.configureBlocking(false);
channel.socket().setReuseAddress(true);
...
- 將新接入的客戶端連接注冊到reactor線程的多路復用器上,監(jiān)聽讀操作菜职,讀取客戶端發(fā)送的網(wǎng)絡消息
SelectionKey key = socketChannel.register( selector, SelectionKey.OP_READ, ioHandler);
- 異步讀取客戶端請求消息到緩沖區(qū)
int readNumber = channel.read(reaceivedBuffer);
- 對ByteBuffer進行編解碼青抛,如果有半包消息指針reset,繼續(xù)讀取后續(xù)的報文酬核,將解碼成功的消息封裝成Task蜜另,投遞到業(yè)務線程池中,進行業(yè)務邏輯編排
Object message = null;
while(buffer.hashRemain()){
byteBuffer.mark();
Object message = decode(byteBuffer);
if(message == null){
byteBuffer.reset();
break;
}
messageList.add(message);
}
if(!byteBuffer.hasRemain()){
byteBuffer.clear();
}else{
byteBuffer.compact();
}
if(messageList != null & !messageList.isEmpty()){
for(Object messageE : messageList){
handlerTask(messageE);
}
}
- 將POJP對象encode成ByteBuffer嫡意,調用SocketChannel的異步write接口蚕钦,將消息異步發(fā)送給客戶端
socketChannel.write(buffer);
簡單描述NIO過程:
- 把ServerSocketChannel注冊到選擇器中,綁定accept事件鹅很。當觸發(fā)accept事件時嘶居,就可以獲取到SocketChannel
- 接收請求參數(shù)的多路復用是吧SocketChannel注冊到選擇器中,綁定read事件促煮,當選擇器檢測到相應的通道準備好進行讀取邮屁,就觸發(fā)read事件,通過相應的SocketChannel讀取數(shù)據(jù)然后處理數(shù)據(jù)
- 處理完成后菠齿,再把SocketChannel綁定到一個新的選擇器中佑吝,綁定wirte事件,等可以寫時绳匀,就把數(shù)據(jù)發(fā)送出去芋忿。