上帝視角拆解 Tomcat 架構設計,在瞭解整個元件設計思路之後。我們需要下凡深入瞭解每個元件的細節實現。從遠到近,架構給人以巨集觀思維,細節展現飽滿的美。關注「碼哥位元組」獲取更多硬核,你,準備好了麼?
上回「碼哥位元組」站在上帝視角給大家拆解了 Tomcat 架構設計,分析 Tomcat 如何實現啟動、停止,通過設計連線池與容器兩大元件完成了一個請求的接受與響應。聯結器負責對外交流,處理 socket 連線,容器對內負責,載入 Servlet 以及處理具體 Request 請求與響應。詳情點我進入傳輸門:Tomcat 架構解析到工作借鑑。
高併發拆解核心準備
這回,再次拆解,專注 Tomcat 高併發設計之道與效能調優,讓大家對整個架構有更高層次的瞭解與感悟。其中設計的每個元件思路都是將 Java 物件導向、面向介面、如何封裝變與不變,如何根據實際需求抽象不同元件分工合作,如何設計類實現單一職責,怎麼做到將相似功能高內聚低耦合,設計模式運用到極致的學習借鑑。
這次主要涉及到的是 I/O 模型,以及執行緒池的基礎內容。
在學習之前,希望大家積累以下一些技術內容,很多內容「碼哥位元組」也在歷史文章中分享過。大家可爬樓回顧……。希望大家重視如下幾個知識點,在掌握以下知識點再來拆解 Tomcat,就會事半功倍,否則很容易迷失方向不得其法。
一起來看 Tomcat 如何實現併發連線處理以及任務處理,效能的優化是每一個元件都起到對應的作用,如何使用最少的記憶體,最快的速度執行是我們的目標。
設計模式
模板方法模式: 抽象演算法流程在抽象類中,封裝流程中的變化與不變點。將變化點延遲到子類實現,達到程式碼複用,開閉原則。
觀察者模式:針對事件不同元件有不同響應機制的需求場景,達到解耦靈活通知下游。
責任鏈模式:將物件連線成一條鏈,將沿著這條鏈傳遞請求。在 Tomcat 中的 Valve 就是該設計模式的運用。
更多設計模式可檢視「碼哥位元組」之前的設計模式專輯,這裡是傳送門。
I/O 模型
Tomcat 實現高併發接收連線,必然涉及到 I/O 模型的運用,瞭解同步阻塞、非同步阻塞、I/O 多路複用,非同步非阻塞相關概念以及 Java NIO 包的運用很有必要。本文也會帶大家著重說明 I/O 是如何在 Tomcat 運用實現高併發連線。大家通過本文我相信對 I/O 模型也會有一個深刻認識。
Java 併發程式設計
實現高併發,除了整體每個元件的優雅設計、設計模式的合理、I/O 的運用,還需要執行緒模型,如何高效的併發程式設計技巧。在高併發過程中,不可避免的會出現多個執行緒對共享變數的訪問,需要加鎖實現,如何高效的降低鎖衝突。因此作為程式設計師,要有意識的儘量避免鎖的使用,比如可以使用原子類 CAS 或者併發集合來代替。如果萬不得已需要用到鎖,也要儘量縮小鎖的範圍和鎖的強度。
對於併發相關的基礎知識,如果讀者感興趣「碼哥位元組」後面也給大家安排上,目前也寫了部分併發專輯,大家可移步到歷史文章或者專輯翻閱,這裡是傳送門,主要講解了併發實現的原理、什麼是記憶體可見性,JMM 記憶體模模型、讀寫鎖等併發知識點。
Tomcat 總體架構
再次回顧下 Tomcat 整體架構設計,主要設計了 connector 聯結器處理 TCP/IP 連線,container 容器作為 Servlet 容器,處理具體的業務請求。對外對內分別抽象兩個元件實現擴充。
- 一個 Tomcat 例項預設會有一個 Service,而一個 Service 可以包含多個聯結器。聯結器主要有 ProtocalHandler 和 Adapter 兩個元件共同完成聯結器核心功能。
ProtocolHandler
主要由Acceptor
以及SocketProcessor
構成,實現了 TCP/IP 層 的 Socket 讀取並轉換成TomcatRequest
和TomcatResponse
,最後根據 http 或者 ajp 協議獲取合適的Processor
解析為應用層協議,並通過 Adapter 將 TomcatRequest、TomcatResponse 轉化成 標準的 ServletRequest、ServletResponse。通過getAdapter().service(request, response);
將請求傳遞到 Container 容器。- adapter.service()實現將請求轉發到容器
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 的架構設計重要元件清晰可見,希望大家將這個全域性架構圖深深印在腦海裡,掌握全域性思路才能更好地分析細節之美。
啟動流程:startup.sh 指令碼到底發生了什麼
- Tomcat 本生就是一個 Java 程式,所以 startup.sh 指令碼就是啟動一個 JVM 來執行 Tomcat 的啟動類 Bootstrap。
- Bootstrap 主要就是例項化 Catalina 和初始化 Tomcat 自定義的類載入器。熱載入與熱部署就是靠他實現。
- Catalina: 解析 server.xml 建立 Server 元件,並且呼叫 Server.start() 方法。
- Server:管理 Service 元件,呼叫 Server 的 start() 方法。
- Service:主要職責就是管理簡介器的頂層容器 Engine,分別呼叫
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 中。
Catalina
主要任務就是建立 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 方法會釋放和清理所有的資源。
Server 元件
來體會下面向介面設計美,看 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
同樣是面向介面設計,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 在容器元件之後啟動。元件停止的順序跟啟動順序正好相反的,也是基於它們的依賴關係。
Engine
作為 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 做了什麼...
- initInternal 定義了容器初始化,同時建立了專門用於啟動停止容器的執行緒池。
- startInternal:容器啟動預設實現,通過組合模式構建容器父子關係,首先獲取自己的子容器,使用 startStopExecutor 啟動子容器。
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 的設計幾乎都是面向介面設計,也就是通過介面隔離功能設計其實就是單一職責的體現,每個介面抽象物件不同的元件,通過抽象類定義元件的共同執行流程。單一職責四個字的含義其實就是在這裡體現出來了。在分析過程中,我們看到了觀察者模式、模板方法模式、組合模式、責任鏈模式以及如何抽象元件面向介面設計的設計哲學。
聯結器之 I/O 模型與執行緒池設計
聯結器主要功能就是接受 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 操作的使用者執行緒和作業系統核心。一個程式的地址空間分為使用者空間和核心空間,使用者執行緒不能直接訪問核心空間。
網路讀取主要有兩個步驟:
- 使用者執行緒等待核心將資料從網路卡複製到核心空間。
- 核心將資料從核心空間複製到使用者空間。
同理,將資料傳送到網路也是一樣的流程,將資料從使用者執行緒複製到核心空間,核心空間將資料複製到網路卡傳送。
不同 I/O 模型的區別:實現這兩個步驟的方式不一樣。
- 對於同步,則指的應用程式呼叫一個方法是否立馬返回,而不需要等待。
- 對於阻塞與非阻塞:主要就是資料從核心複製到使用者空間的讀寫操作是否是阻塞等待的。
同步阻塞 I/O
使用者執行緒發起read
呼叫的時候,執行緒就阻塞了,只能讓出 CPU,而核心則等待網路卡資料到來,並把資料從網路卡拷貝到核心空間,當核心把資料拷貝到使用者空間,再把剛剛阻塞的讀取使用者執行緒喚醒,兩個步驟的執行緒都是阻塞的。
同步非阻塞
使用者執行緒一直不停的呼叫read
方法,如果資料還沒有複製到核心空間則返回失敗,直到資料到達核心空間。使用者執行緒在等待資料從核心空間複製到使用者空間的時間裡一直是阻塞的,等資料到達使用者空間才被喚醒。迴圈呼叫read
方法的時候不阻塞。
I/O 多路複用
使用者執行緒的讀取操作被劃分為兩步:
- 使用者執行緒先發起
select
呼叫,主要就是詢問核心資料轉備好了沒?當核心把資料準備好了就執行第二步。 - 使用者執行緒再發起
read
呼叫,在等待核心把資料從核心空間複製到使用者空間的時間裡,發起 read 執行緒是阻塞的。
為何叫 I/O 多路複用,核心主要就是:一次 select
呼叫可以向核心查詢多個資料通道(Channel)的狀態,因此叫多路複用。
非同步 I/O
使用者執行緒執行 read 呼叫的時候會註冊一個回撥函式, read 呼叫立即返回,不會阻塞執行緒,在等待核心將資料準備好以後,再呼叫剛剛註冊的回撥函式處理資料,在整個過程中使用者執行緒一直沒有阻塞。
Tomcat NioEndpoint
Tomcat 的 NioEndpoit 元件實際上就是實現了 I/O 多路複用模型,正式因為這個併發能力才足夠優秀。讓我們一起窺探下 Tomcat NioEndpoint 的設計原理。
對於 Java 的多路複用器的使用,無非是兩步:
- 建立一個 Seletor,在它身上註冊各種感興趣的事件,然後呼叫 select 方法,等待感興趣的事情發生。
- 感興趣的事情發生了,比如可以讀了,這時便建立一個新的執行緒從 Channel 中讀資料。
Tomcat 的 NioEndpoint 元件雖然實現比較複雜,但基本原理就是上面兩步。我們先來看看它有哪些元件,它一共包含 LimitLatch、Acceptor、Poller、SocketProcessor 和 Executor 共 5 個元件,它們的工作過程如下圖所示:
正是由於使用了 I/O 多路複用,Poller 內部本質就是持有 Java Selector 檢測 channel 的 I/O 時間,當資料可讀寫的時候建立 SocketProcessor 任務丟到執行緒池執行,也就是少量執行緒監聽讀寫事件,接著專屬的執行緒池執行讀寫,提高效能。
自定義執行緒池模型
為了提高處理能力和併發度, Web 容器通常會把處理請求的工作放線上程池來處理, Tomcat 擴充了 Java 原生的執行緒池來提升併發需求,在進入 Tomcat 執行緒池原理之前,我們先回顧下 Java 執行緒池原理。
Java 執行緒池
簡單的說,Java 執行緒池裡內部維護一個執行緒陣列和一個任務佇列,當任務處理不過來的時,就把任務放到佇列裡慢慢處理。
ThreadPoolExecutor
來窺探執行緒池核心類的建構函式,我們需要理解每一個引數的作用,才能理解執行緒池的工作原理。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
......
}
- corePoolSize:保留在池中的執行緒數,即使它們空閒,除非設定了 allowCoreThreadTimeOut,不然不會關閉。
- maximumPoolSize:佇列滿後池中允許的最大執行緒數。
- keepAliveTime、TimeUnit:如果執行緒數大於核心數,多餘的空閒執行緒的保持的最長時間會被銷燬。unit 是 keepAliveTime 引數的時間單位。當設定
allowCoreThreadTimeOut(true)
時,執行緒池中 corePoolSize 範圍內的執行緒空閒時間達到 keepAliveTime 也將回收。 - workQueue:當執行緒數達到 corePoolSize 後,新增的任務就放到工作佇列 workQueue 裡,而執行緒池中的執行緒則努力地從 workQueue 里拉活來幹,也就是呼叫 poll 方法來獲取任務。
- ThreadFactory:建立執行緒的工廠,比如設定是否是後臺執行緒、執行緒名等。
- RejectedExecutionHandler:拒絕策略,處理程式因為達到了執行緒界限和佇列容量執行拒絕策略。也可以自定義拒絕策略,只要實現
RejectedExecutionHandler
即可。預設的拒絕策略:AbortPolicy
拒絕任務並丟擲RejectedExecutionException
異常;CallerRunsPolicy
提交該任務的執行緒執行;``
來分析下每個引數之間的關係:
提交新任務的時候,如果執行緒池數 < corePoolSize,則建立新的執行緒池執行任務,當執行緒數 = corePoolSize 時,新的任務就會被放到工作佇列 workQueue 中,執行緒池中的執行緒儘量從佇列裡取任務來執行。
如果任務很多,workQueue 滿了,且 當前執行緒數 < maximumPoolSize 時則臨時建立執行緒執行任務,如果匯流排程數量超過 maximumPoolSize,則不再建立執行緒,而是執行拒絕策略。DiscardPolicy
什麼都不做直接丟棄任務;DiscardOldestPolicy
丟棄最舊的未處理程式;
具體執行流程如下圖所示:
Tomcat 執行緒池
定製版的 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 有自己的定製版任務佇列和執行緒工廠,並且可以限制任務佇列的長度,它的最大長度是 maxQueueSize。
- Tomcat 對執行緒數也有限制,設定了核心執行緒數(minSpareThreads)和最大執行緒池數(maxThreads)。
除此之外, Tomcat 在官方原有基礎上重新定義了自己的執行緒池處理流程,原生的處理流程上文已經說過。
- 前 corePoolSize 個任務時,來一個任務就建立一個新執行緒。
- 還有任務提交,直接放到佇列,佇列滿了,但是沒有達到最大執行緒池數則建立臨時執行緒救火。
- 執行緒匯流排數達到 maximumPoolSize ,直接執行拒絕策略。
Tomcat 執行緒池擴充套件了原生的 ThreadPoolExecutor,通過重寫 execute 方法實現了自己的任務處理邏輯:
- 前 corePoolSize 個任務時,來一個任務就建立一個新執行緒。
- 還有任務提交,直接放到佇列,佇列滿了,但是沒有達到最大執行緒池數則建立臨時執行緒救火。
- 執行緒匯流排數達到 maximumPoolSize ,繼續嘗試把任務放到佇列中。如果佇列也滿了,插入任務失敗,才執行拒絕策略。
最大的差別在於 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 時間 = 平均請求處理時間。
Tomcat 記憶體溢位的原因分析及調優
JVM 在丟擲 java.lang.OutOfMemoryError 時,除了會列印出一行描述資訊,還會列印堆疊跟蹤,因此我們可以通過這些資訊來找到導致異常的原因。在尋找原因前,我們先來看看有哪些因素會導致 OutOfMemoryError,其中記憶體洩漏是導致 OutOfMemoryError 的一個比較常見的原因。
其實調優很多時候都是在找系統瓶頸,假如有個狀況:系統響應比較慢,但 CPU 的用率不高,記憶體有所增加,通過分析 Heap Dump 發現大量請求堆積線上程池的佇列中,請問這種情況下應該怎麼辦呢?可能是請求處理時間太長,去排查是不是訪問資料庫或者外部應用遇到了延遲。
java.lang.OutOfMemoryError: Java heap space
當 JVM 無法在堆中分配物件的會丟擲此異常,一般有以下原因:
- 記憶體洩漏:本該回收的物件唄程式一直持有引用導致物件無法被回收,比如線上程池中使用 ThreadLocal、物件池、記憶體池。為了找到記憶體洩漏點,我們通過 jmap 工具生成 Heap Dump,再利用 MAT 分析找到記憶體洩漏點。
jmap -dump:live,format=b,file=filename.bin pid
- 記憶體不足:我們設定的堆大小對於應用程式來說不夠,修改 JVM 引數調整堆大小,比如 -Xms256m -Xmx2048m。
- finalize 方法的過度使用。如果我們想在 Java 類例項被 GC 之前執行一些邏輯,比如清理物件持有的資源,可以在 Java 類中定義 finalize 方法,這樣 JVM GC 不會立即回收這些物件例項,而是將物件例項新增到一個叫“java.lang.ref.Finalizer.ReferenceQueue”的佇列中,執行物件的 finalize 方法,之後才會回收這些物件。Finalizer 執行緒會和主執行緒競爭 CPU 資源,但由於優先順序低,所以處理速度跟不上主執行緒建立物件的速度,因此 ReferenceQueue 佇列中的物件就越來越多,最終會丟擲 OutOfMemoryError。解決辦法是儘量不要給 Java 類定義 finalize 方法。
java.lang.OutOfMemoryError: GC overhead limit exceeded
垃圾收集器持續執行,但是效率很低幾乎沒有回收記憶體。比如 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 分析垃圾回收情況。
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
丟擲這種異常的原因是“請求的陣列大小超過 JVM 限制”,應用程式嘗試分配一個超大的陣列。比如程式嘗試分配 128M 的陣列,但是堆最大 100M,一般這個也是配置問題,有可能 JVM 堆設定太小,也有可能是程式的 bug,是不是建立了超大陣列。
java.lang.OutOfMemoryError: MetaSpace
JVM 元空間的記憶體在本地記憶體中分配,但是它的大小受引數 MaxMetaSpaceSize 的限制。當元空間大小超過 MaxMetaSpaceSize 時,JVM 將丟擲帶有 MetaSpace 字樣的 OutOfMemoryError。解決辦法是加大 MaxMetaSpaceSize 引數的值。
java.lang.OutOfMemoryError: Request size bytes for reason. Out of swap space
當本地堆記憶體分配失敗或者本地記憶體快要耗盡時,Java HotSpot VM 程式碼會丟擲這個異常,VM 會觸發“致命錯誤處理機制”,它會生成“致命錯誤”日誌檔案,其中包含崩潰時執行緒、程式和作業系統的有用資訊。如果碰到此型別的 OutOfMemoryError,你需要根據 JVM 丟擲的錯誤資訊來進行診斷;或者使用作業系統提供的 DTrace 工具來跟蹤系統呼叫,看看是什麼樣的程式程式碼在不斷地分配本地記憶體。
java.lang.OutOfMemoryError: Unable to create native threads
- Java 程式向 JVM 請求建立一個新的 Java 執行緒。
- JVM 原生程式碼(Native Code)代理該請求,通過呼叫作業系統 API 去建立一個作業系統級別的執行緒 Native Thread。
- 作業系統嘗試建立一個新的 Native Thread,需要同時分配一些記憶體給該執行緒,每一個 Native Thread 都有一個執行緒棧,執行緒棧的大小由 JVM 引數
-Xss
決定。 - 由於各種原因,作業系統建立新的執行緒可能會失敗,下面會詳細談到。
- JVM 丟擲“java.lang.OutOfMemoryError: Unable to create new native thread”錯誤。
這裡只是概述場景,對於生產線上排查後續會陸續推出,受限於篇幅不再展開。關注「碼哥位元組」給你硬貨來啃!
總結
回顧 Tomcat 總結架構設計,詳細拆解 Tomcat 如何處理高併發連線設計。並且分享瞭如何高效閱讀開源框架原始碼思路,設計模式、併發程式設計基礎是重中之重,讀者朋友可以翻閱歷史「碼哥位元組」的歷史文章學習。
推薦閱讀
拆解 Tomcat 核心元件,去體會 Tomcat 如何面向介面設計、落實單一職責的設計哲學思想。接著概括了 聯結器涉及到的 I/O 模型,並對不同的 I/O 模型進行了詳解,接著看 Tomcat 如何實現 NIO,如何自定義執行緒池以及佇列實現高併發設計,最後簡單分享常見的 OOM 場景以及解決思路,限於篇幅不再詳細展開,關注「碼哥位元組」後續會分享各種線上故障排查調優思路,敬請期待…...
有任何疑問或者計數探討可以加個人微信:MageByte1024,一起學習進步。
也可以通過公眾號選單加入技術群,裡面有阿里、騰訊的大佬。
編寫文章不易,如果閱讀後覺得有用,希望關注「碼哥位元組」公眾號,點選「分享」、「點贊」、「在看」是最大的鼓勵。