淺嘗Java NIO與Tomcat簡單連接調優
P本文使用jdk1.8.0_45
spring boot 2.1.4.RELEASE
涉及源碼都放在
https://github.com/sabersword/Nio
前因
這周遇到一個連接斷開的問題,便沿着這條線學習了一下Java NIO,順便驗證一下Tomcat作爲spring boot默認的web容器,是怎樣管理空閒連接的。
Java NIO(new IO/non-blockingIO)不同於BIO,BIO是堵塞型的,並且每一條學習路線的IO章節都會從BIO說起,因此大家非常熟悉。而NIO涉及Linux底層的select,poll,epoll等,要求對Linux的網絡編程有紮實功底,反正我是沒有搞清楚,在此推薦一篇通俗易懂的入門文章:
https://www.jianshu.com/p/ef418ccf2f7d
此處先引用文章的結論:
-
對於socket的文件描述符纔有所謂BIO和NIO。
-
多線程+BIO模式會帶來大量的資源浪費,而NIO+IO多路複用可以解決這個問題。
-
在Linux下,基於epoll的IO多路複用是解決這個問題的最佳方案;epoll相比select和poll有很大的性能優勢和功能優勢,適合實現高性能網絡服務。
底層的技術先交給大神們解決,我們着重從 Java 上層應用的角度瞭解一下。
從 JDK 1.5 起使用 epoll 代替了傳統的 select/poll ,極大提升了 NIO 的通信性能,因此下文提到 Java NIO 都是使用 epoll 的。
Java NIO 涉及到的三大核心部分 Channel 、 Buffer 、 Selector ,它們都十分複雜,單單其中一部分都能寫成一篇文章,就不班門弄斧了。此處貼上一個自己學習 NIO 時設計的樣例,功能是服務器發佈服務,客戶端連上服務器,客戶端向服務器發送若干次請求,達到若干次答覆後,服務器率先斷開連接,隨後客戶端也斷開連接。
NIO服務器端關鍵代碼
public void handleRead(SelectionKey key) { SocketChannel sc = (SocketChannel) key.channel(); ByteBuffer buf = (ByteBuffer) key.attachment(); try { long bytesRead = sc.read(buf); StringBuffer sb = new StringBuffer(); while (bytesRead > 0) { buf.flip(); while (buf.hasRemaining()) { sb.append((char) buf.get()); } buf.clear(); bytesRead = sc.read(buf); } LOGGER.info("收到客戶端的消息:{}", sb.toString()); writeResponse(sc, sb.toString()); if (sb.toString().contains("3")) { sc.close(); } } catch (IOException e) { key.cancel(); e.printStackTrace(); LOGGER.info("疑似一個客戶端斷開連接"); try { sc.close(); } catch (IOException e1) { LOGGER.info("SocketChannel 關閉異常"); } } }
NIO客戶端關鍵代碼
Iterator<SelectionKey> iter = selector.selectedKeys().iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); if (key.isConnectable()) { while (!socketChannel.finishConnect()) ; socketChannel.configureBlocking(false); socketChannel.register(key.selector(), SelectionKey.OP_READ, ByteBuffer.allocateDirect(1024)); LOGGER.info("與服務器連接成功,使用本地端口{}", socketChannel.socket().getLocalPort()); } if (key.isReadable()) { SocketChannel sc = (SocketChannel) key.channel(); ByteBuffer buf = (ByteBuffer) key.attachment(); long bytesRead; try { bytesRead = sc.read(buf); } catch (IOException e) { e.printStackTrace(); LOGGER.info("遠程服務器斷開了與本機的連接,本機也進行斷開"); sc.close(); continue; } while (bytesRead > 0) { buf.flip(); while (buf.hasRemaining()) { System.out.print((char) buf.get()); } System.out.println(); buf.clear(); bytesRead = sc.read(buf); } TimeUnit.SECONDS.sleep(2); String info = "I'm " + i++ + "-th information from client"; buffer.clear(); buffer.put(info.getBytes()); buffer.flip(); while (buffer.hasRemaining()) { sc.write(buffer); } } iter.remove(); }
服務器日誌
客戶端日誌
從這個樣例可以看到,客戶端和服務器都能根據自身的策略,與對端斷開連接,本例中是服務器首先斷開連接,根據TCP協議,必然有一個時刻服務器處於FIN_WAIT_2狀態,而客戶端處於CLOSE_WAIT狀態
我們通過 netstat 命令找出這個狀態,果不其然。
但是 JDK 提供的 NIO 接口還是很複雜很難寫的,要用好它就必須藉助於 Netty 、 Mina 等第三方庫的封裝,這部分就先不寫了。接下來考慮另外一個問題,在大併發的場景下,成千上萬的客戶端湧入與服務器連接,連接成功後不發送請求,浪費了服務器寶貴的資源,這時服務器該如何應對?
答案當然是設計合適的連接池來管理這些寶貴的資源,爲此我們選用 Tomcat 作爲學習對象,瞭解一下它是如何管理空閒連接的。
Tomcat 的 Connector 組件用於管理連接, Tomcat8 默認使用 Http11NioProtocol ,它有一個屬性 ConnectionTimeout ,註釋如下:
可以簡單理解成空閒超時時間,超時後 Tomcat 會主動關閉該連接來回收資源。
我們將它修改爲 10 秒,得到如下配置類,並將該 spring boot 應用打包成 tomcat-server.jar
@Component public class MyEmbeddedServletContainerFactory extends TomcatServletWebServerFactory { public WebServer getWebServer(ServletContextInitializer... initializers) { // 設置端口 this.setPort(8080); return super.getWebServer(initializers); } protected void customizeConnector(Connector connector) { super.customizeConnector(connector); Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler(); // 設置最大連接數 protocol.setMaxConnections(2000); // 設置最大線程數 protocol.setMaxThreads(2000); // 設置連接空閒超時 protocol.setConnectionTimeout(10 * 1000); } }
我們將上文的 NIO 客戶端略微修改一下形成 TomcatClient ,功能就是連上服務器後什麼都不做。
Iterator<SelectionKey> iter = selector.selectedKeys().iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); if (key.isConnectable()) { while (!socketChannel.finishConnect()) ; socketChannel.configureBlocking(false); socketChannel.register(key.selector(), SelectionKey.OP_READ, ByteBuffer.allocateDirect(1024)); LOGGER.info("與遠程服務器連接成功,使用本地端口{}", socketChannel.socket().getLocalPort()); } if (key.isReadable()) { SocketChannel sc = (SocketChannel) key.channel(); ByteBuffer buf = (ByteBuffer) key.attachment(); long readCount; readCount = sc.read(buf); while (readCount > 0) { buf.flip(); while (buf.hasRemaining()) { System.out.print((char) buf.get()); } System.out.println(); buf.clear(); readCount = sc.read(buf); } // 遠程服務器斷開連接後會不停觸發OP_READ,並收到-1代表End-Of-Stream if (readCount == -1) { LOGGER.info("遠程服務器斷開了與本機的連接,本機也進行斷開"); sc.close(); } } iter.remove(); }
分別運行服務器和客戶端,可以看到客戶端打印如下日誌
30:27 連上服務器,不進行任何請求,經過 10 秒後到 30:37 被服務器斷開了連接。
此時 netstat 會發現還有一個 TIME_WAIT 的連接
根據 TCP 協議主動斷開方必須等待 2MSL 才能關閉連接, Linux 默認的 2MSL=60 秒(順帶說一句網上很多資料說 CentOS 的 /proc/sys/net/ipv4/tcp_fin_timeout 能修改 2MSL 的時間,實際並沒有效果,這個參數應該是被寫進內核,必須重新編譯內核才能修改 2MSL )。持續觀察 netstat 發現 31:36 的時候 TIME_WAIT 連接還在,到了 31:38 連接消失了,可以認爲是 31:37 關閉連接,對比上文 30:37 剛好經過了 2MSL (默認 60 秒)的時間。