兩週沒更新了,感覺不寫點什麼,有點不舒服的感覺。
前言
回憶一下學Java的歷程,當時是看JavaSE(基本語法、線程、泛型),然後是JavaEE,JavaEE也基本就是圍繞着Servlet的使用、JSP、JDBC來學習,當時看的是B站up主顏羣的教學視頻:
- JavaWeb視頻教程(JSP/Servlet/上傳/下載/分頁/MVC/三層架構/Ajax)https://www.bilibili.com/video/BV18s411u7EH?p=6&vd_source=aae...
現在一看這個播放量破百萬了,當初我看的時候應該播放量很少,現在這麼多倒是有點昨舌。學完了這個之後,開始學習框架:Spring、SpringMVC、MyBatis、SpringBoot。雖然Spring MVC本質上也是基於Servlet做封裝,但後面基本就轉型成Spring 工程師了,最近碰到一些問題,又看了一篇文章,覺得一些問題之前自己還是沒考慮到,頗有種離了Spring家族,不會寫後端一樣。本來今天的行文最初是什麼是異步Servlet,異步Servlet該如何使用。但是想想沒有切入本質,所以將其換成了對話體。
正文
我們接着有請之前的實習生小陳,每當我們需要用到對話體、故事體這樣的行文。實習生小陳就會出場。今天的小陳呢覺得行情有些不好,但是還是覺得想出去看看,畢竟金三銀四,於是下午就向領導請假去面試了。進到面試的地方,一番自我介紹,面試官首先問了這樣一個問題:
一個請求是怎麼被Tomcat所處理的呢?
小陳回答到:
我目前用的都是Spring Boot工程,我看都是啓動都是在main函數裏面啓動整個項目的,而main函數又被main線程執行,所以我想應該是請求過來之後,被main線程所處理,給出響應的。
面試官:
╮(╯▽╰)╭,main函數的確是被main線程執行,但都是被main線程處理的? 這不合理吧,假設某個請求佔用了main線程三秒,那這三秒內,系統都無法再回應請求了。你要不再想想?
小陳撓了撓頭,接着答到:
確實是,瀏覽器和Tomcat通訊用的是HTTP協議,我也學過網絡編程,所以我覺得應該是一個線程一個請求吧。像下面這樣:
public class ServerSocketDemo {
private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(4);
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8080);
while (true){
// 一個socket對象代表一個連接
// 等待TCP連接請求的建立,在TCP連接請求建立完成之前,會陷入阻塞
Socket socket = serverSocket.accept();
System.out.println("當前連接建立:"+ socket.getInetAddress().getHostName()+socket);
EXECUTOR_SERVICE.submit(()->{
try {
// 從輸入流中讀取客户端發送的內容
InputStream inputStream = socket.getInputStream();
// 從輸出流裏向客户端寫入數據
OutputStream outPutStream = socket.getOutputStream();
} catch (IOException e) {
e.printStackTrace();
}
});
}
}
}
serverSocket的accept在連接建立起來會陷入阻塞。
面試官點了點頭, 接着問到:
你這個main線程負責檢測連接是否建立,然後建立之後將後續的業務處理放入線程池,這個是NIO吧。
小陳笑了笑説道:
雖然我對NIO瞭解不多,但這應該也不是NIO,因為後面的線程在等待數據可讀可寫的過程中會陷入阻塞。在操作系統中,線程是一個相當昂貴的資源,我們一般使用線程池,可以讓線程的創建和回收成本相對較低,在活動連接數不是特別高的情況下(單機小於1000),這種,模型是比較不錯的,可以讓每一個連接專注於自己的I/O並且編程模型簡單。但要命的就是在連接上來之後,這種模型出現了問題。我們來分析一下我們上面的BIO模型存在的問題,主線程在接受連接之後返回一個Socket對象,將Socket對象提交給線程池處理。由這個線程池的線程來執行讀寫操作,那事實上這個線程承擔的工作有判斷數據可讀、判斷數據可寫,對可讀數據進行業務操作之後,將需要寫入的數據進行寫入。 那陷入阻塞的就是在等待數據可寫、等待數據可讀的過程,在NIO模型下對原本一個線程的任務進行了拆分,將判斷可讀可寫任務進行了分離或者對原先的模型進行了改造,原先的業務處理就只做業務處理,將判斷可讀還是可寫、以及寫入這個任務專門進行分離。
我們將判斷可讀、可寫、有新連接建立的線程姑且就稱之為I/O主線程吧,這個主線程在不斷輪詢這三個事件是否發生,如果發生了就將其就給對應的處理器。這也就是最簡單的Reactor模式: 註冊所有感興趣的事件處理器,單線程輪詢選擇就緒事件,執行事件處理器。
現在我們就可以大致總結出來NIO是怎麼解決掉線程的瓶頸並處理海量連接的: 由原來的阻塞讀寫變成了單線程輪詢事件,找到可以進行讀寫的網絡描述符進行讀寫。除了事件的輪詢是阻塞的(沒有滿足的事件就必須要要阻塞),剩餘的I/O操作都是純CPU操作,沒有必要開啓多線程。
面試官點了點頭,説道:
還可以嘛,小夥子,剛剛問怎麼卡(qia)殼了?
小陳不好意思的撓撓頭, 笑道:
其實之前看過這部分內容,只不過可能知識不用就想不起來,您提示了一下,我才想起來。
面試官笑了一下,接着問:
那現在的服務器,一般都是多核處理,如果能夠利用多核心進行I/O, 無疑對效率會有更大的提高。 能否對上面的模型進行持續優化呢?
小陳想了想答道:
仔細分一下我們需要的線程,其實主要包括以下幾種:
- 事件分發器,單線程選擇就緒的事件。
- I/O處理器,包括connect、read、writer等,這種純CPU操作,一般開啓CPU核心個線程就可以了
- 業務線程,在處理完I/O後,業務一般還會有自己的業務邏輯,有的還會有其他的阻塞I/O,如DB操作,RPC等。只要有阻塞,就需要單獨的線程。
面試官點了點頭,接着問道:
不錯,不錯。那Java的NIO知道嘛。
小陳點了點頭説道:
知道,Java引入了Selector、Channel 、Buffer,來實現我們新建立的模型,Selector字面意思是選擇器,負責感應事件,也就是我們上面提到的事件分發器。Channel是一種對I/O操作的抽象,可以用於讀取和寫入數據。Buffer則是一種用於存儲數據的緩衝區,提供統一存取的操作。
面試官又問道:
有了解過Java的Selector在Linux系統下的限制嘛?
小陳答道:
Java的Selector對於Linux系統來説,有一個致命的限制: 同一個channel的select不能被併發的調用。因此,如果有多個I/O線程,必須保證: 一個socket只能屬於一個IO線程,而一個IO線程可以管理多個socket。
面試官點了點頭:
不錯,不錯。Tomcat有常用的默認配置參數有: acceptorThreadCount 、 maxConnections、maxThreads 。解釋一下這幾個參數的意義,並且給出一個請求在到達Tomcat之後是怎麼被處理的,要求結合Servlet來進行説明。
小陳沉思了一下道:
acceptorThreadCount 用來控制接收連接的線程數,如果服務器是多核心,可以調大一點。但是Tomcat的官方文檔建議不要超過2個。控制接收連接這部分的代碼在Acceptor這個類裏,你可以看到這個類是Runnable的實現類。在Tomcat的8.0版本,你還能查到這個參數的説明,但是在8.5這個版本就查不到,我沒找到對應的説明,但是在Tomcat 9.0源碼的AbstractProtocol類中的setAcceptorThreadCount方法可以看到,這個參數被廢棄,上面還有説明,説這個參數將在Tomcat的10.0被移除。maxConnections用於控制Tomcat能夠承受的TCP連接數,當達到最大連接數時,操作系統會將請求的連接放入到隊列裏面,這個隊列的數目由acceptCount這個參數控制,默認值為100,如果超過了操作系統能承受的連接數目,這個參數也會不起作用,TCP連接會被操作系統拒絕。maxConnections在NIO和NIO2下, 默認值是10000,在APR/native模式下,默認值是8192.
maxThreads控制最大線程數,一個HTTP請求默認會被一個線程處理,也就是一個Servlet一個線程,可以這麼理解maxThreads的數目決定了Tomcat能夠同時處理的HTTP請求數。默認為200。
面試官似乎很滿意,點了點頭,接着道:
小夥子,看的還挺多,NIO上面你已經講了, NIO2和APR是什麼,你有了解過嘛?
小陳思索了一下回答到:
我先來介紹APR吧,APR是 Apache Portable Runtime的縮寫,是一個為Tomcat提供擴展能力的庫,之所以帶上native的原因是APR不使用Java編寫的連接器,而是選擇直接調用操作系統,避免了JVM級別的開銷,理論上性能會更好。NIO2增強了NIO,我們先在只討論網絡方面的增強,NIO上面我們是啓用了輪詢來判斷對應的事件是否可以進行,NIO2則引入了異步IO,我們不用再輪詢,只用接收操作系統給我們的通知。
面試官:
現在我們將上面的問題連接在一起,向Tomcat應用服務器發出HTTP請求,在NIO模式下,這個請求是如何被Tomcat所處理的。
小陳道:
請求會首先到達操作系統,建立TCP連接,這個過程由操作系統完成,我們暫時忽略,現在這個連接請求完成到達了Acceptor(連接器),連接器在NIO模式下會藉助NIO中的channel,將其設置為非阻塞模式,然後將NioChannel註冊到輪詢線程上,輪詢工作由Poller這個類來完成,然後由Poller將就緒的事件生成SocketProcessor, 交給Excutor去執行,Excutor這是一個線程池,線程池的大小就是在Connector 節點配置的 maxThreads 的值,這個線程池處理的任務為:
- 從socket中讀取http request
- 解析生成HttpServletRequest對象
- 分派到相應的servlet並完成邏輯
- 將response通過socket發回client。
面試官:
這個線程池,你有了解過嘛?
小陳道:
這個線程池不是JDK的線程池,繼承了JDK的ThreadPoolExecutor, 自身做了一些擴寫,我看網上的一些博客是説的是這個ThreadPoolExecutor跟JDK的ThreadPoolExecutor行為不太一致,JDK裏面的ThreadPoolExecutor在接收到任務的時候是,看當前線程池活躍的線程數目是否小於核心線程數,如果小於就創建一個線程來執行當前提交的任務,如果當前活躍的線程數目等於核心線程數,那麼就將這個任務放到阻塞隊列中,如果阻塞隊列滿了,判斷當前活躍的線程數目是否到達最大線程數目,如果沒達到,就創建新線程去執行提交的任務。當任務處理完畢,線程池中活躍的線程數超過核心線程池數,超出的在存活keepAliveTime和unit的時間,就會被回收。 簡單的説,就是JDK的線程池是先核心線程,再隊列,最後是最大線程數。我看到的一些博客説Tomcat是先核心線程,再最大線程數,最後是隊列。但是我看了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);
}
你説的那個線程池在StandardThreadExecutor這個類的startInternal裏面被初始化,我們看看有沒有什麼生面孔,恐怕唯一的生面孔就是這個TaskQueue,我們簡單的看下這個隊列。從源碼裏面我們可以看出來,這個類繼承了LinkedBlockingQueue,我們重點看入隊和出隊的方法
@Override
public boolean offer(Runnable o) {
//we can't do any checks
if (parent==null) {
return super.offer(o);
}
//we are maxed out on threads, simply queue the object
if (parent.getPoolSize() == parent.getMaximumPoolSize()) {
return super.offer(o);
}
//we have idle threads, just add it to the queue
if (parent.getSubmittedCount()<=(parent.getPoolSize())) {
return super.offer(o);
}
//if we have less threads than maximum force creation of a new thread
if (parent.getPoolSize()<parent.getMaximumPoolSize()) {
return false;
}
//if we reached here, we need to add it to the queue
return super.offer(o);
}
@Override
public Runnable poll(long timeout, TimeUnit unit)
throws InterruptedException {
Runnable runnable = super.poll(timeout, unit);
if (runnable == null && parent != null) {
// the poll timed out, it gives an opportunity to stop the current
// thread if needed to avoid memory leaks.
parent.stopCurrentThreadIfNeeded();
}
return runnable;
}
@Override
public Runnable take() throws InterruptedException {
if (parent != null && parent.currentThreadShouldBeStopped()) {
return poll(parent.getKeepAliveTime(TimeUnit.MILLISECONDS),
TimeUnit.MILLISECONDS);
// yes, this may return null (in case of timeout) which normally
// does not occur with take()
// but the ThreadPoolExecutor implementation allows this
}
return super.take();
}
通過上文我們可以知道,如果在線程池的線程數量和最大線程數相等,才會入隊。當前未完成的任務小於當前線程池的線程數目也會入隊。如果當前線程池的線程數目小於最大線程數,入隊失敗返回false。Tomcat的ThreadPoolExecutor繼承了JDK的線程池,但在執行任務的時候依然調用的是父類的方法,看下面的代碼:
public void execute(Runnable command, long timeout, TimeUnit unit) {
submittedCount.incrementAndGet();
try {
super.execute(command);
} catch (RejectedExecutionException rx) {
if (super.getQueue() instanceof TaskQueue) {
final TaskQueue queue = (TaskQueue)super.getQueue();
try {
if (!queue.force(command, timeout, unit)) {
submittedCount.decrementAndGet();
throw new RejectedExecutionException(sm.getString("threadPoolExecutor.queueFull"));
}
} catch (InterruptedException x) {
submittedCount.decrementAndGet();
throw new RejectedExecutionException(x);
}
} else {
submittedCount.decrementAndGet();
throw rx;
}
}
}
所以我們還是要進JDK的線程池看這個execute方法是怎麼執行的:
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}
這個代碼也比較直觀,不如你提交了一個null值,拋空指針異常。然後判斷當前線程池的線程數是否小於核心線程數,小於則添加線程。如果不小於核心線程數,判斷當前線程池是否還在運行,如果還在運行,就嘗試將任務添加進隊列,走到這個判斷説明當前線程池的線程已經達到核心線程數,但是還小於最大線程數,然後TaskQueue返回false,就接着向線程池添加線程。那麼現在整個Tomcat處理請求的流程,我們心裏就大致有數了,現在我想問一個問題,現在已知的是,我可以認為執行我們controller方法的是線程池的線程,但是如果方法裏面執行時間比較長,那麼線程池的線程就會一直被佔用,我們的系統現在隨着業務的增長剛好面臨着這樣的問題,一些文件上傳碰上流量高峯期,就會一直佔用這個線程,導致整個系統處於一種不可用的狀態。請問該如何解決?
小陳道:
通過異步可以解決嘛,就是將這類任務進行隔離,碰上這類任務先進行返回,等到執行完畢再給響應?我的意思是説使用線程池。
面試官道:
但用户怎麼知道我上傳的圖片是否成功呢,你返回的結果是什麼呢,是未知,然後讓用户過幾分鐘再看看上傳結果? 這看起來有些不友好哦。 你能分析一下核心問題在哪裏嘛?
小陳陷入了沉思,想了一會説道:
是的,您説的對,這確實有些不友好,我想核心問題還是釋放執行controller層方法線程,同時保持TCP連接。
面試官點了點頭:
還可以,其實這個可以通過異步Servlet來解決,Servlet 3.0 引入了異步Servlet,解決了我們上面的問題,我們可以將這種任務專門交付給一個線程池處理的同事,也保持着原本的HTTP連接。具體的使用如下:
@WebServlet(urlPatterns = "/asyncServlet",asyncSupported = true)
public class AsynchronousServlet extends HttpServlet {
private static final ExecutorService BIG_FILE_POOL = Executors.newFixedThreadPool(10);
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
AsyncContext asyncContext = req.startAsync(req,resp);
BIG_FILE_POOL.submit(()->{
try {
TimeUnit.SECONDS.sleep(10);
ServletOutputStream outputStream = resp.getOutputStream();
outputStream.write("task complete".getBytes(StandardCharsets.UTF_8));
outputStream.flush();
} catch (Exception e) {
e.printStackTrace();
}
asyncContext.complete();
});
}
}
在Spring MVC下 該如何使用呢, Spring MVC對異步Servlet進行了封裝,只需要返回DeferredResult,就能簡便的使用異步Servlet:
@RequestMapping("/quotes")
@ResponseBody
public DeferredResult<String> quotes() {
DeferredResult<String> deferredResult = new DeferredResult<String>();
// Add deferredResult to a Queue or a Map...
return deferredResult;
}
// In some other thread...
deferredResult.setResult(data);
// Remove deferredResult from the Queue or Map
@RestController
public class AsyncTestController {
private ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(4,4,4L,TimeUnit.SECONDS,new LinkedBlockingQueue<>());
@GetMapping("/asnc")
public DeferredResult<String> pictureUrl(){
DeferredResult<String> deferredResult = new DeferredResult<>();
threadPoolExecutor.execute(()->{
try {
// 模擬耗時操作
TimeUnit.SECONDS.sleep(6);
} catch (InterruptedException e) {
e.printStackTrace();
}
deferredResult.setResult("hello world");
});
return deferredResult;
}
}
哈哈哈哈,感覺不是面試,感覺我在給你上課一樣。我對你的感覺還可以,等二面吧。
小陳:
啊,好的。
寫在最後
寫本文的時候用到了 chatGPT來查資料,但是chatGPT給的資料存在很多錯誤,chatGPT出現了認知偏差,比如將Jetty處理請求流程當成了Tomcat處理請求的流程,更細一點感覺還是沒辦法回答出來。還是要自己去看的。
參考資料
- Java NIO淺析 https://zhuanlan.zhihu.com/p/23488863
- 深度解讀 Tomcat 中的 NIO 模型 https://klose911.github.io/html/nio/tomcat.html
- Tomcat - maxThreads vs. maxConnections https://stackoverflow.com/questions/24678661/tomcat-maxthreads-vs-maxconnections
- 從一次線上問題説起,詳解 TCP 半連接隊列、全連接隊列 https://developer.aliyun.com/article/804896
- 就是要你懂TCP--半連接隊列和全連接隊列 https://plantegg.github.io/2017/06/07/%E5%B0%B1%E6%98%AF%E8%A...
- Tomcat 配置文檔 https://tomcat.apache.org/tomcat-8.5-doc/config/http.html
- Java NIO 系列文章之 淺析Reactor模式 https://pjmike.github.io/2018/09/20/Java-NIO-%E7%B3%BB%E5%88%...
- Java NIO - non-blocking channels vs AsynchronousChannels https://stackoverflow.com/questions/22177722/java-nio-non-blocking-channels-vs-asynchronouschannels
- asynchronous and non-blocking calls? also between blocking and synchronous https://stackoverflow.com/questions/2625493/asynchronous-and-non-blocking-calls-also-between-blocking-and-synchronous
- Java AIO 源碼解析 https://cdf.wiki/posts/2976168065/
- 每天都在用,但你知道 Tomcat 的線程池有多努力嗎? https://www.cnblogs.com/thisiswhy/p/12782548.html
- 異步Servlet在轉轉圖片服務的實踐 https://juejin.cn/post/7124116514382774286