阿里畢玄-測試Java編程能力-我的回答(一)

畢玄老師發表了一篇公眾號文章:來測試下你的Java編程能力,本系列文章為其中問題的個人解答。

第一個問題:

基於BIO實現的Server端,當建立了100個連接時,會有多少個線程?如果基於NIO,又會是多少個線程? 為什麼?

說實話,如果面試被問到這個問題,也不敢保證能完全答對。那麼就回爐重造一下吧。

最簡單的BIO Server

服務端

package com.xetlab.javatest.question1;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.Charset;
public class ServerMain1 {
 private static final Logger logger = LoggerFactory.getLogger(ServerMain1.class);
 public static void main(String[] args) {
 logger.info("0.主線程啟動");
 try {
 //服務端初始化,在9999端口監聽
 ServerSocket serverSocket = new ServerSocket(9999);
 while (true) {
 //等待客戶端連接,如果沒有連接就阻塞當前線程
 Socket clientSocket = serverSocket.accept();
 logger.info("1.客戶端 {}:{} 已連接", clientSocket.getInetAddress().getHostAddress(), clientSocket.getPort());
 //向客戶端發消息
 logger.info("2.向客戶端發歡迎消息");
 clientSocket.getOutputStream().write("你好,請報上名來!".getBytes("UTF8"));
 clientSocket.getOutputStream().flush();
 //從客戶端讀取消息
 StringBuffer msgBuf = new StringBuffer();
 byte[] byteBuf = new byte[1024];
 clientSocket.getInputStream().read(byteBuf);
 msgBuf.append(new String(byteBuf, "UTF8"));
 logger.info("5.收到客戶端消息:{}", msgBuf);
 //向客戶端發消息
 logger.info("6.向客戶端發退出消息");
 clientSocket.getOutputStream().write(String.format("退下,%s!", msgBuf.toString()).getBytes(Charset.forName("UTF8")));
 clientSocket.getOutputStream().flush();
 }
 } catch (IOException e) {
 logger.error("server error", e);
 System.exit(1);
 }
 }
}

客戶端

package com.xetlab.javatest.question1;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.Socket;
public class ClientMain1 {
 private static final Logger logger = LoggerFactory.getLogger(ClientMain1.class);
 public static void main(String[] args) {
 try {
 Socket socket = new Socket("127.0.0.1", 9999);
 while (true) {
 StringBuffer msgBuf = new StringBuffer();
 byte[] byteBuf = new byte[1024];
 socket.getInputStream().read(byteBuf);
 msgBuf.append(new String(byteBuf, "UTF8"));
 logger.info("3.收到服務端消息:{}", msgBuf);
 logger.info("4.向服務端發送名字消息");
 socket.getOutputStream().write("Mr Nobody.".getBytes("UTF8"));
 socket.getOutputStream().flush();
 msgBuf = new StringBuffer();
 byteBuf = new byte[1024];
 socket.getInputStream().read(byteBuf);
 msgBuf.append(new String(byteBuf, "UTF8"));
 logger.info("7.收到服務端消息:{}", msgBuf);
 if (msgBuf.toString().startsWith("退下")) {
 socket.close();
 logger.info("8.客戶端退出");
 break;
 }
 }
 } catch (IOException e) {
 logger.error("client error", e);
 System.exit(1);
 }
 }
}

對應的輸出(已按順序組織)

2019-03-23 23:36:39,480 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 0.主線程啟動
2019-03-23 23:36:44,883 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 1.客戶端 127.0.0.1:7473 已連接
2019-03-23 23:36:44,884 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 2.向客戶端發歡迎消息
2019-03-23 23:36:44,888 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 3.收到服務端消息:你好,請報上名來! 
2019-03-23 23:36:44,891 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 4.向服務端發送名字消息
2019-03-23 23:36:44,891 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 5.收到客戶端消息:Mr Nobody. 
2019-03-23 23:36:44,892 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 6.向客戶端發退出消息
2019-03-23 23:36:44,892 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 7.收到服務端消息:退下,Mr Nobody. 
2019-03-23 23:36:44,892 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 8.客戶端退出

如果我們按上面的方式實現Server端,答案會是:BIO Server端,一個線程就夠了。我們來分析下這種實現方式的優缺點。

優點

  1. 簡單,適合java socket編程入門。
  2. 好像只有簡單了。

