Java 阻塞


對於用ServerSocket 及 Socket 編寫的服務器程序和客戶程序, 他們在運行過程中常常會阻塞. 例如, 當一個線程執行 ServerSocket 的accept() 方法時, 假如沒有客戶連接, 該線程就會一直等到有客戶連接才從 accept() 方法返回. 再例如, 當線程執行 Socket 的 read() 方法時, 如果輸入流中沒有數據, 該線程就會一直等到讀入足夠的數據才從 read() 方法返回. 

      假如服務器程序需要同時與多個客戶通信, 就必須分配多個工作線程, 讓他們分別負責與一個客戶通信, 當然每個工作線程都有可能經常處於長時間的阻塞狀態. 

      從 JDK1.4 版本開始, 引入了非阻塞的通信機制. 服務器程序接收客戶連接, 客戶程序建立與服務器的連接, 以及服務器程序和客戶程序收發數據的操作都可以按非阻塞的方式進行. 服務器程序只需要創建一個線程, 就能完成同時與多個客戶通信的任務. 

      非阻塞的通信機制主要由 java.nio 包(新I/O包) 中的類實現, 主要的類包括 ServerSocketChannel, SocketChannel, Selector, SelectionKey 和 ByteBuffer 等. 

      本章介紹如何用 java.nio 包中的類來創建服務器程序和客戶程序, 並且 分別采用阻塞模式和非阻塞模式來實現它們. 通過比較不同的實現方式, 可以幫助讀者理解它們的區別和適用范圍. 

一. 線程阻塞的概念 

      在生活中, 最常見的阻塞現象是公路上汽車的堵塞. 汽車在公路上快速行駛, 如果前方交通受阻, 就只好停下來等待, 等到交通暢順, 才能恢復行駛. 

      線程在運行中也會因為某些原因而阻塞. 所有處於阻塞狀態的線程的共同特征是: 放棄CPU, 暫停運行, 只有等到導致阻塞的原因消除, 才能恢復運行; 或者被其他線程中斷, 該線程會退出阻塞狀態, 並且拋出 InterruptedException. 

1.1 線程阻塞的原因 

      導致線程阻塞的原因主要有以下幾方面. 

線程執行了 Thread.sleep(int n) 方法, 線程放棄 CPU, 睡眠 n 毫秒, 然后恢復運行. 
線程要執行一段同步代碼, 由於無法獲得相關的同步鎖, 只好進入阻塞狀態, 等到獲得了同步鎖, 才能恢復運行. 
線程執行了一個對象的 wait() 方法, 進入阻塞狀態, 只有等到其他線程執行了該對象的 notify() 和 notifyAll() 方法, 才可能將其呼醒. 
線程執行 I/O 操作或進行遠程通信時, 會因為等待相關的資源而進入阻塞狀態. 例如, 當線程執行 System.in.read() 方法時, 如果用戶沒有向控制台輸入數據, 則該線程會一直等讀到了用戶的輸入數據才從 read() 方法返回. 
進行遠程通信時, 在客戶程序中, 線程在以下情況可能進入阻塞狀態. 

請求與服務器建立連接時, 即當線程執行 Socket 的帶參數構造方法, 或執行 Socket 的 connect() 方法時, 會進入阻塞狀態, 直到連接成功, 此線程才從 Socket 的構造方法或 connect() 方法返回. 
線程從 Socket 的輸入流讀入數據時, 如果沒有足夠的數據, 就會進入阻塞狀態, 直到讀到了足夠的數據, 或者到達輸入流的末尾, 或者出現了異常, 才從輸入流的 read() 方法返回或異常中斷. 輸入流中有多少數據才算足夠呢? 這要看線程執行的 read() 方法的類型. 
> int read(): 只要輸入流中有一個字節, 就算足夠. 

> int read( byte[] buff): 只要輸入流中的字節數目與參數buff 數組的長度相同, 就算足夠. 

> String readLine(): 只要輸入流中有一行字符串, 就算足夠. 值得注意的是, InputStream 類並沒有 readLine() 方法, 在過濾流 BufferedReader 類中才有此方法. 

