NIO原理及例項

Allen烽發表於2018-12-25

知識點:

  1. 阻塞的概念,同步非同步的區別
  2. Bio 及多路複用
  3. NIO概要
  4. NIO之Buffer(緩衝區)
  5. NIO之Channel(通路)
  6. NIO之Selector(選擇器)
  7. NIO之Reactor(反應堆)
  8. 基於NIO的聊天室例項

學習NIO我們先了解前置概念:
1)阻塞和非阻塞
阻塞和非阻塞是程式在訪問資料的時候,資料是否準備就緒的一種處理方式,當資料沒有準備的時候
阻塞:往往需要等待緩衝區中的資料準備好過後才處理其他事情,否則就一直等待。 非阻塞:當我們的程式範文我們的資料緩衝區的

2)同步 非同步區別
基於應用程式和作業系統處理IO事件採取的方式來區分:
非同步:同一時刻可以處理多個io讀寫,應用程式等待作業系統通知
同步:同一時間只能處理一條io讀寫,應用程式直接參與io讀寫

我們接著看下圖

NIO原理及例項
簡單的說,必須等待資料接受完畢之後才能處理,否則一直阻塞,形象地說就好比一個人去買奶茶,但是奶茶店前排了很多人的隊,你就在隊伍後面排隊等待,期間你啥都做不了,這就是bio。 然後我們看下nio的多路複用

NIO原理及例項
多路複用要跟bio進行對比才能理解,首先bio(同步阻塞),使用者控制元件應用執行一個系統呼叫,會一直阻塞,知道系統呼叫完成為止。 就以讀寫為例,首先我們發起呼叫read發起io讀的操作,由使用者空間轉到核心空間,核心等待資料包到達,然後把接受到的資料複製到使用者空間,完成read。在等待讀動作把socket中的資料讀取到buffer後,才能接受資料,期間是一直阻塞的。【之後會有關於netty的博文,那邊會講到netty的零拷貝】

NIO原理及例項
怕大家不理解,另外找了一張圖,io多路複用相當於通過多個io的阻塞複用到了同一個select的阻塞上,從而使單執行緒情況下,可以處理多個客戶端請求。與傳統的多執行緒/多程式的模型比起來。多路複用最大的優勢就是系統開銷小,系統不需要建立額外的程式或者執行緒,也不需要維護這些程式和執行緒的執行,降低了系統的維護量,節省系統開銷。 理解多路複用就要理解select函式:此函式允許程式指示核心等待多個事件的任何一個傳送,只有一個或者多個事件發生或者經歷一段指定時間才喚醒。相當於我們把多個socket都註冊到select上,任何一個socket的資料準備好,select就返回,此時使用者程式再呼叫read,把資料拷貝到使用者程式。這個過程是不斷輪詢的,只要監聽到某個檔案控制程式碼被啟用(可讀/可寫),select就返回。所以它能夠在一個通路中放置多個io,實現了多路複用。

我們正式開始學習NIO

一 JAVA NIO之概念

Java NIO 是 java 1.4, 之後新出的一套IO介面NIO中的N可以理解為Non-blocking,有些人會認為是new,其實也沒錯。 BIO(Block IO)和Nio(Non-Block IO)的對比

NIO原理及例項

Nio主要用到的是塊,所以nio效率比io高。
JavaAPI中有倆套nio:
1)針對標準輸入輸出nio
2)網路程式設計nio
Io以流的形式處理資料,nio以塊的形式處理資料。面向流的io一次處理一個位元組,一個輸入流產生了一個位元組,一個輸出流就消費一個位元組。
面向塊的io,每個操作都在一步中產生或者消費一個資料塊。
它讀取資料方式和寫資料的方式否必須要通過通道來操作緩衝區實現。
核心元件包括 Channels Buffers Selectors

二 Java NIO之Buffer(緩衝區)

