Java NIO4:Socket通道


Socket通道

上文講述了通道、文件通道,這篇文章來講述一下Socket通道,Socket通道與文件通道有着不一樣的特征,分三點說:

1、NIO的Socket通道類可以運行於非阻塞模式並且是可選擇的,這兩個性能可以激活大程序(如網絡服務器和中間件組件)巨大的可伸縮性和靈活性,因此,再也沒有為每個Socket連接使用一個線程的必要了。這一特性避免了管理大量線程所需的上下文交換總開銷,借助NIO類,一個或幾個線程就可以管理成百上千的活動Socket連接了並且只有很少甚至沒有性能損失

2、全部Socket通道類(DatagramChannel、SocketChannel和ServerSocketChannel)在被實例化時都會創建一個對應的Socket對象,就是我們所熟悉的來自java.net的類(Socket、ServerSocket和DatagramSocket),這些Socket可以通過調用socket()方法從通道類獲取,此外,這三個java.net類現在都有getChannel()方法

3、每個Socket通道(在java.nio.channels包中)都有一個關聯的java.net.socket對象,反之卻不是如此,如果使用傳統方式(直接實例化)創建了一個Socket對象,它就不會有關聯的SocketChannel並且它的getChannel()方法將總是返回null

概括地講,這就是Socket通道所要掌握的知識點知識點,不難,記住並通過自己寫代碼/查看JDK源碼來加深理解。

 

非阻塞模式

前面第一點說了,NIO的Socket通道可以運行於非阻塞模式,這個陳述雖然簡單卻有着深遠的含義。傳統Java Socket的阻塞性質曾經是Java程序可伸縮性的最重要制約之一,非阻塞I/O是許多復雜的、高性能的程序構建的基礎。

要把一個Socket通道置於非阻塞模式,要依賴的是Socket通道類的弗雷SelectableChannel,下面看一下這個類的簡單定義:

public abstract class SelectableChannel extends AbstractInterruptibleChannel implements Channel
{
    ...
    public abstract void configureBlocking(boolean block) throws IOException;
    public abstract boolean isBlocking();
    public abstract Object blockngLock();
    ...
}

因為這篇文章是講述Socket通道的,因此省略了和選擇器相關的方法,這些省略的內容將在下一篇文章中說明。

從SelectableChannel的API中可以看出,設置或重新設置一個通道的阻塞模式是很簡單的,只要調用configureBlocking()方法即可,傳遞參數值為true則設為阻塞模式,參數值為false則設為非阻塞模式,就這么簡單。同時,我們可以通過調用isBlocking()方法來判斷某個Socket通道當前處於哪種模式中。

偶爾,我們也會需要放置Socket通道的阻塞模式被更改,所以API中有一個blockingLock()方法,該方法會返回一個非透明對象引用,返回的對象是通道實現修改阻塞模式時內部使用的,只有擁有此對象的鎖的線程才能更改通道的阻塞模式,對於確保在執行代碼的關鍵部分時Socket通道的阻塞模式不會改變以及在不影響其他線程的前提下暫時改變阻塞模式來說,這個方法是非常方便的。

 

Socket通道服務端程序

OK,接下來先看下Socket通道服務端程序應該如何編寫:

 1 public class NonBlockingSocketServer
 2 {
 3     public static void main(String[] args) throws Exception
 4     {
 5         int port = 1234;
 6         if (args != null && args.length > 0)
 7         {
 8             port = Integer.parseInt(args[0]);
 9         }
10         ServerSocketChannel ssc = ServerSocketChannel.open();
11         ssc.configureBlocking(false);
12         ServerSocket ss = ssc.socket();
13         ss.bind(new InetSocketAddress(port));
14         System.out.println("開始等待客戶端的數據!時間為" + System.currentTimeMillis());
15         while (true)
16         {
17             SocketChannel sc = ssc.accept();
18             if (sc == null)
19             {
20                 // 如果當前沒有數據,等待1秒鍾再次輪詢是否有數據,在學習了Selector之后此處可以使用Selector
21                 Thread.sleep(1000);
22             }
23             else
24             {
25                 System.out.println("客戶端已有數據到來,客戶端ip為:" + sc.socket().getRemoteSocketAddress() 
26                         + ", 時間為" + System.currentTimeMillis()) ;
27                 ByteBuffer bb = ByteBuffer.allocate(100);
28                 sc.read(bb);
29                 bb.flip();
30                 while (bb.hasRemaining())
31                 {
32                     System.out.print((char)bb.get());
33                 }
34                 sc.close();
35                 System.exit(0);
36             }
37         }
38     }
39 }

整個代碼流程大致上就是這樣,沒什么特別值得講的,注意一下第18行~第22行,由於這里還沒有講到Selector,因此當客戶端Socket沒有到來的時候選擇的處理辦法是每隔1秒鍾輪詢一次。

 

