怎么做好Java性能優化
引言
性能優化是一個很復雜的工作,且充滿了不確定性。
它不像Java業務代碼,可以一次編寫到處運行(write once, run anywhere),往往一些我們可能并不能察覺的變化,就會帶來驚喜/驚嚇。
能夠全面的了解并評估我們所負責應用的性能,我認為是提升技術確定性和技術感知能力的非常有效的手段。
本文盡可能簡短的總結我自己在性能優化上面的一些體會和經驗,從實踐的角度出發盡量避免過于啰嗦和生硬,但相關的知識實在太多,受限于個人經驗和技術深度,不足之外還請大家補充。
- 第1部分是偏背景類知識的介紹,有這方面知識的同學可以直接跳過。
一. 了解運行環境
大多數的編程語言(尤其是Java)做了非常多的事情來幫助我們不用太了解硬件也能很容易的寫出正確工作的代碼,但你如果要全面了解性能,卻需要具備不少的從硬件、操作系統到軟件層面的知識。
1.1 服務器
目前我們大量使用Intel 64位架構的Xeon處理器,除此之外還會有AMD x64處理器、ARM服務器處理器(如:華為鯤鵬、阿里倚天)、未來還會有RISC-V架構的處理器、以及一些專用FPGA芯片等等。
我們這里主要聊聊目前我們大量使用的阿里云ECS使用的Intel 8269CY處理器。
1.1.1 處理器
Intel Xeon Platinum 8269CY,阿里云使用的這一款處理器是阿里定制款,并不能在Intel的官方手冊中查詢到,不過我們可以通過下方Intel處理器的命名規則了解到不少的信息,它是一款這樣的處理器:主頻2.5GHz(睿頻3.2GHz、最大睿頻3.8GHz),26核心52線程(具備超線程技術),6通道DDR4-2933內存,最大配置內存1T,Cascade Lake微架構,48通道PCI-E 3.0,14nm光刻工藝,205W TDP。
它是這一代至強處理器中性能比較強的型號了,最大支持8路部署。
具備動態自動超頻的能力將能夠短時間提升性能,同時在少數核心忙碌的時候還可以讓它們保持長時間的自動超頻,這會嚴重的影響我們對應用性能的評估(少量測試時性能很好,大規模測試時下降很厲害)。
最大配置內存1TB,代表處理器具備48bit的VA(虛擬地址),也就是通常需要四級頁表(下一代具備57bit VA的處理器已經在設計中了,通常需要五級頁表),過深的頁表顯然是極大的影響內存訪問的性能以及占用內存(頁表也是存儲在內存中的)的,所以Intel設計了大頁(2MB、1GB大頁)機制,以減少過深的頁表帶來的影響。
6通道2933MHz的內存總線代表它具備總計約137GB/s(內存總線是64bit位寬)的寬帶,不過需要記住他們是高度并行設計的。
這個微處理的CPU核架構如下圖所示,采用8發射亂序架構, 32KB指令+32KB數據 L1 Cache,1MB L2 Cache。
發射單元是CPU內部真正的計算單元,它的多少是CPU性能的關鍵因素。8個發射單元中有4個單元都可以進行基本整數運算(ALU單元),只有2個可以進行整數乘除和浮點運算,所以對于大量浮點運算的場景并行效率會偏低。
1個CPU核對應的2個HT(這里指超線程技術虛擬出的硬件線程)是共享8個發射單元的,所以這兩個HT之間將會有非常大的相互影響(這也會導致操作系統內CPU的使用率不再是線性值,具體請查閱相關資料),L1、L2 Cache同樣也是共享的,所以也會相互影響。
Intel Xeon處理器在分支預測上面花了很多功夫,所以在較多分支代碼(通常就是if else這類代碼)時性能往往也能做的很好,比大多數的ARM架構做的都要好。
Java常用的指針壓縮技術也受益于x86架構靈活的尋址能力(如:mov eax, ecx * 8 + 8),可以一條指令完成,同時也不會帶來性能的損失,但是這在ARM、RISC-V等RISC(精簡指令集架構,Reduced Instruction Set Computing)處理器上就不適用了。
從可靠渠道了解道,下一代架構(Sunny Cove)將大幅度的進行架構優化,升級為10發射,同時L1 Cache將數據部分增加到48KB,這代表接下來的處理器將更加側重于提升SIMD(單指令多操作數,Signle Instruction Multiple Data)等的數據計算性能。
一顆8269CY內部有26個CPU核,采用如下的拓撲結構進行連接。這一代處理器最多有28個CPU核,8269CY屏蔽掉了2個核心,以降低對產品良率的要求(節約成本)。
可以看到總計35.75MB的L3 Cache(圖中為LLC: Last Level Cache)被分為了13塊(圖中是14塊,屏蔽2個核心的同時也屏蔽了與之匹配的L3 Cache),每塊2.75MB,并不是簡單意義理解上的是一大塊統一的區域。
6通道的內存控制器也分布在左右兩側,與實際主板上內存插槽的位置關系是對應的。
這些信息都告訴我們,這是一顆并行能力非常強的多核心處理器。
1.1.2 服務器
阿里云通常都是采用雙Intel處理器的2U機型(基于散熱、密度、性價比等等的考慮),基本都是2個NUMA(非一致性內存訪問,Non Uniform Memory Access)節點。
具體到Intel 8269CY,代表一臺服務器具備52個物理核心,104個硬件線程,通常阿里云會稱之為104核。
NUMA技術的出現是硬件工程師的妥協(他們實在沒有能力做到在多CPU的情況下還能實現訪問任何地址的性能一致性),所以做的不好也會嚴重的降低性能,大多數情況下虛擬機/容器調度團隊要做的是將NUMA打開,同時將一個虛擬機/容器部署到同一個NUMA節點上。
這幾年AMD的發展很好,它的多核架構與Intel有很大的不同,不久的將來阿里云將會部署不少采用AMD處理器的機型。
AMD處理器的NUMA節點將會更多,而且拓撲關系也會更復雜,阿里自研的倚天(采用ARM架構)就更復雜了。這意味著虛擬機/容器調度團隊夠得忙了。
多數情況下服務器大都采用CPU:內存為1:2或1:4的配置,即配置雙Intel 8269CY的物理機,通常都會配備192GB或384GB的內存。
如果虛擬機/容器需要的內存:CPU過大的情況下,將很難實現內存在CPU對應的NUMA節點上就近分配了,也就是說性能就不能得到保證。
由于2U機型的物理高度是1U機型的2倍,所以有更多的空間放下更多的SSD盤、高性能PCI-E設備等。
不過云廠商肯定是不愿意直接將物理機賣給用戶(畢竟他們已經不再是以前的托管物理機公司)的,再怎么也得在上面架一層,也就是做成ECS再賣給客戶,這樣諸如熱遷移、高可用等功能才能實現。
前述的"架一層"是通過虛擬化技術來實現的。
1.1.3 虛擬化技術
一臺物理機性能很強大,通常我們只需要里面的一小塊,但我們又希望不要感知到其他人在共享這臺物理機,所以催生了虛擬化技術(簡單來說就是讓可以讓一個CPU工作起來就像多個CPU并行運行,從而使得在一臺服務器內可以同時運行多個操作系統)。
早期的虛擬機技術是通過軟件實現的,老牌廠商如VMWare,但是性能犧牲的有點多,硬件廠商也看好虛擬機技術的前景,所以便有了硬件虛擬化技術。
各廠商的實現并不相同,但差異不是很大,好在有專門的虛擬化處理模塊去兼容就可以了。
Intel的虛擬化技術叫Intel VT(Virtualization Technology),它包括VT-x(處理器的虛擬化支持)、VT-d(直接I/O訪問的虛擬化)、VT-c(網絡連接的虛擬化),以及在網絡性能上的SR-IOV技術(Single Root I/O Virtualization)。
這里面一個很重要的事情是,原本我們訪問內存的一層轉換(線性地址->物理地址)會變成二層轉換(VM內線性地址->Host線性地址->物理地址),這會引入更多的內存開銷以及頁表的轉換工作。
所以大多數云廠商會在Host操作系統上開啟大頁(Linux 操作系統通常是使用透明大頁技術),以減少內存相關的虛擬化開銷。
服務器對網絡性能的要求是很高的,現在的網絡硬件都支持網卡多隊列技術,通常情況下需要將VM中的網絡中斷分散給不同的CPU核來處理,以避免單核轉發帶來的性能瓶頸。
Host操作系統需要管理它上面的一個或多個VM(虛擬機,Virtual Machine),以及前述提及的處理網卡中斷,這會帶來一定的CPU消耗。
阿里云上一代機型,服務器總計是96核(即96個硬件線程HT,實際是48個物理核),但最多只能分配出88核,需要保留8個核(相當于物理機CPU減少8.3%)給Host操作系統使用,同時由于I/O相關的虛擬化開銷,整機性能會下降超過10%。
阿里云為了最大限度的降低虛擬化的開銷,研發了牛逼的“彈性裸金屬服務器 - 神龍”,號稱不但不會因為虛擬化降低性能,反而會提升部分性能(主要是網絡轉發)。
1.1.4 神龍服務器
為了避免虛擬化對性能的影響,阿里云(類似還有亞馬遜等云廠商的類似方案)研發了神龍服務器。
簡單來說就是設計了神龍MOC卡,將大部分虛擬機管理工作、網絡中斷處理等從CPU offload到MOC卡進行處理。
神龍MOC卡是一塊PCI-E 3.0設備,其內有專門設計的用于網絡處理的FPGA芯片,以及2顆低功耗的x86處理器(據傳是Intel Atom),最大限度的接手Host操作系統的虛擬化管理工作。
通過這樣的設計,在網絡轉發性能上甚至能做到10倍于裸物理機,做到了當之無愧的裸金屬。
104核的物理機可以直接虛擬出一臺104核的超大ECS,再也不用保留幾個核心給Host操作系統使用了。
1.2 VPC
VPC(虛擬專有云,Virtual Private Cloud),大多數云上的用戶都希望自己的網絡與其它的客戶隔離,就像自建機房一樣,這里面最重要的是網絡虛擬化技術,目前阿里云采用的是VxLAN協議,它底層采用UDP協議進行數據傳輸,整體數據包結構如下圖所示。
VxLAN在VxLAN幀頭中引入了類似VLAN ID的網絡標識,稱為VxLAN網絡標識VNI(VxLAN Network ID),由24比特組成,理論上可支持多達16M的VxLAN段,從而滿足了大規模不同網絡之間的標識、隔離需求。
這一層的引入將會使原始的網絡包增加50 Bytes的固定長度的頭。當然,還需要與之匹配的交換機、路由器、網關等等。
1.3 容器技術
虛擬化技術的極致優化雖然已經極大解決了VM層虛擬化的額外開銷問題,但VM操作系統層的開銷是無法避免的,同時如今的Java應用大多都可以做到單進程部署,VM操作系統這一層的開銷顯得有一些浪費(當前,它換來了極強的隔離性和安全性)。
容器技術構建于操作系統的支持,目前主要使用Linux操作系統,容器最有名的是Docker,它是基于Linux 的 cgroup 技術構建的。
VM的體驗實在是太好了,所以容器的終極目標就是具備VM的體驗的同時還沒有VM操作系統層的開銷。在容器里,執行top、free等命令時,我們只希望看到容器視圖,同時網絡也是容器視圖,不得不排查問題需要抓包時可以僅抓容器網絡的包。
目前阿里云ECS 16GB內存的機型,實際上操作系統內看到的可用內存只有15GB,32GB的機型則只有30.75GB。
容器沒有這個問題,因為實際上在容器上運行的任務僅僅是操作系統上的一個或多個進程而已。
由于容器的這種邏輯隔離特性,所以不同企業的應用基本上是不太可能部署到同一個操作系統上的(即同一臺ECS)。
容器最影響性能的點是容器是否超賣、是否綁核、核分配的策略等等,以及前述的眾多知識點都會對性能有不小的影響。
通常企業核心應用都會要求綁核(即容器的多個vCPU的分布位置是確定以及專用的,同時還得考慮Intel HT、AMD CCD/CCX、NUMA等問題),這樣性能的確定性才可以得到保證。
在Docker與VM之間,其實還存在別的更均衡的容器技術,大多數公司稱之為安全容器,它采用了硬件虛擬化來實現強隔離,但并不需要一個很重的VM操作系統(比如 Linux),取而代之的是一個非常輕的微內核(它僅支持實現容器所必須的部分內核功能,同時大多數工作會轉發給Host操作系統處理)。
這個技術是云廠商很想要的,這是他們售賣可靠FaaS(功能即服務,Function as a Service)的基礎。
二. 獲取性能數據
進行性能優化前,我們需要做的是收集到足夠、準確以及有代表性的性能數據,分析性能瓶頸,然后才能進行有效的優化。
評估一個應用的性能無疑是一件非常復雜的事,大多數情況下一個應用會有很多個接口,且同一個接品會因為入參的不同或者內部業務邏輯的不同帶來非常大的執行邏輯的變化,所以我們得首先想清楚,我們到底是要優化什么業務場景下的性能(對于訂單來說,也許就是下單)。
在性能測試用例跑起來后,怎么樣拿到我們想要的真實的性能數據就很關鍵了,因為觀測者效應的存在(指“觀測”這種行為對被觀測對象造成一定影響的效應,它在生活中極其常見),獲取性能數據的同時也會對被測應用產生或多或少的影響,所以我們需要深入的了解我們所使用的性能數據獲取工具的工作原理。
具體到Java上(其它語言也基本是類似的),我們想知道一個應用到底在做什么,主要有兩種手段:
- Instrumentation(代碼嵌入):
指的是可以用獨立于應用程序之外的代理(Agent)程序來監測運行在JVM上的應用程序,包括但不限于獲取JVM運行時狀態,替換和修改類定義等。
通俗點理解就是在函數的執行前后插代碼,統計函數執行的耗時。
了解基本原理后,我們大概會知道,這種方式對性能的影響是比較大的,函數越簡短執行的次數越多影響也會越大,不同它的好處也是顯而易見的:可以統計出函數的執行次數以及不漏過任何一個細節。
這種方式一般用于應用早期的優化分析。
- Sampling(采樣):
采用固定的頻率打斷程序的執行,然后拉取各線程的執行棧進行統計分析。
采樣頻率的大小決定了觀測結果的最小粒度和誤差,一些執行次數較多的小函數可能會被統計的偏多,一些執行次數較少的小函數可能不會被統計到。
主流的操作系統都會從內核層進行支持,所以這種方式對應用的性能影響相對較少(具體多少和采樣頻率強相關)。
在性能數據里,時間也是一個非常重要的指標,主要有兩類:
- CPU Time(CPU時間):
占用的CPU時間片的總和。
這個時間主要用來分析高CPU消耗。
- Wall Time(墻上時間):
真實流逝的時間。
除了CPU消耗,還有資源等待的時間等等,這個時間主要用來分析rt(響應時間,Response time)。
性能數據獲取方式+時間指標一共有四種組合方式,每一種都有它們的最適用的場景。
不過需要記住,Java應用通常都需要至少5分鐘(這是一個經驗值,通常Server模式的JVM需要在方法執行5000~10000次后才會進行JIT編譯)的大流量持續測試才能使應用的性能達到穩定狀態,所以除非你要分析的是應用正在預熱時的性能,否則你需要等待5分鐘以上再開始收集性能數據。
Linux系統上面也有不少非常好用的性能監控工具,如下圖:
2.1 構造性能測試用例
通常我們都會分析一個或多個典型業務場景的性能,而不僅僅是某一個或多個API接口。
比如對于雙11大促,我們要分析的是導購、交易、發優惠券等業務場景的性能。
好的性能測試用例的需要能夠反映典型的用戶和系統行為(并不是所有的,我們無法做到100%反映真實用戶場景,只能逐漸接近它),比如一次下單平均購買多少個商品(實際的用例里會細分為:購買一個商品的占比多少,二個商品的占比多少,等等)、熱點售買商品的數量與成交占比、用戶數、商品數、熱點庫存分布、買家人均有多少張券等等。
像淘寶的雙11的壓測用例,像這樣關鍵的參數會多達200多個。
實際執行時,我們期望測試用例是可以穩定持續的運行的(比如不會跑30分鐘下單發現庫存沒了,優惠券也沒了),緩存的命中率、DB流量等等的外部依賴也可以達到一個穩定狀態,在秒級時間(一般不需要更細了)粒度上應用的性能也是穩定的(即請求的計算復雜度在時間粒度上是均勻分布的,不會一會高一會低的來回抖動)。
為了配合性能測試用例的執行,有的時候還需要應用系統做一些相應的改造。
比如對于會使用到緩存的場景來說,剛開始命中率肯定是不高的,但跑了一會兒過后就會慢慢的變為100%,這顯然不是通常真實的情況,所以可能會配合寫一些邏輯,來讓緩存命中率一直維持在某個特定值上。
綜上,一套好的性能測試用例是開展后續工作所必不可少的,值得我們在它上面花時間。
2.2 真實的測試環境
保持測試環境與其實環境的一致性是極其重要的,但是往往也是很難做到的,所以大多數互聯網公司的全鏈路壓測方案都是直接使用線上環境來做性能測試。
如果我們無法做到使用線上環境來做性能測試,那么就需要花上不少的精力來仔細對比我們所使用的環境與線上環境的差異,確保我們知道哪一些性能數據是可以值得相信的。
直接使用線上環境來做性能測試也并不是那么簡單,這需要我們有一套整體解決方案來讓壓測流量與真實流量進行區分,一般都是在流量中加一個壓測標進行全鏈路的透傳。
同時基本上所有的基礎組件都需要進行改造來支持壓測,主要有:
- DB:
為業務表建立對應的壓測表來存儲壓測數據。不使用增加字段做邏輯隔離的原因是容易把它們與正式數據搞混,同時也不便于單獨清理壓測數據。
不使用新建壓測庫的原因是:一方面它違背了我們使用線上環境做性能壓測的基本考慮,另一方面也會導致應用端多了一倍的數據庫連接。
- 緩存:
為緩存Key增加特殊的前綴,如__yt_。
緩存大多數沒有表的概念,看起來就是一個巨大的Map存儲一樣,所以除了加固定前綴并沒有太好的辦法。
不過為了減少壓測數據的存儲成本,通常需要:1) 在緩存client包中做一些處理來減少壓測數據的緩存過期時間;2)緩存控制臺提供專門清理壓測數據的功能。
- 消息:
在發送、消費時透傳壓測標。
盡量做到不需要業務團隊的開發同學感知,在消息的內部結構中增加是否是壓測數據的標記,不需要業務團隊申請新的壓測專用的Topic之類。
- RPC:
透傳壓測標。
當然,HTTP、DUBBO等具體的RPC接口透傳的方案會是不同的。
- 緩存、數據庫 client包:
根據壓測標做請求的路由。
這需要配合前面提到的具體緩存、DB的具體實現方案。
- 異步線程池:
透傳壓測標。
為了減少支持壓測的改造代價,通常都會使用ThreadLocal來存儲壓測標,所以當使用到異步線程池的時候,需要記得帶上它。
- 應用內緩存:
做好壓測數據與正式數據的隔離。
如果壓測數據的主鍵或者其它的唯一標識符可以讓我們顯著的讓它與正式數據區分開來,也許不用做太多,否則我們也許需要考慮要么再new一套緩存、要么為壓測數據加上一個什么特別的前綴。
- 自建任務:
透傳壓測標。
需要我們自行做一些與前述提到的消息組件類似的事情,畢竟任務和消息從技術上來說是很像很像的。
- 二方、三方接口:
具體分析與解決。
需要看二方、三方接口是否支持壓測,如果支持那么很好,我們按照對方期望的方式進行參數的傳遞即可,如果不支持,那么我們需要想一些別的奇技婬巧(比如開設一個壓測專用的商戶、賬戶之類)了。
為了降低性能測試對用戶的影響,通常都會選擇流量低峰時進行,一般都是半夜。
當然,如果我們能有一套相對獨立的折中方案,比如使用小得物環境、在支持單元化的系統中使用部分單元等等,就可以做到在任何時候進行性能測試,實現性能測試的常態化。
2.3 JProfiler的使用
JProfiler是一款非常成熟的產品,很貴很好用,它是專門為Java應用的性能分析所準備的,而且是跨平臺的產品,是我經常使用的工具。
它的大體的架構如下圖所示,Linux agent加上Windows UI是最推薦的使用方式,它不但同時支持Instrumentation & Sampling,CPU Time & Wall Time的選項,而且還擁有非常易用的圖形界面。
分析時,我們只需要將其agent包上傳到應用中的某個目錄中(如:/opt/jprofiler11.1.2),然后添加JVM的啟動選項來加載它,我通常都這樣配置:
接下來我們重啟應用,這里的修改就會生效了。使用這個配置,Java進程在開始啟動時需要等待JProfiler UI的連接才會繼續啟動,這樣我們可以進行應用啟動時性能的分析了。
JProfiler的功能很多,就不一一介紹了,大家可以閱讀其官方文檔。
采集的性能數據還可以保存為*.jps文件,方便后續的分析與交流。
其典型的分析界面如下圖所示:
JProfiler的一些缺點:
1)需要在Java應用啟動加載agent(當然它也有啟動后attach的方式,但是有不少的限制),不太便于短時間的分析一些緊急的性能問題;
2)對Java應用的性能影響偏大。使用采樣的方式來采集性能數據開銷肯定會低很多,但還是沒有接下來要介紹的perf做的更好。
2.4 perf的使用
perf是Linux上面當之無愧的性能分析工具的一哥,這一點需要特別強調一下。
不但可以用來分析Linux用戶態應用的性能,甚至還常用來分析內核的性能。
它的模塊結構如下圖所示:
想像一下這樣的場景,如果我們換了一家云廠商,或者云廠商的服務器硬件(主要就是CPU了)有了更新迭代,我們想知道具體性能變化的原因,有什么辦法嗎?
perf就能很好的勝任這個工作。
CPU的設計者為了幫助我們分析應用執行時的性能,專門設計了相關的硬件電路,PMU(性能監控單元,Performance Monitor Unit)就是這其中最重要的部分。
簡單來說里面包含了很多性能計數器(圖中的PMCs,Performance Monitor Counters),perf可以讀取這些數據。
不僅如此,內核層面還提供了很多軟件級別的計數器,perf同樣可以讀取它們。
一些和CPU架構相關的關鍵指標,可以了解一下:
- IPC(每周期執行指令條數,Instruction per cycle):
基于功耗/性能的考慮,大多數服務器處理器的頻率都在2.5~2.8GHz的范圍,這代表同一時間片內的周期數是差異不大的,所以單個周期能夠執行的指令條數越多說明我們的應用優化的越好。
過多的跳轉指令(即if else這類代碼)、浮點計算、內存隨機訪問等操作顯然是非常影響IPC的。
有的人比較喜歡說CPI(Cycle per instruction),它是IPC的倒數。
- LLC Cache Miss(最后一級緩存丟失):
偏內存型的應用需要關注這個指標,過大的話代表我們沒有利用好處理器或操作系統的緩存預加載機制。
- Branch Misses(預測錯誤的分支指令數):
這個值過高代表了我們的分支類代碼設計的不夠友好,應該做一些調整盡量滿足處理器的分支預測算法的期望。
如果我們的分支邏輯依賴于數據的話,做一些數據的調整一樣可以提高性能(比如這個經典案例:數據有100萬個元素,值在0-255之間,需要統計值小于128的元素個數。
提前對數組排序再進行for循環判斷會運行的更快)。
因為perf是為Linux上的原生應用準備的,所以直接使用它分析Java應用程序的話,它只會把Java程序當成一個普通的C++程序來看待,不能顯示出Java的調用棧和符號信息。
好消息是perf-map-agent插件項目解決了這個問題,這個插件可以導出Java的符號信息并幫助perf進行Java線程的?;厮?,這樣我們就可以使用perf來分析Java應用程序的性能了。
執行 perf top -p 后,就可以看到perf顯示的實時性能統計信息了,如下圖:
perf僅支持采樣 + CPU Time的工作模式,不過它的性能非常好,進行普通的Java性能分析任務時通常只會引入5%以內的額外開銷。
使用環境變量PERF_RECORD_FREQ來設置采樣頻率,推薦值是999。不過如你所見,它是標準的Linux命令行式的交互行為,不是那么方便。
同時雖然他是可以把性能數據錄制為文件供后續繼續分析的,但要記得同時保存Java進程的符號文件,不然你就無法查看Java的調用棧信息了。
雖然限制不少,但是perf卻是最適合用來即時分析線上性能問題的工具,不需要任何前期的準備,隨時可用,同時對線上性能的影響也很小,可以很快的找到性能瓶頸點。
在安裝好perf(需要sudo權限)以及perf-map-agent插件后,通常使用如下的命令來打開它:
重點需要介紹的信息就是這么多,實踐過程中需要用好perf的話需要再查閱相關的一些文檔。
2.5 內核態與用戶態
對操作系統有了解的同學會經常聽到這兩個詞,也都知道經常在內核態與用戶態之間交互是非常影響性能的。
從執行層面來說,它是處理器的設計者設計出來構建如今穩定的操作系統的基礎。
有了它,用戶態(x86上面是ring3)進程無法執行特權指令與訪問內核內存。
大多數時候為了安全,內核也不能把某部分內核內存直接映射到用戶態上,所以在進行內核調用時,需要先將那部分參數寫入到特定的傳參位置,然后內核再從這里把它想要的內容復制走,所以多會一次內存的復制開銷。
你看到了,內核為了安全,總是小心翼翼的面對每一次的請求。
在Linux上,TCP協議支持是在內核態實現的,曾經這有很多充分的理由,但內核上的更新迭代速度肯定是慢于如今互聯網行業的要求的,所以QUIC(Quick UDP Internet Connection,谷歌制定的一種基于UDP的低時延的互聯網傳輸層協議)誕生了。
如今主流的發展思路是能不用內核就不用內核,盡量都在用戶態實現一切。
有一個例外,就是搶占式的線程調度在用戶態做不到,因為實現它需要的定時時鐘中斷只能在內核態設置和處理。
協程技術一直是重IO型Java應用減少內核調度開銷的極好的技術,但是很遺憾它需要執行線程主動讓出剩余時間片,不然與內核線程關聯的多個用戶態線程就可能會餓死。
阿里巴巴的Dragonwell版JVM還嘗試了動態調整策略(即用戶態線程不與固定的內核態線程關聯,在需要時可以切換),不過由于前述的時鐘中斷的限制,也不能工作的很好。
包括如今的虛擬化技術,尤其是SR-IOV技術,只需要內核參與接口分配/回收的工作,中間的通信部分完全是在用戶態完成的,不需要內核參與。
所以,如果你發現你的應用程序在內核態上耗費了太多的時間,需要想一想是否可以讓它們在用戶態完成。
2.6 JVM關鍵指標
JVM的指標很多,但有幾個關鍵的指標需要大家經常關注。
1.GC次數與時間:包括Young GC、Full GC、Concurrent GC等等,Young GC頻率過高往往代表過多臨時對象的產生。
- Java堆大小:包括整個Java堆的大?。ㄓ蒟mx、Xms兩個參數控制),年輕代、老年代分別的大小。不同時指定Xms和Xmx很可能會讓你的Java進程一直使用很小的堆空間,過大的老年代空間大多數時候也意味著內存的浪費(多分配一些給年輕代將顯著降低Young GC頻率)。
- 線程數:通常我們采用的都是4C8G(4Core vCPU,8GB內存)、8C16G的機型,分配出上千個線程大多數時候都是錯誤的。
- Metaspace大小和使用率:不要讓JVM動態的擴展元空間的大小,盡量通過設置MetaspaceSize、MaxMetaspaceSize讓它固定住。我們需要知道我們的應用到底需要多少元空間,過多的元空間占用以及過快的增長都意味著我們可能錯誤的使用了動態代理或腳本語言。
- CodeCache大小和使用率:同樣的,不要讓JVM動態的擴展代碼緩存的大小,盡量通過設置InitialCodeCacheSize、ReservedCodeCacheSize讓它固定住。我們可以通過它的變化來發現最近是不是又引入了新的類庫。
- 堆外內存大?。合拗谱畲蠖淹鈨却娴拇笮。嬎愫肑VM各塊內存的大小,不要給操作系統觸發OOM Killer的機會。
2.7 了解JIT
字節碼的解釋執行肯定是相當慢的,Java之所以這么流行和他擁有高性能的JIT(即時,Just in time)編譯器也有很大的關系。
但編譯過程本身也是相當消耗性能的,且由于Java的動態特性,也很難做到像C/C++這樣的編程語言提前編譯為native code再執行,這導致Java應用的啟動是相當慢的(大多數都需要3分鐘以上,Windows操作系統的啟動都不需要這么久),而且同一個應用的多臺機器之間并不能共享JIT的經驗(這顯然是極大的浪費)。
我們使用的JVM都采用分層編譯的策略,根據優化的程度不同從低到高分別是C1、C2、C3、C4,C4是最快的。
JIT編譯器會收集不少運行時的數據,來指導它的編譯策略,核心假設是可以逐步收集信息、僅編譯熱點方法和路徑。
但是這個假設并不總是對的,比如對于雙11大促的場景來說,我們的流量是到點突然垂直增加的,以及部分代碼分支在某個時間點之前并不會運行(比如某種優惠要零點過后才會生效可用)。
2.7.1 編譯
極熱的函數通常JIT編譯器會函數進行內聯(inlining)優化,就相當于直接把代碼抄寫到調用它的地方來減少一次函數調用的開銷,但是如果函數體過大的話(具體要看JVM的實現,通常是幾百字節)將不能內聯,這也是為什么編程規范里面通常都會說不要將一個函數寫的過大的原因。
JVM并不會對所有執行過的方法都進行JIT優化,通常需要5000~10000次的執行后才進行,而且它還僅僅編譯那些曾經執行過的分支(以減少編譯所需要的時間和Code Cache的占用,優化CPU的執行性能)。
所以在寫代碼的時候,if代碼后面緊跟的代碼塊最好是較大概率會執行到的,同時盡量讓代碼執行流比較固定。
阿里巴巴的Dragonwell版JVM新增了一些功能,可以讓JVM在運行時記錄編譯了哪些方法,再把它們寫入文件中(還可以分發給應用集群中別的機器),下次JVM啟動時可以利用這部分信息,在第一次運行這些方法時就觸發JIT編譯,而不是在執行上千次以后,這會極大的提升應用啟動的速度以及啟動時CPU的消耗。
不過動態AOP(Aspect Oriented Programming)代碼以及lambda代碼將不能享受這個紅利,因為它們運行時實際生成的函數名都是形如MethodAccessor$1586 這類以數字結尾的不穩定的名稱,這次是1586,下一次就不知道什么了。
2.7.2 退優化
JIT編譯器的激進優化并不總是對的,如果它發現目前需要的執行流在以前的編譯中被省略了的話,它就會進行退優化,即重新提交該方法的編譯請求。
在新的編譯請求完成之前,該方法很大可能是進行解釋執行(如果存在還未丟棄的低階編譯代碼,比如C1,那么就會執行C1的代碼),加上編譯線程的開銷,這會導致短時間內應用性能的下降。
在雙11大促這種場景下,也就是零點的高峰時刻,由于退優化的發生,導致應用的性能比壓測時有相當顯著的降低。
阿里巴巴的Dragonwell版JVM在這一塊也提供了一些選項,可以在JIT編譯時去除一些激進優化,以防止退優化的發生。
當然,這會導致應用的性能有微弱的下降。
2.8 真實案例
在性能優化的實踐過程中,有一句話需要反復深刻的理解:通過數據反映一切,而不是聽說或者經驗。
下面列舉一個在重構項目中進行優化的應用的性能比較數據,以展示如何利用我們前面說到的知識。
這個應用是偏末端的應用,下游基本不再依賴其它應用。
- 特別說明,【此案例非得物案例】,也不對應任何一個真實的案例。
應用容器:8C32G,Intel 8269CY處理器,8個處理器綁定到4個物理核的8個HT上。
老應用:12.177.126.52,12.177.126.141
新應用:12.177.128.150, 12.177.128.28
測試接口與流量:
時間:2021-05-03 基礎數據:
緩存訪問:
DB訪問:
2.8.1 初步發現
通過外部依賴的差異可以發現,新老應用的代碼邏輯會有不同,需要繼續深入評估差異是什么。通常在做收集性能數據的同時,我們需要有一個簡單的分析和判斷,首先確保業務邏輯的正確性,不然性能數據就沒有多少意義。
Java Exception是很消耗性能的,主要消耗在收集異常棧信息,新老應用較大的異常數區別需要找到原因并解決。
新應用的接口“下單確認優惠”RT有過于明顯的下降,其它接口都是提升的,說明很可能存在執行路徑上較大的差別,也需要深入的分析。
三 . 開始做性能優化
和獲取性能數據需要從底層逐步了解到上層業務不同,做性能優化卻是從上層業務開始,逐步推進到底層,即從高到低進行分層優化。
越高層的優化往往難度更低,而且收益還越大,只是需要與業務的深度結合。
越低層的優化往往難度比較大,很難獲得較大的收益(畢竟一堆技術精英一直在做著呢),但是通用性比較好,往往可以適用于多類業務場景。
接下來分別聊一聊每一層可以思考的一些方向和實際的例子。
3.1 優化的目的與原則
在聊具體的優化措施之前,我們先聊一聊為什么要做性能優化。
多數情況下,性能優化的目的都是為了成本、效率與穩定。
達到同樣的業務效果,使用更少的資源,或者帶來更好的用戶體驗(通常是指頁面的響應更快)。
不怎么考慮成本的技術方案往往沒有太多的挑戰,對于電商平臺來說,我們常常用單訂單成本來衡量機器成本,比如淘寶這個值可能在0.17元左右。
業務發展的早期往往并不是那么在意成本,反而更加看重效率,等到逐步成熟起來過后,會慢慢的開始重視成本,通俗的講就是開始比的是有沒有,然后比的是好不好。
所以在不同的時期,我們進行性能優化的目的和方向會有所側重。
互聯網行業是一個快速發展的行業,研發效率對業務的健康發展是至關重要的,在進行優化的過程中,我們在技術方案的選擇上需要兼顧研發效率的提升(至少不能損害過多),給人一種“它本來應該就是這樣”的感覺,而不是做一些明顯無法長期持續、后期維護成本過高的設計。
好的優化方案就像藝術品一樣,每一個看到的人都會為之贊嘆。
3.2 業務
大家為什么會在雙11的零點開始上各大電商網站買東西?
春節前大家為什么都在上午10點搶火車票?
等等,其實都是業務上的設計。
準備一大波機器資源使用2個月就為了雙11峰值的那幾分鐘,實際上是極大的成本浪費,所以為了不那么浪費,淘寶的雙11預售付尾款的時間通常都放在凌晨1點。
12306早幾年是每臨進春節必掛,因為想要回家的游子實在是太多,所以后面慢慢按照車次將售賣時間打散,參考其公告:
自今年1月8日起,為避免大量旅客在互聯網排隊購票,把原來的8點、10點、12點、15點四個時間節點放票改為15個節點放票,即:8點-18點,其間每小時或每半小時均有部分新票起售。
這些策略都可以極大的降低系統的峰值流量,同時對于用戶使用體驗來說基本是無感的,諸如此類的許多優化是我們最開始就要去思考的(不過請記住永遠要把業務效果放在第一位,和業務講業務,而不是和業務講技術)。
3.3 系統架構
諸如商品詳情頁動靜分離(靜態頁面與動態頁面分開不同系統訪問),用戶接口層(即HTTP/S層)與后端(Java層)合并部署等等,都是架構優化的成功典范。
業務架構師往往會將系統設計為很多層,但是在運行時,他們往往可以部署在一塊兒,以減少跨進程、跨機器、跨地域通信。
淘寶的單元化架構在性能上來看也是一個很好的設計,一個交易請求幾乎所有的處理都可以封閉在單元內完成,減少了很多跨地域的網絡長傳帶寬需求。
富客戶端方案對于像商品信息、用戶信息等基礎數據來說也是很好的方案,畢竟大多數情況下它們都是訪問Redis等緩存,多一次到服務端的RPC請求總是顯得很多余,當然,后續需要升級數據結構的時候則需要做更多的工作。
關于架構的討論是永恒的話題,同時不同的公司有不同的背景,實際進行優化時也需要根據實際情況來取舍。
3.4 調用鏈路
在分布式系統里不可避免需要依賴很多下游服務才能完成業務動作,怎么依賴、依賴什么接口、依賴多少次則是需要深入思考的問題。
借助調用鏈查看工具(在得物,這個工具應該是dependency),我們可以仔細分析每一個業務請求,然后去思考它是不是最優的方式。
舉一個我聽說過的例子(特別說明,【此案例非得物案例】):
背景:營銷團隊接到了一個拉新的需求,它會在公司周年慶的10:00形成爆點,預計會產生最高30萬的UV,然后只需要點擊活動頁的參與按鈕(預估轉化率是75%),就會彈出一個組團頁,讓用戶邀請他的好友參與組團,每多邀請一個朋友,在團內的用戶都可以享受更多的優惠折扣。
營銷團隊為組團頁提供了一個新的后臺接口,最開始這個接口需要完成這些事:
在“為用戶創建一個新團”這一步,會同時將新團的信息持久化到數據庫中,按照業務的轉化率預估,這會有30W*75%=22.5W QPS的峰值流量,基本上我們需要10個左右的數據庫實例才能支撐這么高的并發寫入。
但這是必要的嗎?
顯然不是,我們都知道這類需要用戶轉發的活動的轉化率是有多么的低(大多數不到8%),同時對于那些沒人參與的團,將它們的信息保存在數據庫中也是意義不大的。
最后的優化方案是:
1)在“為用戶創建一個新團”時,僅將團信息寫入Redis緩存中;
2)在用戶邀請的朋友同意參團時,再將團信息持久化到數據庫中。新的設計,僅僅需要1.8W QPS的數據庫并發寫入量,使用原有的單個數據庫實例就可以支撐,在業務效果上也沒有任何區別。
除了上述的例子,鏈路優化還有非常多的方法,比如多次調用的合并、僅在必要時才調用(類似COW [Copy on write]思想)等等,需要大家結合具體的場景去分析設計。
3.5 應用代碼
應用代碼的優化往往是我們最熱衷和擅長的,畢竟業務與系統架構的優化往往需要架構師出馬。
JProfiler或者perf的剖析(Profiling)數據是非常有用的參考,任何不基于實際運行數據的猜測往往會讓我們誤入歧途,接下來我們需要做的大多數時候都是“找熱點 -> 優化” ,然后“找熱點 -> 優化”,然后一直循環。
找熱點不是那么難,難在準確的分析代碼的邏輯然后判斷到底它應該消耗多少資源(通常都是CPU),然后制定優化方案來達到目標,這需要相當多的優化經驗。
從我做過的性能優化來總結,大概主要的問題都發生在這些地方:
- 和字符串過不去:非常多的代碼喜歡將多個Java變量使用StringBuilder拼接起來(那些連StringBuilder都不會用,只會使用 + 的家伙就更讓人頭疼了),然后再找準時機spilt成多個String,然后再轉換成別的類型(Long等)。好吧,下次使用StringBuilder時記得指定初始的容量大小。
- 日志滿天飛:管它有用沒有,反正打了是不會錯的,畢竟誰都經歷過沒有日志時排查問題的痛苦。怎么說呢,打印有用的、有效的日志是程序員的必修課。
- 喜愛Exception:不知道是不是某些Java的追隨者吹過頭了,說什么Java的Exception和C/C++的錯誤碼一樣高效。然而事實并不是這樣的,Exception進行調用棧的回溯是相當消耗性能的,尤其是還需要將它們打印在日志中的時候,會更加糟糕。
- 容器的深拷貝:List、HashMap等是大家非常喜歡的Java容器,但Java語言并沒有好的機制阻止別人修改它,所以大家常常深拷貝一個新的出來,反正也就是一句代碼的事兒。
- 對JSON情有獨鐘:將對象序列化為JSON string,將JSON string反序列化為對象。前者主要用來打日志,后者主要用來讀配置。JSON是挺好,只是請別用的到處都是(還把每個屬性的名字都取的老長)。
- 重復重復再重復:一個請求里查詢同樣的商品3次,查詢同樣的用戶2次,查詢同樣的緩存5次,都是常有的事,也許多查詢幾次一致性更好吧 :(。還有一些基本不會變的配置,也會放到緩存中,每次使用的時候都會從緩存中讀出來,反序列化,然后再使用,嗯,挺重的。
- 多線程的樂趣:不會寫多線程程序的開發不是好開發,所以大家都喜歡new線程池,然后異步套異步。在流量很低的時候,看起來多線程的確解決了問題(比如RT的確變小了),但是流量上來過后,問題反而惡化了(畢竟我們主流的機器都是8核的)。
再重申一遍,在這一步,找到問題并不是太困難,找到好的優化方案卻是很困難和充滿考驗的。
3.6 緩存
大多數的緩存都是Key、Value的結構,選擇緊湊的Key、Value以及高效的序列化、反序列化算法是重中之重(二進制序列化協議比文本序列化協議快的太多了)。
還有的緩存是Prefix、Key、Value的結構,主要的區別是誰來決定實際的數據路由到哪臺服務器進行處理。單條緩存不能太大,基本上大于64KB就需要小心了,因為它總是由某一臺實際的服務器在處理,很容易將出口寬帶或計算性能打滿。
3.7 DB
數據庫比起緩存來說他能抗的流量就低太多了,基本上是差一個數量級。
SQL通信協議雖然很易用,但實際上是非常低效的通信協議。關于DB的優化,通常都是從減少寫入量、減少讀取量、減少交互次數、進行批處理等等方面著手。
DB的優化是一門復雜的學問,很難用一篇文章說清楚,這里僅舉一些我認為比較有代表性的例子:
- 使用MultiQuery減少網絡交互:MySQL等數據庫都支持將多條SQL語言寫到一起,一起發送給DB服務器,這會將多次網絡交互減少為一次。
- 使用BatchInsert代替多次insert:這個很常見。
- 使用KV協議取代SQL:阿里云數據庫團隊在數據庫服務器上面外掛了一個KV引擎,可以直接讀取InnoDB引擎中的數據,bypass掉了數據庫的數據層,使得基于唯一鍵的查詢可以比使用SQL快10倍。
- 與業務結合:淘寶下單時可以同時使用多達10個紅包,這意味著一次下單需要發送至多10次update SQL。假設一次下單使用了N個紅包,基于對業務行為的分析,會發現前N-1個都是全額使用的,最后一個可能會部分使用。對于使用完的紅包,我們可以使用一條SQL就完成更新。
- 熱點優化:庫存的熱點問題是每個電商平臺都面臨的問題,使用數據庫來扣減庫存肯定是可靠性最高的方案,但是基本上都很難突破500tps的瓶頸。阿里云數據庫團隊設計了新的SQL hint,配合上第1條說的MultiQuery技術,與數據庫進行一次交互就可以完成庫存的扣減。同時加上數據庫內核的針對性優化,已經可以實現8W tps的熱點扣減能力。下表中的commit_on_success用來表明,如果update執行成功就立即提交,這樣可以讓庫存熱點行的鎖占用時間降到最低。target_affect_row(1)以及rollback_on_fail用來限制當庫存售罄時(即inv_count - 1 >= 0不成立)update執行失敗并回滾整個事務(即前面插入的庫存流水作廢)。
3.8 運行環境
我們的代碼是運行在某個環境中的,這個環境有很多知識是我們需要了解的,如果上面所有的優化完成后還不能滿足要求,那么我們也不得不向下深入。
這可能是一個困難的過程,但也會是一個有趣的過程,因為你終于有了和各領域的大佬們交流討論的機會。
3.8.1 中間件
目前大多數中間件的代碼是和我們的業務代碼運行在一起的,比如監控采集、消息client、RPC client、配置推送 client、DB連接組件等等。如果你發現這些組件的性能問題,那么可以大膽的提出來,不要害怕傷害到誰。
我遇到過這樣的一些場景:
- 應用偶爾會大量的發生ygc:
排查到的原因是,在我們依賴的服務發生地址列表變化(比如發生了重啟、掉線、擴容等場景)時,RPC client會接收到大量的推送,然后解析這些推送的信息,然后再更新一大堆內存結構。
提出的優化建議是:
1)地址推送從全量推送改變為增量推送;
2)地址列表從掛接到服務接口維度更改為掛接到應用維度。
- DB連接組件過多的字符串拼接:
DB連接組件需要進行SQL的解析來計算分庫分表等信息,但實現上面不夠優雅,拼接的字符串過多了,導致執行SQL時內存消耗過多。
3.8.2 容器
關于容器技術本身大多數時候我們做不了什么,往往就是盡量采用最新的技術(比如使用阿里云的神龍服務器什么的),不過在梆核(即容器調度)方面往往可以做不少事。
我們的應用和誰運行在一起、相互之間有資源爭搶嗎、有沒有跨NUMA調度、有沒有資源超賣等等問題需要我們關注(當然,這需要容器團隊提供相應的查看工具)。
這里主要有兩個需要考慮的點:
- 是不是支持離在線混部:
在線任務要求實時響應,而離線任務的運行又需要耗費非常多的機器。在雙11大促這樣的場景,把離線機器借過來用幾個小時就可以減少相應的在線機器采購,能省下很多錢。
- 基于業務的調度:
把高消耗的應用和低消耗的應用部署在一起,同時如果雙方的峰值時刻還不完全相同,那就太美妙啦。
3.8.3 JVM
為了解決重IO型應用線程過多的問題開發了協程。
為了解決Java容器過多小對象的問題(如HashMap的K, V都只能是包裝類型)開發了值容器。
為了解決Java堆過大時GC時間過長的問題(當然還有覺得Java的內存管理不夠靈活的原因)開發了GCIH(GC Invisible Heap,淘寶雙11期間部分熱點優惠活動的數據都是存在GCIH當中的)。
為了解決Java啟動時的性能問題(即代碼要跑好幾千次才進行JIT,而且每次啟動都還要重復這個過程)開發了啟動Hint功能。
為了解決業務峰值時刻JIT退優化的問題(即平時不使用的代碼執行路徑在業務峰值時候需要使用,比如0點才生效的優惠)開發了JIT編譯激進優化去除選項。
雖然目前JVM的實現就是你知道的那樣,但是并不代表這樣做就一直是合理的。
3.8.4 操作系統
基本上我們都是使用Linux操作系統,新版本的內核通常會帶來一些新功能和性能的提升,同時操作系統還需要為支撐容器(即Docker等)做不少事情。
對Host操作系統來說,開啟透明大頁、配置好網卡中斷CPU打散、配置好NUMA、配置好NTP(Network Time Protocol)服務、配置好時鐘源(不然clock_gettime可能會很慢)等等都是必要的。
還有就是需要做好各種資源的隔離,比如CPU隔離(高優先級任務優先調度、LLC隔離、超線程技術隔離等)、內存隔離(內存寬帶、內存回收隔離避免全局內存回收)、網絡隔離(網絡寬帶、數據包金銀銅等級劃分)、文件IO隔離(文件IO寬帶的上限與下限、特定文件操作限制)等等。
大多數內核級別的優化都不是我們能做的,但我們需要知道關鍵的一些影響性能的內核參數,并能夠理解大多數內核機制的工作原理。
3.9 硬件
通常我們都是使用Intel的x86架構的CPU,比如我們正在使用的Intel 8269CY,不過它的單顆售價得賣到4萬多塊人民幣,卻只有區區26C52T(26核52線程)。
相比之下,AMD的EPYC 7763的規格就比較牛逼了(64C128T,256MB三級緩存,8通道 DDR4 3200MHz內存,擁有204GB/s的超高內存寬帶),但卻只要3萬多一顆。
當然,用AMD 2021年的產品和Intel 2019的產品對比并不是太公平,主要Intel 2021的新品Intel Xeon Platinum 8368Q處理器并不爭氣,僅僅只是提升到了38C76T而已(雖然和自家的上一代產品相比已經大幅提升了近50%)。
除了x86處理器,ARM 64位處理器也在向服務端產品發力,而且這個產業鏈還可以實現全國產化。
華為2019年初發布的鯤鵬920-6426處理器,采用7nm工藝,具備64個CPU核,主頻2.6GHz。
雖然單核性能上其只有Intel 8269CY的近2/3,但是其CPU核數卻要多上一倍還多,加上其售價親民,同樣計算能力的情況下CPU部分的成本會下降近一半(當然計算整個物理機成本的話其實下降有限)。
2020年雙11開始,淘寶在江蘇南通部署了支撐1萬筆/s交易的國產化機房,正是采用了鯤鵬920-6426處理器,同時在2021年雙11,更是用上了阿里云自主研發的倚天710處理器(也是采用ARM 64位架構)。
在未來,更是有可能基于RISC-V架構設計自己的處理器。這些事實都在說明,在處理器的選擇上,我們還是有不少空間的。
除了采用通用處理器,在一些特殊的計算領域,我們還可以采用專用的芯片,比如:使用GPU加速深度學習計算,在AI推理時使用神經網絡加速芯片-含光NPU,以及使用FPGA芯片進行高性能的網絡數據處理(阿里云神龍服務器上使用的神龍MOC卡)等等。
曾經還有人想過設計可以直接運行Java字節碼的處理器,雖然最終因為復雜度太高而放棄。
這一切都說明,硬件也是一直在根據使用場景在不斷的進化之中的,永遠要充滿想像。