Java的客戶/服務器通信模式中,服務器端需要創建監聽特定端口的ServerSocket,ServerSocket負責接收客戶連接請求。本實驗提供線程池的一種實現方式,線程池包括一個工作隊列和若干工作線程,服務器程序向工作隊列中加入與客戶通信的任務,工作線程不斷從工作隊列中取出任務並執行它。
一、構造ServerSocket
ServerSocket的構造方法有以下幾種重載形式:
| ServerSocket()throws IOException
| ServerSocket(int port) throws IOException
| ServerSocket(int port, int backlog) throws IOException
| ServerSocket(int port, int backlog, InetAddress bindAddr) throws IOException
在以上構造方法中,參數port指定服務器要綁定的端口(服務器要監聽的端口),參數backlog指定客戶連接請求隊列的長度,參數bindAddr指定服務器要綁定的IP地址。
二、綁定端口
除了第一個不帶參數的構造方法以外,其他構造方法都會使服務器與特定端口綁定,該端口由參數port指定。例如,以下代碼創建了一個與80端口綁定的服務器:
ServerSocket serverSocket = new ServerSocket(80);
如果運行時無法綁定到80端口,以上代碼會拋出IOException,更確切地說,是拋出BindException,它是IOException的子類。BindException一般是由以下原因造成的:
- ① 端口已經被其他服務器進程佔用;
- ② 在某些操作系統中,如果沒有以超級用戶的身份來運行服務器程序,那麼操作系統不允許服務器綁定到1~1023之間的端口。
如果把參數port設爲0,表示由操作系統來爲服務器分配一個任意可用的端口。由操作系統分配的端口也稱爲匿名端口。對於多數服務器,會使用明確的端口,而不會使用匿名端口,因爲客戶程序需要事先知道服務器的端口,才能方便地訪問服務器。
三、設定客戶連接請求隊列的長度
當服務器進程運行時,可能會同時監聽到多個客戶的連接請求。例如,每當一個客戶進程執行以下代碼:
Socket socket = new Socket(www.baidu.com,80);
這意味着在遠程www.baidu.com主機的80端口上,監聽到了一個客戶的連接請求。管理客戶連接請求的任務是由操作系統來完成的。操作系統把這些連接請求存儲在一個先進先出的隊列中。許多操作系統限定了隊列的最大長度,一般爲50。當隊列中的連接請求達到了隊列的最大容量時,服務器進程所在的主機會拒絕新的連接請求。只有當服務器進程通過ServerSocket的accept()方法從隊列中取出連接請求,使隊列騰出空位時,隊列才能繼續加入新的連接請求。
對於客戶進程,如果它發出的連接請求被加入到服務器的隊列中,就意味着客戶與服務器的連接建立成功,客戶進程從Socket構造方法中正常返回。如果客戶進程發出的連接請求被服務器拒絕,Socket構造方法就會拋出ConnectionException異常。
ServerSocket構造方法的backlog參數用來顯式設置連接請求隊列的長度,它將覆蓋操作系統限定的隊列的最大長度。值得注意的是,在以下幾種情況中,仍然會採用操作系統限定的隊列的最大長度:
- (1) backlog參數的值大於操作系統限定的隊列的最大長度;
- (2) backlog參數的值小於或等於0;
- (3) 在ServerSocket構造方法中沒有設置backlog參數。
四、接收和關閉與客戶的連接
ServerSocket的accept()方法從連接請求隊列中取出一個客戶的連接請求,然後創建與客戶連接的Socket對象,並將它返回。如果隊列中沒有連接請求,accept()方法就會一直等待,直到接收到了連接請求才返回。
接下來,服務器從Socket對象中獲得輸入流和輸出流,就能與客戶交換數據。當服務器正在進行發送數據的操作時,如果客戶端斷開了連接,那麼服務器端會拋出一個IOException的子類SocketException異常:
java.net.SocketException: Connection reset by peer
這只是服務器與單個客戶通信中出現的異常,這種異常應該被捕獲,使得服務器能繼續與其他客戶通信。以下程序顯示了單線程服務器採用的通信流程:
public static void main(String[] args) throws IOException {
// 服務器監聽80端口
ServerSocket serverSocket = new ServerSocket(80);
while (true) {
Socket socket = null;
try {
//從連接請求隊列中取出一個連接
socket = serverSocket.accept();
//接收和發送數據
//...
}catch (IOException e) {
//與單個客戶通信時遇到的異常,可能是由於客戶端過早斷開連接引起的
//這種異常不應該中斷整個while循環
e.printStackTrace();
}finally {
try{
//與一個客戶通信結束後,要關閉Socket
if(socket != null){
socket.close();
}
}catch (IOException e) {
e.printStackTrace();
}
}
}
}
與單個客戶通信的代碼放在一個try代碼塊中,如果遇到異常,該異常被catch代碼塊捕獲。try代碼塊後面還有一個finally代碼塊,它保證不管與客戶通信正常結束還是異常結束,最後都會關閉Socket,斷開與這個客戶的連接。
五、關閉ServerSocket
ServerSocket的close()方法使服務器釋放佔用的端口,並且斷開與所有客戶的連接。當一個服務器程序運行結束時,即使沒有執行ServerSocket的close()方法,操作系統也會釋放這個服務器佔用的端口。因此,服務器程序並不一定要在結束之前執行ServerSocket的close()方法。
在某些情況下,如果希望及時釋放服務器的端口,以便讓其他程序能佔用該端口,則可以顯式調用ServerSocket的close()方法。例如,以下代碼用於掃描1~65535之間的端口號。如果ServerSocket成功創建,意味着該端口未被其他服務器進程綁定,否者說明該端口已經被其他進程佔用:
try{
ServerSocket serverSocket = new ServerSocket(80);
serverSocket.close(); //及時關閉ServerSocket
}catch(IOException e){
System.out.println("80端口已經被其他服務器進程佔用");
}
ServerSocket的isClosed()方法判斷ServerSocket是否關閉,只有執行了ServerSocket的close()方法,isClosed()方法才返回true;否則,即使ServerSocket還沒有和特定端口綁定,isClosed()方法也會返回false。
ServerSocket的isBound()方法判斷ServerSocket是否已經與一個端口綁定,只要ServerSocket已經與一個端口綁定,即使它已經被關閉,isBound()方法也會返回true。
如果需要確定一個ServerSocket已經與特定端口綁定,並且還沒有被關閉,則可以採用以下方式:
boolean isOpen=serverSocket.isBound() && !serverSocket.isClosed();
六、創建多線程的服務器
6.1 概述
例如上述四的代碼中是常規的單線程類。Server每次只能接收一個客戶連接,只有與該客戶連接通信完畢,斷開連接釋放資源之後才能接收下一個客戶連接。因此,假如同時有多個客戶請求連接,這些客戶就必須排隊等待上一個客戶通信完畢。
許多實際應用要求服務器具有同時爲多個客戶提供服務的能力。HTTP服務器就是最明顯的例子。任何時刻,HTTP服務器都可能接收到大量的客戶請求,每個客戶都希望能快速得到HTTP服務器的響應。如果長時間讓客戶等待,會使網站失去信譽,從而降低訪問量。
可以用併發性能來衡量一個服務器同時響應多個客戶的能力。一個具有好的併發性能的服務器,必須符合兩個條件:
- Ⅰ.能同時接收並處理多個客戶連接;
- Ⅱ.對於每個客戶,都會迅速給予響應。
服務器同時處理的客戶連接數目越多,並且對每個客戶作出響應的速度越快,就表明併發性能越高。
用多個線程來同時爲多個客戶提供服務,這是提高服務器的併發性能的最常用的手段。下面案例將按照已下三中方式來實現具備多線程的處理能力。
- Ⅰ.爲每個客戶分配一個工作線程。
- Ⅱ.創建一個線程池,由其中的工作線程來爲客戶服務。
- Ⅲ.利用JDK的Java類庫中現成的線程池,由它的工作線程來爲客戶服務。
6.2 案例解析
① 爲每個客戶分配一個線程
服務器的主線程負責接收客戶的連接,每次接收到一個客戶連接,就會創建一個工作線程,由它負責與客戶的通信。案例代碼如下所示:
while (true) {
Socket socket = null;
try {
socket = serverSocket.accept();
// 爲當前連接對象創建並啓動輸出流工作線程
new Thread(new InThread(socket)).start();
} catch (IOException e) {
e.printStackTrace();
}
}
以上工作線程執行InThread類的run()方法。InThread類實現了Runnable接口,它的run()方法負責與單個客戶通信,與客戶通信結束後,就會斷開連接,執行InThread的run()方法的工作線程也會自然終止。案例程序如下所示。
public class InThread implements Runnable {
private Socket socket;
public InThread (Socket socket) {
this.socket = socket;
}
public void run() {
try {
DataInputStream in = new DataInputStream(socket.getInputStream());
System.out.println(in.readUTF());
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (socket != null){
socket.close(); // 斷開連接
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
② 創建線程池
線程池額外知識:https://baijiahao.baidu.com/s?id=1622998393605590757&wfr=spider&for=pc
前面所介紹的實現方式中,對每一個連接的客戶Socket都分配一個工作線程。當工作線程與客戶端通信結束之後,這個線程就被銷燬。但是這種實現方式有以下不足之處。
- Ⅰ.服務器創建和銷燬工作線程的開銷很大。如果服務器需要與許多客戶通信,並且與每個客戶的通信時間都很短,那麼有可能服務器爲客戶創建新線程的開銷比實際與客戶通信的開銷還要大。
- Ⅱ.除了創建和銷燬線程的開銷之外,活動的線程也消耗系統資源。每個線程本身都會佔用一定的內存(每個線程需要大約1M內存),如果同時有大量客戶連接服務器,就必須創建大量工作線程,它們消耗了大量內存,可能會導致系統的內存空間不足。
- Ⅲ.如果線程數目固定,並且每個線程都有很長的生命週期,那麼線程切換也是相對固定的(這裏所說的線程切換是指在Java虛擬機,以及底層操作系統的調度下,線程之間轉讓CPU的使用權)。如果頻繁創建和銷燬線程,那麼將導致頻繁地切換線程,因爲一個線程被銷燬後,必然要把CPU轉讓給另一個已經就緒的線程,使該線程獲得運行機會。在這種情況下,線程之間的切換不再遵循系統的固定切換週期,切換線程的開銷甚至比創建及銷燬線程的開銷還大。
線程池爲線程生命週期開銷問題和系統資源不足問題提供瞭解決方案。線程池中預先創建了一些工作線程,它們不斷從工作隊列中取出任務,然後執行該任務。當工作線程執行完一個任務時,就會繼續執行工作隊列中的下一個任務。線程池具有以下優點:
- Ⅰ.減少了創建和銷燬線程的次數,每個工作線程都可以一直被重用,能執行多個任務。
- Ⅱ.可以根據系統的承載能力,方便地調整線程池中線程的數目,防止因爲消耗過量系統資源而導致系統崩潰。
如下面的範例程序所示,ThreadPool類提供了線程池的一種實現方案
public class ThreadPool extends ThreadGroup {
private int threadID; // 表示工作線程ID
private static int threadPoolID; // 表示線程池ID
private boolean isClosed = false; // 線程池是否關閉
private LinkedList<Runnable> workQueue; // 表示工作隊列
public ThreadPool(int poolSize) { // poolSize指定線程池中的工作線程數目
super("ThreadPool-" + (threadPoolID++));
setDaemon(true);
workQueue = new LinkedList<Runnable>(); // 創建工作隊列
for (int i = 0; i < poolSize; i++) {
new WorkThread().start(); // 創建並啓動工作線程
}
}
/** 內部類:工作線程 */
private class WorkThread extends Thread {
public WorkThread() {
// 加入到當前ThreadPool線程組中
super(ThreadPool.this, "WorkThread-" + (threadID++));
}
public void run() {
// isInterrupted()方法繼承自Thread類,判斷線程是否被中斷
while (!isInterrupted()) {
Runnable task = null;
try { // 取出任務
task = getTask();
} catch (InterruptedException ex) {
ex.printStackTreace();
}
// 如果getTask()返回null或者線程執行getTask()時被中斷,則結束此線程
if (task == null) {
return;
}
// 運行任務,異常在catch代碼塊中捕獲
try {
task.run();
} catch (Throwable t) {
t.printStackTrace();
}
}
}
}
/** 向工作隊列中加入一個新任務,由工作線程去執行該任務 */
public synchronized void execute(Runnable task) {
if (isClosed) { // 線程池被關則拋出IllegalStateException異常
throw new IllegalStateException();
}
if (task != null) {
workQueue.add(task);
notify(); // 喚醒正在getTask()方法中等待任務的工作線程
}
}
/** 從工作隊列中取出一個任務,工作線程會調用此方法 */
protected synchronized Runnable getTask() throws InterruptedException {
while (workQueue.size() == 0) {
if (isClosed)
return null;
wait(); // 如果工作隊列中沒有任務,就等待任務
}
return workQueue.removeFirst();
}
/** 關閉線程池 */
public synchronized void close() {
if (!isClosed) {
isClosed = true;
workQueue.clear(); // 清空工作隊列
interrupt(); // 中斷所有的工作線程,該方法繼承自ThreadGroup類
}
}
/** 等待工作線程把所有任務執行完 */
public void join() {
synchronized (this) {
isClosed = true;
notifyAll(); // 喚醒還在getTask()方法中等待任務的工作線程
}
Thread[] threads = new Thread[activeCount()];
// enumerate()方法繼承自ThreadGroup類,獲得線程組中當前所有活着的工作線程
int count = enumerate(threads);
for (int i = 0; i < count; i++) { // 等待所有工作線程運行結束
try {
threads[i].join(); // 等待工作線程運行結束
} catch (InterruptedException ex) {
}
}
}
}
在ThreadPool類中定義了一個LinkedList類型的workQueue成員變量,它表示工作隊列,用來存放線程池要執行的任務,每個任務都是Runnable實例。主程序調用ThreadPool類的execute(Runnable task)方法,就能向線程池提交任務。在該方法中,先判斷線程池是否已經關閉。如果線程池已經關閉,就不再接收任務,否則就把任務加入到工作隊列中,並且喚醒正在等待任務的工作線程。
在ThreadPool類的構造方法中,會創建並啓動若干工作線程,工作線程的數目由構造方法的參數poolSize決定。WorkThread類表示工作線程,它是ThreadPool類的內部類。工作線程從工作隊列中取出一個任務,接着執行該任務,然後再從工作隊列中取出下一個任務並執行它,如此反覆。
工作線程從工作隊列中取任務的操作是由ThreadPool類的getTask()方法實現的,它的處理邏輯如下:
- Ⅰ.如果隊列爲空並且線程池已關閉,那就返回null,表示已經沒有任務可以執行了
- Ⅱ.如果隊列爲空並且線程池沒有關閉,就此等待,直到其他線程將其喚醒或者中斷
- Ⅲ.如果隊列中有任務,就取出第一個任務並將其返回
線程池的join()和close()方法都可用來關閉線程池。join()方法確保在關閉線程池之前,工作線程把隊列中的所有任務都執行完。而close()方法則立即清空隊列,並且中斷所有的工作線程。
接下來,我們創建ServerThread類測試ThreadPool類的用法,案例如下:
public class ServerThread {
public static void main(String[] args) {
int numTasks = 5; // 任務數目
int poolSize = 3; // 線程池中的線程數目
// 創建線程池
ThreadPool threadPool = new ThreadPool(poolSize);
// 創建任務線程並運行
for (int i = 0; i < numTasks; i++){
threadPool.execute(createTask(i));
}
// 等待工作線程完成所有的任務
threadPool.join();
// 關閉線程池
// threadPool.close();
}
/** 創建任務線程(打印ID) */
private static Runnable createTask(final int taskID) {
return new Runnable() {
public void run() {
System.out.println("Task " + taskID + ": start");
try {
Thread.sleep(500); // 增加執行一個任務的時間
} catch (InterruptedException ex) {
ex.printStackTrace();
}
System.out.println("Task " + taskID + ": end");
}
};
}
}
ServerThread類的main()方法指定創建線程池的工作線程數目,通過循環調用createTask()方法創建多個任務線程傳遞給ThreadPool類的execute()方法執行線程,最後調用線程池的join()方法,等待線程池把所有的任務執行完畢或者強制關閉線程池中所有的任務(包括正在執行中的任務)。
執行join()方法的運行結果如下所示。
Task 0: start
Task 2: start
Task 1: start
Task 2: end
Task 3: start
Task 0: end
Task 4: start
Task 1: end
Task 4: end
Task 3: end
執行close()方法的運行結果如下所示。
Task 1: start
Task 0: start
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at Group.Server$1.run(Server.java:26)
at Group.ThreadPool$WorkThread.run(ThreadPool.java:44)
Task 1: end
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at Group.Server$1.run(Server.java:26)
at Group.ThreadPool$WorkThread.run(ThreadPool.java:44)
Task 0: end
服務器利用ThreadPool類完成與客戶端通信的程序代碼如下所示。
public class Server {
private int port = 8000;
private ServerSocket serverSocket;
private ThreadPool threadPool; // 線程池
private final int POOL_SIZE = 3; // 單個CPU時線程池中工作線程的數目
public Server() throws IOException {
System.err.println("服務器啓動,監聽" + port + "端口。");
serverSocket = new ServerSocket(port);
// 創建線程池
// Runtime的availableProcessors()方法返回當前系統的CPU的數目
// 系統的CPU越多,線程池中工作線程的數目也越多
threadPool = new ThreadPool(Runtime.getRuntime().availableProcessors() * POOL_SIZE);
}
public static void main(String args[]) throws IOException {
new Server().service();
}
public void service() {
while (true) {
try {
Socket socket = serverSocket.accept();
// 獲取客戶端消息的任務提交給線程池
threadPool.execute(new InThread(socket));
} catch (IOException e) {
e.printStackTrace();
}
}
}
class InThread implements Runnable {
private Socket socket;
public InThread(Socket socket) {
this.socket = socket;
}
public void run() {
try {
while(true){
DataInputStream in = new DataInputStream(socket.getInputStream());
System.out.println(in.readUTF());
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (socket != null) {
socket.close(); // 斷開連接
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
最終的結果如下圖所示。
參考資料:
① 介紹幾種Java中網絡通信的方式
地址:https://blog.csdn.net/mjm_49/article/details/77461322
② 深入理解線程和線程池(圖文詳解)
地址:https://blog.csdn.net/weixin_40271838/article/details/79998327
③ 如何設計一個使用的線程池
地址:https://baijiahao.baidu.com/s?id=1622998393605590757&wfr=spider&for=pc