Socket通道客戶端程序

服務器端經常會使用非阻塞Socket通達,因為它們使同時管理很多Socket通道變得更容易,客戶端卻並不強求,因為客戶端發起的Socket操作往往比較少,且都是一個接着一個發起的。但是,在客戶端使用一個或幾個非阻塞模式的Socket通道也是有益處的,例如借助非阻塞Socket通道,GUI程序可以專注於用戶請求並且同時維護與一個或多個服務器的會話。在很多程序上,非阻塞模式都是有用的,所以,我們看一下客戶端應該如何使用Socket通道:

 1 public class NonBlockingSocketClient
 2 {
 3     private static final String STR = "Hello World!";
 4     private static final String REMOTE_IP= "127.0.0.1";
 5     
 6     public static void main(String[] args) throws Exception
 7     {
 8         int port = 1234;
 9         if (args != null && args.length > 0)
10         {
11             port = Integer.parseInt(args[0]);
12         }
13         SocketChannel sc = SocketChannel.open();
14         sc.configureBlocking(false);
15         sc.connect(new InetSocketAddress(REMOTE_IP, port));
16         while (!sc.finishConnect())
17         {
18             System.out.println("同" + REMOTE_IP+ "的連接正在建立,請稍等!");
19             Thread.sleep(10);
20         }
21         System.out.println("連接已建立,待寫入內容至指定ip+端口!時間為" + System.currentTimeMillis());
22         ByteBuffer bb = ByteBuffer.allocate(STR.length());
23         bb.put(STR.getBytes());
24         bb.flip(); // 寫緩沖區的數據之前一定要先反轉(flip)
25         sc.write(bb);
26         bb.clear();
27         sc.close();
28     }
29 }

總得來說和普通的Socket操作差不多,通過通道讀寫數據,非常方便。不過再次提醒,通道只能操作字節緩沖區也就是ByteBuffer的數據

 

運行結果展示

上面的代碼,為了展示結果的需要,在關鍵點上都加上了時間打印,這樣會更清楚地看到運行結果。

首先運行服務端程序(注意不可以先運行客戶端程序,如果先運行客戶端程序,客戶端程序會因為服務端未開啟監聽而拋出ConnectionException),看一下:

看到紅色方塊,此時程序是運行的,接着運行客戶端程序:

看到客戶端已經將"Hello World!"寫入了Socket並通過通道傳到了服務器端,方框變灰,說明程序運行結束了。此時看一下服務器端有什么變化:

看到服務器端打印出了字符串"Hello World!",並且方框變灰,程序運行結束,這和代碼是一致的。

注意一點,客戶端看到的時間是XXX10307,服務器端看到的時間是XXX10544,這是很正常的,因為前面說過了,服務器端程序是每隔一秒鍾輪詢一次是否有Socket到來的。

當然,由於服務端程序的作用是監聽1234端口,因此完全可以寫客戶端的代碼,可以直接訪問http://127.0.0.1:1234/a/b/c/d/?e=5&f=6&g=7就可以了,看一下效果:

有了這個基礎,我們就可以自己解析HTTP請求,甚至可以自己寫一個Web服務器。

 

客戶端Socket通道復用性的研究

這個是我今天上班的時候想到的一個問題,補充到最后。

服務器端程序不變,客戶端現在是單個線程發送了一次數據到服務端的,假如現在我的客戶端有多條線程同時通過Socket通道發送數據到服務端又會是怎么樣的現象?首先將服務端端的代碼稍作改變,讓服務端SocketChannel在拿到客戶端的數據之后程序不會停止運行而是會持續監聽來自客戶端的Socket,由於服務器端的代碼比較多,這里只列一下改動的地方,:

...
bb.flip();
while (bb.hasRemaining())
{
    System.out.print((char)bb.get());
}
System.out.println();
//sc.close();
//System.exit(0);
...