1) Buffer介紹: 緩衝區,本質就是一個陣列,但是它是特殊的陣列,緩衝區物件內建了一些機制,能夠追蹤和記錄緩衝區的狀態變化情況,如果我們使用get方法從緩衝區中獲取資料或者用put方法吧資料寫入緩衝區,都會引起緩衝區的狀態變化
在緩衝區中,最重要的屬性是如下三個,他們一起合作完成了對緩衝區內容狀態的變化跟蹤
1)position:指定了下一個將要被寫入或者讀取的元素索引,它的值由get()/put() 方法自動更新,在新建立一個Buffer物件時,position被初始化為0
2)limit:操作緩衝區的可操作空間和可操作範圍,指定還有多少資料需要去除,或者還有多少空間可以放入資料
3)capacity:指定了可以儲存在緩衝區中的最大資料容量,實際上,它指定了底層陣列的大小,或者至少是指定了准許我們使用的底層陣列的容量。

以上三個屬性值之間有一些相對的大小的關係:0<=position<=limit<=capacity
如果我們建立了一個新的容量為10的bytebuffer物件,在初始化的時候。position設定為0,limit和capacity被設定為10,在以後使用bytebuffer 物件過程中,capacity的值不會再發生變化,而其他倆個值會順著使用而變化 如下圖:

NIO原理及例項

現在我們可以從通道中讀取一些資料到緩衝區,注意從通道讀取資料,相當於往緩衝區中寫入資料。如果讀取四個自己的資料,則此時的position為4,即下一個將要被寫入的位元組索引為4,而limit依舊是10

NIO原理及例項
下一步把讀取的資料寫入到輸出通道,相當於從緩衝區讀取資料,在此之前,必須呼叫flip()方法,該方法將完成倆件事: 1)把limit設定成position值
2)把position值設定為0
【flip】 需要將緩衝區資料取出來解析,固定住

NIO原理及例項
取出之後呼叫clear方法 迴歸到最初的狀態。

package com.Allen.buffer;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class testBufferDemo01 {
	public static void main(String[] args) throws IOException {
		String fileURL="F://a.txt";
		FileInputStream fis=new FileInputStream(fileURL);
		//獲取通路
		FileChannel channel=fis.getChannel();
		//定義緩衝區大小
		ByteBuffer buffer=ByteBuffer.allocate(10);
		output("init", buffer);
		//先讀
		channel.read(buffer);
		output("read", buffer);
		buffer.flip();
		output("flip", buffer);		
		while (buffer.hasRemaining()) {
			byte b=buffer.get();
		}
		output("get", buffer);
		buffer.clear();
		output("clear", buffer);
		fis.close();
	}
	
	public static void output(String string,ByteBuffer buffer){
		System.out.println(string);
		System.out.println(buffer.capacity()+":"+buffer.position()+":"+buffer.limit());
	}
}
複製程式碼

結果

NIO原理及例項

三 Java NIO之Channel(通路)

通道是個物件,通過它可以讀取和寫入資料,所有的資料都是通過buffer物件來處理。我們永遠不會把位元組直接寫入通道,相反是吧資料寫入包含一個或者多個位元組的緩衝區。同樣不會直接讀取位元組,而是把資料從通道讀入緩衝區,再從緩衝區獲取這個位元組,nio中提供了多種通道物件,而所有的通道物件都實現了channel介面。

使用nIo讀取資料】
任何時候讀取資料,都不是直接從通道中讀取,而是從通道讀取到緩衝區,所以使用NIO讀取資料可以分成下面三個步驟
1)從FileInputStream獲取Channel
2)建立Buffer
3)將資料從Channel 讀取到Buffer中
下面就是一個nio讀複製檔案的例項

package com.allen.test;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class testNio {
	public static void main(String[] args) throws IOException {
		String oldFileUrl="E://1.txt";
		String newFileUrl="E://2.txt";
		FileInputStream fis=new FileInputStream(oldFileUrl);
		FileChannel inChannel=fis.getChannel();
		ByteBuffer bf=ByteBuffer.allocate(1024);
		FileOutputStream fos=new FileOutputStream(newFileUrl);
		FileChannel outChannel=fos.getChannel();
		while(true){
			int eof=inChannel.read(bf);
			if(eof==-1){
				break;
			}else{
				bf.flip();
				outChannel.write(bf);
				bf.clear();
			}
		}
		inChannel.close();
		fis.close();
		outChannel.close();
		fos.close();	
	}
}
複製程式碼

四 JAVA NIO之Selector(選擇器)

