對于即時類應用或者即時類的游戲,HTTP協議很多時候無法滿足于我們的需求。這會,Socket對于我們來說就非常實用了。下面是本次學習的筆記。主要分異常類型、交互原理、Socket、ServerSocket、多線程這幾個方面闡述。
異常類型
在了解Socket的內容之前,先要了解一下涉及到的一些異常類型。以下四種類型都是繼承于IOException,所以很多之后直接彈出IOException即可。
UnkownHostException:? ? 主機名字或IP錯誤
ConnectException:? ? 服務器拒絕連接、服務器沒有啟動、(超出隊列數,拒絕連接)
SocketTimeoutException:? ? ??連接超時
BindException:? ? Socket對象無法與制定的本地IP地址或端口綁定
交互過程
Socket與ServerSocket的交互,下面的圖片我覺得已經說的很詳細很清楚了。
在客戶/服務器通信模式中,服務器端需要創建監聽特定端口的ServerSocket,ServerSocket負責接收客戶連接請求。本章首先介紹ServerSocket類的各個構造方法,以及成員方法的用法,接著介紹服務器如何用多線程來處理與多個客戶的通信任務。
本章提供線程池的一種實現方式。線程池包括一個工作隊列和若干工作線程。服務器程序向工作隊列中加入與客戶通信的任務,工作線程不斷從工作隊列中取出任務并執行它。本章還介紹了Java.util.concurrent包中的線程池類的用法,在服務器程序中可以直接使用它們。
3.1? 構造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地址。
3.1.1? 綁定端口
除了第一個不帶參數的構造方法以外,其他構造方法都會使服務器與特定端口綁定,該端口由參數port指定。例如,以下代碼創建了一個與80端口綁定的服務器:
[java]view plaincopy?
?
ServerSocket?serverSocket=new?ServerSocket(80);??
◆端口已經被其他服務器進程占用;如果運行時無法綁定到80端口,以上代碼會拋出IOException,更確切地說,是拋出BindException,它是IOException的子類。BindException一般是由以下原因造成的:
◆在某些操作系統中,如果沒有以超級用戶的身份來運行服務器程序,那么操作系統不允許服務器綁定到1~1023之間的端口。
如果把參數port設為0,表示由操作系統來為服務器分配一個任意可用的端口。由操作系統分配的端口也稱為匿名端口。對于多數服務器,會使用明確的端口,而不會使用匿名端口,因為客戶程序需要事先知道服務器的端口,才能方便地訪問服務器。在某些場合,匿名端口有著特殊的用途,本章3.4節會對此作介紹。
3.1.2? 設定客戶連接請求隊列的長度
當服務器進程運行時,可能會同時監聽到多個客戶的連接請求。例如,每當一個客戶進程執行以下代碼:
[java]view plaincopy?
?
Socket?socket=new?Socket(www.javathinker.org,80);??
就意味著在遠程www.javathinker.org主機的80端口上,監聽到了一個客戶的連接請求。管理客戶連接請求的任務是由操作系統來完成的。操作系統把這些連接請求存儲在一個先進先出的隊列中。許多操作系統限定了隊列的最大長度,一般為50。當隊列中的連接請求達到了隊列的最大容量時,服務器進程所在的主機會拒絕新的連接請求。只有當服務器進程通過ServerSocket的accept()方法從隊列中取出連接請求,使隊列騰出空位時,隊列才能繼續加入新的連接請求。
對于客戶進程,如果它發出的連接請求被加入到服務器的隊列中,就意味著客戶與服務器的連接建立成功,客戶進程從Socket構造方法中正常返回。如果客戶進程發出的連接請求被服務器拒絕,Socket構造方法就會拋出ConnectionException。
ServerSocket構造方法的backlog參數用來顯式設置連接請求隊列的長度,它將覆蓋操作系統限定的隊列的最大長度。值得注意的是,在以下幾種情況中,仍然會采用操作系統限定的隊列的最大長度:
◆backlog參數的值大于操作系統限定的隊列的最大長度;
◆backlog參數的值小于或等于0;
◆在ServerSocket構造方法中沒有設置backlog參數。
以下例程3-1的Client.java和例程3-2的Server.java用來演示服務器的連接請求隊列的特性。
例程3-1? Client.java
[java]view plaincopy?
?
import?java.net.*;??
public?class?Client?{??
public?static?void?main(String?args[])throws?Exception{??
final?int?length=100;??
String?host="localhost";??
int?port=8000;??
Socket[]?sockets=new?Socket[length];??
for(int?i=0;i
sockets[i]=new?Socket(host,?port);??
System.out.println("第"+(i+1)+"次連接成功");??
????}??
Thread.sleep(3000);??
for(int?i=0;i
sockets[i].close();//斷開連接??
????}???
??}??
}??
[java]view plaincopy?
?
import?java.io.*;??
import?java.net.*;??
public?class?Server?{??
private?int?port=8000;??
private?ServerSocket?serverSocket;??
public?Server()?throws?IOException?{??
serverSocket?=new?ServerSocket(port,3);????//連接請求隊列的長度為3??
System.out.println("服務器啟動");??
??}??
public?void?service()?{??
while?(true)?{??
Socket?socket=null;??
try?{??
socket?=?serverSocket.accept();//從連接請求隊列中取出一個連接???????????
System.out.println("New?connection?accepted?"?+??
socket.getInetAddress()?+":"?+socket.getPort());??
}catch?(IOException?e)?{??
?????????e.printStackTrace();??
}finally?{??
try{??
if(socket!=null)socket.close();??
}catch?(IOException?e)?{e.printStackTrace();}??
??????}??
????}??
??}??
public?static?void?main(String?args[])throws?Exception?{??
Server?server=new?Server();??
Thread.sleep(60000*10);??????//睡眠10分鐘??
//server.service();??
??}??
}??
例程3-2? Server.java
Client試圖與Server進行100次連接。在Server類中,把連接請求隊列的長度設為3。這意味著當隊列中有了3個連接請求時,如果Client再請求連接,就會被Server拒絕。下面按照以下步驟運行Server和Client程序。
(1)把Server類的main()方法中的“server.service();”這行程序代碼注釋掉。這使得服務器與8000端口綁定后,永遠不會執行serverSocket.accept()方法。這意味著隊列中的連接請求永遠不會被取出。先運行Server程序,然后再運行Client程序,Client程序的打印結果如下:
[java]view plaincopy?
?
第1次連接成功??
第2次連接成功??
第3次連接成功??
Exception?in?thread"main"?java.net.ConnectException:?Connection?refused:?connect??????????
????????at?java.net.PlainSocketImpl.socketConnect(Native?Method)??
????????at?java.net.PlainSocketImpl.doConnect(Unknown?Source)??
????????at?java.net.PlainSocketImpl.connectToAddress(Unknown?Source)??
????????at?java.net.PlainSocketImpl.connect(Unknown?Source)??
????????at?java.net.SocksSocketImpl.connect(Unknown?Source)??
????????at?java.net.Socket.connect(Unknown?Source)??
????????at?java.net.Socket.connect(Unknown?Source)??
????????at?java.net.Socket.(Unknown?Source)??
????????at?java.net.Socket.(Unknown?Source)??
at?Client.main(Client.java:10)??
(2)把Server類的main()方法按如下方式修改:從以上打印結果可以看出,Client與Server在成功地建立了3個連接后,就無法再創建其余的連接了,因為服務器的隊列已經滿了。
[java]view plaincopy?
?
public?static?void?main(String?args[])throws?Exception?{???????????????????
Server?server=new?Server();??
//Thread.sleep(60000*10);??//睡眠10分鐘??
????server.service();??
??}??
作了以上修改,服務器與8 000端口綁定后,就會在一個while循環中不斷執行serverSocket.accept()方法,該方法從隊列中取出連接請求,使得隊列能及時騰出空位,以容納新的連接請求。先運行Server程序,然后再運行Client程序,Client程序的打印結果如下:
[java]view plaincopy?
?
第1次連接成功??
第2次連接成功??
第3次連接成功??
…??
第100次連接成功??
從以上打印結果可以看出,此時Client能順利與Server建立100次連接。
3.1.3? 設定綁定的IP地址
如果主機只有一個IP地址,那么默認情況下,服務器程序就與該IP地址綁定。ServerSocket的第4個構造方法ServerSocket(int port, int backlog, InetAddress bindAddr)有一個bindAddr參數,它顯式指定服務器要綁定的IP地址,該構造方法適用于具有多個IP地址的主機。假定一個主機有兩個網卡,一個網卡用于連接到Internet, IP地址為222.67.5.94,還有一個網卡用于連接到本地局域網,IP地址為192.168.3.4。如果服務器僅僅被本地局域網中的客戶訪問,那么可以按如下方式創建ServerSocket:
[java]view plaincopy?
?
ServerSocket?serverSocket=new?ServerSocket(8000,10,InetAddress.getByName?("192.168.3.4"));??
3.1.4? 默認構造方法的作用
ServerSocket有一個不帶參數的默認構造方法。通過該方法創建的ServerSocket不與任何端口綁定,接下來還需要通過bind()方法與特定端口綁定。
這個默認構造方法的用途是,允許服務器在綁定到特定端口之前,先設置ServerSocket的一些選項。因為一旦服務器與特定端口綁定,有些選項就不能再改變了。
在以下代碼中,先把ServerSocket的SO_REUSEADDR選項設為true,然后再把它與8000端口綁定:
[java]view plaincopy?
?
ServerSocket?serverSocket=new?ServerSocket();??
serverSocket.setReuseAddress(true);??????//設置ServerSocket的選項??
serverSocket.bind(new?InetSocketAddress(8000));???//與8000端口綁定??
如果把以上程序代碼改為:
[java]view plaincopy?
?
ServerSocket?serverSocket=new?ServerSocket(8000);??
serverSocket.setReuseAddress(true);??????//設置ServerSocket的選項??
那么serverSocket.setReuseAddress(true)方法就不起任何作用了,因為SO_ REUSEADDR選項必須在服務器綁定端口之前設置才有效。
3.2? 接收和關閉與客戶的連接
ServerSocket的accept()方法從連接請求隊列中取出一個客戶的連接請求,然后創建與客戶連接的Socket對象,并將它返回。如果隊列中沒有連接請求,accept()方法就會一直等待,直到接收到了連接請求才返回。
接下來,服務器從Socket對象中獲得輸入流和輸出流,就能與客戶交換數據。當服務器正在進行發送數據的操作時,如果客戶端斷開了連接,那么服務器端會拋出一個IOException的子類SocketException異常:
[java]view plaincopy?
?
java.net.SocketException:?Connection?reset?by?peer??
這只是服務器與單個客戶通信中出現的異常,這種異常應該被捕獲,使得服務器能繼續與其他客戶通信。
以下程序顯示了單線程服務器采用的通信流程:
[java]view plaincopy?
?
public?void?service()?{??
while?(true)?{??
Socket?socket=null;??
try?{??
socket?=?serverSocket.accept();//從連接請求隊列中取出一個連接??
System.out.println("New?connection?accepted?"?+??
socket.getInetAddress()?+":"?+socket.getPort());??
//接收和發送數據??
??????…??
}catch?(IOException?e)?{??
//這只是與單個客戶通信時遇到的異常,可能是由于客戶端過早斷開連接引起的?????
//這種異常不應該中斷整個while循環??
???????e.printStackTrace();??
}finally?{??
try{??
if(socket!=null)socket.close();????//與一個客戶通信結束后,要關閉Socket????????????
}catch?(IOException?e)?{e.printStackTrace();}??
????}??
??}??
}??
與單個客戶通信的代碼放在一個try代碼塊中,如果遇到異常,該異常被catch代碼塊捕獲。try代碼塊后面還有一個finally代碼塊,它保證不管與客戶通信正常結束還是異常結束,最后都會關閉Socket,斷開與這個客戶的連接。
3.3? 關閉ServerSocket
ServerSocket的close()方法使服務器釋放占用的端口,并且斷開與所有客戶的連接。當一個服務器程序運行結束時,即使沒有執行ServerSocket的close()方法,操作系統也會釋放這個服務器占用的端口。因此,服務器程序并不一定要在結束之前執行ServerSocket的close()方法。
在某些情況下,如果希望及時釋放服務器的端口,以便讓其他程序能占用該端口,則可以顯式調用ServerSocket的close()方法。例如,以下代碼用于掃描1~65535之間的端口號。如果ServerSocket成功創建,意味著該端口未被其他服務器進程綁定,否者說明該端口已經被其他進程占用:
[java]view plaincopy?
?
for(int?port=1;port<=65535;port++){??
try{??
ServerSocket?serverSocket=new?ServerSocket(port);??
serverSocket.close();//及時關閉ServerSocket??
}catch(IOException?e){??
System.out.println("端口"+port+"?已經被其他服務器進程占用");??
}??
}??
以上程序代碼創建了一個ServerSocket對象后,就馬上關閉它,以便及時釋放它占用的端口,從而避免程序臨時占用系統的大多數端口。
ServerSocket的isClosed()方法判斷ServerSocket是否關閉,只有執行了ServerSocket的close()方法,isClosed()方法才返回true;否則,即使ServerSocket還沒有和特定端口綁定,isClosed()方法也會返回false。
ServerSocket的isBound()方法判斷ServerSocket是否已經與一個端口綁定,只要ServerSocket已經與一個端口綁定,即使它已經被關閉,isBound()方法也會返回true。
如果需要確定一個ServerSocket已經與特定端口綁定,并且還沒有被關閉,則可以采用以下方式:
[java]view plaincopy?
?
boolean?isOpen=serverSocket.isBound()?&&?!serverSocket.isClosed();??
3.4? 獲取ServerSocket的信息
ServerSocket的以下兩個get方法可分別獲得服務器綁定的IP地址,以及綁定的端口:
◆public InetAddress getInetAddress()
◆public int getLocalPort()
前面已經講到,在構造ServerSocket時,如果把端口設為0,那么將由操作系統為服務器分配一個端口(稱為匿名端口),程序只要調用getLocalPort()方法就能獲知這個端口號。如例程3-3所示的RandomPort創建了一個ServerSocket,它使用的就是匿名端口。
#p#
例程3-3? RandomPort.java
[java]view plaincopy?
?
import?java.io.*;??
import?java.net.*;??
public?class?RandomPort{??
public?static?void?main(String?args[])throws?IOException{??
ServerSocket?serverSocket=new?ServerSocket(0);??
System.out.println("監聽的端口為:"+serverSocket.getLocalPort());???????
??}??
}??
多次運行RandomPort程序,可能會得到如下運行結果:
[java]view plaincopy?
?
C:\chapter03\classes>java?RandomPort??
監聽的端口為:3000??
C:\chapter03\classes>java?RandomPort??
監聽的端口為:3004??
C:\chapter03\classes>java?RandomPort??
監聽的端口為:3005??
多數服務器會監聽固定的端口,這樣才便于客戶程序訪問服務器。匿名端口一般適用于服務器與客戶之間的臨時通信,通信結束,就斷開連接,并且ServerSocket占用的臨時端口也被釋放。
FTP(文件傳輸)協議就使用了匿名端口。如圖3-1所示,FTP協議用于在本地文件系統與遠程文件系統之間傳送文件。
圖3-1? FTP協議用于在本地文件系統與遠程文件系統之間傳送文件
FTP使用兩個并行的TCP連接:一個是控制連接,一個是數據連接。控制連接用于在客戶和服務器之間發送控制信息,如用戶名和口令、改變遠程目錄的命令或上傳和下載文件的命令。數據連接用于傳送文件。TCP服務器在21端口上監聽控制連接,如果有客戶要求上傳或下載文件,就另外建立一個數據連接,通過它來傳送文件。數據連接的建立有兩種方式。
(1)如圖3-2所示,TCP服務器在20端口上監聽數據連接,TCP客戶主動請求建立與該端口的連接。
圖3-2? TCP服務器在20端口上監聽數據連接
(2)如圖3-3所示,首先由TCP客戶創建一個監聽匿名端口的ServerSocket,再把這個ServerSocket監聽的端口號(調用ServerSocket的getLocalPort()方法就能得到端口號)發送給TCP服務器,然后由TCP服務器主動請求建立與客戶端的連接。
圖3-3? TCP客戶在匿名端口上監聽數據連接
以上第二種方式就使用了匿名端口,并且是在客戶端使用的,用于和服務器建立臨時的數據連接。在實際應用中,在服務器端也可以使用匿名端口。
3.5? ServerSocket選項
ServerSocket有以下3個選項。
◆SO_TIMEOUT:表示等待客戶連接的超時時間。
◆SO_REUSEADDR:表示是否允許重用服務器所綁定的地址。
◆SO_RCVBUF:表示接收數據的緩沖區的大小。
3.5.1? SO_TIMEOUT選項
◆設置該選項:public void setSoTimeout(int timeout) throws SocketException
◆讀取該選項:public int getSoTimeout () throws IOException
SO_TIMEOUT表示ServerSocket的accept()方法等待客戶連接的超時時間,以毫秒為單位。如果SO_TIMEOUT的值為0,表示永遠不會超時,這是SO_TIMEOUT的默認值。
當服務器執行ServerSocket的accept()方法時,如果連接請求隊列為空,服務器就會一直等待,直到接收到了客戶連接才從accept()方法返回。如果設定了超時時間,那么當服務器等待的時間超過了超時時間,就會拋出SocketTimeoutException,它是InterruptedException的子類。
如例程3-4所示的TimeoutTester把超時時間設為6秒鐘。
#p#
例程3-4? TimeoutTester.java
[java]view plaincopy?
?
import?java.io.*;??
import?java.net.*;??
public?class?TimeoutTester{??
public?static?void?main(String?args[])throws?IOException{??
ServerSocket?serverSocket=new?ServerSocket(8000);??
serverSocket.setSoTimeout(6000);?//等待客戶連接的時間不超過6秒???????????
????Socket?socket=serverSocket.accept();???
????socket.close();??
System.out.println("服務器關閉");??
??}??
}??
運行以上程序,過6秒鐘后,程序會從serverSocket.accept()方法中拋出Socket- TimeoutException:
[java]view plaincopy?
?
C:\chapter03\classes>java?TimeoutTester??
Exception?in?thread"main"?java.net.SocketTimeoutException:?Accept?timed?out????????
????????at?java.net.PlainSocketImpl.socketAccept(Native?Method)??
????????at?java.net.PlainSocketImpl.accept(Unknown?Source)??
????????at?java.net.ServerSocket.implAccept(Unknown?Source)??
????????at?java.net.ServerSocket.accept(Unknown?Source)??
at?TimeoutTester.main(TimeoutTester.java:8)??
如果把程序中的“serverSocket.setSoTimeout(6000)”注釋掉,那么serverSocket. accept()方法永遠不會超時,它會一直等待下去,直到接收到了客戶的連接,才會從accept()方法返回。
Tips:服務器執行serverSocket.accept()方法時,等待客戶連接的過程也稱為阻塞。本書第4章的4.1節(線程阻塞的概念)詳細介紹了阻塞的概念。
3.5.2? SO_REUSEADDR選項
◆設置該選項:public void setResuseAddress(boolean on) throws SocketException
◆讀取該選項:public boolean getResuseAddress() throws SocketException
這個選項與Socket的SO_REUSEADDR選項相同,用于決定如果網絡上仍然有數據向舊的ServerSocket傳輸數據,是否允許新的ServerSocket綁定到與舊的ServerSocket同樣的端口上。SO_REUSEADDR選項的默認值與操作系統有關,在某些操作系統中,允許重用端口,而在某些操作系統中不允許重用端口。
當ServerSocket關閉時,如果網絡上還有發送到這個ServerSocket的數據,這個ServerSocket不會立刻釋放本地端口,而是會等待一段時間,確保接收到了網絡上發送過來的延遲數據,然后再釋放端口。
許多服務器程序都使用固定的端口。當服務器程序關閉后,有可能它的端口還會被占用一段時間,如果此時立刻在同一個主機上重啟服務器程序,由于端口已經被占用,使得服務器程序無法綁定到該端口,服務器啟動失敗,并拋出BindException:
[java]view plaincopy?
?
Exception?in?thread?"main"?java.net.BindException:?Address?already?in?use:?JVM_Bind??
為了確保一個進程關閉了ServerSocket后,即使操作系統還沒釋放端口,同一個主機上的其他進程還可以立刻重用該端口,可以調用ServerSocket的setResuse- Address(true)方法:
[java]view plaincopy?
?
if(!serverSocket.getResuseAddress())serverSocket.setResuseAddress(true);??
值得注意的是,serverSocket.setResuseAddress(true)方法必須在ServerSocket還沒有綁定到一個本地端口之前調用,否則執行serverSocket.setResuseAddress(true)方法無效。此外,兩個共用同一個端口的進程必須都調用serverSocket.setResuseAddress(true)方法,才能使得一個進程關閉ServerSocket后,另一個進程的ServerSocket還能夠立刻重用相同端口。
3.5.3? SO_RCVBUF選項
◆設置該選項:public void setReceiveBufferSize(int size) throws SocketException
◆讀取該選項:public int getReceiveBufferSize() throws SocketException
SO_RCVBUF表示服務器端的用于接收數據的緩沖區的大小,以字節為單位。一般說來,傳輸大的連續的數據塊(基于HTTP或FTP協議的數據傳輸)可以使用較大的緩沖區,這可以減少傳輸數據的次數,從而提高傳輸數據的效率。而對于交互式的通信(Telnet和網絡游戲),則應該采用小的緩沖區,確保能及時把小批量的數據發送給對方。
SO_RCVBUF的默認值與操作系統有關。例如,在Windows 2000中運行以下代碼時,顯示SO_RCVBUF的默認值為8192:
[java]view plaincopy?
?
ServerSocket?serverSocket=new?ServerSocket(8000);??
System.out.println(serverSocket.getReceiveBufferSize());//打印8192??????
無論在ServerSocket綁定到特定端口之前或之后,調用setReceiveBufferSize()方法都有效。例外情況下是如果要設置大于64K的緩沖區,則必須在ServerSocket綁定到特定端口之前進行設置才有效。例如,以下代碼把緩沖區設為128K:
[java]view plaincopy?
?
ServerSocket?serverSocket=new?ServerSocket();??
int?size=serverSocket.getReceiveBufferSize();??
if(size<131072)?serverSocket.setReceiveBufferSize(131072);??//把緩沖區的大小設為128K????????
serverSocket.bind(new?InetSocketAddress(8000));?????//與8000端口綁定??
3.5.4? 設定連接時間、延遲和帶寬的相對重要性執行serverSocket.setReceiveBufferSize()方法,相當于對所有由serverSocket.accept()方法返回的Socket設置接收數據的緩沖區的大小。
◆public void setPerformancePreferences(int connectionTime,int latency,int bandwidth)
該方法的作用與Socket的setPerformancePreferences()方法的作用相同,用于設定連接時間、延遲和帶寬的相對重要性。
轉載自http://www.51cto.com/specbook/11/40196.htm