一、傳統阻塞式 I/O 模型
實現簡易多人聊天系統:服務端與客戶端
服務端
public class ChatServer {int port = 6666;// 定義服務器端口號為 6666ServerSocket ss;// 定義一個 ServerSocket 對象用于監聽客戶端連接//List<Socket> clientSockets = new ArrayList<>();// 定義一個列表用于存儲已連接的客戶端 Socket 對象List<Socket> clientSockets = new CopyOnWriteArrayList<>();//迭代時會復制整個底層數組,因此在遍歷過程中其他線程對集合的修改不會影響當前遍歷,// 有效避免了 ConcurrentModificationException 異常。
}
定義了 ChatServer
類,指定服務器端口為 6666 ,創建 ServerSocket
對象用于監聽客戶端連接,采用 CopyOnWriteArrayList
存儲已連接的客戶端 Socket 對象。相較于普通列表,CopyOnWriteArrayList
在迭代時會復制整個底層數組,確保在遍歷過程中其他線程對集合的修改不會影響當前遍歷,有效避免并發修改異常。
public void initServer() {// 初始化服務器的方法try {ss = new ServerSocket(port);// 創建 ServerSocket 對象并綁定到指定端口System.out.println("服務器啟動,等待客戶端連接...");} catch (IOException e) {throw new RuntimeException(e);}}
?initServer
方法用于創建 ServerSocket
對象并綁定到指定端口,啟動服務器端,等待客戶端連接。
public void listenerConnection() {// 監聽客戶端連接的方法,返回連接的 Socket 對象new Thread(()->{while(true){try {Socket socket = ss.accept();// 調用 accept() 方法等待客戶端連接//clientSockets.add(socket);synchronized (clientSockets) {// 同步操作確保線程安全clientSockets.add(socket);// 將連接的客戶端 Socket 對象添加到列表中}System.out.println("客戶端已連接:" + socket.getInetAddress().getHostAddress());// 輸出客戶端連接成功提示信息及客戶端 IP 地址} catch (IOException e) {throw new RuntimeException(e);}}}).start();}
listenerConnection
方法啟動一個線程,通過 ServerSocket
的 accept()
方法等待客戶端連接。當有客戶端連接時,將其 Socket 對象添加到客戶端列表中,并輸出客戶端 IP 地址。
public void readMsg(List<Socket> clientSockets, JTextArea msgShow) {// 讀取客戶端消息的方法//System.out.println("clientSockets size: " + clientSockets.size()); // 檢查列表大小synchronized (clientSockets) {// 對客戶端列表進行同步操作Thread tt = new Thread(() -> {// 創建一個線程用于讀取并處理客戶端消息//System.out.println("開始讀取客戶端發送的消息");while (true) {// 無限循環持續讀取消息InputStream is;// 定義輸入流對象用于讀取客戶端消息Socket socket = null;try {Thread.sleep(50);} catch (InterruptedException e) {throw new RuntimeException(e);}for (Socket cSocket : clientSockets) {// 遍歷每個客戶端 Socket//System.out.println("循環每個socket");socket = cSocket;if(socket == null){continue;}try {is = socket.getInputStream();// 獲取客戶端 Socket 對象的輸入流} catch (IOException e) {throw new RuntimeException(e);}try {int idLen = is.read();// 讀取消息中發送方名稱長度的字節if(idLen == 0){continue;}byte[] id = new byte[idLen];// 根據讀取的長度創建字節數組存儲發送方名稱is.read(id);// 讀取發送方名稱字節數組int msgLen = is.read();// 讀取消息內容長度的字節if(msgLen == 0){continue;}byte[] msg = new byte[msgLen];// 根據讀取的長度創建字節數組存儲消息內容is.read(msg);// 讀取消息內容字節數組System.out.println(new String(id) + "發送的消息:" + new String(msg));// 將字節數組轉換為字符串并輸出消息內容msgShow.append(new String(id) + "說:" + new String(msg) + "\n");// 轉發信息給所有其他客戶端for (Socket clientSocket : clientSockets) {// 遍歷所有已連接的客戶端 Socket 對象if (clientSocket == socket) {// 如果是當前發送消息的客戶端continue;}OutputStream os = null;// 定義輸出流對象用于向其他客戶端發送消息os = clientSocket.getOutputStream();// 獲取客戶端 Socket 對象的輸出流os.write(id.length);// 發送發送方名稱長度os.write(id);// 發送發送方名稱字節數組os.write(msg.length);// 發送消息內容長度os.write(msg);// 發送消息內容字節數組os.flush();// 刷新輸出流確保數據發送完成}} catch (IOException e) {throw new RuntimeException(e);}}}});tt.start();}}
?readMsg
方法用于讀取客戶端發送的消息。創建一個線程,持續讀取每個客戶端的輸入流。讀取消息時,先讀取發送方名稱長度、名稱字節數組,再讀取消息內容長度、內容字節數組,將其轉換為字符串并顯示在消息區域。然后將消息轉發給所有其他客戶端。
public void start() {// 啟動服務器的方法initServer();// 調用初始化服務器的方法//new Thread(()->{//startSend();// 啟動服務端從控制臺向所有客戶端發送消息的線程//}).start();ChatUI ui = new ChatUI("服務端", clientSockets);ui.setVisible(true); // 確保 UI 可見listenerConnection();// 調用監聽客戶端連接的方法readMsg(clientSockets,ui.msgShow);// 調用讀取消息的方法}
start
方法初始化服務器,創建服務端界面,啟動監聽客戶端連接和讀取消息的功能。
客戶端
public class Client {Socket socket;// 定義 Socket 對象用于與服務器建立連接String ip;// 定義服務器 IP 地址int port;// 定義服務器端口號InputStream in;// 定義輸入流對象用于讀取服務器發送的消息OutputStream out;// 定義輸出流對象用于向服務器發送消息public Client(String ip, int port) {// 構造方法,初始化客戶端 IP 地址和端口號this.ip = ip;this.port = port;}
}
?定義了 Client
類,包含 Socket 對象用于連接服務器,以及服務器的 IP 地址和端口號。構造方法用于初始化客戶端 IP 地址和端口號。
public void connectServer(String userName) {// 連接服務器的方法try {socket = new Socket(ip, port);// 創建 Socket 對象連接到指定 IPin = socket.getInputStream();// 獲取 Socket 對象的輸入流用于讀取消息out = socket.getOutputStream();// 獲取 Socket 對象的輸出流用于發送消息try {out.write(userName.length());out.write(userName.getBytes());out.flush();} catch (IOException e) {throw new RuntimeException(e);}System.out.println("連接服務器成功");} catch (IOException e) {throw new RuntimeException(e);}}
connectServer
方法用于連接服務器。創建 Socket 對象連接到指定 IP 地址和端口號的服務器,獲取輸入流和輸出流。然后向服務器發送用戶名長度和用戶名字節數組,完成連接。
public void readMsg(JTextArea msgShow) {// 讀取服務器發送的消息的方法new Thread(() -> {// 創建一個線程用于讀取并處理服務器消息try {System.out.println("開始讀取消息");while (true) { // 無限循環持續讀取消息int senderNameLength = in.read();// 讀取發送方名稱長度的字節byte[] senderNameBytes = new byte[senderNameLength];// 根據讀取的長度創建字節數組存儲發送方名稱in.read(senderNameBytes);// 讀取發送方名稱字節數組int msgLength = in.read();// 讀取消息內容長度的字節byte[] msgBytes = new byte[msgLength];// 根據讀取的長度創建字節數組存儲消息內容in.read(msgBytes);// 讀取消息內容字節數組System.out.println(new String(senderNameBytes) + "發送的消息:" + new String(msgBytes));// 將字節數組轉換為字符串并輸出消息內容msgShow.append(new String(senderNameBytes) +"說:" + new String(msgBytes) + "\n");}} catch (IOException e) {throw new RuntimeException(e);}}).start();}
?readMsg
方法用于讀取服務器發送的消息。創建一個線程,讀取發送方名稱長度、名稱字節數組,消息內容長度、內容字節數組,將其轉換為字符串并顯示在消息區域。
public void startClient() {// 啟動客戶端的方法String userName = JOptionPane.showInputDialog("請輸入用戶名:");connectServer(userName);// 調用連接服務器的方法ChatUI ui = new ChatUI(userName, out);readMsg(ui.msgShow);// 調用讀取消息的方法//startSend();// 調用發送消息的方法try {Thread.sleep(500);} catch (InterruptedException e) {throw new RuntimeException(e);}new Thread() {public void run() {while (true) {try {out.write(0);out.flush();Thread.sleep(500);} catch (IOException e) {throw new RuntimeException(e);} catch (InterruptedException e) {throw new RuntimeException(e);}}}}.start();}
startClient
方法啟動客戶端。通過對話框獲取用戶名,連接服務器,創建客戶端界面,啟動讀取消息的功能。同時創建一個線程,定期向服務器發送心跳包(0),保持連接。
圖形界面
服務端界面
public ChatUI(String title, List<Socket> clientSockets) {// 服務器端構造方法super(title);// 設置窗口標題setSize(500, 500);// 設置窗口大小setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);// 設置關閉操作JScrollPane scrollPane = new JScrollPane(msgShow);// 創建滾動面板包括消息顯示區域scrollPane.setPreferredSize(new Dimension(0, 350));add(scrollPane, BorderLayout.NORTH);// 添加到窗口北部// 創建消息輸入面板及組件JPanel msgInput = new JPanel();JTextArea msg = new JTextArea();JScrollPane scrollPane1 = new JScrollPane(msg);scrollPane1.setPreferredSize(new Dimension(480, 80));msgInput.add(scrollPane1);JButton send = new JButton("發送");msgInput.add(send);msgInput.setPreferredSize(new Dimension(0, 120));add(msgInput, BorderLayout.SOUTH);// 添加到窗口南部setVisible(true);ChatListener cl = new ChatListener();// 創建事件監聽器send.addActionListener(cl);// 為發送按鈕添加監聽器cl.showMsg = msgShow;// 傳遞消息顯示組件cl.msgInput = msg;cl.userName = title;cl.clientSockets = clientSockets;}
?服務端界面包含顯示消息的文本區域和消息輸入面板。文本區域用于展示聊天記錄,輸入面板包含一個文本區域用于輸入消息,一個發送按鈕用于發送消息。為發送按鈕添加事件監聽器,當點擊按鈕時,觸發發送消息的操作。
客戶端界面
public ChatUI(String title, OutputStream out) {// 客戶端構造方法super(title);setSize(500, 500);setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);JScrollPane scrollPane = new JScrollPane(msgShow);scrollPane.setPreferredSize(new Dimension(0, 350));add(scrollPane, BorderLayout.NORTH);JPanel msgInput = new JPanel();JTextArea msg = new JTextArea();JScrollPane scrollPane1 = new JScrollPane(msg);scrollPane1.setPreferredSize(new Dimension(480, 80));msgInput.add(scrollPane1);JButton send = new JButton("發送");msgInput.add(send);msgInput.setPreferredSize(new Dimension(0, 120));add(msgInput, BorderLayout.SOUTH);setVisible(true);clientListener cl = new clientListener();send.addActionListener(cl);cl.showMsg = msgShow;cl.msgInput = msg;cl.userName = title;cl.out = out;}
客戶端界面與服務端界面結構類似,用于展示聊天記錄和輸入消息。為發送按鈕添加客戶端事件監聽器,當點擊按鈕時,將消息發送給服務器。
事件監聽器
服務端事件監聽器
public class ChatListener implements ActionListener {public List<Socket> clientSockets;// 客戶端 Socket 列表JTextArea showMsg;// 消息顯示區域JTextArea msgInput;// 消息輸入區域String userName;// 用戶名OutputStream out;// 輸出流public void actionPerformed(ActionEvent e) {// 處理發送按鈕點擊事件String text = msgInput.getText();// 獲取輸入的消息文本showMsg.append(userName + ": " + text + "\n");// 在顯示區域追加消息for (Socket cSocket : clientSockets) {// 遍歷所有客戶端Socket socket = cSocket;try {out = socket.getOutputStream();// 獲取客戶端輸出流out.write(userName.getBytes().length);// 發送用戶名長度out.write(userName.getBytes());// 發送用戶名out.write(text.getBytes().length);// 發送消息內容長度out.write(text.getBytes());// 發送消息內容out.flush();// 刷新輸出流} catch (IOException ex) {throw new RuntimeException(ex);}}}}
?服務端事件監聽器實現了 ActionListener
接口。當點擊發送按鈕時,獲取輸入的消息文本,將其添加到顯示區域。然后遍歷所有客戶端 Socket,獲取每個客戶端的輸出流,發送用戶名長度、用戶名、消息內容長度、消息內容給每個客戶端。
客戶端事件監聽器
public class clientListener implements ActionListener {JTextArea showMsg;// 消息顯示區域JTextArea msgInput;// 消息輸入區域String userName;// 用戶名OutputStream out;// 輸出流public void actionPerformed(ActionEvent e) {// 處理發送按鈕點擊String text = msgInput.getText();// 獲取輸入消息showMsg.append(userName + ": " + text + "\n");// 顯示消息try {out.write(userName.getBytes().length);// 發送用戶名長度out.write(userName.getBytes());// 發送用戶名out.write(text.getBytes().length);// 發送消息長度out.write(text.getBytes());// 發送消息內容out.flush();// 刷新輸出流//msgInput.setText(""); // 清空輸入框} catch (IOException ex) {throw new RuntimeException(ex);}}
}
客戶端事件監聽器實現了 ActionListener
接口。當點擊發送按鈕時,獲取輸入的消息文本,將其添加到顯示區域。然后通過客戶端的輸出流向服務器發送用戶名長度、用戶名、消息內容長度、消息內容。
?
運行效果
二、NIO 模型
聊天服務器
public class NIOChatServer {private static final int PORT = 8080;private static final Charset charset = Charset.forName("UTF-8");private static Set<SocketChannel> clients = new HashSet<>();public static void main(String[] args) throws IOException {Selector selector = null;try {selector = Selector.open();ServerSocketChannel serverChannel = ServerSocketChannel.open();serverChannel.bind(new InetSocketAddress(PORT));serverChannel.configureBlocking(false);serverChannel.register(selector, SelectionKey.OP_ACCEPT);} catch (IOException e) {throw new RuntimeException(e);}System.out.println("Server started on port " + PORT);while (true){try {selector.select();} catch (IOException e) {throw new RuntimeException(e);}Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();while (keyIterator.hasNext()){SelectionKey key = keyIterator.next();keyIterator.remove();if (key.isAcceptable()){handleAccept(key,selector);}else if (key.isReadable()){handleRead(key);}}}}
}
在服務器端代碼中,首先創建一個選擇器(Selector
), 它是 NIO 中用于監聽多個通道事件的核心組件。然后打開一個服務器套接字通道(ServerSocketChannel
),綁定到指定端口(8080),并將其設置為非阻塞模式。接著將通道注冊到選擇器上,監聽連接接受事件(SelectionKey.OP_ACCEPT
)。啟動一個無限循環,調用選擇器的 select()
方法,該方法會阻塞直到有通道事件發生,然后迭代處理每個事件。
private static void handleAccept(SelectionKey key, Selector selector) throws IOException{ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();SocketChannel clientChannel = serverChannel.accept();clientChannel.configureBlocking(false);clientChannel.register(selector, SelectionKey.OP_READ);clients.add(clientChannel);System.out.println("New Client connected to " + clientChannel.getRemoteAddress());}
當檢測到客戶端連接事件時,服務器通道(ServerSocketChannel
)調用 accept()
方法接受新連接,返回新的客戶端套接字通道(SocketChannel
)。將客戶端通道設置為非阻塞模式,并注冊到選擇器上,監聽讀事件(SelectionKey.OP_READ
),以便后續接收該客戶端的消息。同時將客戶端通道添加到 clients
集合中,用于后續廣播消息。
private static void handleRead(SelectionKey key) throws IOException {SocketChannel clientChannel = (SocketChannel) key.channel();ByteBuffer buffer = ByteBuffer.allocate(1024);int bytesRead = 0;try {bytesRead = clientChannel.read(buffer);if (bytesRead == -1){disconnectClient(clientChannel);return;}buffer.flip();String message = charset.decode(buffer).toString();System.out.println("Received: " + message);broadcastMessage(message,clientChannel);} catch (IOException e) {disconnectClient(clientChannel);}}
?當檢測到讀事件時,表示客戶端有數據可讀。創建一個緩沖區(ByteBuffer
)用于存儲從客戶端讀取的數據。調用客戶端通道的 read()
方法將數據讀取到緩沖區。如果返回值為 -1,表示客戶端斷開連接,調用 disconnectClient()
方法處理斷開事件。否則,將緩沖區翻轉(flip
),解碼緩沖區中的字節數據為字符串,并調用 broadcastMessage()
方法將消息廣播給其他客戶端。
private static void broadcastMessage(String message, SocketChannel sender) throws IOException {ByteBuffer buffer = charset.encode(message);for (SocketChannel client : clients) {if(client != sender && client.isConnected()){client.write(buffer);buffer.rewind();}}}
將消息字符串編碼為字節緩沖區,然后遍歷所有客戶端通道。對于每個客戶端通道(除了發送消息的客戶端),如果通道處于連接狀態,就將緩沖區中的數據寫入通道,實現消息的廣播。寫完后調用 rewind()
方法重置緩沖區的位置,以便下次寫操作從頭開始。
private static void disconnectClient(SocketChannel client) throws IOException {clients.remove(client);System.out.println("Client disconnected from " + client.getRemoteAddress());client.close();}
?從 clients
集合中移除斷開連接的客戶端通道,關閉該通道,并打印斷開連接的日志。
聊天客戶端
public class BlockingChatClient {public static void main(String[] args) {Socket socket = null;try {socket = new Socket("localhost", 8080);System.out.println("Connected");// 創建輸入輸出流BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));// 啟動一個線程來讀取服務器發送的消息new Thread(() -> {String message;try {while ((message = reader.readLine()) != null) {System.out.println("[Server] " + message);}} catch (IOException e) {System.out.println("Disconnected from server");}}).start();// 從控制臺讀取用戶輸入并發送到服務器BufferedReader consoleReader = new BufferedReader(new InputStreamReader(System.in));String input;while ((input = consoleReader.readLine()) != null) {writer.write(input);writer.newLine();writer.flush();if ("/exit".equalsIgnoreCase(input)) {break;}}} catch (IOException e) {e.printStackTrace();} finally {try {if (socket != null) {socket.close();}} catch (IOException e) {e.printStackTrace();}}}
}
客戶端代碼使用傳統的阻塞式 I/O。啟動時連接到服務器,建立 Socket 連接。然后獲取輸入流和輸出流。啟動一個線程讀取服務器發送的消息并打印到控制臺。在主線程中,從控制臺讀取用戶輸入,發送到服務器。當輸入 "/exit" 時,退出客戶端。最后確保關閉 Socket 連接。?
運行效果
三、兩種模型對比
I/O模型
-
資源消耗大 :每個客戶端都需要一個獨立線程,大量客戶端連接會導致線程數量劇增,增加系統資源消耗和線程切換開銷。
-
擴展性差 :線程數量受限于系統資源,難以處理高并發場景。
NIO 模型
-
高并發支持 :一個線程可管理多個客戶端連接,顯著降低資源消耗,提升系統并發能力。
-
高效復用 :通過選擇器復用線程,減少線程創建和銷毀的開銷。
對比方面 | 傳統阻塞式 I/O | NIO 模型 |
---|---|---|
線程模型 | 一客戶端一線程,資源占用大,線程切換頻繁 | 一線程多客戶端,資源占用小,線程切換少 |
并發能力 | 并發能力受限于線程數量 | 支持高并發,可處理大量客戶端連接 |
阻塞處理 | 依賴線程隔離防止阻塞 | 通過非阻塞 I/O 和多路復用防止阻塞 |
適用場景 | 適合客戶端數量較少,對響應時間要求不高的場景 | 適合高并發場景,如即時通訊、在線游戲等 |