在2018年十月份的十多次面試中晓猛,幾乎每一場面試都會nio,可見nio的重要性凡辱。每當面試官問到nio的時候戒职,我都會從操作系統(tǒng)層面的IO多路復用說起,只要說的明白透乾,一般這一關(guān)就算過了洪燥。
首先要搞清楚nio中n的含義。如果告訴別人n的意思是new续徽,那就給人留下很不好的印象了蚓曼,這里的n一般是被理解為non-blocking亲澡。傳統(tǒng)的InputStream/OutputStream體系的讀寫操作都是阻塞的钦扭,為什么它們是阻塞的?這是因為當調(diào)用讀寫操作時床绪,上層應用程序并不知道底層的socket緩沖區(qū)是否是就緒的客情,如果正好是就緒的,那么不會阻塞癞己;如果不是就緒的膀斋,就會阻塞直到socket緩沖區(qū)就緒。一般情況下痹雅,socket寫緩沖區(qū)大部分時間是就緒的仰担,而讀緩沖區(qū)由于沒有數(shù)據(jù)到來而處于等待狀態(tài)。
所以绩社,阻塞和非阻塞的區(qū)別在于摔蓝,阻塞IO在進行IO操作時可能需要阻塞等待,而非阻塞IO在進行IO操作時不需要等待愉耙。因為后者在進行IO操作時通過一定的機制得知底層socket已經(jīng)準備就緒了贮尉,至于機制,別急朴沿,各位看官往下接著看猜谚。
這里談到了阻塞非阻塞的概念,在IO中還有一個容易混淆的概念是同步和異步赌渣。上面說了傳統(tǒng)InputStream/OutputStream體系是阻塞的魏铅,而nio是非阻塞的。注意這里并沒有說nio是異步的坚芜,因為它本身就不支持異步览芳。nio還需要再經(jīng)過一層封裝才能實現(xiàn)異步的功能,java中netty货岭、nima都是封裝nio實現(xiàn)異步的框架路操。
那么什么是異步疾渴,或者說同步和異步有什么區(qū)別呢?同步是指A對B發(fā)生調(diào)用時屯仗,直到B完成了操作才返回結(jié)果給A搞坝;而異步是指A對B發(fā)生調(diào)用時,B會馬上返回一個狀態(tài)給A魁袜,當B操作完成時桩撮,通過某種方式來通知A。寫過前端js代碼的同學肯定能對異步有比較好的理解峰弹,在前端js代碼中經(jīng)常會設(shè)置異步回調(diào)函數(shù)店量。這里的異步回調(diào)函數(shù)就是當前面的操作完成時能夠得到通知,進而做后續(xù)的處理鞠呈。所以融师,說到異步,總是離不開另一個詞匯:回調(diào)蚁吝。
言歸正傳旱爆,說回nio。nio中窘茁,面向開發(fā)人員的組件主要有三個:ByteBuffer怀伦、Channel、Selector山林。ok房待,先上一段nio的demo代碼。
server端:
public class NioServerTest {
public static void main(String[] args) throws Exception {
Selector selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
serverSocketChannel.socket().bind(new InetSocketAddress("127.0.0.1", 3000));
while (true) {
int num = selector.select();
if (num > 0) {
Set<SelectionKey> set = selector.selectedKeys();
Iterator<SelectionKey> it = set.iterator();
while (it.hasNext()) {
SelectionKey sk = it.next();
if (sk.isAcceptable()) {
System.out.println("sk.isAcceptable()");
SocketChannel sc = serverSocketChannel.accept();
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
} else if (sk.isReadable()) {
System.out.println("sk.isReadable()");
SocketChannel sc = (SocketChannel) sk.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(3);
sc.read(byteBuffer);
String s = new String(byteBuffer.array()).trim();
System.out.println(s);
sc.write(ByteBuffer.wrap(s.getBytes()));
} else if (sk.isWritable()) {
System.out.println("sk.isWritable()");
} else if (sk.isConnectable()) {
System.out.println("sk.isConnectable()");
}
it.remove();
}
}
}
}
}
client端:
public class NioClientTest {
public static void main(String[] args) throws Exception {
Selector selector = Selector.open();
SocketChannel sc = SocketChannel.open(new InetSocketAddress("127.0.0.1", 3000));
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ | SelectionKey.OP_CONNECT);
while (true) {
int num = selector.select();
if (num > 0) {
Set<SelectionKey> set = selector.selectedKeys();
Iterator<SelectionKey> it = set.iterator();
while (it.hasNext()) {
SelectionKey sk = it.next();
if (sk.isAcceptable()) {
System.out.println("sk.isAcceptable()");
} else if (sk.isReadable()) {
System.out.println("sk.isReadable()");
SocketChannel sc2 = (SocketChannel) sk.channel();
ByteBuffer bb = ByteBuffer.allocate(256);
sc2.read(bb);
System.out.println(new String(bb.array()).trim());
} else if (sk.isWritable()) {
System.out.println("sk.isWritable()");
} else if (sk.isConnectable()) {
System.out.println("sk.isConnectable()");
}
it.remove();
}
}
sc.write(ByteBuffer.wrap(("hello world" + (int)(Math.random()*1000)).getBytes()));
Thread.sleep(1000L);
}
}
}
下面驼抹,我們來詳細分析nio的ByteBuffer桑孩、Channel、Selector三個組件砂蔽。
ByteBuffer
ByteBuffer可以理解為操作一段內(nèi)存的工具類洼怔。Channel的數(shù)據(jù)讀寫都是通過ByteBuffer,而不像傳統(tǒng)IO那樣直接操作流左驾。這也是nio和bio很重要的一個不同點镣隶,nio的讀寫是面向緩沖的,bio的讀寫則是面向流的诡右。
其內(nèi)部維護了一個字節(jié)數(shù)組和3個位置相關(guān)的變量:
final byte[] hb;
private int position =0;
private int limit;
private int capacity;
其使用步驟:調(diào)用put()寫入數(shù)據(jù)安岂,當需要讀數(shù)據(jù)時,先flip()切換為讀模式帆吻,然后就可以get()了域那。從position到limit表示數(shù)組中可以操作(讀/寫)的部分,當limit達到capacity時,就不能讀/寫了次员。如:初始狀態(tài)limit==capacity败许,當put()時,position不斷增加淑蔚,直到limit市殷。當flip()時,position變?yōu)?刹衫,limit變?yōu)橹皃osition的位置醋寝,就變成讀模式了,從position的位置開始讀带迟。ByteBuffer還提供了mark/reset來實現(xiàn)做標記音羞,以便重復讀。
ByteBuffer有多個實現(xiàn)類仓犬,用來實現(xiàn)便捷地操作不同基本類型的數(shù)據(jù)嗅绰。另外,ByteBuffer根據(jù)操作的內(nèi)存不同婶肩,可分為HeapByteBuffer和DirectByteBuffer办陷。前者是在jvm的堆中分配一個數(shù)組,后者則是通過Unsafe.allocateMemory()來申請一塊堆外內(nèi)存律歼,并保存內(nèi)存地址,所有的操作都是通過計算內(nèi)存地址然后做讀寫操作啡专。ByteBuffer提供了便捷方法:wrap()险毁、array(),前者是將字符串封裝成ByteBuffer们童,后者是講ByteBuffer導成字符串畔况。后面會單獨寫文章詳細介紹堆外內(nèi)存的相關(guān)知識。
Channel
nio中Channel可以理解為是對Socket的封裝慧库,read跷跪、write、connect齐板、bind都是非阻塞的吵瞻,能夠?qū)崿F(xiàn)非阻塞主要是依賴不同操作系統(tǒng)支持的IO多路復用技術(shù),如window下的IOCP甘磨、Linux下的Epoll橡羞、Mac下的KQueue。針對不同的操作系統(tǒng)济舆,jvm做了不同的封裝卿泽,在Mac下KQueue的操作是封裝為KQueueSelectorImpl以及KQueueArrayWrapper。
Channel的實現(xiàn)類有四個:FileChannel滋觉、SocketChannel签夭、ServerSocketChannel齐邦、DatagramChannel。在nio編程中常用的是SocketChannel第租、ServerSocketChannel侄旬,下面簡單說下這兩個。
SocketChannel負責客戶端連接的讀寫煌妈。其讀寫操作都是通過ByteBuffer儡羔,寫的時候先將數(shù)據(jù)寫到ByteBuffer,再由ByteBuffer傳遞給內(nèi)核璧诵,內(nèi)核傳遞給網(wǎng)卡汰蜘,最終由網(wǎng)卡發(fā)送出去。讀則是先將socket緩沖區(qū)的數(shù)據(jù)經(jīng)過內(nèi)核放到ByteBuffer中之宿,再由SocketChannel從ByteBuffer中讀到數(shù)據(jù)族操。當Selector監(jiān)聽到讀就緒時調(diào)用read,讀已經(jīng)是就緒的比被,可以一次讀完并返回色难,如果沒有讀完會繼續(xù)觸發(fā)讀就緒事件;寫操作道理類似等缀。
ServerSocketChannel負責服務(wù)端監(jiān)聽端口枷莉、接收客戶端連接。它可以理解為封裝了ServerSocket尺迂,它沒有讀寫方法笤妙,只有bind()、accept()方法噪裕。bind()是封裝了socket的bind蹲盘、listen操作,bind做的事情是將socket和地址/端口綁定膳音,listen的作用是監(jiān)聽端口召衔,并提供參數(shù)設(shè)置全連接隊列大小,默認50祭陷,全連接隊列和三次握手的知識有關(guān)苍凛,有興趣的可以自己去查查資料;accept()是去讀全連接隊列颗胡,如果沒有socket連接到來就會阻塞毫深,如果有則返回第一個。nio中通常使用Selector去監(jiān)聽是否有客戶端連接到來毒姨,如果有連接就緒事件哑蔫,應用程序就可以去accept(),這樣就避免了accept時的阻塞。
SocketChannel和ServerSocketChannel都提供了open()來快速創(chuàng)建其實現(xiàn)類的實例闸迷。
Selector
Selector能夠監(jiān)聽多個Channel的多種IO事件嵌纲。只要將channel注冊到Selector上,那么當channel有IO就緒事件到來時腥沽,Selector.select()就會返回就緒Channel的數(shù)量逮走,接下來就可以通過selectedKey()拿到所有就緒的Channel,進而處理Channel今阳。IO就緒事件分為4類:讀就緒(OP_READ)师溅、寫就緒(OP_WRITE)、連接就緒(OP_CONNECT)盾舌、接收就緒(OP_ACCEPT)墓臭。
在mac os上,是用KQueueSelectorImpl作為Selector的實現(xiàn)類妖谴,它利用操作系統(tǒng)提供的KQueue模型窿锉,Selector.select()最終其實是調(diào)用KQueueArrayWrapper的poll(),后者返回就緒數(shù)量膝舅,并且會把就緒事件對應的socket的文件描述符放到一個指定的隊列中嗡载,調(diào)用getDiscriptor(int i)可以得到隊列中的某個元素,然后再根據(jù)返回的FD去找到對應的Channel(對應關(guān)系在Selector中維護)仍稀。
Selector重要的方法:wakeup洼滚、select、register
- wakeup():是通過操作系統(tǒng)的管道實現(xiàn)的琳轿。當創(chuàng)建一個Selector時判沟,會同時創(chuàng)建一個管道,管道分為兩頭崭篡,一頭是讀的,一頭是寫的吧秕,將讀的這頭的socket信息注冊到Selector中琉闪,當需要wakeup時,就往寫的那頭隨便寫一個字節(jié)就好了砸彬。
- register():颠毙,新建一個SelectionKeyImpl,然后向KQueueArrayWrapper的updateList添加一個元素砂碉,其中封裝了channel和interestOps蛀蜜,當selector.select()時會一一取出updatelist中的Update,注冊到kqueue事件增蹭,然后調(diào)用kevent0()去監(jiān)聽是否有就緒事件滴某。
- select():,會去調(diào)用KQueueArrayWrapper的poll(),其中會將updateList中的Update一一注冊kqueue事件霎奢,然后去調(diào)用kevent0()阻塞監(jiān)聽就緒事件户誓。
要理解Selector的工作原理,光看代碼是不夠的幕侠,因為其中相關(guān)的類涉及到很多的本地方法調(diào)用帝美,而大部分的人沒有勇氣也沒有能力或者說精力去看JVM源碼。要理解Selector的工作原理晤硕,其核心是要理解上面提到的IO多路復用模型悼潭。當明白了IO多路復用是怎么回事之后,猜也能猜到Selector的工作原理了舞箍。關(guān)于IO多路復用舰褪,網(wǎng)上有很多的資料,下面我們簡單說下创译。
以Linux下的epoll為例抵知,我們說說IO多路復用。
epoll模型中软族,有三個核心的系統(tǒng)調(diào)用:epoll_create刷喜、epoll_ctrl、epoll_wait
- epoll_create:系統(tǒng)啟動時分配內(nèi)存立砸,構(gòu)造一棵紅黑樹掖疮。
- epoll_ctrl:將一個socket信息和感興趣的事件注冊到紅黑樹中,并綁定一個回調(diào)函數(shù)颗祝,當內(nèi)核收到該socket的相關(guān)IO事件就緒時浊闪,將socket信息寫到一個列表中。
- epoll_wait:讀上面說到的那個列表
以上關(guān)于IO多路復用模型的描述忽略了很多東西螺戳,因為它不是本文要說的重點搁宾,而是結(jié)合nio說說nio如何通過IO多路復用模型來實現(xiàn)非阻塞的io調(diào)用。當調(diào)用register()時倔幼,其實就是將socket信息和感興趣的事件注冊到內(nèi)核盖腿;selector.select()時阻塞去讀上面說的那個列表,當列表有數(shù)據(jù)時损同,將讀到的socket信息保存起來翩腐;read、write膏燃、accept都是selector.select()之后才會去執(zhí)行的操作茂卦,因為此時socket緩存已經(jīng)處于就緒狀態(tài)了,自然不會阻塞了组哩。