1. 程式人生 > >Java IO的工作機制

Java IO的工作機制

      IO問題可以說是當今Web應用中所面臨的主要問題之一,應為當前這個海量資料時代,資料在網路中隨處流動。在這個流動的過程中都涉及IO問題,可以說大部分Web應用系統的瓶頸都是I/O瓶頸。

一、Java的I/O類庫的基本架構


     Java的IO操作類在包java.io下,大概有將近80個類,這些類大概可以分為以下4組:
     ●  基於位元組操作的I/O介面:InputStream 和 OutputStream
     ●  基於字元操作的I/O介面:Writer 和 Reader
     ●  基於磁碟操作的I/O介面:File
     ●  基於網路操作的I/O介面:Socket
     前兩組主要是傳輸資料的資料格式,後兩組主要是傳輸資料的方式,雖然Socket類並不在java.io包下,但仍然將其劃分在一起。I/O的核心問題要麼是資料格式影響I/O操作,要麼是傳輸方式影響I/O操作,既將什麼樣的資料寫到什麼地方的問題。IO只是人與機器或者機器與機器互動的手段,除了它們能夠完成這個互動功能外,我們關注的就是如何提高它的執行效率了,而資料格式和傳輸方式是影響最關鍵的因素。
     不管是磁碟還是網路傳輸,最小的儲存單元都是位元組,而不是字元,所以I/O操作的都是位元組而不是字元,但我們操作的都是字元形式,為了操作便提供一個直接寫字元的I/O介面。我們知道從字元到位元組必須要經過編碼轉換,而這個編碼又非常耗時,而且會經常出現亂碼問題。InputStreamReader類是從位元組到字元的轉化橋樑(需要指定編碼字符集)StreadDecoder正是完成從位元組到字元解碼的實現類。例如用如下的方式讀取檔案時:

//FileReader繼承了InputStreamReader類,實際上是讀取檔案流,然後通過StreamDecode解碼成char
//解碼字符集是預設字符集
try{
     StringBuffer sb = new StringBuffer();
     char[] buf = new char[1024];
     FileReader fr = new FileReader("file");
     while(fr.read(buf)>0){
         str.append(buf);
     }
     str.toString();
}catch (IOException e){
}

     讀取和寫入檔案I/O操作都是呼叫作業系統提供的介面,因為磁碟裝置是由作業系統管理的,應用程式要訪問物理裝置只能通過系統呼叫的方式來工作。讀和寫分別對應Read()和Write()兩個系統呼叫。而只要是系統呼叫就可能存在核心空間地址和使用者空間地址切換的問題,作業系統為了保護系統本身的執行安全,而將核心程式執行使用的記憶體空間和使用者程式執行的記憶體空間進行隔離。但會帶來從核心空間向用戶空間複製資料的問題,例如:資料不一致和耗時問題,此時為了加速I/O訪問,在核心空間使用快取機制,也就是將從磁碟讀取的檔案按照一定的組織方式進行快取,如果使用者訪問的是同一磁碟地址的空間資料。那麼作業系統將從核心快取中直接取出返回給使用者程式。

二、標準訪問檔案的方式


       標準訪問檔案的方式就是當應用程式呼叫read()介面時,作業系統檢查在核心的快取記憶體中有木有需要的資料,如果已經快取了,那麼就直接從快取中返回,如果沒有,則從磁碟中讀取,然後快取在作業系統的快取中。
       寫入的方式是,使用者的應用程式呼叫write()介面將資料從使用者地址空間複製到核心地址空間的快取中。這時對使用者程式來說寫操作就已經完成,至於什麼時候來寫到磁碟中有作業系統決定,除非顯示地呼叫了sync同步命令。
     

 三、Java Socket 的工作機制


      Socket這個概念沒有對應到一個具體的實體,它描述計算機之間完成相互通訊的一種抽象功能。
     

     主機A的應用程式要能和主機B的應用程式通訊,必須通過Socket建立連線,而建立Socket連線必須由底層TCP/IP來建立TCP連線。建立TCP連線需要底層IP來尋找網路的主機。但一臺電腦上可能執行著多個應用程式,如何才能與指定的應用程式通訊就要通過TCP或UDP的地址也就是埠號來指定。這樣就可以通過一個Socket例項來唯一代表一個主機上的應用程式的通訊鏈路了。
     當客戶端要與服務端通訊時,客戶端首先要建立一個Socket例項,作業系統將為這個Socket例項分配一個沒有被使用的本地埠號,並建立一個包含本地地址、遠端地址和埠號的套接字資料結構,這個資料結構將一直儲存在系統中直到這個連線關閉。在建立Socket例項的建構函式正確返回之前,將要進行TCP的3次握手協議,TCP握手成功後,Socket例項建立成功,否則丟擲IOException異常。
     與之對應的服務端將建立一個ServerSocket例項,建立ServerSocket只需要指定一個未佔用的埠即可,同時作業系統也會為ServerSocket例項建立一個底層資料結構,在這個資料結構中包含指定監聽的埠號和包含監聽地址的萬用字元,通常情況下都是“*”,即監聽所有地址。之後當呼叫accept()方法時,將進入阻塞狀態,等待客戶端的請求。當一個新的請求到來時,將為這個連結建立一個新的套接字資料結構。
     資料傳輸是建立連線的主要目的,當連線建立成功時,服務端和客戶端都會擁有一個Socket例項,每個Socket例項都有一個InputStream和OutputStream,並通過這兩個物件來交換資料。同時我們也知道網路I/O都是以位元組流傳輸的,當建立Socket物件時,作業系統將會為InputStream和OutputStream分別分配一定大小的快取區,資料的寫入和讀取都是通過這個快取區完成的。寫入端將資料寫入到OutputStream對應的SendQ佇列中,當資料填滿時,資料將被轉移到另一端InputStream的RecvQ佇列中,如果RecvQ已滿,那麼OutputStream的write就會阻塞,知道ReqvQ佇列有足夠的空間容納SendQ傳送的資料。特別注意的是,這個快取區的大小和寫入端與讀取端的速度非常影響資料的傳輸效率,因為可能會出現阻塞,所以網路I/O與磁碟I/O不同的是資料的寫入和讀取還要有一個協調的過程,如果在兩邊同時傳輸資料可能會產生死鎖。

四、BIO帶來的挑戰


      BIO即阻塞IO,不管是磁碟I/O還是網路I/O,資料在寫入OutputStream或者從InputStream讀取時都有可能出現阻塞,一旦有阻塞,執行緒就會失去CPU的使用權,這在當前的大規模訪問量和有效能要求的情況下是不能接受的。雖然有些解決辦法:例如一個客戶端對應一個處理執行緒,出現阻塞時只是一個執行緒阻塞而不會影響其他執行緒工作,還有為了減小系統執行緒的開銷,採用執行緒池的辦法來減少執行緒建立和回收的成本,但是在一些使用場景下仍然無法解決的。如當前一些需要大量HTTP長連線的情況,如像淘寶現在使用的Web旺旺,服務端需要同時保持幾百萬的HTTP連線,但並不是每時每刻都在保持資料傳輸,在這種情況下不可能同時建立保持幾百萬的HTTP連線。即使執行緒池的數量不是問題,但我們要給某些客戶端更高的服務優先順序時,很難通過設計執行緒的優先順序來完成。還有,每個客戶端需要訪問服務端的競爭資源,這些客戶端在不同執行緒中,因此需要同步,要實現這種同步操作遠比用單執行緒複雜得多。這些問題我們可以通過NIO挺鬆的解決。

五、NIO的工作機制


      NIO中有兩個關鍵類:Channel和Selector,它們是NIO中的兩個核心概念。Channel比Socket更加具體,可以看做是Socket的一種落地實體。Selector主要監控Channel的執行狀態。還有一個Buffer類,它比Stream(只是一個概念)更加具體。Channel如果是汽車的話Buffer就是汽車上的座位。NIO引入了Channel、Buffer和Selector就是想將Socket、Stream等概念具體化,讓程式設計師能夠控制它們。例如BIO中的SendQ佇列值超出佇列大小時需要切割,這個過程需要使用者空間和核心空間進行切換,這個切換不是你可以控制的,但在Buffer中我們可以控制Buffer的容量、是否擴充套件以及如何擴充套件。下面我們看下NIO的典型程式碼:

public void selector() throws IOException {
	ByteBuffer buffer = ByteBuffer.allocate(1024);
	//呼叫Selector的靜態方法建立一個Selector選擇器
	Selector selector = Selector.open();
	//建立一個服務端的Channel,繫結一個Socket
	ServerSocketChannel socketChannel = ServerSocketChannel.open();
	//將管道設定為非阻塞
	socketChannel.configureBlocking(false);
	//為通訊通道繫結一個埠
	socketChannel.bind(new InetSocketAddress(8080));
	//將通訊通道註冊到選擇器上,註冊監聽事件   接收就緒:SelectionKey.OP_ACCEPT
	socketChannel.register(selector, SelectionKey.OP_ACCEPT);
	while(true) {
		//獲取檢查已經註冊在這個選擇器上的所有通訊通道是否有需要的事件發生
		//如果有某個事件發生就會返回所有的SelectorKey
		Set<SelectionKey> selectedKeys = selector.selectedKeys();
		Iterator<SelectionKey> iterator = selectedKeys.iterator();
		while(iterator.hasNext()) {
			SelectionKey selectionKey = iterator.next();
			//建立ready集合的方法:readyOps()返回一個bit mask,代表在相應channel上可以進行的IO操作。
			if((selectionKey.readyOps() & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT) {
				//返回該SelectionKey對應的channel。
				ServerSocketChannel channel = (ServerSocketChannel)selectionKey.channel();
				//接受到服務端的請求 建立一個新連線
				SocketChannel sc = channel.accept();
				sc.configureBlocking(false);
				//宣告這個channel只對讀操作感興趣。
				sc.register(selector, SelectionKey.OP_READ);
				iterator.remove();
			}else if((selectionKey.readyOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ) {
				SocketChannel sc = (SocketChannel) selectionKey.channel();
				while(true) {
					buffer.clear();
                                        //從這裡讀取的資料時buffer,這個buffer是我們可以控制的緩衝區
					int read = sc.read(buffer);
					if(read <= 0) {
						break;
					}
					//將寫模式轉變為讀模式
					buffer.flip();
					System.out.println("received : " + new String(buffer.array()));
				}
				iterator.remove();
			}
		}
	}
}

      在上面的程式中,將Server端的監聽連線請求的事件和處理請求的事件放在一個執行緒中,但是在事件應用中,我們通常會把它們放在兩個執行緒中:一個執行緒專門負責監聽客戶端的連線請求而是以阻塞方式執行的;另一個執行緒專門負責處理請求,這個專門處理請求的執行緒才會真正採用NIO的方式,像Web伺服器Tomcat和Jetty都是使用這個處理方式。
      Selector可以同時監聽一組通訊通道(Channel)上的I/O狀態,前提是這個Selector已經註冊到這些通訊通道中。選擇器Selector可以呼叫select()方法檢查已經註冊的通訊通道上I/O是否已經準備好,如果沒有至少一個通道I/O狀態有變化,那麼select方法會阻塞等待或在超時時間後返回0。如果有多個通道有資料,那麼將會把這些資料分配到對應的資料Buffer中。所以關鍵的地方是,有一個執行緒來處理所有連線的資料互動,每個連線的資料互動都不是阻塞方式,所以可以同時處理大量的連線請求。

六、Buffer 的工作方式


     可以把Buffer簡單地理解為一組基礎資料型別的元素列表,它通過幾個變數來儲存這個資料的當前位置狀態,也就是有4個索引:

索引 說明
capacity 緩衝區陣列的總長度
position 下一個操作的資料元素的位置
limit 緩衝區資料中不可操作的下一個元素的位置,limit<=capacity
mark 用於記錄當前position的前一個位置或者預設是0

     我們通過ByteBuffer.allocate(11)方法建立一個11個byte的資料緩衝區,初始狀態如下:
    

      position的位置為0,capacity和limit預設都是陣列長度。當寫入5個位元組時,位置變化如下圖:
    

     這時我們需要將緩衝區的5個位元組資料寫入Channel通訊通道,所以我們呼叫byteBuffer.filp()方法,陣列的狀態發生如下變化:
    

      這時底層作業系統就可以從緩衝區中正確讀取這5個位元組資料併發送出去了。在下一次寫資料之前我們再強調一下clear()方法,緩衝區的狀態又回到初始位置。mark()方法記錄當前position的前一個位置,當我們呼叫reset時,position將恢復mark記錄下來的值。還有一點就是Channel獲取的I/O資料首先要經過作業系統的Socket緩衝區,再將資料複製到Buffer中,這個作業系統的緩衝區就是底層的TCP所關聯的RecvQ或者SendQ佇列,從作業系統緩衝區到使用者緩衝區複製資料比較耗效能,Buffer提供了另一種直接操作作業系統快取的方式ByteBuffer.allocateDirector(size)。

七、TCP網路引數調優


     要能夠建立一個TCP連線,必須知道對方的IP和一個未被使用的埠號,由於32位作業系統的埠號通常由兩個位元組表示,也就是隻有2^16=65535個,所以一臺主機能夠同時建立的連線數是有限的,當然作業系統還有一些埠是受保護的,如80埠、22埠,這些埠都不能被隨意佔用。在Linux中可以通過檢視/proc/sys/net/ipv4/ip_local-port_range檔案來知道當前這個主機可以使用的埠範圍。如果可使用的埠過少,在遇到大量併發請求時就會成為瓶頸,由於埠有限導致大量請求等待建立連結,這樣效能就會壓不上去。另外如果發下大量的TIME_WAIT的話,可以設定/proc/sys/net/ipv4/tcp_fin_timeout為更小的值來快速釋放請求。