九、Tomcat中NIO2通道原理及性能
從Tomcat8開始出現(xiàn)了NIO2通道,這個通道利用了NIO2中的最重要的特性,異步IO的java API。
從性能角度上來說,從紙面上看該IO模型是非常優(yōu)秀的,這也是很多書籍推崇的最優(yōu)秀的IO模型,例如《Unix網(wǎng)絡(luò)編程》這本圣經(jīng),但取決于目前操作系統(tǒng)的支持程度和環(huán)境,還有業(yè)務(wù)邏輯代碼的編寫,NIO2的程序調(diào)用并不一定比NIO,甚至比BIO的效率要高。
我們在沒有實測的情況之下,本文從源碼的角度去分析一下Tomcat8中的這個NIO2通道,后續(xù)在相應(yīng)的章節(jié)中,我們會進一步的分析一下Tomcat的4個通道的性能差異。
1、NIO2的框圖源碼解讀(源碼詳細分析解讀見視頻)
前面我們已經(jīng)了解了Tomcat的BIO,NIO,APR這三個通道,對于NIO2的通道框圖大體上和這些沒有太大的區(qū)別,如下圖所示,少了一個poller線程,多了一個CompletionHandler。
和其他通道一樣,Tomcat最前端工作的依然是Endpoint類中的Acceptor線程,該線程主要任務(wù)是接收socket包,簡單解析并封裝socket,對其進行包裝為SocketWrapper后,交給工作線程。
在NIO2的通道下,Acceptor線程結(jié)束之后,并不會直接調(diào)用工作線程也就是SocketProcessor,而是利用NIO2的機制,利用CompleteHandler完成處理器去異步處理任務(wù)。
這正是CompleteHandler完成處理器的一個特性。
再對比NIO,BIO兩個通道:
我們不用像BIO通道那樣去拿著SockerWrapper在工作線程進行阻塞讀,這樣工作線程中的時間會占據(jù)網(wǎng)絡(luò)IO讀取的時間,導(dǎo)致大并發(fā)模式下工作線程暴漲,這也就是經(jīng)常我們看到很多cpu為什么被占到99%的原因,再怎么設(shè)置工作線程無濟于事,因為大量的cpu線程切換太耗時間了;
而NIO通道采用Reactor的模式去做這個事,Selector承擔(dān)了多路分離器這個角色,對于BIO是一大改進,其次java NIO的牛B之處就是操作系統(tǒng)內(nèi)核緩沖區(qū)的就緒通知;
2、異步IO的運用(具體源碼分析見視頻)
經(jīng)過以上分析我們得知三件事:
1)NIO2這種純異步IO,必須要有操作系統(tǒng)支持,并且性能和這個內(nèi)核態(tài)的事件分離器有著非常大的關(guān)系。
2)對于內(nèi)核分離器通知CompleteHandler的時機是什么,對比NIO的緩沖區(qū),實質(zhì)是當(dāng)內(nèi)核態(tài)緩沖區(qū)的數(shù)據(jù)已經(jīng)復(fù)制到用戶態(tài)緩沖區(qū)時候,這個時候觸發(fā)CompleteHandler,這相當(dāng)于比NIO的模式更進一步,如下圖:
NIO只是內(nèi)核緩沖區(qū)就緒才告訴客戶端去讀,這個時候用戶態(tài)緩沖區(qū)是空的,你得執(zhí)行完socketChannel.read之后,用戶態(tài)緩沖區(qū)才會填滿;
3).因為NIO2的優(yōu)勢,事件分離器分離器實際是在操作系統(tǒng)內(nèi)核態(tài)的功能,所以不需要用戶態(tài)搞一個Selector做事件分發(fā)。因此,對比NIO的通道框圖,可以看到缺少了Poller線程這一個環(huán)節(jié)。
以下是部分源碼解析(詳細解析見視頻)
從代碼的角度來看看,Tomcat的NIO2的通道,主要集中在NIO2Endpoint這個類的bind方法。
關(guān)注兩個點:
1).AsynchronousChannelGroup是異步通道線程組,通過這個類可以給AsynchronousChannel定義線程池的環(huán)境,而ExecutorService就是Tomcat中的特有的線程池。
TaskQueue是隊列,Thread工廠針對于創(chuàng)建的線程名稱進行了一下修改,并且對于線程池的最大,最小,時間都進行了限定,這個線程池在BIO,NIO通道中也是這個,都是一樣的。
定義完AsynchronousChannelGroup的通道線程組,AsynchronousChannel的read就是運行在通道組中的線程組中,包括從操作系統(tǒng)的內(nèi)核態(tài)多路分離器響應(yīng)的CompleteHandler,也是從該線程池中取出線程進行運行,這個是很重要的,如果每一次都new Thread的話,會有很大的消耗,所以不如都放在一個線程組中隨取隨用,用完再還;
2).隨即開啟 AsynchronousChannel通道,并綁定到對應(yīng)的端口中,這個API使用的就是JAVA NIO2的API。
之后,Acceptor線程獲得socket包,直接進行包裝為SocketWrapper,之后的流程如第一節(jié)中的源碼分析一樣,隨著讀取的執(zhí)行,異步操作就執(zhí)行完了,轉(zhuǎn)而Acceptor線程進行下一個循環(huán),讀取新socket包;
這時候需要注意的是,在NIO模式下,這個時刻是將SocketWrapper扔給Poller線程,Poller線程中的Selector去輪詢key值,而不是NIO2這種的直接就不管不問了,從這一點上也可以看出,NIO2的異步優(yōu)勢就在這,事件觸發(fā)的機制直接由內(nèi)核通知,我搞一個CompleteHandler就行,無需在用戶態(tài)輪詢。
3、總結(jié)
由下圖可見,bio,nio都是由用戶態(tài)發(fā)起數(shù)據(jù)拷貝(read操作),而nio2(aio)則是由操作系統(tǒng)發(fā)起數(shù)據(jù)拷貝,所有的io操作都是由操作系統(tǒng)主動完成。所以io操作和用戶業(yè)務(wù)邏輯的執(zhí)行都是異步化的。
所以從賬面上來講,NIO2通道相比NIO效率高,因為proactor模式本來就比reactor模式要好,另外還省去了Poller線程,但由于多路事件分離器是內(nèi)核提供的,不同內(nèi)核提供的多路事件分離器的事件處理效率不一,對NIO2的通道需要基于實際環(huán)境和場景壓測才能得出最終的結(jié)論。
在后續(xù)的章節(jié)中,會對Tomcat各通道進行壓力實際測試對比,并基于各個通道的實測結(jié)果進行詳細的對比和分析。
十、APR通道到底是個怎么回事?
APR通道是Tomcat比較有特色的通道,在早期的JDK的NIO框架不成熟的時候,因為java的網(wǎng)絡(luò)包的低效,Tomcat使用APR開源項目做網(wǎng)絡(luò)IO,這樣有效的緩解了java語言的不足,提供了一個高性能的直接通過jni接口進行底層IO通信內(nèi)存使用的這么一個通道。
但是,當(dāng)JDK的后續(xù)版本推出之后,JDK的網(wǎng)絡(luò)底層庫的性能也上來了,各種先進的IO模型,線程模型和APR開源項目幾乎不相上下,這個時候,經(jīng)常會出現(xiàn)一種測試場景是,加上APR通道之后并沒有太多的實質(zhì)提升,這是可以理解的,但是JDK中的SSL信道的性能至少從目前的角度來看,和APR通道基于openssl的引擎信道實現(xiàn),還有不小的差距,因為SSL協(xié)議中定義的握手協(xié)議,交互次數(shù)比較多,而openssl項目經(jīng)歷多年,性能極為高效,因此從目前的Tomcat的APR通道來看,主推的就是這個SSL/TLS協(xié)議的高效支持。
1、TomcatAPR通道的架構(gòu)圖
APR通道底層最終是通過tomcat-native實現(xiàn)的,具體的源碼分析講解請觀看視頻
2、APR通道詳解
從上圖中可以看到,對于Connector通道總共有這么幾種通道:BIO是阻塞式的通道,NIO是利用高性能的linux(windows也有)的poll或者epoll模型,APR通道就是本文中講的內(nèi)容,對于目前的JDK還支持NIO2的通道,對于APR來講,SSL Support區(qū)別最大,使用的是openssl作為SSL的信道支持,另外從IO模型角度來看,對于Http請求頭的讀取,SSL握手因為調(diào)用的JNI也是阻塞的,這個是與NIO和NIO2的差距,但是從SSL信道的支持上用的是高效的openssl。APR通道中依然有Acceptor接收線程池,Poller輪詢,Worker工作線程池,這些和其她通道的架構(gòu)區(qū)別不大,重要的是其關(guān)于socket調(diào)用和SSL的握手等內(nèi)容。這部分的源碼分析見視頻
總之一句話
APR通道的Socket全部來自c語言實現(xiàn)的socket,非jdk的socket,直接在tomcat層級調(diào)用native方法。
APR通道的SSL信道上下文直接來自于native底層
3、Tomcat-Native子項目
tomcat中對于這些jni的調(diào)用部分,做出了一個tomcat的子項目,叫做Tomcat-native,在這個調(diào)用層級中,一部分是java部分,也就是AprEndpoint類中看到的native方法,這些native方法有很多,這些java的包,對應(yīng)調(diào)用的就是jni的native的C的代碼,是一一對應(yīng)的,如下圖所示:
對于tomcat-native最好的教程應(yīng)該是在example目錄中,這個目錄使用一個例子完整的復(fù)現(xiàn)了Tomcat前端APREndpoint的幾個線程組件的工作模式;對于test目錄也可以從這個點切入進去,是一個好的調(diào)試tomcat-native代碼的過程。
4、APR高性能網(wǎng)絡(luò)庫(Apache Portable Runtime (APR) project)
下載:https://mirrors.cnnic.cn/apache/apr/apr-1.6.5.tar.gz
tomcat-native項目,可以說是作為一個集成包,有點類似于TomEE對于JAVA EE規(guī)范的集成,她集成的內(nèi)容一個是openssl,這個是ssl信道的實現(xiàn),另外一個就是高性能的apr網(wǎng)絡(luò)庫。
Apache Portable Runtime (APR) project,這個庫定位于在操作系統(tǒng)的底層封裝出一層抽象的高性能庫,在于屏蔽掉操作系統(tǒng)的差異。可以分析出來,APR相當(dāng)于JDK的一個角色了,只不過她關(guān)注的大多在網(wǎng)絡(luò)IO相關(guān)的這塊,有原子類,編解碼,文件IO,鎖,內(nèi)存申請與釋放,內(nèi)存映射,網(wǎng)絡(luò)IO,IO多路復(fù)用,線程池等等。APR庫對眾多操作系統(tǒng)都有支持。
總結(jié)一下就是,APR提供了對于底層高性能的網(wǎng)絡(luò)IO的處理,可以解決Tomcat早期網(wǎng)絡(luò)IO低效的問題。
5、Openssl庫
tomcat-native除了調(diào)用APR網(wǎng)絡(luò)庫保證高性能的網(wǎng)絡(luò)傳輸以外,對于SSL/TLS的支持還調(diào)用了openssl。對于OpenSSL項目來說,市面上大多數(shù)的SSL信道實現(xiàn)都是用OpenSSL做的,這也就是說,如果要OpenSSL暴露出一個漏洞出來,那破壞性都是驚人的。
6、總結(jié)
APR通道只有很小的一部分是java,大部分的源碼都是C的,而且和操作系統(tǒng)的環(huán)境有著密切的關(guān)系,不同操作系統(tǒng)定制的接口不同,性能特色也不同。
如下圖所示,java這一層調(diào)用的是jni,相當(dāng)于是一個接口,然后底層tomcat-native,相當(dāng)于是實現(xiàn),只不過是用c實現(xiàn)的,然后apr和openssl又是獨立的c組件。
十一、Tomcat中各通道的sendfile支持
sendfile實質(zhì)是linux系統(tǒng)中一項優(yōu)化技術(shù),用以發(fā)送文件和網(wǎng)絡(luò)通信時,減少用戶態(tài)空間與磁盤倒換數(shù)據(jù),而直接在內(nèi)核級做數(shù)據(jù)拷貝,這項技術(shù)是linux2.4之后就有的,現(xiàn)在已經(jīng)很普遍的用在了C的網(wǎng)絡(luò)端服務(wù)器上了,而對于java而言,因為java是高級語言中的高級語言,至少在C語言的層面上可以提供sendfile級別的接口,舉個例子,java中可以通過jni的方式調(diào)用c的庫,而這種在tomcat中其實就是APR通道,通過tomcat-native去調(diào)用類似于APR庫,這種調(diào)用思路雖然增大了java調(diào)用鏈條,但可以在java層級中獲得如sendfile的這種linux系統(tǒng)級優(yōu)化的支持,可謂是一舉多得。
上述的內(nèi)容,實際就是本章的背景,本文就從系統(tǒng)調(diào)用的層級,逐步講解tomcat中的sendfile是怎么實現(xiàn)的。
1、傳統(tǒng)的網(wǎng)絡(luò)傳輸機制
大家可以在linux上執(zhí)行 man sendfile 這個命令,查看sendfile的定義
上述定義可以看出,sendfile()實際是作用于數(shù)據(jù)拷貝在兩個文件描述符之間的操作函數(shù).這個拷貝操作是在內(nèi)核中完成的,所以稱為"零拷貝".sendfile函數(shù)比起read和write函數(shù)高效得多,因為read和write是要把數(shù)據(jù)拷貝到用戶應(yīng)用層操作,多了一個步驟,如下圖所示:
那么經(jīng)過sendfile優(yōu)化過的拷貝機制如下圖所示,直接在內(nèi)核態(tài)拷貝,不用經(jīng)過用戶態(tài)了,這大大提高了執(zhí)行效率。
2、linux的sendfile機制(零拷貝)
3、DefaultServlet的sendfile邏輯
對于Tomcat中的靜態(tài)資源處理,直接對應(yīng)的就是DefaultServlet了,這個類是嵌入在Tomcat源碼中,專門處理靜態(tài)資源的類,靜態(tài)資源一般不需要經(jīng)過處理(也就是不需要拿到用戶態(tài)內(nèi)存中去)直接從服務(wù)器返回,所以此類文件最適合走sendfile方式,以下是DefaultServlet中和sendfile相關(guān)的源碼邏輯。
值得注意的一點是,一般http響應(yīng)的數(shù)據(jù)包都會進行壓縮,這樣的好處是能極大的減小帶寬占用,而響應(yīng)頭中發(fā)現(xiàn)了compression壓縮屬性,瀏覽器會自動首先進行解壓縮,從而正確的將response響應(yīng)主體刷到頁面中。
但是,當(dāng)sendfile屬性開啟后,這個compression壓縮屬性就不生效了(后面一章會講解sendfile和compression的互斥性),因此,當(dāng)需要傳輸?shù)奈募浅4蟮臅r候,而網(wǎng)絡(luò)帶寬又是瓶頸的時候,sendfile顯然并不是合適之舉。
4、sendfile在BIO通道中的實現(xiàn)(不支持)
以Tomcat9為例,不同的Tomcat前端通道中的sendfile的java包裝是不同的,但實際上都是在調(diào)用系統(tǒng)調(diào)用sendfile。
對于BIO(從tomcat8開始已經(jīng)拋棄BIO通道了,下面源碼截圖來自于tomcat7)來說,JIOEndpoint是不支持sendfile的,這個可以通過代碼中看出來:
5、sendfile在NIO通道中的實現(xiàn)
在NIO通道中,有一個useSendfile屬性,這個useSendfile屬性是做什么的呢?
這個是可以設(shè)置在Connector中的,以NIO通道為例,這個useSendfile屬性是允許request進行sendfile的總體開關(guān)(前面講的org.apache.tomcat.sendfile.support 屬性是針對于每一個request的),這個useSendfile屬性在NIO通道中默認(rèn)就是打開的,當(dāng)reqeust設(shè)置org.apache.tomcat.sendfile.support 屬性為true的時候,response就會準(zhǔn)備一個SendFileData的數(shù)據(jù)結(jié)構(gòu),這個數(shù)據(jù)結(jié)構(gòu)就是NIO通道下的sendfile的媒介。
因此,NIO的sendfile實現(xiàn)可以分為三個階段(具體的源碼解析請查看視頻):
第一階段,實際上就是前面的XXXDefaultServlet中(不僅僅是DefaultServlet,其她的Servlet只要設(shè)置這個屬性也可以調(diào)用sendfile)對Request的sendfile屬性的設(shè)置,當(dāng)該請求設(shè)置上述的屬性后,證明該請求為sendfile請求。
第二階段,servlet處理完之后,業(yè)務(wù)邏輯完成,對應(yīng)的Response該commit了,而在Response的準(zhǔn)備階段,會初始化這個SendFileData的數(shù)據(jù)結(jié)構(gòu),這塊的代碼邏輯都在Http11NioProcessor類中,下圖中的prepareSendfile方法就是從前面DefaultServlet中設(shè)置的reqeust屬性中拿到file名稱,字符位置的start,end,然后將這些屬性作為傳入的參數(shù),初始化SendFileData實例。
第三階段,我們記得NIO前端通道的Acceptor,Poller線程,Worker線程的三個線程,當(dāng)Worker線程干完活之后,返回給客戶端,依然要通過Poller線程,也就是會重新注冊KeyEvent,讀取KeyAttachment,這個時候當(dāng)為sendfile的時候,前面初始化的SendFileData實例是會注冊在KeyAttachment上的,上圖的processSendfile就是Poller線程的run中的一個判斷分支,當(dāng)為sendfile的時候,Poller線程就對SendFileData數(shù)據(jù)結(jié)構(gòu)中的file名字取出,通過FileChannel的transferTo方法,這個transferTo方法本質(zhì)上就是sendfile在tomcat源碼中的具體體現(xiàn),如下圖所示
6、sendfile在APR通道中的實現(xiàn)(具體源碼跟蹤分析見視頻)
在NIO通道中sendfile實現(xiàn)算是比較復(fù)雜的了,在APR通道中更加的復(fù)雜,我們可以回過頭先看看NIO通道中的sendfile,實際是通過每一個Poller線程中的FileChannel的transferTo方法來實現(xiàn)的,對于transferTo方法是阻塞的,這也就意味著,當(dāng)文件進行sendfile的時候,Poller線程是阻塞的,而我們前面研究過Tomcat前端,Poller線程是很珍貴的,不僅僅是為某幾個sendfile服務(wù)的,這樣會導(dǎo)致Poller線程產(chǎn)生瓶頸,從而拖慢了整個Tomcat前端的效率。
APR通道是開辟一個獨立的線程來處理sendfile的,如下圖所示,這樣做的好處不言自明,Poller就干Poller的事,而遇到Sendfile的需求的時候,sendfile線程就挺身而出,把活兒給接了。
最后,對于APR通道是通過JNI調(diào)用的APR庫,sendfile自然就不是java的API了
7、總結(jié)
SendFile實際上是操作系統(tǒng)的優(yōu)化,Tomcat中基于在不同的通道中有不同的實現(xiàn),配置也不盡相同,但實際上都是底層操作系統(tǒng)的SendFile的系統(tǒng)調(diào)用!
十、調(diào)整和tomcat相關(guān)的JVM參數(shù)進行優(yōu)化
1、設(shè)置串行垃圾回收器(nio模式,最大線程1000)
壓測步驟:
1)在tomcat啟動腳本catalina.sh里設(shè)置以下腳本:
年輕代、老年代均使用串行收集器,初始堆內(nèi)存64M,最大堆內(nèi)存512M,打印gc時間戳等信息,生成gc日志文件
JAVA_OPTS="-XX:+UseSerialGC -Xms64m -Xmx512m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX: +PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:../logs/gc.log"
2)設(shè)置后啟動tomcat,使用jmeter進行壓測(jmeter設(shè)置線程為1000,每個線程循環(huán)10次),訪問test_web
3)查看吞吐量
壓測結(jié)果:平均時間1.585s,吞吐量378.6/s,異常1.12%
將gc.log拷貝出來,改名gc1.log。預(yù)備比較
2、設(shè)置并行垃圾回收器(nio模式,最大線程1000)
壓測步驟:
1)、在tomcat啟動腳本catalina.sh里設(shè)置以下腳本:
年輕代、老年代均改成并行垃圾收集器,初始堆內(nèi)存64M,最大堆內(nèi)存512M,打印gc時間戳等信息,生成gc日志文件。
#JAVA_OPTS="-XX:+UseParallelGC -XX:+UseParallelOldGC -Xms64m -Xmx512m -XX:+PrintGCDetails -XX :+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:../logs/gc.log"
2)、刪除gc.log
rm -rf gc.log
3)、設(shè)置后重啟tomcat,使用jmeter進行壓測(jmeter設(shè)置線程為1000,每個線程循環(huán)10次),訪問test_web,查看吞吐量
壓測結(jié)果:平均時間1.161s,吞吐量407.7/s,異常0.40%
將gc.log拷貝出來,改名gc2.log。預(yù)備比較
分析結(jié)論:
可以看出設(shè)置成并行垃圾收集器之后平均執(zhí)行時間減少了,吞吐量增加了,異常率也減少了,總體性能有了很大的提高。
3、查看gc日志文件
將gc1.log和gc2.log文件分別上傳到gceasy.io進行在線分析,分析結(jié)果如下:
gc1.log中的gc總次數(shù)是13次
gc2.log中g(shù)c總次數(shù)12次,比串行時少了1次,性能是有所提升的。
4、調(diào)整年輕代大小
再次重新設(shè)置啟動參數(shù),依然是并行垃圾收集器,不過我們增加了初始化堆內(nèi)存和最大堆內(nèi)存,分別設(shè)置為128m和1024m。
JAVA_OPTS="-XX:+UseParallelGC -XX:+UseParallelOldGC -Xms128m -Xmx1024m -XX:NewSize=64m -XX:M axNewSize=256m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHe apAtGC -Xloggc:../logs/gc.log"
設(shè)置完后再次重啟,用jmeter進行壓測(壓測參數(shù)不變),結(jié)果如下:
壓測結(jié)果:平均時間0.943s,吞吐量433.5/s,異常0.29%
性能再一次的得到了提升。再次分析gc.log 如下圖:
gc收集總次數(shù)減少為8次,從gc的收集次數(shù)也再次證明了調(diào)整參數(shù)后性能的確得到了極大的提升。
5、設(shè)置G1垃圾回收器(jdk9之后默認(rèn)G1,測試用的jdk8)
再次重新設(shè)置啟動參數(shù),修改垃圾收集器為G1收集器,參數(shù)如下:
JAVA_OPTS="-XX:+UseG1GC -Xms128m -Xmx1024m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+Pr intGCDateStamps -XX:+PrintHeapAtGC -Xloggc:../logs/gc.log"
重啟tomcat后使用jmeter再次壓測(壓測參數(shù)不變),壓測結(jié)果如圖:
壓測結(jié)果:平均時間0.897s,吞吐量431.2/s,異常0.14%
總體性能再一次得到了提升。
6、總結(jié)
通過不斷的調(diào)優(yōu),我們得出4次壓測結(jié)果如下:
第1次壓測結(jié)果:平均時間1.585s,吞吐量378.6/s,異常1.12%
第2次壓測結(jié)果:平均時間1.161s,吞吐量407.7/s,異常0.40%
第3次壓測結(jié)果:平均時間0.943s,吞吐量433.5/s,異常0.29%
第4次壓測結(jié)果:平均時間0.897s,吞吐量431.2/s,異常0.14%
平均時間一次比一次短,吞吐量一次比一次大,異常率一次比一次少,所以總體性能一次比一次優(yōu)越。
結(jié)論:對tomcat性能優(yōu)化需要不斷的進行參數(shù)調(diào)整,然后測試結(jié)果,可能每次調(diào)優(yōu)結(jié)果都有差異,這就需要借助于gc的可視化工具來看gc的情況,再幫我我們做出決策應(yīng)該調(diào)整哪些參數(shù),從而達到一個相對理想的優(yōu)化效果。