缺點

  1. 一次只能服務一個客戶端,別的客戶端只能等待,具體表現是:如果同時啟動兩個慢客戶端,那麼兩個客戶端的底層TCP連接是建立好的,先啟動的客戶端會先得到服務,但後啟動的那個客戶端會在讀取數據時一直被阻塞,如下所示(windows):
  2. netstat -ano|find "9999"
 TCP 127.0.0.1:9999 127.0.0.1:29712 ESTABLISHED 16996
 TCP 127.0.0.1:9999 127.0.0.1:29740 ESTABLISHED 16996
  1. 服務端輸出
2019-03-24 10:47:48,881 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 0.主線程啟動
2019-03-24 10:47:52,549 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 1.客戶端 127.0.0.1:29712 已連接
2019-03-24 10:47:52,550 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 2.向客戶端發歡迎消息
  1. 客戶端1收到消息後,休眠
2019-03-24 10:47:52,555 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 3.收到服務端消息:你好,請報上名來!
  1. 客戶端2
//客戶端2在此處被阻塞
socket.getInputStream().read(byteBuf);
  1. 實現不了同時服務100個客戶端。

因此這種方式實現的Server端,只能用於入門示例,不能用於生產環境。另外BIO全稱是Blocking IO,即阻塞式IO,這個BIO體現在哪呢?體現在這兩處:

//1.當客戶端沒發消息過來時,此時服務端讀取消息時就會阻塞
//2.當讀取的數據較多時,線程沒有阻塞,但是讀取數據的耗時會挺久
clientSocket.getInputStream().read(bytes);
//當給客戶端發送的數據較多時,這裡線程沒有阻塞,但是寫數據的耗時會挺久
clientSocket.getOutputStream().write(bytes);

Tips

  • BIO其實包含兩層含義:讀取時數據未準備好,當前線程會阻塞;數據的讀寫是耗時的操作。
  • server和client之間的通信通過socket的InputStream和OutputStream進行。
  • server和client之間的通信需要預先定義好通信協議(如示例中就隱含了一個規定,大家每次發送的消息不超過1024個字節,讀取時也是讀取最多1024個字節,如果違反了這個規定,要嗎數據亂了,要嗎server或client在讀取數據時被阻塞)。
  • 寫數據時要記得flush一下,不然數據只是寫到緩存裡,並沒有發送出去。

引入多線程

服務端

package com.xetlab.javatest.question1;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.Charset;
public class ServerMain2 {
 private static final Logger logger = LoggerFactory.getLogger(ServerMain2.class);
 public static void main(String[] args) {
 logger.info("0.主線程啟動");
 try {
 //服務端初始化,在9999端口監聽
 ServerSocket serverSocket = new ServerSocket(9999);
 while (true) {
 //等待客戶端連接,如果沒有連接就阻塞當前線程
 Socket clientSocket = serverSocket.accept();
 String clientId = String.format("%s:%s", clientSocket.getInetAddress().getHostAddress(), clientSocket.getPort());
 logger.info("1.客戶端 {} 已連接", clientId);
 new Thread(new Handler(clientSocket), clientId).start();
 }
 } catch (IOException e) {
 logger.error("server error", e);
 System.exit(1);
 }
 }
 static class Handler implements Runnable {
 private Socket clientSocket;
 public Handler(Socket clientSocket) {
 this.clientSocket = clientSocket;
 }
 public void run() {
 try {
 //向客戶端發消息
 logger.info("2.向客戶端發歡迎消息");
 clientSocket.getOutputStream().write("你好,請報上名來!".getBytes("UTF8"));
 clientSocket.getOutputStream().flush();
 //從客戶端讀取消息
 StringBuffer msgBuf = new StringBuffer();
 byte[] byteBuf = new byte[1024];
 clientSocket.getInputStream().read(byteBuf);
 msgBuf.append(new String(byteBuf, "UTF8"));
 logger.info("5.收到客戶端消息:{}", msgBuf);
 //向客戶端發消息
 logger.info("6.向客戶端發退出消息");
 clientSocket.getOutputStream().write(String.format("退下,%s!", msgBuf.toString()).getBytes(Charset.forName("UTF8")));
 clientSocket.getOutputStream().flush();
 } catch (IOException e) {
 logger.error("io error", e);
 }
 }
 }
}
 

輸出

客戶端保持不變,只是把其中一個在回覆名字前故意休眠很久,另一個保持正常。此時各端的輸出如下:

服務端

2019-03-24 12:50:56,514 [INFO] com.xetlab.javatest.question1.ServerMain2 [main] - 0.主線程啟動
2019-03-24 12:51:02,613 [INFO] com.xetlab.javatest.question1.ServerMain2 [main] - 1.客戶端 127.0.0.1:44334 已連接
2019-03-24 12:51:02,613 [INFO] com.xetlab.javatest.question1.ServerMain2 [127.0.0.1:44334] - 2.向客戶端發歡迎消息
2019-03-24 12:51:08,331 [INFO] com.xetlab.javatest.question1.ServerMain2 [main] - 1.客戶端 127.0.0.1:44347 已連接
2019-03-24 12:51:08,331 [INFO] com.xetlab.javatest.question1.ServerMain2 [127.0.0.1:44347] - 2.向客戶端發歡迎消息
2019-03-24 12:51:08,339 [INFO] com.xetlab.javatest.question1.ServerMain2 [127.0.0.1:44347] - 5.收到客戶端消息:Mr Nobody. 
2019-03-24 12:51:08,339 [INFO] com.xetlab.javatest.question1.ServerMain2 [127.0.0.1:44347] - 6.向客戶端發退出消息

慢客戶端先連接,收到消息後,休眠

2019-03-24 12:51:02,619 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 3.收到服務端消息:你好,請報上名來! 

正常客戶端後連接

2019-03-24 12:51:08,336 [INFO] com.xetlab.javatest.question1.ClientMain2 [main] - 3.收到服務端消息:你好,請報上名來! 
2019-03-24 12:51:08,338 [INFO] com.xetlab.javatest.question1.ClientMain2 [main] - 4.向服務端發送名字消息
2019-03-24 12:51:08,339 [INFO] com.xetlab.javatest.question1.ClientMain2 [main] - 7.收到服務端消息:退下,Mr Nobody. 
2019-03-24 12:51:08,340 [INFO] com.xetlab.javatest.question1.ClientMain2 [main] - 8.客戶端退出

可以看到,引入多線程後,每個線程服務一個客戶端,可以同時服務100個連接了,如果這樣實現Server端,IO還是BIO,線程數需要101個,一個線程用於接受客戶端連接,100個線程用於服務客戶端。同樣來分析下優缺點。

優點

  1. 簡單,和最簡單版本相比,只是把和客戶端IO相關的處理放到了線程裡處理。
  2. 可以同時服務N個連接。

缺點

  1. 每個線程都要佔用內存,當客戶端保持長連接,數量越來越多達到一定值時,就會出現錯誤:OutOfMemoryError:unable to create new native thread。
  2. 一個客戶端分配一個線程,太浪費資源了,因為BIO的緣故,線程大部分時間都處於阻塞或等待讀寫狀態。
  3. 即使機器性能高,內存大,當線程很多時,線程上下文切換也會帶來很大的開銷。

Tips

編寫多線程任務時,可以把執行任務的邏輯使用Runnable接口來實現,這樣任務可以直接放到Thread線程對象裡執行,也可以提交到線程池中去執行。

NIO上場

有沒有可能同時具備方式一和二的優點呢,具體來說就是,一個線程同時服務N個客戶端?Yes,NIO就可以!那什麼是NIO?NIO即New IO,更多時候我們是看成Non blocking IO,就是非阻塞IO。

具體NIO如何實現一個線程服務N個客戶端,在深入代碼細節前,我們先理一理。

回顧上面的BIO實現,我們知道有這幾個點會阻塞或者響應慢:

  1. serverSocket.accept(),這裡是服務端等待客戶端連接。
  2. clientSocket.getInputStream().read(),這裡是等待客戶端傳送數據過來。
  3. clientSocket.getOutputStream().write(),這裡是往客戶端寫數據。

由於會阻塞或者響應慢BIO用了不同的線程去分別處理,如果可以只由一個線程去負責檢查是否有客戶端連接,客戶端的數據是否可讀,是否可以往客戶端寫數據,當有對應的事件已經準備好時,再由於當前線程去處理相應的任務,那就完美了。

NIO裡有個對象是Selector,這個Selector就是用於註冊事件,並檢查事件是否已準備好。現在來看下具體代碼。