Selector 一般稱 為選擇器 ,當然你也可以翻譯為 多路複用器 。它是Java NIO核心元件中的一個,用於檢查一個或多個NIO Channel(通道)的狀態是否處於可讀、可寫。如此可以實現單執行緒管理多個channels,也就是可以管理多個網路連結。 使用Selector的好處在於: 使用更少的執行緒來就可以來處理通道了, 相比使用多個執行緒,避免了執行緒上下文切換帶來的開銷。

有了selector,可以用一個執行緒處理所有的channel。執行緒之間的切換對作業系統來說,代建是很高的,並且每個執行緒也會佔用一定的系統資源,所以對於系統而言,執行緒越少越好(但是也不是絕對的,若cpu有多個核心,不使用多工是在浪費CPU能力)

Selector selector=Selector.open();
註冊channel到selector上
Channel.configureBlocking(false)
SelectionKey key=channel.register(selector,SelectionKey.OP_READ)

註冊到server上的channel必須設定成非同步模式,否則非同步io無法工作,這就意味著我們不可以把一個Filechannel註冊到selector,因為filechannel沒有非同步模式,但是socketchannel有非同步模式

Register方法的第二個引數,它是一個interst set ,意思是註冊的selector對channel中的那些事務感興趣。事件分成四種:read write connect accept,通道觸發一個時間指該事件已經Read,所有某個channel成功連線到另一個伺服器稱之為connect ready。一個serversocketchanel準備好接受新的連線稱為connect ready。一個資料可讀的通道可以說read ready。等待寫資料的通道write ready。
Wirte:SelectionKey.OP_WRITE
Read:SelectionKey.OP_READ
Accept:SelectionKey.OP_ACCEPT
Connect:SelectionKey.OP_CONNECT
若是對多個事件感情求,可以寫為(用or)
Int interest=SelectionKey.OP_READ|SelectionKey.OP_ACCEPT
SelectionKey表示通道在selector上這個註冊,通過SelectionKey可以得到selector和註冊的channel.selector感興趣的事。 一旦向selector註冊了一個或者多個通道,可以呼叫過載的select方法返回你所感興趣的事件已經準備就緒的通道。

五 JAVA NIO 之 Reactor(反應堆)

阻塞/IO通訊模型

NIO原理及例項
java 在上圖客戶端增多的情況下右邊的執行緒會出現不可控的情況。
引入了pool的概念,
所以Nio 是jdk1.4開始使用的,可以說是想新io,也可以說是非阻塞io
以下是nio工作原理:
1)由一個專門的執行緒去處理所有的io事件並且負責分發
2)事件驅動機制,時間到的時候觸發,而不是同步地去監聽事件
3)執行緒通訊,執行緒之間通過wait,notify等方式通訊,保證每次上下文切換都是有意義的,減少無畏的執行緒切換。

NIO原理及例項

六例項

伺服器

package com.allen.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.Channel;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;

/**
 * 網路多客戶端聊天室
 * 功能1: 客戶端通過Java NIO連線到服務端,支援多客戶端的連線
 * 功能2:客戶端初次連線時,服務端提示輸入暱稱,如果暱稱已經有人使用,提示重新輸入,如果暱稱唯一,則登入成功,之後傳送訊息都需要按照規定格式帶著暱稱傳送訊息
 * 功能3:客戶端登入後,傳送已經設定好的歡迎資訊和線上人數給客戶端,並且通知其他客戶端該客戶端上線
 * 功能4:伺服器收到已登入客戶端輸入內容,轉發至其他登入客戶端。
 * 
 * TODO 客戶端下線檢測
 */
public class NIOServer {

    private int port = 8080;
    private Charset charset = Charset.forName("UTF-8");
    //用來記錄線上人數,以及暱稱
    private static HashSet<String> users = new HashSet<String>();
    
    private static String USER_EXIST = "系統提示:該暱稱已經存在,請換一個暱稱";
    //相當於自定義協議格式,與客戶端協商好
    private static String USER_CONTENT_SPILIT = "#@#";
    
