阿里畢玄-測試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端,一個執行緒就夠了。我們來分析下這種實現方式的優缺點。
優點
- 簡單,適合java socket程式設計入門。
- 好像只有簡單了。
缺點
-
一次只能服務一個客戶端,別的客戶端只能等待,具體表現是:如果同時啟動兩個慢客戶端,那麼兩個客戶端的底層TCP連線是建立好的,先啟動的客戶端會先得到服務,但後啟動的那個客戶端會在讀取資料時一直被阻塞,如下所示(windows):
netstat -ano|find “9999”
TCP127.0.0.1:9999127.0.0.1:29712ESTABLISHED16996 TCP127.0.0.1:9999127.0.0.1:29740ESTABLISHED16996
服務端輸出
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收到訊息後,休眠
2019-03-24 10:47:52,555 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 3.收到服務端訊息:你好,請報上名來!
客戶端2
//客戶端2在此處被阻塞 socket.getInputStream().read(byteBuf);
- 實現不了同時服務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個執行緒用於服務客戶端。同樣來分析下優缺點。
優點
- 簡單,和最簡單版本相比,只是把和客戶端IO相關的處理放到了執行緒裡處理。
- 可以同時服務N個連線。
缺點
- 每個執行緒都要佔用記憶體,當客戶端保持長連線,數量越來越多達到一定值時,就會出現錯誤:OutOfMemoryError:unable to create new native thread。
- 一個客戶端分配一個執行緒,太浪費資源了,因為BIO的緣故,執行緒大部分時間都處於阻塞或等待讀寫狀態。
- 即使機器效能高,記憶體大,當執行緒很多時,執行緒上下文切換也會帶來很大的開銷。
Tips
編寫多執行緒任務時,可以把執行任務的邏輯使用Runnable介面來實現,這樣任務可以直接放到Thread執行緒物件裡執行,也可以提交到執行緒池中去執行。
NIO上場
有沒有可能同時具備方式一和二的優點呢,具體來說就是,一個執行緒同時服務N個客戶端?Yes,NIO就可以!那什麼是NIO?NIO即New IO,更多時候我們是看成Non blocking IO,就是非阻塞IO。
具體NIO如何實現一個執行緒服務N個客戶端,在深入程式碼細節前,我們先理一理。
回顧上面的BIO實現,我們知道有這幾個點會阻塞或者響應慢:
- serverSocket.accept(),這裡是服務端等待客戶端連線。
- clientSocket.getInputStream().read(),這裡是等待客戶端傳送資料過來。
- 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<SocketChannel, Queue> msgQueueMap = new ConcurrentHashMap<SocketChannel, Queue>(); //建立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<SelectionKey> readyKeys = selector.selectedKeys(); Iterator<SelectionKey> 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<String> 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確實是只用一個執行緒高效的解決了問題,但是程式碼看起來複雜多了。不過我們用虛擬碼總結一下,會簡單一點:
- 準備好Selector(原始碼註釋中叫channel多路複用器)。
- 準備好ServerSocketChannel(對應BIO裡的ServerSocket)。
- ServerSocketChannel向Selector註冊accept事件(即客戶端連線就緒事件)
-
迴圈
- 檢查Selector是否有新的就緒事件,如果沒有就阻塞等待,如果有就返回產生的就緒事件列表。
- 如果是accept事件(客戶端連線就緒事件),就接受客戶端連線得到SocketChannel(對應BIO中的Socket),SocketChannel向Selector註冊讀寫就緒事件。
- 如果是讀就緒事件,那麼讀取對應SocketChannel的資料,並進行相應的處理。
- 如果是寫就緒事件,那麼就把資料寫到對應的SocketChannel。
Tips
NIO中,由於是單執行緒,不能在連線就緒,讀寫就緒之後的事件處理邏輯執行耗時操作,那樣將會讓服務效能急劇下降,正確方法應該是把耗時的邏輯放在獨立的執行緒中去執行,或放到專門的worker執行緒池中執行。
原始碼
https://github.com/huangyemin/javatest https://gitee.com/huangyemin/javatest