接着看一下對客戶端代碼的啟動,把寫數據的操作放到線程的run方法中去:

 1 public class NonBlockingSocketClient
 2 {
 3     private static final String STR = "Hello World!";
 4     private static final String REMOTE_IP = "127.0.0.1";
 5     private static final int THREAD_COUNT = 5;
 6     
 7     private static class NonBlockingSocketThread extends Thread
 8     {
 9         private SocketChannel sc;
10         
11         public NonBlockingSocketThread(SocketChannel sc)
12         {
13             this.sc = sc;
14         }
15         
16         public void run()
17         {
18             try
19             {
20                 System.out.println("連接已建立,待寫入內容至指定ip+端口!時間為" + System.currentTimeMillis());
21                 String writeStr = STR + this.getName();
22                 ByteBuffer bb = ByteBuffer.allocate(writeStr.length());
23                 bb.put(writeStr.getBytes());
24                 bb.flip(); // 寫緩沖區的數據之前一定要先反轉(flip)
25                 sc.write(bb);
26                 bb.clear();
27             } 
28             catch (IOException e)
29             {
30                 e.printStackTrace();
31             }
32         }
33     }
34     
35     public static void main(String[] args) throws Exception
36     {
37         int port = 1234;
38         if (args != null && args.length > 0)
39         {
40             port = Integer.parseInt(args[0]);
41         }
42         SocketChannel sc = SocketChannel.open();
43         sc.configureBlocking(false);
44         sc.connect(new InetSocketAddress(REMOTE_IP, port));
45         while (!sc.finishConnect())
46         {
47             System.out.println("同" + REMOTE_IP + "的連接正在建立,請稍等!");
48             Thread.sleep(10);
49         }
50         
51         NonBlockingSocketThread[] nbsts = new NonBlockingSocketThread[THREAD_COUNT];
52         for (int i = 0; i < THREAD_COUNT; i++)
53             nbsts[i] = new NonBlockingSocketThread(sc);
54         for (int i = 0; i < THREAD_COUNT; i++)
55             nbsts[i].start();
56         // 一定要join保證線程代碼先於sc.close()運行,否則會有AsynchronousCloseException
57         for (int i = 0; i < THREAD_COUNT; i++)
58             nbsts[i].join();
59         
60         sc.close();
61     }
62 }

啟動了5個線程,我們可能期待服務端能有5次的數據到來,實際上是:

原因就是客戶端的五個線程共用了同一個SocketChannel,這樣相當於五個線程把數據輪番寫到緩沖區,寫完之后再把數據通過通道傳輸到服務器端。ByteBuffer的write方法放心,是加鎖的,反編譯一下sun.nio.ch.SocketChannelImpl就知道了,因此不會出現"Hello World!Thread-X"這些字符交叉的情況。

所以有了這個經驗,我們讓每個線程都new一個自己的SocketChannel,於是客戶端程序變成了:

 1 public class NonBlockingSocketClient
 2 {
 3     private static final String STR = "Hello World!";
 4     private static final String REMOTE_IP = "127.0.0.1";
 5     private static final int THREAD_COUNT = 5;
 6     
 7     private static class NonBlockingSocketThread extends Thread
 8     {
 9         public void run()
10         {
11             try
12             {
13                 int port = 1234;
14                 SocketChannel sc = SocketChannel.open();
15                 sc.configureBlocking(false);
16                 sc.connect(new InetSocketAddress(REMOTE_IP, port));
17                 while (!sc.finishConnect())
18                 {
19                     System.out.println("同" + REMOTE_IP + "的連接正在建立,請稍等!");
20                     Thread.sleep(10);
21                 }
22                 System.out.println("連接已建立,待寫入內容至指定ip+端口!時間為" + System.currentTimeMillis());
23                 String writeStr = STR + this.getName();
24                 ByteBuffer bb = ByteBuffer.allocate(writeStr.length());
25                 bb.put(writeStr.getBytes());
26                 bb.flip(); // 寫緩沖區的數據之前一定要先反轉(flip)
27                 sc.write(bb);
28                 bb.clear();
29                 sc.close();
30             } 
31             catch (IOException e)
32             {
33                 e.printStackTrace();
34             } 
35             catch (InterruptedException e)
36             {
37                 e.printStackTrace();
38             }
39         }
40     }
41     
42     public static void main(String[] args) throws Exception
43     {
44         NonBlockingSocketThread[] nbsts = new NonBlockingSocketThread[THREAD_COUNT];
45         for (int i = 0; i < THREAD_COUNT; i++)
46             nbsts[i] = new NonBlockingSocketThread();
47         for (int i = 0; i < THREAD_COUNT; i++)
48             nbsts[i].start();
49         // 一定要join保證線程代碼先於sc.close()運行,否則會有AsynchronousCloseException
50         for (int i = 0; i < THREAD_COUNT; i++)
51             nbsts[i].join();
52     }
53 }

此時再運行,觀察結果:

看到沒有問題,服務器端分五次接收來自客戶端的請求了。

當然,這也是有一定問題的:

1、如果服務器端開放多線程使用ServerSocket通道去處理來自客戶端的數據的話,面對成千上萬的高並發很容易地就會耗盡服務器端寶貴的線程資源

2、如果服務器端只有一條ServerSocket通道線程處理來自客戶端的數據的話,一個客戶端的數據處理得慢將直接影響后面線程的數據處理

這么一說似乎又回到了非阻塞I/O的老問題了。不過,Socket通道講解到此,大體的概念我們已經清楚了,接着就輪到NIO的最后也是最難、最核心的部分----選擇器,將在下一篇文章進行詳細的講解。


免責聲明!

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



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