    private Selector selector = null;
    
    
    public NIOServer(int port) throws IOException{
		
		this.port = port;
		//要想富,先修路
		//先把通道開啟
		ServerSocketChannel server = ServerSocketChannel.open();
		
		//設定高速公路的關卡
		server.bind(new InetSocketAddress(this.port));
		server.configureBlocking(false);
		
		
		//開門迎客,排隊叫號大廳開始工作
		selector = Selector.open();
		
		//告訴服務叫號大廳的工作人員,你可以接待了(事件)
		server.register(selector, SelectionKey.OP_ACCEPT);
		
		System.out.println("服務已啟動,監聽埠是:" + this.port);
	}
    
    
    public void listener() throws IOException{
    	
    	//死迴圈,這裡不會阻塞
    	//CPU工作頻率可控了,是可控的固定值
    	while(true) {
    		
    		//在輪詢,我們服務大廳中,到底有多少個人正在排隊
            int wait = selector.select();
            if(wait == 0) continue; //如果沒有人排隊,進入下一次輪詢
            
            //取號,預設給他分配個號碼(排隊號碼)
            Set<SelectionKey> keys = selector.selectedKeys();  //可以通過這個方法,知道可用通道的集合
            Iterator<SelectionKey> iterator = keys.iterator();
            while(iterator.hasNext()) {
				SelectionKey key = (SelectionKey) iterator.next();
				//處理一個,號碼就要被消除,打發他走人(別在服務大廳佔著茅坑不拉屎了)
				//過號不候
				iterator.remove();
				//處理邏輯
				process(key);
            }
        }
		
	}
    
    
    public void process(SelectionKey key) throws IOException {
    	//判斷客戶端確定已經進入服務大廳並且已經可以實現互動了
        if(key.isAcceptable()){
        	ServerSocketChannel server = (ServerSocketChannel)key.channel();
            SocketChannel client = server.accept();
            //非阻塞模式
            client.configureBlocking(false);
            //註冊選擇器,並設定為讀取模式,收到一個連線請求,然後起一個SocketChannel,並註冊到selector上,之後這個連線的資料,就由這個SocketChannel處理
            client.register(selector, SelectionKey.OP_READ);
            
            //將此對應的channel設定為準備接受其他客戶端請求
            key.interestOps(SelectionKey.OP_ACCEPT);
//            System.out.println("有客戶端連線,IP地址為 :" + sc.getRemoteAddress());
            client.write(charset.encode("請輸入你的暱稱"));
        }
        //處理來自客戶端的資料讀取請求
        if(key.isReadable()){
            //返回該SelectionKey對應的 Channel,其中有資料需要讀取
            SocketChannel client = (SocketChannel)key.channel(); 
            
            //往緩衝區讀資料
            ByteBuffer buff = ByteBuffer.allocate(1024);
            StringBuilder content = new StringBuilder();
            try{
                while(client.read(buff) > 0)
                {
                    buff.flip();
                    content.append(charset.decode(buff));
                    
                }
//                System.out.println("從IP地址為:" + sc.getRemoteAddress() + "的獲取到訊息: " + content);
                //將此對應的channel設定為準備下一次接受資料
                key.interestOps(SelectionKey.OP_READ);
            }catch (IOException io){
            	key.cancel();
                if(key.channel() != null)
                {
                	key.channel().close();
                }
            }
            if(content.length() > 0) {
                String[] arrayContent = content.toString().split(USER_CONTENT_SPILIT);
                //註冊使用者
                if(arrayContent != null && arrayContent.length == 1) {
                    String nickName = arrayContent[0];
                    if(users.contains(nickName)) {
                    	client.write(charset.encode(USER_EXIST));
                    } else {
                        users.add(nickName);
                        int onlineCount = onlineCount();
                        String message = "歡迎 " + nickName + " 進入聊天室! 當前線上人數:" + onlineCount;
                        broadCast(null, message);
                    }
                } 
                //註冊完了,傳送訊息
                else if(arrayContent != null && arrayContent.length > 1) {
                    String nickName = arrayContent[0];
                    String message = content.substring(nickName.length() + USER_CONTENT_SPILIT.length());
                    message = nickName + "說 : " + message;
                    if(users.contains(nickName)) {
                        //不回發給傳送此內容的客戶端
                    	broadCast(client, message);
                    }
                }
            }
            
        }
    }
    
