兩週沒更新了,感覺不寫點什麼,有點不舒服的感覺。
前言
回憶一下學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