前言
最近剛讀完Java並發編程實戰、深入理解Java虛擬機。打算寫一篇總結性文章,思來想去文章的內容,最后決定還是不要限定於Java這門語言,應該從提升性能的整體出發,所以就有了這篇文章。
一、什么是串行程序?
串行程序就是一次只能做一件事情。拿一個早上起床去上班的例子來說,它分為以下幾個步驟,這些步驟跟串行程序的語義是一樣的。它們必須一件一件來完成。

二、什么是並發程序
現在假設人的需求變了,需要在刷牙的時候煮個雞蛋、熱個牛奶當早餐吃。如果完全按照串行程序的語義來執行,事情就會變成這樣:我在煮雞蛋的過程中什么都不能做,必須等雞蛋煮好后才能走開去熱牛奶。顯然在現實生活中,雞蛋放在鍋里煮的時候我就可以去刷牙洗臉了,不必一直在這里無意義的等待
,所以,在程序世界中也必須能夠支持這些符合現實世界的行為。
等待雞蛋煮好、牛奶熱好的動作,在程序里有一個名詞叫“IO等待”。處於IO等待時進程(或線程)將處於一種叫“阻塞”的狀態,此時是不消耗CPU時間的,所以可以用來做其他事情。這就叫並發程序。
三、摩爾定律
摩爾定律是由Intel公司創始人之一Gordon Moore發現的規律,意思是隨着芯片制造技術的發展,晶體管的體積越來越小,從而有可能將越來越多的晶體管放入一個芯片中(引自Andrew S.Tanenbaum和Herbert Bos的《現代操作系統》)。這種基於經驗的法則揭示了CPU運行指令(可比喻現實世界的某個事件)的速度會越來越快,但是實際上並非CPU越快程序就越快了,這中間存在着溝通問題。
比如有個程序員,ta的編程能力很強,但是編程這個動作是需要需求來驅動,如果溝通過程中不夠順暢,很片段化,那么整個開發進度就會被溝通成本拖慢。同樣,在計算器系統中也存在這樣的問題。假如有一個程序是打印一個“hello world”,hello程序的機器指令最初是存放在磁盤上,當程序加載時,它們被復制到主存,當處理器要運行程序時,指令又被復制到處理器。這其中,CPU從主存讀取到寄存器的速度大概是從磁盤讀取到主存的1000萬倍,從寄存器讀取的速度大概是主存的100倍。所以就有了后來的多核處理器,也就是多個cpu組合在一起,這樣才能同時做更多的事情。
四、什么是並行程序
1. 進程
進程是處理器對自己的抽象,它由程序加數據兩部分組成,進程在創建時候會在寄存器、CPU高速緩存(如果有的話)、主存中加載需要的數據和程序本身(指令集合),主存的數據是每個進程獨有的,程序是可以多個進程共享的。在單核處理器系統中,同一時刻只能有一個進程在運行,但是CPU切換進程的速度特別快,導致大家以為程序都是同時執行的,這個叫做偽並發。
人們通常能夠感知到的時間大概是以秒為單位的,但現在CPU的時鍾周期已經遠遠超過了人們的感知尺度,比如一個1GHZ的CPU在一秒內能有10的9次方個時鍾周期,如果三個時鍾周期能運行一個指令,那么在一秒內CPU就能運行大約3億個指令,很顯然在這種速度下人們就會誤以為程序是同時執行的了。
2. 線程
線程是在進程之上的抽象,一個進程可以有多個線程,一個線程必須屬於某個進程。線程可以共享進程的數據,可以把進程理解為一個主線程。當主線程被譬如磁盤IO之類的進入阻塞狀態后,可以再創建一個線程來做其他的操作,這就是上面所說的並發。特別是在互聯網服務器的進程上,線程發揮了很大的作用,因為很多的請求都不是CPU密集型的,都是IO密集型的。
3. 超線程技術
為了讓單核CPU能夠並行執行程序,CPU制造廠商發明了超線程技術。顧名思義,並行就是同一時間做很多事情。比如人在聽音樂(是指耳朵)的同時可以擺動身體(手腳等),因為它們都是我們身體的不同部分,所以可以同時運行(暫且用這個詞表達。手動狗頭)。CPU也是一樣,它可以分析指令用到了哪些執行單元,比如一條指令用到了加法器另一條用到了浮點數計算器,那么就可以同時讓這兩條指令執行。所以大家在買CPU的時候要小心了,需要區分什么是兩核四線程和四核CPU。
4. 多核處理器系統
多核CPU大家應該通過上文可以猜到了,它就是將多個CPU集成到一起的一個“CPU”。擁有多核CPU的操作系統就會有並行的能力了。並行就是同時能處理多個任務,比如蓋房子的時候,CPU的一個核心就代表一個工人,增加工人就代表着並行搬磚或砌磚的能力。
只有多核處理器才能同時運行多個進程,同時運行進程數等於核心數。多核處理器系統特別適合軟件算法中的分治法,將一個規模很大的問題分解為若干個小問題,通過並行解決這些小問題,來解決大問題。隨着現在互聯網用戶越來越多,高並發的場景也就越密集,多核cpu能支撐單核cpu所不能承載的高並發訪問。而且多核處理器可以避免一些線程的上下文切換開銷,在第六節會講到。
五、阿姆達爾定律
從摩爾定律提升cpu運算能力的時代,到多核高並發的時代,出來了一個新的概念,叫阿姆達爾定律(Amdahl's law,由 Gene Amdahl 於1967年提出)。它揭示了一個問題和一個預測公式,問題就是:有些程序並不是增加cpu核心數就可以讓它的運行速度成比例增長的,有時甚至是完全沒有作用的。就比如,你可以通過增加工人來加快造房子的速度,但你無法通過增加設計師來加快設計房屋圖紙的速度。因為很多程序在本質上還是串行的,如果不把串行這部分改為並行程序,將無法利用多核處理器的優勢。
預測公式是指:在增加CPU核心的情況下,程序在理論上能夠達到的加速比,它的公式如下:
Speedup= 1/(F+(1−F)/N)
其中Speedup代表加速比,F代表程序的串行部分,N代表CPU核心數。在N接近於無窮大時,最大加速比接近於1/F,假設F為50%,那么加速比最多為2(不管多少個CPU核心)。因此需要將程序中串行的部分減少,來利用多處理器的優勢。
在阿姆達爾定律的作用下,程序要不僅要提升單線程程序的性能,還要提高程序的可伸縮性,可伸縮性就是指程序在提升計算機資源(CPU核心數、內存、IO帶寬等)的情況下,吞吐量或者處理能力能否相應的增加。可伸縮性在並發編程中尤為重要,因為很多程序都還是串行的。
不僅要優化邏輯上的串行,還要盡量避免在訪問共享區域時使用同步,將同步鎖分解、分段,如果可以的話應該使用CAS(compare and swap)。這些技術能有效地降低線程在訪問共享區域時的串行動作。
有一個最快衡量程序並行度的方法,那就是查看服務器的CPU利用率,如果不是你們業務量特別低的話,CPU的利用率應該長期處於接近於滿載的狀態才是最好的並行程序(雖然並不能由此得出你們的應用程序效率有多低,但是至少知道CPU能力沒有被完整開發)。
六、線程實現的模型
實現線程有兩種方式,一種是內核線程、一種是用戶線程。
1、內核線程
內核線程就是在操作系統內核態實現的線程,是受操作系統直接管理的線程模型。Java的Hot spot虛擬機就是用的這種線程模型,一個輕量級線程(輕量級線程(lightweight process)是專門用來對應內核線程的用戶態線程)對應一個內核線程,1:1的關系。在這種模型下,線程調度、上下文切換都是由內核來完成的,Hot spot虛擬機只需要調用操作系統原語即可。
它的缺點就在於線程上下文切換。由於線程是跟進程共享數據的,所以沒有虛擬內存的數據需要保存。線程上下文的內容就是:程序計數器: 指令序列中下一條指令的位置、寄存器中的操作數:運算中的變量的值。
通常中斷一個線程並運行另一個線程時,需要將當前線程上下文存在主存,然后把下一個要執行的線程的上下文恢復到cpu的寄存器中,並且讓cpu從程序計數器處繼續執行。這些過程會讓CPU重復無效勞動,如果過於頻繁的話,就會降低程序的吞吐量。
只要線程的數量超過了CPU的核心數,就會發生上下文切換。有些線程池比如Java的ForkJoinPool,就會默認線程數為CPU可用核心數,防止線程上下文切換的開銷。
2、用戶線程
用戶線程是指在用戶態的進程自己實現線程的管理、調度算法等。它采用1:M的模式,即一個輕量級線程多個用戶線程。因為內核不知道用戶線程的存在,所以內核只能調度內核線程。這種實現模式就是靈活,但缺點也很明顯,調度算法實現很復雜,並且這種模型無法利用多核CPU的優勢。
七、Golang的線程模型-協程
Golang作為專為並發而生的編程語言,它原生支持並發編程,最大限度的使易於編寫和高並發性融合在一起。第六節我們講到,內核線程和用戶線程的實現模型分別是1:1和1:M,它們各有優缺點。而Golang使用的是M:N模型,也就是多個內核線程對應多個用戶線程,如下圖所示(LWP是輕量級線程的簡寫)

Go會使用M個內核線程來支撐N的名叫goroutine的協程,由於內核線程可以由內核調度,就充分利用了多核CPU的優勢,而且goroutine的調度算法是由Go來實現的,所以能夠減少內核線程在高並發時頻繁的上下文切換問題。
Goroutine非常輕量級,linux操作系統線程的上限是1024個,用滿時會占用內存1個G。一個線程大概需要1MB的棧,而協程可能只需要不到1kb,如果按1G的內存算的話,goroutine能創建大約10萬個。最重要的是goroutine的調度完全是GO自己實現的機制,它可以在某個goroutine被IO阻塞時,將CPU資源分配給其他協程,再加上用戶態的上下文切換,CPU浪費被大大優化。
八. Java的解決方案
golang的協程主要解決了兩個大問題,一是:IO等待讓CPU處於閑置狀態不作為,二是:內核線程頻繁的上下文切換導致CPU做無用功。對於這兩個問題,Java早就有了自己的解決方案。這些解決方案有些是受到操作系統、Java虛擬機甚至是硬件級別支持的。
1. NIO
NIO(Non-blocking-I/O)就跟它的名字一樣,非阻塞IO。跟BIO(Blocking I/O)不同的是,它在接收數據的Buffer處不會發生阻塞,而是通過一個select的系統調用來等待某些Buffer里數據的到來,當數據到來時select會通知等待的線程來處理,也不用線程一直輪詢調用系統函數來查詢有沒有數據,屬於多路復用I/O。這樣實現的效果就是,線程不會因為某個客戶端不發送消息就阻塞在那里不做其他的工作,還可以接收其他客戶端發來的數據,從而提高並發性。
2. 自旋鎖
除了內核調度算法強制拿走CPU執行權,Java並發程序性能受影響的部分主要就是線程在訪問內存共享區的同步操作了。所以Java引入了CAS這種處理器級別的原子操作原語,使用這個原語修改共享數據時不會發生阻塞,而是以測試的方式來判斷是否可修改,不能修改就循環嘗試,直到可修改為止。
在Java的java.util.concurrent.atomic下有很多原子變量類,可以實現原子的共享數據訪問。而且不止是在編碼層面,在虛擬機層面也會統計獲取鎖的時間,如果很短的話也會優化成自旋操作。這么做好處就是不會頻繁的上下文切換,但是會占用CPU時間片。
3. 鎖消除、鎖粗化
Hot spot虛擬機的JIT編譯器會自動優化一些不必要的鎖獲取操作,比如一個引用沒有逃逸出方法的StringBuffer的連續append操作,實際上可以不用鎖。鎖粗化就是指有些地方連續不必要的加鎖解鎖,就算不存在線程競爭,這種操作會導致性能更低下。於是虛擬機就會把這些鎖粗化到包含它們。
4. 鎖升級
Jdk1.6后增加新的鎖優化機制,分別是偏向鎖、輕量級鎖。也就是說,在某個需要同步的對象上,被第一個線程加鎖時,會使用偏向鎖模式,如果后面沒有其他線程競爭過這個對象上的鎖,第一個線程下次再進入臨界區的時候就不會再加鎖。當虛擬機發現有其他線程競爭時,就會升級為輕量級鎖。輕量級鎖是用CAS操作來嘗試進入臨界區的,如果線程獲取鎖的操作是斷斷續續的,輕量級鎖就會繼續下去。但是如果某個線程在持有鎖時,被其他線程發現了,就會升級成重量級鎖。重量級鎖就會在每次拿不到鎖時阻塞並導致上下文切換了。
5. 纖程
纖程(Fiber)是一個實驗中的項目,目的也是要做到和Golang的協程一樣做自己調度的混合型線程,它屬於OpenJDK在2018年開始的Loom項目。
總結
軟件開發沒有銀彈,這是軟件界有名的書《人月神話》的作者說的。所謂的沒有銀彈是指沒有任何一項技術或方法可使軟件工程的生產力在十年內提高十倍。Golang在近幾年的開發者和金融領域的應用確實越來越多,但是Java的整個生態和多年的優化還是不可替代的,特別是還在實驗室的一些項目比如Graal VM、Graal編譯器、Loom項目都非常有潛力。不管是軟件優化還是架構,都沒有一套完整而又高性能的框架,是需要一步一步迭代出來,沒有最適合的只有更適合的。最后,編寫完這篇文章的時間正好是2021年10月24日,1024程序員節,祝各位程序員朋友節日快樂!