    //TODO 要是能檢測下線,就不用這麼統計了
    public int onlineCount() {
        int res = 0;
        for(SelectionKey key : selector.keys()){
            Channel target = key.channel();
            
            if(target instanceof SocketChannel){
                res++;
            }
        }
        return res;
    }
    
    
    public void broadCast(SocketChannel client, String content) throws IOException {
        //廣播資料到所有的SocketChannel中
        for(SelectionKey key : selector.keys()) {
            Channel targetchannel = key.channel();
            //如果client不為空,不回發給傳送此內容的客戶端
            if(targetchannel instanceof SocketChannel && targetchannel != client) {
                SocketChannel target = (SocketChannel)targetchannel;
                target.write(charset.encode(content));
            }
        }
    }
    
    
    public static void main(String[] args) throws IOException {
        new NIOServer(8080).listener();
    }
}
複製程式碼

客戶端

package com.allen.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.Iterator;
import java.util.Scanner;
import java.util.Set;

public class NIOClient {

	private final InetSocketAddress serverAdrress = new InetSocketAddress("localhost", 8080);
    private Selector selector = null;
    private SocketChannel client = null;
    
    private String nickName = "";
    private Charset charset = Charset.forName("UTF-8");
    private static String USER_EXIST = "系統提示:該暱稱已經存在,請換一個暱稱";
    private static String USER_CONTENT_SPILIT = "#@#";
    
    
    public NIOClient() throws IOException{
    	
    	//不管三七二十一,先把路修好,把關卡開放
        //連線遠端主機的IP和埠
        client = SocketChannel.open(serverAdrress);
        client.configureBlocking(false);
        
        //開門接客
        selector = Selector.open();
        client.register(selector, SelectionKey.OP_READ);
    }
    
    public void session(){
    	//開闢一個新執行緒從伺服器端讀資料
        new Reader().start();
        //開闢一個新執行緒往伺服器端寫資料
        new Writer().start();
	}
    
    private class Writer extends Thread{

		@Override
		public void run() {
			try{
				//在主執行緒中 從鍵盤讀取資料輸入到伺服器端
		        Scanner scan = new Scanner(System.in);
		        while(scan.hasNextLine()){
		            String line = scan.nextLine();
		            if("".equals(line)) continue; //不允許發空訊息
		            if("".equals(nickName)) {
		            	nickName = line;
		                line = nickName + USER_CONTENT_SPILIT;
		            } else {
		                line = nickName + USER_CONTENT_SPILIT + line;
		            }
//		            client.register(selector, SelectionKey.OP_WRITE);
		            client.write(charset.encode(line));//client既能寫也能讀,這邊是寫
		        }
		        scan.close();
			}catch(Exception e){
				
			}
		}
    	
    }
    
    
    private class Reader extends Thread {
        public void run() {
            try {
            	
            	//輪詢
                while(true) {
                    int readyChannels = selector.select();
                    if(readyChannels == 0) continue;
                    Set<SelectionKey> selectedKeys = selector.selectedKeys();  //可以通過這個方法,知道可用通道的集合
                    Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
                    while(keyIterator.hasNext()) {
                         SelectionKey key = (SelectionKey) keyIterator.next();
                         keyIterator.remove();
                         process(key);
                    }
                }
            }
            catch (IOException io){
            	
            }
        }

        private void process(SelectionKey key) throws IOException {
            if(key.isReadable()){
                //使用 NIO 讀取 Channel中的資料,這個和全域性變數client是一樣的,因為只註冊了一個SocketChannel
                //client既能寫也能讀,這邊是讀
                SocketChannel sc = (SocketChannel)key.channel();
                
                ByteBuffer buff = ByteBuffer.allocate(1024);
                String content = "";
                while(sc.read(buff) > 0)
                {
                    buff.flip();
                    content += charset.decode(buff);
                }
                //若系統傳送通知名字已經存在,則需要換個暱稱
                if(USER_EXIST.equals(content)) {
                	nickName = "";
                }
                System.out.println(content);
                key.interestOps(SelectionKey.OP_READ);
            }
        }
    }
    
    
    
    public static void main(String[] args) throws IOException
    {
        new NIOClient().session();
    }
}
複製程式碼

相關文章