線程向 Socket 的輸出流寫一批數據時, 可能會進入阻塞狀態, 等到輸出了所有的數據, 或者出現異常, 才從輸出流 的 write() 方法返回或異常中斷. 
調用 SOcket 的setSoLinger() 方法設置了關閉 Socket 的延遲時間, 那么當線程執行 Socket 的 close() 方法時, 會進入阻塞狀態, 直到底層 Socket 發送完所有剩余數據, 或者超過了 setSoLinger() 方法設置的延遲時間, 才從 close() 方法返回. 
在服務器程序中, 線程在以下情況下可能會進入阻塞狀態. 

線程執行 ServerSocket 的 accept() 方法, 等待客戶的連接, 直到接收到了客戶連接, 才從 accept() 方法返回.       
線程從 Socket 的輸入流讀入數據時, 如果輸入流沒有足夠的數據, 就會進入阻塞狀態. 
線程向 Socket 的輸出流寫一批數據時, 可能會進入阻塞狀態, 等到輸出了所有的數據, 或者出現異常, 才從輸出流的 write() 方法返回或異常中斷. 
由此可見, 無論在服務器程序還是客戶程序中, 當通過 Socket 的輸入流和輸出流來讀寫數據時, 都可能進入阻塞狀態. 這種可能出現阻塞的輸入和輸出操作被稱為阻塞 I/O. 與此對照, 如果執行輸入和輸出操作時, 不會發生阻塞, 則稱為非阻塞 I/O. 

1.2 服務器程序用多線程處理阻塞通信的局限 

      本書第三章的第六節(創建多線程的服務器) 已經介紹了服務器程序用多線程來同時處理多個客戶連接的方式. 服務器程序的處理流程如圖 4-1 所示. 主線程負責接收客戶的連接. 在線程池中有若干工作線程, 他們負責處理具體的客戶連接. 每當主線程接收到一個客戶連接, 就會把與這個客戶交互的任務交給一個空閑的工作線程去完成, 主線程繼續負責接收下一個客戶連接. 


                              圖4-1 服務器程序用多線程處理阻塞通信 

      在圖4-1 總, 用粗體框標識的步驟為可能引起阻塞的步驟. 從圖中可以看出, 當主線程接收客戶連接, 以及工作線程執行 I/O 操作時, 都有可能進入阻塞狀態. 

      服務器程序用多線程來處理阻塞 I/O, 盡管能滿足同時響應多個客戶請求的需求, 但是有以下局限: 

      ⑴ Java 虛擬機會為每個線程分配獨立的堆棧空間, 工作線程數目越多, 系統開銷就越大, 而且增加了 Java虛擬機調度線程的負擔, 增加了線程之間同步的復雜性, 提高了線程死鎖的可能性; 

      ⑵ 工作線程的許多時間都浪費在阻塞 I/O 操作上, Java 虛擬機需要頻繁地轉讓 CPU 的使用權, 使進入阻塞狀態的線程放棄CPU, 再把CPU 分配給處於可運行狀態的線程. 

      由此可見, 工作線程並不是越多越好. 如圖 4-2 所示, 保持適量的工作線程, 會提高服務器的並發性能, 但是當工作線程的數目達到某個極限, 超出了系統的負荷時, 反而會減低並發性能, 使得多數客戶無法快速得到服務器的響應.


                      圖4-2 線程數目與並發性能的更新                  