package com.xetlab.javatest.question1;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
public class ServerMain3 {
 private static final Logger logger = LoggerFactory.getLogger(ServerMain3.class);
 public static void main(String[] args) {
 logger.info("0.主線程啟動");
 try {
 Map msgQueueMap = new ConcurrentHashMap();
 //創建channel管理器,用於註冊channel的事件
 Selector selector = Selector.open();
 //服務端初始化,在9999端口監聽,保留BIO初始化方式用於參照
 //ServerSocket serverSocket = new ServerSocket(9999);
 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
 //設置非阻塞
 serverSocketChannel.configureBlocking(false);
 serverSocketChannel.socket().bind(new InetSocketAddress(9999));
 //註冊可accept事件
 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
 while (true) {
 //NIO僅有的一個阻塞方法,當有註冊的事件產生時,才會返回
 selector.select();
 //產生事件的事件源列表
 Set readyKeys = selector.selectedKeys();
 Iterator keyItr = readyKeys.iterator();
 while (keyItr.hasNext()) {
 SelectionKey readyKey = keyItr.next();
 keyItr.remove();
 if (readyKey.isAcceptable()) {
 ServerSocketChannel serverChannel = (ServerSocketChannel) readyKey.channel();
 //接受客戶端
 SocketChannel clientChannel = serverChannel.accept();
 String clientId = String.format("%s:%s", clientChannel.socket().getInetAddress().getHostAddress(), clientChannel.socket().getPort());
 logger.info("1.客戶端 {} 已連接", clientId);
 msgQueueMap.put(clientChannel, new ArrayBlockingQueue(100));
 logger.info("2.向客戶端發歡迎消息");
 //NIO發消息先放到消息隊列裡,等可寫時再發
 msgQueueMap.get(clientChannel).add("你好,請報上名來!");
 
 //設置非阻塞
 clientChannel.configureBlocking(false);
 //註冊可讀和可寫事件
 clientChannel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);
 } else if (readyKey.isReadable()) {
 SocketChannel clientChannel = (SocketChannel) readyKey.channel();
 ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
 int bytesRead = clientChannel.read(byteBuffer);
 if (bytesRead <= 0) {
 continue;
 }
 byteBuffer.flip();
 byte[] msgByte = new byte[bytesRead];
 byteBuffer.get(msgByte);
 final String clientName = new String(msgByte, "UTF8");
 logger.info("5.收到客戶端消息:{}", clientName);
 msgQueueMap.get(clientChannel).add(String.format("退下!%s", clientName));
 } else if (readyKey.isWritable()) {
 SocketChannel clientChannel = (SocketChannel) readyKey.channel();
 Queue msgQueue = msgQueueMap.get(clientChannel);
 String msg = msgQueue.poll();
 if (msg != null) {
 ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
 byteBuffer.put(msg.getBytes("UTF8"));
 byteBuffer.flip();
 clientChannel.write(byteBuffer);
 logger.info("6.向客戶端發退出消息");
 }
 }
 }
 }
 } catch (IOException e) {
 logger.error("server error", e);
 System.exit(1);
 }
 }
}

上面我們用NIO實現了和原來BIO一模一樣的邏輯,NIO確實是只用一個線程高效的解決了問題,但是代碼看起來複雜多了。不過我們用偽代碼總結一下,會簡單一點:

  1. 準備好Selector(源代碼註釋中叫channel多路複用器)。
  2. 準備好ServerSocketChannel(對應BIO裡的ServerSocket)。
  3. ServerSocketChannel向Selector註冊accept事件(即客戶端連接就緒事件)
  4. 循環
  • 檢查Selector是否有新的就緒事件,如果沒有就阻塞等待,如果有就返回產生的就緒事件列表。
  • 如果是accept事件(客戶端連接就緒事件),就接受客戶端連接得到SocketChannel(對應BIO中的Socket),SocketChannel向Selector註冊讀寫就緒事件。
  • 如果是讀就緒事件,那麼讀取對應SocketChannel的數據,並進行相應的處理。
  • 如果是寫就緒事件,那麼就把數據寫到對應的SocketChannel。

Tips

NIO中,由於是單線程,不能在連接就緒,讀寫就緒之後的事件處理邏輯執行耗時操作,那樣將會讓服務性能急劇下降,正確方法應該是把耗時的邏輯放在獨立的線程中去執行,或放到專門的worker線程池中執行。

源代碼

https://github.com/huangyemin/javatest
https://gitee.com/huangyemin/javatest


分享到:


相關文章: