上帝視角拆解 Tomcat 架構設計,在瞭解整個組件設計思路以後。咱們須要下凡深刻了解每一個組件的細節實現。從遠到近,架構給人以宏觀思惟,細節展示飽滿的美。關注「碼哥字節」獲取更多硬核,你,準備好了麼?
上回「碼哥字節」站在上帝視角給你們拆解了 Tomcat 架構設計,分析 Tomcat 如何實現啓動、中止,經過設計鏈接池與容器兩大組件完成了一個請求的接受與響應。鏈接器負責對外交流,處理 socket 鏈接,容器對內負責,加載 Servlet 以及處理具體 Request 請求與響應。詳情點我進入傳輸門:Tomcat 架構解析到工做借鑑。java
這回,再次拆解,專一 Tomcat 高併發設計之道與性能調優,讓你們對整個架構有更高層次的瞭解與感悟。其中設計的每一個組件思路都是將 Java 面向對象、面向接口、如何封裝變與不變,如何根據實際需求抽象不一樣組件分工合做,如何設計類實現單一職責,怎麼作到將類似功能高內聚低耦合,設計模式運用到極致的學習借鑑。程序員
此次主要涉及到的是 I/O 模型,以及線程池的基礎內容。算法
在學習以前,但願你們積累如下一些技術內容,不少內容「碼哥字節」也在歷史文章中分享過。你們可爬樓回顧……。但願你們重視以下幾個知識點,在掌握如下知識點再來拆解 Tomcat,就會事半功倍,不然很容易迷失方向不得其法。shell
一塊兒來看 Tomcat 如何實現併發鏈接處理以及任務處理,性能的優化是每個組件都起到對應的做用,如何使用最少的內存,最快的速度執行是咱們的目標。數據庫
模板方法模式: 抽象算法流程在抽象類中,封裝流程中的變化與不變點。將變化點延遲到子類實現,達到代碼複用,開閉原則。apache
觀察者模式:針對事件不一樣組件有不一樣響應機制的需求場景,達到解耦靈活通知下游。編程
責任鏈模式:將對象鏈接成一條鏈,將沿着這條鏈傳遞請求。在 Tomcat 中的 Valve 就是該設計模式的運用。設計模式
更多設計模式可查看「碼哥字節」以前的設計模式專輯,這裏是傳送門。數組
Tomcat 實現高併發接收鏈接,必然涉及到 I/O 模型的運用,瞭解同步阻塞、異步阻塞、I/O 多路複用,異步非阻塞相關概念以及 Java NIO 包的運用頗有必要。本文也會帶你們着重說明 I/O 是如何在 Tomcat 運用實現高併發鏈接。你們經過本文我相信對 I/O 模型也會有一個深入認識。性能優化
實現高併發,除了總體每一個組件的優雅設計、設計模式的合理、I/O 的運用,還須要線程模型,如何高效的併發編程技巧。在高併發過程當中,不可避免的會出現多個線程對共享變量的訪問,須要加鎖實現,如何高效的下降鎖衝突。所以做爲程序員,要有意識的儘可能避免鎖的使用,好比可使用原子類 CAS 或者併發集合來代替。若是萬不得已須要用到鎖,也要儘可能縮小鎖的範圍和鎖的強度。
對於併發相關的基礎知識,若是讀者感興趣「碼哥字節」後面也給你們安排上,目前也寫了部分併發專輯,你們可移步到歷史文章或者專輯翻閱,這裏是傳送門,主要講解了併發實現的原理、什麼是內存可見性,JMM 內存模模型、讀寫鎖等併發知識點。
再次回顧下 Tomcat 總體架構設計,主要設計了 connector 鏈接器處理 TCP/IP 鏈接,container 容器做爲 Servlet 容器,處理具體的業務請求。對外對內分別抽象兩個組件實現拓展。
ProtocolHandler
主要由 Acceptor
以及 SocketProcessor
構成,實現了 TCP/IP 層 的 Socket 讀取並轉換成 TomcatRequest
和 TomcatResponse
,最後根據 http 或者 ajp 協議獲取合適的 Processor
解析爲應用層協議,並經過 Adapter 將 TomcatRequest、TomcatResponse 轉化成 標準的 ServletRequest、ServletResponse。經過 getAdapter().service(request, response);
將請求傳遞到 Container 容器。org.apache.catalina.connector.CoyoteAdapter
// Calling the container connector.getService().getContainer().getPipeline().getFirst().invoke( request, response);
這個調用會觸發 getPipeline 構成的責任鏈模式將請求一步步走入容器內部,每一個容器都有一條 Pipeline,經過 First 開始到 Basic 結束並進入容器內部持有的子類容器,最後到 Servlet,這裏就是責任鏈模式的經典運用。具體的源碼組件是 Pipeline 構成一條請求鏈,每個鏈點由 Valve 組成。「碼哥字節」在上一篇Tomcat 架構解析到工做借鑑 已經詳細講解。以下圖所示,整個 Tomcat 的架構設計重要組件清晰可見,但願你們將這個全局架構圖深深印在腦海裏,掌握全局思路才能更好地分析細節之美。
Connector
和 Engine
的 start
方法。Engine 容器主要就是組合模式將各個容器根據父子關係關聯,而且 Container 容器繼承了 Lifecycle 實現各個容器的初始化與啓動。Lifecycle 定義了 init()、start()、stop()
控制整個容器組件的生命週期實現一鍵啓停。
這裏就是一個面向接口、單一職責的設計思想 ,Container 利用組合模式管理容器,LifecycleBase 抽象類繼承 Lifecycle 將各大容器生命週期統一管理這裏即是,而實現初始化與啓動的過程又 LifecycleBase 運用了模板方法設計模式抽象出組件變化與不變的點,將不一樣組件的初始化延遲到具體子類實現。而且利用觀察者模式發佈啓動事件解耦。
具體的 init 與 start 流程以下泳道圖所示:這是我在閱讀源碼 debug 所作的筆記,讀者朋友們不要怕筆記花費時間長,本身跟着 debug 慢慢記錄,相信會有更深的感悟。
init 流程
start 流程
讀者朋友根據個人兩篇內容,抓住主線組件去 debug,而後跟着該泳道圖閱讀源碼,我相信都會有所收穫,而且事半功倍。在讀源碼的過程當中,切勿進入某個細節,必定要先把各個組件抽象出來,瞭解每一個組件的職責便可。最後在瞭解每一個組件的職責與設計哲學以後再深刻理解每一個組件的實現細節,千萬不要一開始就想着深刻理解具體一篇葉子。
每一個核心類我在架構設計圖以及泳道圖都標識出來了,「碼哥字節」給你們分享下如何高效閱讀源碼,以及保持學習興趣的心得體會。
切勿陷入細節,不看全局:我還沒弄清楚森林長啥樣,就盯着葉子看 ,看不到全貌和總體設計思路。因此閱讀源碼學習的時候不要一開始就進入細節,而是宏觀看待總體架構設計思想,模塊之間的關係。
1.閱讀源碼以前,須要有必定的技術儲備
好比經常使用的設計模式,這個必須掌握,尤爲是:模板方法、策略模式、單例、工廠、觀察者、動態代理、適配器、責任鏈、裝飾器。你們能夠看 「碼哥字節」關於設計模式的歷史文章,打造好的基礎。
2.必須會使用這個框架/類庫,精通各類變通用法
魔鬼都在細節中,若是有些用法根本不知道,可能你能看明白代碼是什麼意思,可是不知道它爲何這些寫。
3.先去找書,找資料,瞭解這個軟件的總體設計。
從全局的視角去看待,上帝視角理出主要核心架構設計,先森林後樹葉。都有哪些模塊? 模塊之間是怎麼關聯的?怎麼關聯的?
可能一會兒理解不了,可是要創建一個總體的概念,就像一個地圖,防止你迷航。
在讀源碼的時候能夠時不時看看本身在什麼地方。就像「碼哥字節」給你們梳理好了 Tomcat 相關架構設計,而後本身再嘗試跟着 debug,這樣的效率如虎添翼。
4. 搭建系統,把源代碼跑起來!
Debug 是很是很是重要的手段, 你想經過只看而不運行就把系統搞清楚,那是根本不可能的!合理運用調用棧(觀察調用過程上下文)。
5.筆記
一個很是重要的工做就是記筆記(又是寫做!),畫出系統的類圖(不要依靠 IDE 給你生成的), 記錄下主要的函數調用, 方便後續查看。
文檔工做極爲重要,由於代碼太複雜,人的大腦容量也有限,記不住全部的細節。 文檔能夠幫助你記住關鍵點, 到時候能夠回想起來,迅速地接着往下看。
要否則,你今天看的,可能到明天就忘個差很少了。因此朋友們記得收藏後多翻來看看,嘗試把源碼下載下來反覆調試。
當咱們接到一個功能需求的時候,最重要的就是抽象設計,將功能拆解主要核心組件,而後找到需求的變化與不變點,將類似功能內聚,功能之間若耦合,同時對外支持可拓展,對內關閉修改。努力作到一個需求下來的時候咱們須要合理的抽象能力抽象出不一樣組件,而不是一鍋端將全部功能糅合在一個類甚至一個方法之中,這樣的代碼牽一髮而動全身,沒法拓展,難以維護和閱讀。
帶着問題咱們來分析 Tomcat 如何設計組件完成鏈接與容器管理。
看看 Tomcat 如何實現將 Tomcat 啓動,而且又是如何接受請求,將請求轉發到咱們的 Servlet 中。
主要任務就是建立 Server,並非簡單建立,而是解析 server.xml 文件把文件配置的各個組件意義建立出來,接着調用 Server 的 init() 和 start() 方法,啓動之旅從這裏開始…,同時還要兼顧異常,好比關閉 Tomcat 還須要作到優雅關閉啓動過程建立的資源須要釋放,Tomcat 則是在 JVM 註冊一個「關閉鉤子」,源碼我都加了註釋,省略了部分無關代碼。同時經過 await()
監聽中止指令關閉 Tomcat。
/** * Start a new server instance. */ public void start() { // 若 server 爲空,則解析 server.xml 建立 if (getServer() == null) { load(); } // 建立失敗則報錯並退出啓動 if (getServer() == null) { log.fatal("Cannot start server. Server instance is not configured."); return; } // 開始啓動 server try { getServer().start(); } catch (LifecycleException e) { log.fatal(sm.getString("catalina.serverStartFail"), e); try { // 異常則執行 destroy 銷燬資源 getServer().destroy(); } catch (LifecycleException e1) { log.debug("destroy() failed for failed Server ", e1); } return; } // 建立並註冊 JVM 關閉鉤子 if (useShutdownHook) { if (shutdownHook == null) { shutdownHook = new CatalinaShutdownHook(); } Runtime.getRuntime().addShutdownHook(shutdownHook); } // 經過 await 方法監聽中止請求 if (await) { await(); stop(); } }
經過「關閉鉤子」,就是當 JVM 關閉的時候作一些清理工做,好比說釋放線程池,清理一些零時文件,刷新內存數據到磁盤中…...
「關閉鉤子」本質就是一個線程,JVM 在中止以前會嘗試執行這個線程。咱們來看下 CatalinaShutdownHook 這個鉤子到底作了什麼。
/** * Shutdown hook which will perform a clean shutdown of Catalina if needed. */ protected class CatalinaShutdownHook extends Thread { @Override public void run() { try { if (getServer() != null) { Catalina.this.stop(); } } catch (Throwable ex) { ... } } /** * 關閉已經建立的 Server 實例 */ public void stop() { try { // Remove the ShutdownHook first so that server.stop() // doesn't get invoked twice if (useShutdownHook) { Runtime.getRuntime().removeShutdownHook(shutdownHook); } } catch (Throwable t) { ...... } // 關閉 Server try { Server s = getServer(); LifecycleState state = s.getState(); // 判斷是否已經關閉,如果在關閉中,則不執行任何操做 if (LifecycleState.STOPPING_PREP.compareTo(state) <= 0 && LifecycleState.DESTROYED.compareTo(state) >= 0) { // Nothing to do. stop() was already called } else { s.stop(); s.destroy(); } } catch (LifecycleException e) { log.error("Catalina.stop", e); } }
實際上就是執行了 Server 的 stop 方法,Server 的 stop 方法會釋放和清理全部的資源。
來體會下面向接口設計美,看 Tomcat 如何設計組件與接口,抽象 Server 組件,Server 組件須要生命週期管理,因此繼承 Lifecycle 實現一鍵啓停。
它的具體實現類是 StandardServer,以下圖所示,咱們知道 Lifecycle 主要的方法是組件的 初始化、啓動、中止、銷燬,和 監聽器的管理維護,其實就是觀察者模式的設計,當觸發不一樣事件的時候發佈事件給監聽器執行不一樣業務處理,這裏就是如何解耦的設計哲學體現。
而 Server 自生則是負責管理 Service 組件。
接着,咱們再看 Server 組件的具體實現類是 StandardServer 有哪些功能,又跟哪些類關聯?
在閱讀源碼的過程當中,咱們必定要多關注接口與抽象類,接口是組件全局設計的抽象;而抽象類基本上是模板方法模式的運用,主要目的就是抽象整個算法流程,將變化點交給子類,將不變點實現代碼複用。
StandardServer 繼承了 LifeCycleBase,它的生命週期被統一管理,而且它的子組件是 Service,所以它還須要管理 Service 的生命週期,也就是說在啓動時調用 Service 組件的啓動方法,在中止時調用它們的中止方法。Server 在內部維護了若干 Service 組件,它是以數組來保存的,那 Server 是如何添加一個 Service 到數組中的呢?
/** * 添加 Service 到定義的數組中 * * @param service The Service to be added */ @Override public void addService(Service service) { service.setServer(this); synchronized (servicesLock) { // 建立一個 services.length + 1 長度的 results 數組 Service results[] = new Service[services.length + 1]; // 將老的數據複製到 results 數組 System.arraycopy(services, 0, results, 0, services.length); results[services.length] = service; services = results; // 啓動 Service 組件 if (getState().isAvailable()) { try { service.start(); } catch (LifecycleException e) { // Ignore } } // 觀察者模式運用,觸發監聽事件 support.firePropertyChange("service", null, service); } }
從上面的代碼能夠知道,並非一開始就分配一個很長的數組,而是在新增過程當中動態拓展長度,這裏就是爲了節省空間,對於咱們平時開發是否是也要主要空間複雜度帶來的內存損耗,追求的就是極致的美。
除此以外,還有一個重要功能,上面 Caralina 的啓動方法的最後一行代碼就是調用了 Server 的 await 方法。
這個方法主要就是監聽中止端口,在 await 方法裏會建立一個 Socket 監聽 8005 端口,並在一個死循環裏接收 Socket 上的鏈接請求,若是有新的鏈接到來就創建鏈接,而後從 Socket 中讀取數據;若是讀到的數據是中止命令「SHUTDOWN」,就退出循環,進入 stop 流程。
一樣是面向接口設計,Service 組件的具體實現類是 StandardService,Service 組件依然是繼承 Lifecycle 管理生命週期,這裏再也不累贅展現圖片關係圖。咱們先來看看 Service 接口主要定義的方法以及成員變量。經過接口咱們才能知道核心功能,在閱讀源碼的時候必定要多關注每一個接口之間的關係,不要急着進入實現類。
public interface Service extends Lifecycle { // ----------主要成員變量 //Service 組件包含的頂層容器 Engine public Engine getContainer(); // 設置 Service 的 Engine 容器 public void setContainer(Engine engine); // 該 Service 所屬的 Server 組件 public Server getServer(); // --------------------------------------------------------- Public Methods // 添加 Service 關聯的鏈接器 public void addConnector(Connector connector); public Connector[] findConnectors(); // 自定義線程池 public void addExecutor(Executor ex); // 主要做用就是根據 url 定位到 Service,Mapper 的主要做用就是用於定位一個請求所在的組件處理 Mapper getMapper(); }
接着再來細看 Service 的實現類:
public class StandardService extends LifecycleBase implements Service { // 名字 private String name = null; //Server 實例 private Server server = null; // 鏈接器數組 protected Connector connectors[] = new Connector[0]; private final Object connectorsLock = new Object(); // 對應的 Engine 容器 private Engine engine = null; // 映射器及其監聽器,又是觀察者模式的運用 protected final Mapper mapper = new Mapper(); protected final MapperListener mapperListener = new MapperListener(this); }
StandardService 繼承了 LifecycleBase 抽象類,抽象類定義了 三個 final 模板方法定義生命週期,每一個方法將變化點定義抽象方法讓不一樣組件時間本身的流程。這裏也是咱們學習的地方,利用模板方法抽象變與不變。
此外 StandardService 中還有一些咱們熟悉的組件,好比 Server、Connector、Engine 和 Mapper。
那爲何還有一個 MapperListener?這是由於 Tomcat 支持熱部署,當 Web 應用的部署發生變化時,Mapper 中的映射信息也要跟着變化,MapperListener 就是一個監聽器,它監聽容器的變化,並把信息更新到 Mapper 中,這是典型的觀察者模式。下游服務根據多上游服務的動做作出不一樣處理,這就是觀察者模式的運用場景,實現一個事件多個監聽器觸發,事件發佈者不用調用全部下游,而是經過觀察者模式觸發達到解耦。
Service 管理了 鏈接器以及 Engine 頂層容器,因此繼續進入它的 startInternal 方法,其實就是 LifecycleBase 模板定義的 抽象方法。看看他是怎麼啓動每一個組件順序。
protected void startInternal() throws LifecycleException { //1. 觸發啓動監聽器 setState(LifecycleState.STARTING); //2. 先啓動 Engine,Engine 會啓動它子容器,由於運用了組合模式,因此每一層容器在會先啓動本身的子容器。 if (engine != null) { synchronized (engine) { engine.start(); } } //3. 再啓動 Mapper 監聽器 mapperListener.start(); //4. 最後啓動鏈接器,鏈接器會啓動它子組件,好比 Endpoint synchronized (connectorsLock) { for (Connector connector: connectors) { if (connector.getState() != LifecycleState.FAILED) { connector.start(); } } } }
Service 先啓動了 Engine 組件,再啓動 Mapper 監聽器,最後纔是啓動鏈接器。這很好理解,由於內層組件啓動好了才能對外提供服務,才能啓動外層的鏈接器組件。而 Mapper 也依賴容器組件,容器組件啓動好了才能監聽它們的變化,所以 Mapper 和 MapperListener 在容器組件以後啓動。組件中止的順序跟啓動順序正好相反的,也是基於它們的依賴關係。
做爲 Container 的頂層組件,因此 Engine 本質就是一個容器,繼承了 ContainerBase ,看到抽象類再次運用了模板方法設計模式。ContainerBase 使用一個 HashMap<String, Container> children = new HashMap<>();
成員變量保存每一個組件的子容器。同時使用 protected final Pipeline pipeline = new StandardPipeline(this);
Pipeline 組成一個管道用於處理鏈接器傳過來的請求,責任鏈模式構建管道。
public class StandardEngine extends ContainerBase implements Engine { }
Engine 的子容器是 Host,因此 children 保存的就是 Host。
咱們來看看 ContainerBase 作了什麼...
public abstract class ContainerBase extends LifecycleMBeanBase implements Container { // 提供了默認初始化邏輯 @Override protected void initInternal() throws LifecycleException { BlockingQueue<Runnable> startStopQueue = new LinkedBlockingQueue<>(); // 建立線程池用於啓動或者中止容器 startStopExecutor = new ThreadPoolExecutor( getStartStopThreadsInternal(), getStartStopThreadsInternal(), 10, TimeUnit.SECONDS, startStopQueue, new StartStopThreadFactory(getName() + "-startStop-")); startStopExecutor.allowCoreThreadTimeOut(true); super.initInternal(); } // 容器啓動 @Override protected synchronized void startInternal() throws LifecycleException { // 獲取子容器並提交到線程池啓動 Container children[] = findChildren(); List<Future<Void>> results = new ArrayList<>(); for (Container child : children) { results.add(startStopExecutor.submit(new StartChild(child))); } MultiThrowable multiThrowable = null; // 獲取啓動結果 for (Future<Void> result : results) { try { result.get(); } catch (Throwable e) { log.error(sm.getString("containerBase.threadedStartFailed"), e); if (multiThrowable == null) { multiThrowable = new MultiThrowable(); } multiThrowable.add(e); } } ...... // 啓動 pipeline 管道,用於處理鏈接器傳遞過來的請求 if (pipeline instanceof Lifecycle) { ((Lifecycle) pipeline).start(); } // 發佈啓動事件 setState(LifecycleState.STARTING); // Start our thread threadStart(); } }
繼承了 LifecycleMBeanBase 也就是還實現了生命週期的管理,提供了子容器默認的啓動方式,同時提供了對子容器的 CRUD 功能。
Engine 在啓動 Host 容器就是 使用了 ContainerBase 的 startInternal 方法。Engine 本身還作了什麼呢?
咱們看下 構造方法,pipeline 設置了 setBasic,建立了 StandardEngineValve。
/** * Create a new StandardEngine component with the default basic Valve. */ public StandardEngine() { super(); pipeline.setBasic(new StandardEngineValve()); ..... }
容器主要的功能就是處理請求,把請求轉發給某一個 Host 子容器來處理,具體是經過 Valve 來實現的。每一個容器組件都有一個 Pipeline 用於組成一個責任鏈傳遞請求。而 Pipeline 中有一個基礎閥(Basic Valve),而 Engine 容器的基礎閥定義以下:
final class StandardEngineValve extends ValveBase { @Override public final void invoke(Request request, Response response) throws IOException, ServletException { // 選擇一個合適的 Host 處理請求,經過 Mapper 組件獲取到合適的 Host Host host = request.getHost(); if (host == null) { response.sendError (HttpServletResponse.SC_BAD_REQUEST, sm.getString("standardEngine.noHost", request.getServerName())); return; } if (request.isAsyncSupported()) { request.setAsyncSupported(host.getPipeline().isAsyncSupported()); } // 獲取 Host 容器的 Pipeline first Valve ,將請求轉發到 Host host.getPipeline().getFirst().invoke(request, response); }
這個基礎閥實現很是簡單,就是把請求轉發到 Host 容器。處理請求的 Host 容器對象是從請求中拿到的,請求對象中怎麼會有 Host 容器呢?這是由於請求到達 Engine 容器中以前,Mapper 組件已經對請求進行了路由處理,Mapper 組件經過請求的 URL 定位了相應的容器,而且把容器對象保存到了請求對象中。
你們有沒有發現,Tomcat 的設計幾乎都是面向接口設計,也就是經過接口隔離功能設計其實就是單一職責的體現,每一個接口抽象對象不一樣的組件,經過抽象類定義組件的共同執行流程。單一職責四個字的含義其實就是在這裏體現出來了。在分析過程當中,咱們看到了觀察者模式、模板方法模式、組合模式、責任鏈模式以及如何抽象組件面向接口設計的設計哲學。
鏈接器主要功能就是接受 TCP/IP 鏈接,限制鏈接數而後讀取數據,最後將請求轉發到 Container
容器。因此這裏必然涉及到 I/O 編程,今天帶你們一塊兒分析 Tomcat 如何運用 I/O 模型實現高併發的,一塊兒進入 I/O 的世界。
I/O 模型主要有 5 種:同步阻塞、同步非阻塞、I/O 多路複用、信號驅動、異步 I/O。是否是很熟悉可是又傻傻分不清他們有何區別?
所謂的I/O 就是計算機內存與外部設備之間拷貝數據的過程。
CPU 是先把外部設備的數據讀到內存裏,而後再進行處理。請考慮一下這個場景,當程序經過 CPU 向外部設備發出一個讀指令時,數據從外部設備拷貝到內存每每須要一段時間,這個時候 CPU 沒事幹了,程序是主動把 CPU 讓給別人?仍是讓 CPU 不停地查:數據到了嗎,數據到了嗎……
這就是 I/O 模型要解決的問題。今天我會先說說各類 I/O 模型的區別,而後重點分析 Tomcat 的 NioEndpoint 組件是如何實現非阻塞 I/O 模型的。
一個網絡 I/O 通訊過程,好比網絡數據讀取,會涉及到兩個對象,分別是調用這個 I/O 操做的用戶線程和操做系統內核。一個進程的地址空間分爲用戶空間和內核空間,用戶線程不能直接訪問內核空間。
網絡讀取主要有兩個步驟:
同理,將數據發送到網絡也是同樣的流程,將數據從用戶線程複製到內核空間,內核空間將數據複製到網卡發送。
不一樣 I/O 模型的區別:實現這兩個步驟的方式不同。
用戶線程發起read
調用的時候,線程就阻塞了,只能讓出 CPU,而內核則等待網卡數據到來,並把數據從網卡拷貝到內核空間,當內核把數據拷貝到用戶空間,再把剛剛阻塞的讀取用戶線程喚醒,兩個步驟的線程都是阻塞的。
用戶線程一直不停的調用read
方法,若是數據尚未複製到內核空間則返回失敗,直到數據到達內核空間。用戶線程在等待數據從內核空間複製到用戶空間的時間裏一直是阻塞的,等數據到達用戶空間才被喚醒。循環調用read
方法的時候不阻塞。
用戶線程的讀取操做被劃分爲兩步:
select
調用,主要就是詢問內核數據轉備好了沒?當內核把數據準備好了就執行第二步。read
調用,在等待內核把數據從內核空間複製到用戶空間的時間裏,發起 read 線程是阻塞的。爲什麼叫 I/O 多路複用,核心主要就是:一次 select
調用能夠向內核查詢多個數據通道(Channel)的狀態,所以叫多路複用。
用戶線程執行 read 調用的時候會註冊一個回調函數, read 調用當即返回,不會阻塞線程,在等待內核將數據準備好之後,再調用剛剛註冊的回調函數處理數據,在整個過程當中用戶線程一直沒有阻塞。
Tomcat 的 NioEndpoit 組件實際上就是實現了 I/O 多路複用模型,正式由於這個併發能力才足夠優秀。讓咱們一塊兒窺探下 Tomcat NioEndpoint 的設計原理。
對於 Java 的多路複用器的使用,無非是兩步:
Tomcat 的 NioEndpoint 組件雖然實現比較複雜,但基本原理就是上面兩步。咱們先來看看它有哪些組件,它一共包含 LimitLatch、Acceptor、Poller、SocketProcessor 和 Executor 共 5 個組件,它們的工做過程以下圖所示:
正是因爲使用了 I/O 多路複用,Poller 內部本質就是持有 Java Selector 檢測 channel 的 I/O 時間,當數據可讀寫的時候建立 SocketProcessor 任務丟到線程池執行,也就是少許線程監聽讀寫事件,接着專屬的線程池執行讀寫,提升性能。
爲了提升處理能力和併發度, Web 容器一般會把處理請求的工做放在線程池來處理, Tomcat 拓展了 Java 原生的線程池來提高併發需求,在進入 Tomcat 線程池原理以前,咱們先回顧下 Java 線程池原理。
簡單的說,Java 線程池裏內部維護一個線程數組和一個任務隊列,當任務處理不過來的時,就把任務放到隊列裏慢慢處理。
來窺探線程池核心類的構造函數,咱們須要理解每個參數的做用,才能理解線程池的工做原理。
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) { ...... }
allowCoreThreadTimeOut(true)
時,線程池中 corePoolSize 範圍內的線程空閒時間達到 keepAliveTime 也將回收。RejectedExecutionHandler
便可。默認的拒絕策略:AbortPolicy
拒絕任務並拋出 RejectedExecutionException
異常;CallerRunsPolicy
提交該任務的線程執行;``來分析下每一個參數之間的關係:
提交新任務的時候,若是線程池數 < corePoolSize,則建立新的線程池執行任務,當線程數 = corePoolSize 時,新的任務就會被放到工做隊列 workQueue 中,線程池中的線程儘可能從隊列裏取任務來執行。
若是任務不少,workQueue 滿了,且 當前線程數 < maximumPoolSize 時則臨時建立線程執行任務,若是總線程數量超過 maximumPoolSize,則再也不建立線程,而是執行拒絕策略。DiscardPolicy
什麼都不作直接丟棄任務;DiscardOldestPolicy
丟棄最舊的未處理程序;
具體執行流程以下圖所示:
定製版的 ThreadPoolExecutor,繼承了 java.util.concurrent.ThreadPoolExecutor。 對於線程池有兩個很關鍵的參數:
Tomcat 必然須要限定想着兩個參數否則在高併發場景下可能致使 CPU 和內存有資源耗盡的風險。繼承了 與 java.util.concurrent.ThreadPoolExecutor 相同,但實現的效率更高。
其構造方法以下,跟 Java 官方的一模一樣
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler) { super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, handler); prestartAllCoreThreads(); }
在 Tomcat 中控制線程池的組件是 StandardThreadExecutor
, 也是實現了生命週期接口,下面是啓動線程池的代碼
@Override protected void startInternal() throws LifecycleException { // 自定義任務隊列 taskqueue = new TaskQueue(maxQueueSize); // 自定義線程工廠 TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon,getThreadPriority()); // 建立定製版線程池 executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS,taskqueue, tf); executor.setThreadRenewalDelay(threadRenewalDelay); if (prestartminSpareThreads) { executor.prestartAllCoreThreads(); } taskqueue.setParent(executor); // 觀察者模式,發佈啓動事件 setState(LifecycleState.STARTING); }
其中的關鍵點在於:
除此以外, Tomcat 在官方原有基礎上從新定義了本身的線程池處理流程,原生的處理流程上文已經說過。
Tomcat 線程池擴展了原生的 ThreadPoolExecutor,經過重寫 execute 方法實現了本身的任務處理邏輯:
最大的差異在於 Tomcat 在線程總數達到最大數時,不是當即執行拒絕策略,而是再嘗試向任務隊列添加任務,添加失敗後再執行拒絕策略。
代碼以下所示:
public void execute(Runnable command, long timeout, TimeUnit unit) { // 記錄提交任務數 +1 submittedCount.incrementAndGet(); try { // 調用 java 原生線程池來執行任務,當原生拋出拒絕策略 super.execute(command); } catch (RejectedExecutionException rx) { //總線程數達到 maximumPoolSize,Java 原生會執行拒絕策略 if (super.getQueue() instanceof TaskQueue) { final TaskQueue queue = (TaskQueue)super.getQueue(); try { // 嘗試把任務放入隊列中 if (!queue.force(command, timeout, unit)) { submittedCount.decrementAndGet(); // 隊列仍是滿的,插入失敗則執行拒絕策略 throw new RejectedExecutionException("Queue capacity is full."); } } catch (InterruptedException x) { submittedCount.decrementAndGet(); throw new RejectedExecutionException(x); } } else { // 提交任務書 -1 submittedCount.decrementAndGet(); throw rx; } } }
Tomcat 線程池是用 submittedCount 來維護已經提交到了線程池,這跟 Tomcat 的定製版的任務隊列有關。Tomcat 的任務隊列 TaskQueue 擴展了 Java 中的 LinkedBlockingQueue,咱們知道 LinkedBlockingQueue 默認狀況下長度是沒有限制的,除非給它一個 capacity。所以 Tomcat 給了它一個 capacity,TaskQueue 的構造函數中有個整型的參數 capacity,TaskQueue 將 capacity 傳給父類 LinkedBlockingQueue 的構造函數,防止無限添加任務致使內存溢出。並且默認是無限制,就會致使當前線程數達到核心線程數以後,再來任務的話線程池會把任務添加到任務隊列,而且老是會成功,這樣永遠不會有機會建立新線程了。
爲了解決這個問題,TaskQueue 重寫了 LinkedBlockingQueue 的 offer 方法,在合適的時機返回 false,返回 false 表示任務添加失敗,這時線程池會建立新的線程。
public class TaskQueue extends LinkedBlockingQueue<Runnable> { ... @Override // 線程池調用任務隊列的方法時,當前線程數確定已經大於核心線程數了 public boolean offer(Runnable o) { // 若是線程數已經到了最大值,不能建立新線程了,只能把任務添加到任務隊列。 if (parent.getPoolSize() == parent.getMaximumPoolSize()) return super.offer(o); // 執行到這裏,代表當前線程數大於核心線程數,而且小於最大線程數。 // 代表是能夠建立新線程的,那到底要不要建立呢?分兩種狀況: //1. 若是已提交的任務數小於當前線程數,表示還有空閒線程,無需建立新線程 if (parent.getSubmittedCount()<=(parent.getPoolSize())) return super.offer(o); //2. 若是已提交的任務數大於當前線程數,線程不夠用了,返回 false 去建立新線程 if (parent.getPoolSize()<parent.getMaximumPoolSize()) return false; // 默認狀況下老是把任務添加到任務隊列 return super.offer(o); } }
只有當前線程數大於核心線程數、小於最大線程數,而且已提交的任務個數大於當前線程數時,也就是說線程不夠用了,可是線程數又沒達到極限,纔會去建立新的線程。這就是爲何 Tomcat 須要維護已提交任務數這個變量,它的目的就是在任務隊列的長度無限制的狀況下,讓線程池有機會建立新的線程。能夠經過設置 maxQueueSize 參數來限制任務隊列的長度。
跟 I/O 模型緊密相關的是線程池,線程池的調優就是設置合理的線程池參數。咱們先來看看 Tomcat 線程池中有哪些關鍵參數:
參數 | 詳情 |
---|---|
threadPriority | 線程優先級,默認是 5 |
daemon | 是不是 後臺線程,默認 true |
namePrefix | 線程名前綴 |
maxThreads | 最大線程數,默認 200 |
minSpareThreads | 最小線程數(空閒超過必定時間會被回收),默認 25 |
maxIdleTime | 線程最大空閒時間,超過該時間的會被回收,直到只有 minSpareThreads 個。默認是 1 分鐘 |
maxQueueSize | 任務隊列最大長度 |
prestartAllCoreThreads | 是否在線程池啓動的時候就建立 minSpareThreads 個線程,默認是 fasle |
這裏面最核心的就是如何肯定 maxThreads 的值,若是這個參數設置小了,Tomcat 會發生線程飢餓,而且請求的處理會在隊列中排隊等待,致使響應時間變長;若是 maxThreads 參數值過大,一樣也會有問題,由於服務器的 CPU 的核數有限,線程數太多會致使線程在 CPU 上來回切換,耗費大量的切換開銷。
線程 I/O 時間與 CPU 時間
至此咱們又獲得一個線程池個數的計算公式,假設服務器是單核的:
線程池大小 = (線程 I/O 阻塞時間 + 線程 CPU 時間 )/ 線程 CPU 時間
其中:線程 I/O 阻塞時間 + 線程 CPU 時間 = 平均請求處理時間。
JVM 在拋出 java.lang.OutOfMemoryError 時,除了會打印出一行描述信息,還會打印堆棧跟蹤,所以咱們能夠經過這些信息來找到致使異常的緣由。在尋找緣由前,咱們先來看看有哪些因素會致使 OutOfMemoryError,其中內存泄漏是致使 OutOfMemoryError 的一個比較常見的緣由。
其實調優不少時候都是在找系統瓶頸,假若有個情況:系統響應比較慢,但 CPU 的用率不高,內存有所增長,經過分析 Heap Dump 發現大量請求堆積在線程池的隊列中,請問這種狀況下應該怎麼辦呢?多是請求處理時間太長,去排查是否是訪問數據庫或者外部應用遇到了延遲。
當 JVM 沒法在堆中分配對象的會拋出此異常,通常有如下緣由:
jmap -dump:live,format=b,file=filename.bin pid
垃圾收集器持續運行,可是效率很低幾乎沒有回收內存。好比 Java 進程花費超過 96%的 CPU 時間來進行一次 GC,可是回收的內存少於 3%的 JVM 堆,而且連續 5 次 GC 都是這種狀況,就會拋出 OutOfMemoryError。
這個問題 IDE 解決方法就是查看 GC 日誌或者生成 Heap Dump,先確認是不是內存溢出,不是的話能夠嘗試增長堆大小。能夠經過以下 JVM 啓動參數打印 GC 日誌:
-verbose:gc //在控制檯輸出GC狀況 -XX:+PrintGCDetails //在控制檯輸出詳細的GC狀況 -Xloggc: filepath //將GC日誌輸出到指定文件中
好比 可使用 java -verbose:gc -Xloggc:gc.log -XX:+PrintGCDetails -jar xxx.jar
記錄 GC 日誌,經過 GCViewer 工具查看 GC 日誌,用 GCViewer 打開產生的 gc.log 分析垃圾回收狀況。
拋出這種異常的緣由是「請求的數組大小超過 JVM 限制」,應用程序嘗試分配一個超大的數組。好比程序嘗試分配 128M 的數組,可是堆最大 100M,通常這個也是配置問題,有可能 JVM 堆設置過小,也有多是程序的 bug,是否是建立了超大數組。
JVM 元空間的內存在本地內存中分配,可是它的大小受參數 MaxMetaSpaceSize 的限制。當元空間大小超過 MaxMetaSpaceSize 時,JVM 將拋出帶有 MetaSpace 字樣的 OutOfMemoryError。解決辦法是加大 MaxMetaSpaceSize 參數的值。
當本地堆內存分配失敗或者本地內存快要耗盡時,Java HotSpot VM 代碼會拋出這個異常,VM 會觸發「致命錯誤處理機制」,它會生成「致命錯誤」日誌文件,其中包含崩潰時線程、進程和操做系統的有用信息。若是碰到此類型的 OutOfMemoryError,你須要根據 JVM 拋出的錯誤信息來進行診斷;或者使用操做系統提供的 DTrace 工具來跟蹤系統調用,看看是什麼樣的程序代碼在不斷地分配本地內存。
-Xss
決定。這裏只是概述場景,對於生產在線排查後續會陸續推出,受限於篇幅再也不展開。關注「碼哥字節」給你硬貨來啃!
回顧 Tomcat 總結架構設計,詳細拆解 Tomcat 如何處理高併發鏈接設計。而且分享瞭如何高效閱讀開源框架源碼思路,設計模式、併發編程基礎是重中之重,讀者朋友能夠翻閱歷史「碼哥字節」的歷史文章學習。
拆解 Tomcat 核心組件,去體會 Tomcat 如何面向接口設計、落實單一職責的設計哲學思想。接着歸納了 鏈接器涉及到的 I/O 模型,並對不一樣的 I/O 模型進行了詳解,接着看 Tomcat 如何實現 NIO,如何自定義線程池以及隊列實現高併發設計,最後簡單分享常見的 OOM 場景以及解決思路,限於篇幅再也不詳細展開,關注「碼哥字節」後續會分享各類線上故障排查調優思路,敬請期待…...
有任何疑問或者計數探討能夠加我的微信:MageByte1024,一塊兒學習進步。
也能夠經過公衆號菜單加入技術羣,裏面有阿里、騰訊的大佬。
編寫文章不易,若是閱讀後以爲有用,但願關注「碼哥字節」公衆號,點擊「分享」、「點贊」、「在看」是最大的鼓勵。