1.3 非阻塞通信的基本思想 

      假如要同時做兩件事: 燒開水和燒粥. 燒開水的步驟如下: 

      鍋里放水, 打開煤氣爐; 

      等待水燒開;                                                            //阻塞 

      關閉煤氣爐, 把開水灌到水壺里; 

      燒粥的步驟如下: 

      鍋里放水和米, 打開煤氣爐; 

      等待粥燒開;                                                             //阻塞 

      調整煤氣爐, 改為小火;   

      等待粥燒熟;                                                             //阻塞 

      關閉煤氣爐; 

      為了同時完成兩件事, 一個方案是同時請兩個人分別做其中的一件事, 這相當於采用多線程來同時完成多個任務. 還有一種方案是讓一個人同時完成兩件事, 這個人應該善於利用一件事的空閑時間去做另一件事, 一刻也不應該閑着: 

      鍋子里放水, 打開煤氣爐;                      //開始燒水 

      鍋子力放水和米, 打開煤氣爐;                //開始燒粥 

      while(一直等待, 直到有水燒開, 粥燒開或粥燒熟事件發生){          //阻塞 

            if(水燒開) 

                   關閉煤氣爐, 把開水灌到水壺里; 

            if(粥燒開) 

                   調整煤氣爐, 改為小火; 

            if(粥燒熟) 

                   關閉煤氣爐; 

            if(水已經燒開並且粥已經燒熟) 

                   退出循環; 

      }         //這里的煤氣爐我可以理解為每件事就有一個煤氣爐配給吧, 這也是一部分的開銷呢 

                 //並且if里面的動作必須要能快速完成的才行, 不然后面的就要排隊了 

                 //如是太累的工作還是不要用這個好                                   

      這個人不斷監控燒水及燒粥的狀態, 如果發生了 "水燒開", "粥燒開" 或 "粥燒熟" 事件, 就去處理這些事件, 處理完一件事后進行監控燒水及燒粥的狀態, 直到所有的任務都完成. 

       以上工作方式也可以運用到服務器程序中, 服務器程序只需要一個線程就能同時負責接收客戶的連接, 接收各個客戶發送的數據, 以及向各個客戶發送響應數據. 服務器程序的處理流程如下: 

       while(一直等待, 直到有接收連接就緒事件, 讀就緒事件或寫就緒事件發生){             //阻塞 

              if(有客戶連接) 

                   接收客戶的連接;                                                    //非阻塞 

              if(某個 Socket 的輸入流中有可讀數據) 

                   從輸入流中讀數據;                                                 //非阻塞 

              if(某個 Socket 的輸出流可以寫數據) 

                   向輸出流寫數據;                                                    //非阻塞 

       } 

      以上處理流程采用了輪詢的工作方式, 當某一種操作就緒時, 就執行該操作, 否則就查看是否還有其他就緒的操作可以執行. 線程不會因為某一個操作還沒有就緒, 就進入阻塞狀態, 一直傻傻地在那里等待這個操作就緒. 

      為了使輪詢的工作方式順利進行, 接收客戶的連接, 從輸入流讀數據, 以及向輸出流寫數據的操作都應該以非阻塞的方式運行. 所謂非阻塞, 就是指當線程執行這些方法時, 如果操作還沒有就緒, 就立即返回, 而不會一直等到操作就緒. 例如, 當線程接收客戶連接時, 如果沒有客戶連接, 就立即返回; 再例如, 當線程從輸入流中讀數據時, 如果輸入流中還沒有數據, 就立即返回, 或者如果輸入流還沒有足夠的數據, 那么就讀取現有的數據, 然后返回. 值得注意的是, 以上 while 學校條件中的操作還是按照阻塞方式進行的, 如果未發生任何事件, 就會進入阻塞狀態, 直到接收連接就緒事件, 讀就緒事件或寫就緒事件中至少有一個事件發生時, 才會執行 while 循環體中的操作. 在while 循環體中, 一般會包含在特定條件下退出循環的操作. 

二. java.nio 包中的主要類 

        java.nio 包提供了支持非阻塞通信的類. 

ServerSocketChannel: ServerSocket 的替代類, 支持阻塞通信與非阻塞通信. 
SocketChannel: Socket 的替代類, 支持阻塞通信與非阻塞通信. 
Selector: 為ServerSocketChannel 監控接收連接就緒事件, 為 SocketChannel 監控連接就緒, 讀就緒和寫就緒事件. 
SelectionKey: 代表 ServerSocketChannel 及 SocketChannel 向 Selector 注冊事件的句柄. 當一個 SelectionKey 對象位於Selector 對象的 selected-keys 集合中時, 就表示與這個 SelectionKey 對象相關的事件發生了.       
ServerSocketChannel 及 SocketChannel 都是 SelectableChannel 的子類, 如圖 4-3 所示. SelectableChannel 類及其子類都能委托 Selector 來監控他們可能發生的一些事件, 這種委托過程也稱為注冊事件過程.


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM